@robota-sdk/agent-transport-webrtc 3.0.0-beta.81

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +661 -0
  2. package/dist/node/index.cjs +1 -0
  3. package/dist/node/index.d.cts +361 -0
  4. package/dist/node/index.d.cts.map +1 -0
  5. package/dist/node/index.d.ts +361 -0
  6. package/dist/node/index.d.ts.map +1 -0
  7. package/dist/node/index.js +2 -0
  8. package/dist/node/index.js.map +1 -0
  9. package/package.json +88 -0
  10. package/src/__tests__/admission-secure-by-default.test.ts +61 -0
  11. package/src/__tests__/admission-steps.test.ts +46 -0
  12. package/src/__tests__/cve-2024-29415-reachability.test.ts +80 -0
  13. package/src/__tests__/dtls-fingerprint-binding.test.ts +60 -0
  14. package/src/__tests__/handoff-grant-gate.test.ts +401 -0
  15. package/src/__tests__/local-peer-proof-gate.test.ts +213 -0
  16. package/src/__tests__/mesh-admission-contract.test.ts +38 -0
  17. package/src/__tests__/negotiated-certificate.test.ts +80 -0
  18. package/src/__tests__/operator-approval-gate.test.ts +306 -0
  19. package/src/__tests__/outbound-delivery-carrier.test.ts +152 -0
  20. package/src/__tests__/pairing-e2e.test.ts +238 -0
  21. package/src/__tests__/pairing-gate-e3.test.ts +192 -0
  22. package/src/__tests__/pairing-gate-e4.test.ts +108 -0
  23. package/src/__tests__/pairing-gate.test.ts +276 -0
  24. package/src/__tests__/pairing-relay-in-the-middle.test.ts +229 -0
  25. package/src/__tests__/session-attachment-budget.test.ts +136 -0
  26. package/src/__tests__/webrtc-transport.test.ts +349 -0
  27. package/src/__tests__/werift-loader.test.ts +18 -0
  28. package/src/__tests__/ws-signaling-client.test.ts +108 -0
  29. package/src/admission-steps.ts +55 -0
  30. package/src/channel-delivery.ts +44 -0
  31. package/src/handoff-grant-gate.ts +186 -0
  32. package/src/index.ts +21 -0
  33. package/src/local-peer-proof.ts +135 -0
  34. package/src/negotiated-certificate.ts +80 -0
  35. package/src/pairing-channel-lifecycle.ts +31 -0
  36. package/src/pairing-controllers.ts +79 -0
  37. package/src/pairing-frames.ts +34 -0
  38. package/src/pairing-gate-options.ts +129 -0
  39. package/src/pairing-gate.ts +376 -0
  40. package/src/session-attachment.ts +88 -0
  41. package/src/signaling.ts +62 -0
  42. package/src/transport-lifecycle-error.ts +11 -0
  43. package/src/webrtc-delivery-lifecycle.ts +51 -0
  44. package/src/webrtc-transport-options.ts +84 -0
  45. package/src/webrtc-transport.ts +328 -0
  46. package/src/werift-loader.ts +40 -0
  47. package/src/ws-signaling-client.ts +139 -0
@@ -0,0 +1,80 @@
1
+ import { extractDtlsFingerprint } from '@robota-sdk/agent-remote-pairing';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { RTCPeerConnection } from 'werift';
4
+
5
+ import { certificateFingerprint, whenRemoteCertificateVerified } from '../negotiated-certificate.js';
6
+
7
+ /** Connect two real werift peers directly and return them once the offerer's DTLS is connected. */
8
+ async function connectedPair(): Promise<{ host: RTCPeerConnection; remote: RTCPeerConnection }> {
9
+ const host = new RTCPeerConnection();
10
+ const remote = new RTCPeerConnection();
11
+ host.createDataChannel('robota-session');
12
+ host.onIceCandidate.subscribe((c) => {
13
+ if (c) void remote.addIceCandidate(c.toJSON());
14
+ });
15
+ remote.onIceCandidate.subscribe((c) => {
16
+ if (c) void host.addIceCandidate(c.toJSON());
17
+ });
18
+ await host.setLocalDescription(await host.createOffer());
19
+ await remote.setRemoteDescription(host.localDescription!);
20
+ await remote.setLocalDescription(await remote.createAnswer());
21
+ await host.setRemoteDescription(remote.localDescription!);
22
+ return { host, remote };
23
+ }
24
+
25
+ describe('negotiated certificate fingerprint', () => {
26
+ it('reports the fingerprint of the certificate the DTLS layer verified', async () => {
27
+ const { host, remote } = await connectedPair();
28
+ const fingerprint = await new Promise<string>((resolve, reject) => {
29
+ whenRemoteCertificateVerified(host, 'sha-256', resolve, (reason) => reject(new Error(reason)));
30
+ });
31
+ expect(fingerprint).toBe(extractDtlsFingerprint(remote.localDescription!.sdp));
32
+ await host.close();
33
+ await remote.close();
34
+ }, 15000);
35
+
36
+ it('fails on an algorithm it cannot hash', async () => {
37
+ const { host, remote } = await connectedPair();
38
+ const reason = await new Promise<string>((resolve) => {
39
+ whenRemoteCertificateVerified(host, 'md5', () => resolve('verified'), resolve);
40
+ });
41
+ expect(reason).toMatch(/unsupported DTLS fingerprint algorithm/);
42
+ await host.close();
43
+ await remote.close();
44
+ }, 15000);
45
+
46
+ it('formats a digest as upper-case colon-separated pairs', () => {
47
+ expect(certificateFingerprint(new Uint8Array([1, 2, 3]), 'SHA-256')).toMatch(
48
+ /^([0-9A-F]{2}:){31}[0-9A-F]{2}$/,
49
+ );
50
+ });
51
+ });
52
+
53
+ describe('negotiated certificate — failed handshake', () => {
54
+ it('reports a DTLS handshake that fails instead of waiting forever', async () => {
55
+ const host = new RTCPeerConnection();
56
+ const remote = new RTCPeerConnection();
57
+ host.createDataChannel('robota-session');
58
+ host.onIceCandidate.subscribe((c) => {
59
+ if (c) void remote.addIceCandidate(c.toJSON());
60
+ });
61
+ remote.onIceCandidate.subscribe((c) => {
62
+ if (c) void host.addIceCandidate(c.toJSON());
63
+ });
64
+ await host.setLocalDescription(await host.createOffer());
65
+ await remote.setRemoteDescription(host.localDescription!);
66
+ await remote.setLocalDescription(await remote.createAnswer());
67
+ // The host is told a fingerprint the remote's certificate does not have.
68
+ const bogus = Array.from({ length: 32 }, () => 'AA').join(':');
69
+ await host.setRemoteDescription({
70
+ type: 'answer',
71
+ sdp: remote.localDescription!.sdp.replace(/a=fingerprint:(\S+) (\S+)/, `a=fingerprint:$1 ${bogus}`),
72
+ });
73
+ const reason = await new Promise<string>((resolve) => {
74
+ whenRemoteCertificateVerified(host, 'sha-256', () => resolve('verified'), resolve);
75
+ });
76
+ expect(reason).toMatch(/DTLS handshake ended (failed|closed)/);
77
+ await host.close();
78
+ await remote.close();
79
+ }, 20000);
80
+ });
@@ -0,0 +1,306 @@
1
+ import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
2
+ import {
3
+ deriveIdentityId,
4
+ exportPublicKey,
5
+ generateIdentityKeyPair,
6
+ importPublicKey,
7
+ startDeviceReconnect,
8
+ type TReconnectFrame,
9
+ } from '@robota-sdk/agent-remote-pairing';
10
+ import { describe, expect, it, vi } from 'vitest';
11
+
12
+ import { nextAdmissionStep } from '../admission-steps.js';
13
+ import {
14
+ PairingGate,
15
+ type IConnectionApproval,
16
+ type IConnectionApprovalContext,
17
+ type IHostReconnectConfig,
18
+ type IPairingGateOptions,
19
+ } from '../pairing-gate.js';
20
+
21
+ import type { createSessionMessageHandler } from '@robota-sdk/agent-transport';
22
+
23
+ /**
24
+ * A connection that would drive the session is the receiving operator's to allow, every time: the
25
+ * handshake proves who is on the other end, and the operator decides whether that device drives now.
26
+ * Until they say yes nothing reaches the session, and a device they refuse is not remembered.
27
+ */
28
+
29
+ const FP = { localFingerprint: 'HOST:FP', remoteFingerprint: 'DEV:FP' };
30
+
31
+ function deferred(): { promise: Promise<boolean>; resolve: (value: boolean) => void } {
32
+ let resolve!: (value: boolean) => void;
33
+ const promise = new Promise<boolean>((res) => {
34
+ resolve = res;
35
+ });
36
+ return { promise, resolve };
37
+ }
38
+
39
+ function makeGate(over: Partial<IPairingGateOptions> = {}) {
40
+ const sent: unknown[] = [];
41
+ let closed = false;
42
+ const channel = {
43
+ send: (d: string) => {
44
+ sent.push(JSON.parse(d));
45
+ },
46
+ close: () => {
47
+ closed = true;
48
+ },
49
+ };
50
+ const sessionOnMessage = vi.fn();
51
+ const createHandler: typeof createSessionMessageHandler = () => ({
52
+ onMessage: sessionOnMessage,
53
+ cleanup: vi.fn(),
54
+ });
55
+ const onAccept = vi.fn();
56
+ const onReject = vi.fn();
57
+ const fakeHandshake = (() => ({
58
+ result: Promise.resolve({ sessionKey: 'k' }),
59
+ onFrame: () => {},
60
+ })) as never;
61
+ const gate = new PairingGate({
62
+ channel,
63
+ session: createTestInteractiveSession(),
64
+ secret: 's',
65
+ role: 'initiator',
66
+ ...FP,
67
+ startHandshake: fakeHandshake,
68
+ createHandler,
69
+ onAccept,
70
+ onReject,
71
+ ...over,
72
+ });
73
+ return { gate, sent, closed: () => closed, sessionOnMessage, onAccept, onReject };
74
+ }
75
+
76
+ async function flush(): Promise<void> {
77
+ for (let i = 0; i < 5; i += 1) await Promise.resolve();
78
+ }
79
+
80
+ describe('operator approval of a driving connection', () => {
81
+ it('is the last step owed, after every proof the channel owes', () => {
82
+ const configured = { localPeer: {}, handoffGrant: {}, connectionApproval: {} };
83
+ expect(nextAdmissionStep({ connectionApproval: {} }, 'pairing')).toBe('operator-approval');
84
+ expect(nextAdmissionStep(configured, 'pairing')).toBe('local-proof');
85
+ expect(nextAdmissionStep(configured, 'local-proof')).toBe('handoff-grant');
86
+ expect(nextAdmissionStep(configured, 'handoff-grant')).toBe('operator-approval');
87
+ expect(nextAdmissionStep(configured, 'operator-approval')).toBe(null);
88
+ });
89
+
90
+ it('exposes nothing while the operator has not answered', async () => {
91
+ const answer = deferred();
92
+ const approve = vi.fn(() => answer.promise);
93
+ const { gate, sessionOnMessage, onAccept } = makeGate({ connectionApproval: { approve } });
94
+ await flush();
95
+ expect(approve).toHaveBeenCalledTimes(1);
96
+ gate.onInbound(JSON.stringify({ type: 'submit', prompt: 'p' }));
97
+ expect(sessionOnMessage).not.toHaveBeenCalled();
98
+ expect(onAccept).not.toHaveBeenCalled();
99
+
100
+ answer.resolve(true);
101
+ await vi.waitFor(() => expect(onAccept).toHaveBeenCalledTimes(1));
102
+ // What the peer said while waiting reaches the session only now, in order, then live frames.
103
+ expect(sessionOnMessage).toHaveBeenCalledTimes(1);
104
+ gate.onInbound(JSON.stringify({ type: 'get-messages' }));
105
+ expect(sessionOnMessage.mock.calls.map(([frame]) => JSON.parse(frame as string).type)).toEqual([
106
+ 'submit',
107
+ 'get-messages',
108
+ ]);
109
+ });
110
+
111
+ it('drops what the peer said while waiting when the operator refuses', async () => {
112
+ const answer = deferred();
113
+ const { gate, sessionOnMessage, onReject } = makeGate({
114
+ connectionApproval: { approve: () => answer.promise },
115
+ });
116
+ await flush();
117
+ gate.onInbound(JSON.stringify({ type: 'submit', prompt: 'p' }));
118
+ answer.resolve(false);
119
+ await vi.waitFor(() => expect(onReject).toHaveBeenCalledTimes(1));
120
+ expect(sessionOnMessage).not.toHaveBeenCalled();
121
+ });
122
+
123
+ it('withdraws the question and admits nothing when the channel closes while the operator decides', async () => {
124
+ const answer = deferred();
125
+ let signal: AbortSignal | undefined;
126
+ const approve = vi.fn((context: IConnectionApprovalContext) => {
127
+ signal = context.signal;
128
+ return answer.promise;
129
+ });
130
+ const { gate, sessionOnMessage, onAccept, onReject } = makeGate({
131
+ connectionApproval: { approve },
132
+ });
133
+ await flush();
134
+ gate.onInbound(JSON.stringify({ type: 'get-messages' }));
135
+ expect(signal?.aborted).toBe(false);
136
+
137
+ gate.onChannelClosed();
138
+ expect(signal?.aborted).toBe(true);
139
+ expect(onReject).toHaveBeenCalledTimes(1);
140
+
141
+ answer.resolve(true);
142
+ await flush();
143
+ expect(onAccept).not.toHaveBeenCalled();
144
+ expect(sessionOnMessage).not.toHaveBeenCalled();
145
+ });
146
+
147
+ it('does not pin an enrolling device whose channel closed while the operator decided', async () => {
148
+ const cfg = await hostConfig();
149
+ const answer = deferred();
150
+ const approve = vi.fn(() => answer.promise);
151
+ const { gate, onAccept } = makeGate({ reconnect: cfg, connectionApproval: { approve } });
152
+ gate.onInbound(JSON.stringify({ t: 'pair-nonce', nonce: 'n' }));
153
+ await flush();
154
+ const deviceKey = await generateIdentityKeyPair(false);
155
+ gate.onInbound(
156
+ JSON.stringify({ t: 'enroll-key', spki: await exportPublicKey(deviceKey.publicKey) }),
157
+ );
158
+ await vi.waitFor(() => expect(approve).toHaveBeenCalledTimes(1));
159
+ gate.onChannelClosed();
160
+ answer.resolve(true);
161
+ await flush();
162
+ expect(cfg.onEnroll).not.toHaveBeenCalled();
163
+ expect(onAccept).not.toHaveBeenCalled();
164
+ });
165
+
166
+ it('an accepted channel closing is the transport’s drop, not a refusal', async () => {
167
+ const { gate, onAccept, onReject } = makeGate({
168
+ connectionApproval: { approve: async () => true },
169
+ });
170
+ await vi.waitFor(() => expect(onAccept).toHaveBeenCalledTimes(1));
171
+ gate.onChannelClosed();
172
+ expect(onReject).not.toHaveBeenCalled();
173
+ });
174
+
175
+ it('refuses a peer that floods the channel while the operator decides', async () => {
176
+ const answer = deferred();
177
+ const { gate, closed, onReject, sessionOnMessage } = makeGate({
178
+ connectionApproval: { approve: () => answer.promise },
179
+ });
180
+ await flush();
181
+ for (let i = 0; i < 100; i += 1) gate.onInbound(JSON.stringify({ type: 'get-messages' }));
182
+ expect(onReject).toHaveBeenCalledTimes(1);
183
+ expect(closed()).toBe(true);
184
+ answer.resolve(true);
185
+ await flush();
186
+ expect(sessionOnMessage).not.toHaveBeenCalled();
187
+ });
188
+
189
+ it('refuses the connection the operator declines: closed, and the session never reached', async () => {
190
+ const { gate, closed, sessionOnMessage, onAccept, onReject } = makeGate({
191
+ connectionApproval: { approve: async () => false },
192
+ });
193
+ await vi.waitFor(() => expect(onReject).toHaveBeenCalledTimes(1));
194
+ expect(closed()).toBe(true);
195
+ gate.onInbound(JSON.stringify({ type: 'submit', prompt: 'p' }));
196
+ expect(sessionOnMessage).not.toHaveBeenCalled();
197
+ expect(onAccept).not.toHaveBeenCalled();
198
+ });
199
+
200
+ it('refuses when asking the operator fails', async () => {
201
+ const { closed, onAccept, onReject } = makeGate({
202
+ connectionApproval: { approve: () => Promise.reject(new Error('no terminal')) },
203
+ });
204
+ await vi.waitFor(() => expect(onReject).toHaveBeenCalledTimes(1));
205
+ expect(closed()).toBe(true);
206
+ expect(onAccept).not.toHaveBeenCalled();
207
+ });
208
+
209
+ it('does not accept an answer that arrives after the channel was torn down', async () => {
210
+ const answer = deferred();
211
+ const { gate, sessionOnMessage, onAccept } = makeGate({
212
+ connectionApproval: { approve: () => answer.promise },
213
+ });
214
+ await flush();
215
+ gate.cleanup();
216
+ answer.resolve(true);
217
+ await flush();
218
+ expect(onAccept).not.toHaveBeenCalled();
219
+ gate.onInbound(JSON.stringify({ type: 'submit', prompt: 'p' }));
220
+ expect(sessionOnMessage).not.toHaveBeenCalled();
221
+ });
222
+ });
223
+
224
+ async function hostConfig(over: Partial<IHostReconnectConfig> = {}): Promise<IHostReconnectConfig> {
225
+ const hostKeyPair = await generateIdentityKeyPair(true);
226
+ const hostPublicSpki = await exportPublicKey(hostKeyPair.publicKey);
227
+ return {
228
+ hostIdentityId: await deriveIdentityId(hostPublicSpki),
229
+ hostPublicSpki,
230
+ hostPrivateKey: hostKeyPair.privateKey,
231
+ resolveDevicePublicKey: async () => undefined,
232
+ onEnroll: vi.fn(),
233
+ ...over,
234
+ };
235
+ }
236
+
237
+ describe('operator approval with trusted-device enrollment', () => {
238
+ async function enrollingGate(approval: IConnectionApproval) {
239
+ const cfg = await hostConfig();
240
+ const made = makeGate({ reconnect: cfg, connectionApproval: approval });
241
+ made.gate.onInbound(JSON.stringify({ t: 'pair-nonce', nonce: 'n' }));
242
+ await flush();
243
+ const deviceKey = await generateIdentityKeyPair(false);
244
+ const deviceSpki = await exportPublicKey(deviceKey.publicKey);
245
+ made.gate.onInbound(JSON.stringify({ t: 'enroll-key', spki: deviceSpki }));
246
+ return { ...made, cfg, deviceSpki, deviceId: await deriveIdentityId(deviceSpki) };
247
+ }
248
+
249
+ it('asks about the device that enrolled, and pins it only once the operator allows it', async () => {
250
+ const approve = vi.fn(async () => true);
251
+ const { cfg, onAccept, deviceId, deviceSpki } = await enrollingGate({ approve });
252
+ await vi.waitFor(() => expect(onAccept).toHaveBeenCalledTimes(1));
253
+ expect(approve).toHaveBeenCalledWith(
254
+ expect.objectContaining({ deviceId, viaReconnect: false }),
255
+ );
256
+ expect(cfg.onEnroll).toHaveBeenCalledWith(deviceId, deviceSpki);
257
+ });
258
+
259
+ it('does not pin a device the operator refuses', async () => {
260
+ const { cfg, onReject } = await enrollingGate({ approve: async () => false });
261
+ await vi.waitFor(() => expect(onReject).toHaveBeenCalledTimes(1));
262
+ expect(cfg.onEnroll).not.toHaveBeenCalled();
263
+ });
264
+
265
+ it('asks again when a trusted device reconnects, naming it', async () => {
266
+ const keyPair = await generateIdentityKeyPair(false);
267
+ const spki = await exportPublicKey(keyPair.publicKey);
268
+ const deviceId = await deriveIdentityId(spki);
269
+ const publicKey = await importPublicKey(spki);
270
+ const cfg = await hostConfig({
271
+ resolveDevicePublicKey: async (id) => (id === deviceId ? publicKey : undefined),
272
+ });
273
+ const approve = vi.fn(async () => false);
274
+ let deviceCtrl!: ReturnType<typeof startDeviceReconnect>;
275
+ const onAccept = vi.fn();
276
+ const onReject = vi.fn();
277
+ const gate = new PairingGate({
278
+ channel: {
279
+ send: (d: string) => deviceCtrl.onFrame(JSON.parse(d) as TReconnectFrame),
280
+ close: () => {},
281
+ },
282
+ session: createTestInteractiveSession(),
283
+ secret: 's',
284
+ role: 'initiator',
285
+ ...FP,
286
+ reconnect: cfg,
287
+ connectionApproval: { approve },
288
+ onAccept,
289
+ onReject,
290
+ });
291
+ deviceCtrl = startDeviceReconnect({
292
+ deviceId,
293
+ hostIdentityId: cfg.hostIdentityId,
294
+ localFingerprint: FP.remoteFingerprint,
295
+ remoteFingerprint: FP.localFingerprint,
296
+ devicePrivateKey: keyPair.privateKey,
297
+ pinnedHostPublicKey: await importPublicKey(cfg.hostPublicSpki),
298
+ send: (frame) => gate.onInbound(JSON.stringify(frame)),
299
+ timeoutMs: 1000,
300
+ });
301
+ await deviceCtrl.result;
302
+ await vi.waitFor(() => expect(onReject).toHaveBeenCalledTimes(1));
303
+ expect(approve).toHaveBeenCalledWith(expect.objectContaining({ deviceId, viaReconnect: true }));
304
+ expect(onAccept).not.toHaveBeenCalled();
305
+ });
306
+ });
@@ -0,0 +1,152 @@
1
+ /**
2
+ * ARCH-030 at the WebRTC carrier.
3
+ *
4
+ * The item named WebRTC as affected, and its stated reason was wrong in a way worth recording: the
5
+ * PAIRED-with-resume-bridge path was already guarded (the bridge routed replies through its own
6
+ * try/catch). The genuinely unguarded WebRTC exposure was the BARE `createSessionMessageHandler` — `PairingGate`'s
7
+ * no-`resumeBridge` branch, taken after the handshake accepts, and `WebRtcTransport`'s no-secret
8
+ * branch, which is the same construction. This suite drives the gate's branch with a stubbed
9
+ * handshake, the way `pairing-gate.test.ts` does, because that is the one that ships by default.
10
+ */
11
+
12
+ import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
13
+ import { describe, expect, it, vi } from 'vitest';
14
+
15
+ import { PairingGate } from '../pairing-gate.js';
16
+
17
+ import type { startPairingHandshake, TPairingFrame } from '@robota-sdk/agent-remote-pairing';
18
+ import type { IInteractiveSession } from '@robota-sdk/agent-interface-session';
19
+ import type { RTCDataChannel } from 'werift';
20
+
21
+ /** Collect unhandled rejections while `run` executes, then drain the queue Node reports them on. */
22
+ async function withUnhandledRejectionCapture(run: () => void | Promise<void>): Promise<unknown[]> {
23
+ const captured: unknown[] = [];
24
+ const existing = process.listeners('unhandledRejection');
25
+ for (const listener of existing) process.off('unhandledRejection', listener);
26
+ const collect = (reason: unknown): void => {
27
+ captured.push(reason);
28
+ };
29
+ process.on('unhandledRejection', collect);
30
+ try {
31
+ await run();
32
+ await new Promise((resolve) => setTimeout(resolve, 20));
33
+ } finally {
34
+ process.off('unhandledRejection', collect);
35
+ for (const listener of existing)
36
+ process.on('unhandledRejection', listener as NodeJS.UnhandledRejectionListener);
37
+ }
38
+ return captured;
39
+ }
40
+
41
+ /** A controllable handshake stub: the test decides when pairing accepts. */
42
+ function makeHandshakeStub(): { start: typeof startPairingHandshake; accept: () => void } {
43
+ let resolveResult!: (value: { sessionKey: string }) => void;
44
+ const start: typeof startPairingHandshake = (options) => {
45
+ const controller = {
46
+ result: new Promise<{ sessionKey: string }>((res) => {
47
+ resolveResult = res;
48
+ }),
49
+ onFrame: (_frame: TPairingFrame) => undefined,
50
+ };
51
+ options.send({ t: 'pair-nonce', nonce: 'stub' });
52
+ return controller;
53
+ };
54
+ return { start, accept: () => resolveResult({ sessionKey: 'k' }) };
55
+ }
56
+
57
+ /** A data channel whose `send` starts throwing once dropped, as werift's does on a closed channel. */
58
+ function createDroppableChannel(): {
59
+ channel: RTCDataChannel;
60
+ close: ReturnType<typeof vi.fn>;
61
+ drop: () => void;
62
+ } {
63
+ let open = true;
64
+ const close = vi.fn();
65
+ const channel = {
66
+ send: (data: string) => {
67
+ if (!open) throw new Error('RTCDataChannel is not open');
68
+ void data;
69
+ },
70
+ close,
71
+ } as unknown as RTCDataChannel;
72
+ return {
73
+ channel,
74
+ close,
75
+ drop: () => {
76
+ open = false;
77
+ },
78
+ };
79
+ }
80
+
81
+ /** Build an accepted gate on its bare-handler branch (no `resumeBridge`). */
82
+ async function acceptedGate(session: IInteractiveSession): Promise<{
83
+ gate: PairingGate;
84
+ channelClose: ReturnType<typeof vi.fn>;
85
+ drop: () => void;
86
+ deliveryErrors: Array<{ message: string; event: string }>;
87
+ }> {
88
+ const { channel, close, drop } = createDroppableChannel();
89
+ const deliveryErrors: Array<{ message: string; event: string }> = [];
90
+ const handshake = makeHandshakeStub();
91
+ const gate = new PairingGate({
92
+ channel,
93
+ session,
94
+ secret: 's',
95
+ role: 'initiator',
96
+ localFingerprint: 'AA',
97
+ remoteFingerprint: 'BB',
98
+ startHandshake: handshake.start,
99
+ onDeliveryError: (error, event) => deliveryErrors.push({ message: error.message, event }),
100
+ });
101
+ handshake.accept();
102
+ await new Promise((resolve) => setTimeout(resolve, 0)); // the gate accepts on a microtask
103
+ return { gate, channelClose: close, drop, deliveryErrors };
104
+ }
105
+
106
+ describe('PairingGate bare-handler branch + the outbound boundary (ARCH-030)', () => {
107
+ it('a command resolving after the channel dropped reports once and leaks no rejection', async () => {
108
+ let releaseCommand: (() => void) | undefined;
109
+ const commandGate = new Promise<void>((resolve) => {
110
+ releaseCommand = resolve;
111
+ });
112
+ const executed: string[] = [];
113
+ const session: IInteractiveSession = createTestInteractiveSession({
114
+ executeCommand: (name: string) =>
115
+ commandGate.then(() => {
116
+ executed.push(name);
117
+ return { success: true, message: 'done' };
118
+ }),
119
+ } as Partial<IInteractiveSession>);
120
+ const { gate, channelClose, drop, deliveryErrors } = await acceptedGate(session);
121
+
122
+ const rejections = await withUnhandledRejectionCapture(async () => {
123
+ gate.onInbound(JSON.stringify({ type: 'command', name: 'status' }));
124
+ drop();
125
+ releaseCommand?.();
126
+ });
127
+
128
+ expect(rejections).toEqual([]);
129
+ expect(executed).toEqual(['status']); // the command committed regardless of delivery
130
+ expect(deliveryErrors).toEqual([
131
+ { message: 'RTCDataChannel is not open', event: 'command_result' },
132
+ ]);
133
+ // Carrier cleanup ran: the gate closed its channel on the delivery failure. Before ARCH-030 the
134
+ // reply threw from a Promise continuation and none of this happened.
135
+ expect(channelClose).toHaveBeenCalledTimes(1);
136
+ });
137
+
138
+ // As on the WS side, this is not the boundary latch's own proof — `PairingGate.handleSessionDeliveryError`
139
+ // returns early once its state is `closed`, so the single report predates ARCH-030. It pins that the
140
+ // boundary above it did not change what the gate observes. The latch is red-proved in the protocol suite.
141
+ it('reports once for a burst of replies after the drop', async () => {
142
+ const session = createTestInteractiveSession();
143
+ const { gate, drop, deliveryErrors } = await acceptedGate(session);
144
+ drop();
145
+
146
+ gate.onInbound(JSON.stringify({ type: 'get-executing' }));
147
+ gate.onInbound(JSON.stringify({ type: 'get-messages' }));
148
+ gate.onInbound(JSON.stringify({ type: 'get-pending' }));
149
+
150
+ expect(deliveryErrors).toEqual([{ message: 'RTCDataChannel is not open', event: 'executing' }]);
151
+ });
152
+ });