@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.
- package/LICENSE +661 -0
- package/dist/node/index.cjs +1 -0
- package/dist/node/index.d.cts +361 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +361 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +2 -0
- package/dist/node/index.js.map +1 -0
- package/package.json +88 -0
- package/src/__tests__/admission-secure-by-default.test.ts +61 -0
- package/src/__tests__/admission-steps.test.ts +46 -0
- package/src/__tests__/cve-2024-29415-reachability.test.ts +80 -0
- package/src/__tests__/dtls-fingerprint-binding.test.ts +60 -0
- package/src/__tests__/handoff-grant-gate.test.ts +401 -0
- package/src/__tests__/local-peer-proof-gate.test.ts +213 -0
- package/src/__tests__/mesh-admission-contract.test.ts +38 -0
- package/src/__tests__/negotiated-certificate.test.ts +80 -0
- package/src/__tests__/operator-approval-gate.test.ts +306 -0
- package/src/__tests__/outbound-delivery-carrier.test.ts +152 -0
- package/src/__tests__/pairing-e2e.test.ts +238 -0
- package/src/__tests__/pairing-gate-e3.test.ts +192 -0
- package/src/__tests__/pairing-gate-e4.test.ts +108 -0
- package/src/__tests__/pairing-gate.test.ts +276 -0
- package/src/__tests__/pairing-relay-in-the-middle.test.ts +229 -0
- package/src/__tests__/session-attachment-budget.test.ts +136 -0
- package/src/__tests__/webrtc-transport.test.ts +349 -0
- package/src/__tests__/werift-loader.test.ts +18 -0
- package/src/__tests__/ws-signaling-client.test.ts +108 -0
- package/src/admission-steps.ts +55 -0
- package/src/channel-delivery.ts +44 -0
- package/src/handoff-grant-gate.ts +186 -0
- package/src/index.ts +21 -0
- package/src/local-peer-proof.ts +135 -0
- package/src/negotiated-certificate.ts +80 -0
- package/src/pairing-channel-lifecycle.ts +31 -0
- package/src/pairing-controllers.ts +79 -0
- package/src/pairing-frames.ts +34 -0
- package/src/pairing-gate-options.ts +129 -0
- package/src/pairing-gate.ts +376 -0
- package/src/session-attachment.ts +88 -0
- package/src/signaling.ts +62 -0
- package/src/transport-lifecycle-error.ts +11 -0
- package/src/webrtc-delivery-lifecycle.ts +51 -0
- package/src/webrtc-transport-options.ts +84 -0
- package/src/webrtc-transport.ts +328 -0
- package/src/werift-loader.ts +40 -0
- package/src/ws-signaling-client.ts +139 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { PairingGate, type IPairingGateOptions } from '../pairing-gate.js';
|
|
6
|
+
import { WebRtcDeliveryLifecycle } from '../webrtc-delivery-lifecycle.js';
|
|
7
|
+
|
|
8
|
+
import type { startPairingHandshake, TPairingFrame } from '@robota-sdk/agent-remote-pairing';
|
|
9
|
+
import type { createSessionMessageHandler } from '@robota-sdk/agent-transport';
|
|
10
|
+
import type { IInteractiveSession } from '@robota-sdk/agent-interface-session';
|
|
11
|
+
import type { RTCDataChannel } from 'werift';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* REMOTE-008 Step 1 (SECURITY milestone) — the pairing gate's fail-closed routing switch, driven with a stub
|
|
15
|
+
* channel + injected handshake + injected session-bridge (no real peer/crypto/session).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** A controllable stub of the pairing handshake: capture frames, resolve/reject on demand. */
|
|
19
|
+
function makeHandshakeStub() {
|
|
20
|
+
const sent: TPairingFrame[] = [];
|
|
21
|
+
const received: TPairingFrame[] = [];
|
|
22
|
+
let resolveResult!: (value: { sessionKey: string }) => void;
|
|
23
|
+
let rejectResult!: (error: Error) => void;
|
|
24
|
+
const start: typeof startPairingHandshake = (options) => {
|
|
25
|
+
// Record what the gate asked us to send (it wraps this over the channel).
|
|
26
|
+
const controller = {
|
|
27
|
+
result: new Promise<{ sessionKey: string }>((res, rej) => {
|
|
28
|
+
resolveResult = res;
|
|
29
|
+
rejectResult = rej;
|
|
30
|
+
}),
|
|
31
|
+
onFrame: (frame: TPairingFrame) => received.push(frame),
|
|
32
|
+
};
|
|
33
|
+
// Expose the send seam so a test can assert the gate serialized a frame.
|
|
34
|
+
options.send({ t: 'pair-nonce', nonce: 'stub' });
|
|
35
|
+
return controller;
|
|
36
|
+
};
|
|
37
|
+
return {
|
|
38
|
+
start,
|
|
39
|
+
received,
|
|
40
|
+
sentByGate: sent,
|
|
41
|
+
accept: () => resolveResult({ sessionKey: 'k' }),
|
|
42
|
+
reject: () => rejectResult(new Error('pairing rejected')),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function makeGate(over: Partial<IPairingGateOptions> = {}) {
|
|
47
|
+
const channelSends: string[] = [];
|
|
48
|
+
const channel = { send: (d: string) => channelSends.push(d), close: vi.fn() };
|
|
49
|
+
const session = Object.assign(createTestInteractiveSession(), {});
|
|
50
|
+
const sessionOnMessage = vi.fn();
|
|
51
|
+
const handlerCleanup = vi.fn();
|
|
52
|
+
const createHandler: typeof createSessionMessageHandler = () => ({
|
|
53
|
+
onMessage: sessionOnMessage,
|
|
54
|
+
cleanup: handlerCleanup,
|
|
55
|
+
});
|
|
56
|
+
const hs = makeHandshakeStub();
|
|
57
|
+
const gate = new PairingGate({
|
|
58
|
+
channel,
|
|
59
|
+
session,
|
|
60
|
+
secret: 's',
|
|
61
|
+
role: 'initiator',
|
|
62
|
+
localFingerprint: 'AA',
|
|
63
|
+
remoteFingerprint: 'BB',
|
|
64
|
+
startHandshake: hs.start,
|
|
65
|
+
createHandler,
|
|
66
|
+
...over,
|
|
67
|
+
});
|
|
68
|
+
return { gate, channel, channelSends, sessionOnMessage, handlerCleanup, hs };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe('PairingGate (REMOTE-008 Step 1 — fail-closed routing switch)', () => {
|
|
72
|
+
it('does not expose the session before the handshake accepts (no session bridge built)', () => {
|
|
73
|
+
const { sessionOnMessage } = makeGate();
|
|
74
|
+
// The handshake sent its nonce, but no session handler exists yet.
|
|
75
|
+
expect(sessionOnMessage).not.toHaveBeenCalled();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('serializes the handshake frames it sends over the channel', () => {
|
|
79
|
+
const { channelSends } = makeGate();
|
|
80
|
+
// The stub handshake emits a pair-nonce on start; the gate must have serialized it.
|
|
81
|
+
expect(channelSends).toContain(JSON.stringify({ t: 'pair-nonce', nonce: 'stub' }));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('pre-accept: routes a pairing frame to the handshake and DROPS a non-pairing frame', () => {
|
|
85
|
+
const { gate, hs, sessionOnMessage } = makeGate();
|
|
86
|
+
gate.onInbound(JSON.stringify({ t: 'pair-confirm', mac: 'm' }));
|
|
87
|
+
expect(hs.received).toEqual([{ t: 'pair-confirm', mac: 'm' }]);
|
|
88
|
+
// A well-formed but non-pairing frame pre-accept must never reach the (nonexistent) session.
|
|
89
|
+
gate.onInbound(JSON.stringify({ type: 'submit', prompt: 'p' }));
|
|
90
|
+
expect(sessionOnMessage).not.toHaveBeenCalled();
|
|
91
|
+
// Undecodable garbage pre-accept is dropped without throwing.
|
|
92
|
+
expect(() => gate.onInbound('not json')).not.toThrow();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it.each([
|
|
96
|
+
['an empty mac', { t: 'pair-confirm', mac: '' }],
|
|
97
|
+
['a numeric nonce', { t: 'pair-nonce', nonce: 7 }],
|
|
98
|
+
['a padded (non-base64url) mac', { t: 'pair-confirm', mac: 'bWFj==' }],
|
|
99
|
+
['a mac over the ceiling', { t: 'pair-confirm', mac: 'A'.repeat(129) }],
|
|
100
|
+
])(
|
|
101
|
+
'issue #2046: a pairing frame with the right discriminator but %s never reaches the handshake',
|
|
102
|
+
(_label, frame) => {
|
|
103
|
+
// Against the old discriminator-only predicate every one of these was handed to the controller.
|
|
104
|
+
const { gate, hs } = makeGate();
|
|
105
|
+
gate.onInbound(JSON.stringify(frame));
|
|
106
|
+
expect(hs.received).toEqual([]);
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
it('on ACCEPT: builds the session bridge and routes subsequent frames to it', async () => {
|
|
111
|
+
const { gate, hs, sessionOnMessage } = makeGate();
|
|
112
|
+
hs.accept();
|
|
113
|
+
await Promise.resolve(); // let the result.then microtask run
|
|
114
|
+
gate.onInbound(JSON.stringify({ type: 'submit', prompt: 'hi' }));
|
|
115
|
+
expect(sessionOnMessage).toHaveBeenCalledWith(JSON.stringify({ type: 'submit', prompt: 'hi' }));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('on REJECT: closes the channel and NEVER exposes the session', async () => {
|
|
119
|
+
const { gate, channel, hs, sessionOnMessage } = makeGate();
|
|
120
|
+
hs.reject();
|
|
121
|
+
await Promise.resolve();
|
|
122
|
+
expect(channel.close).toHaveBeenCalledTimes(1);
|
|
123
|
+
// Any frame after a failed pairing is ignored — the session is never reachable.
|
|
124
|
+
gate.onInbound(JSON.stringify({ type: 'submit', prompt: 'hi' }));
|
|
125
|
+
expect(sessionOnMessage).not.toHaveBeenCalled();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('post-close (cleanup): ignores further inbound frames', async () => {
|
|
129
|
+
const { gate, hs, sessionOnMessage } = makeGate();
|
|
130
|
+
hs.accept();
|
|
131
|
+
await Promise.resolve();
|
|
132
|
+
gate.cleanup();
|
|
133
|
+
gate.onInbound(JSON.stringify({ type: 'submit', prompt: 'hi' }));
|
|
134
|
+
expect(sessionOnMessage).not.toHaveBeenCalled();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('fires onAccept on accept (not onReject)', async () => {
|
|
138
|
+
const onAccept = vi.fn();
|
|
139
|
+
const onReject = vi.fn();
|
|
140
|
+
const { hs } = makeGate({ onAccept, onReject });
|
|
141
|
+
hs.accept();
|
|
142
|
+
await Promise.resolve();
|
|
143
|
+
expect(onAccept).toHaveBeenCalledTimes(1);
|
|
144
|
+
expect(onReject).not.toHaveBeenCalled();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('fires onReject on reject (not onAccept)', async () => {
|
|
148
|
+
const onAccept = vi.fn();
|
|
149
|
+
const onReject = vi.fn();
|
|
150
|
+
const { hs } = makeGate({ onAccept, onReject });
|
|
151
|
+
hs.reject();
|
|
152
|
+
await Promise.resolve();
|
|
153
|
+
expect(onReject).toHaveBeenCalledTimes(1);
|
|
154
|
+
expect(onAccept).not.toHaveBeenCalled();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('cleanup tears down the session bridge when one was built', async () => {
|
|
158
|
+
const { gate, hs, handlerCleanup } = makeGate();
|
|
159
|
+
hs.accept();
|
|
160
|
+
await Promise.resolve();
|
|
161
|
+
gate.cleanup();
|
|
162
|
+
expect(handlerCleanup).toHaveBeenCalledTimes(1);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('post-accept send failure closes the carrier and reports without escaping the session event', async () => {
|
|
166
|
+
const handlers = new Map<string, (value: unknown) => void>();
|
|
167
|
+
const session = createTestInteractiveSession({
|
|
168
|
+
on: ((event: string, handler: (value: unknown) => void) => {
|
|
169
|
+
handlers.set(event, handler);
|
|
170
|
+
}) as IInteractiveSession['on'],
|
|
171
|
+
off: ((event: string) => {
|
|
172
|
+
handlers.delete(event);
|
|
173
|
+
}) as IInteractiveSession['off'],
|
|
174
|
+
});
|
|
175
|
+
let failSend = false;
|
|
176
|
+
const channel = {
|
|
177
|
+
send: vi.fn(() => {
|
|
178
|
+
if (failSend) throw new Error('paired channel closed');
|
|
179
|
+
}),
|
|
180
|
+
close: vi.fn(),
|
|
181
|
+
};
|
|
182
|
+
const onDeliveryError = vi.fn();
|
|
183
|
+
const hs = makeHandshakeStub();
|
|
184
|
+
new PairingGate({
|
|
185
|
+
channel,
|
|
186
|
+
session,
|
|
187
|
+
secret: 's',
|
|
188
|
+
role: 'initiator',
|
|
189
|
+
localFingerprint: 'AA',
|
|
190
|
+
remoteFingerprint: 'BB',
|
|
191
|
+
startHandshake: hs.start,
|
|
192
|
+
onDeliveryError,
|
|
193
|
+
});
|
|
194
|
+
hs.accept();
|
|
195
|
+
await Promise.resolve();
|
|
196
|
+
failSend = true;
|
|
197
|
+
|
|
198
|
+
expect(() =>
|
|
199
|
+
handlers.get('branch_event')?.({
|
|
200
|
+
kind: 'checkpoint_created',
|
|
201
|
+
checkpointId: 'turn-0001',
|
|
202
|
+
branchId: 'main',
|
|
203
|
+
}),
|
|
204
|
+
).not.toThrow();
|
|
205
|
+
expect(channel.close).toHaveBeenCalledTimes(1);
|
|
206
|
+
expect(onDeliveryError).toHaveBeenCalledWith(
|
|
207
|
+
expect.objectContaining({ message: 'paired channel closed' }),
|
|
208
|
+
'branch_event',
|
|
209
|
+
);
|
|
210
|
+
expect(handlers.size).toBe(0);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('reports a paired delivery failure before a synchronous closing transition drops the carrier', async () => {
|
|
214
|
+
const handlers = new Map<string, (value: unknown) => void>();
|
|
215
|
+
const session = createTestInteractiveSession({
|
|
216
|
+
on: ((event: string, handler: (value: unknown) => void) => {
|
|
217
|
+
handlers.set(event, handler);
|
|
218
|
+
}) as IInteractiveSession['on'],
|
|
219
|
+
off: ((event: string) => {
|
|
220
|
+
handlers.delete(event);
|
|
221
|
+
}) as IInteractiveSession['off'],
|
|
222
|
+
});
|
|
223
|
+
const cleanup = vi.fn();
|
|
224
|
+
const onDropped = vi.fn();
|
|
225
|
+
const onDeliveryError = vi.fn();
|
|
226
|
+
let gate!: PairingGate;
|
|
227
|
+
let lifecycle!: WebRtcDeliveryLifecycle;
|
|
228
|
+
let failSend = false;
|
|
229
|
+
const channel = {
|
|
230
|
+
send: vi.fn(() => {
|
|
231
|
+
if (failSend) throw new Error('paired channel closed synchronously');
|
|
232
|
+
}),
|
|
233
|
+
close: vi.fn(() => lifecycle.handleDrop(1)),
|
|
234
|
+
};
|
|
235
|
+
lifecycle = new WebRtcDeliveryLifecycle({
|
|
236
|
+
cleanup: () => {
|
|
237
|
+
cleanup();
|
|
238
|
+
gate.cleanup();
|
|
239
|
+
},
|
|
240
|
+
onDropped,
|
|
241
|
+
onDeliveryError,
|
|
242
|
+
});
|
|
243
|
+
lifecycle.reset(1);
|
|
244
|
+
const hs = makeHandshakeStub();
|
|
245
|
+
gate = new PairingGate({
|
|
246
|
+
channel,
|
|
247
|
+
session,
|
|
248
|
+
secret: 's',
|
|
249
|
+
role: 'initiator',
|
|
250
|
+
localFingerprint: 'AA',
|
|
251
|
+
remoteFingerprint: 'BB',
|
|
252
|
+
startHandshake: hs.start,
|
|
253
|
+
onAccept: () => lifecycle.accept(1),
|
|
254
|
+
onDeliveryError: (error, event) =>
|
|
255
|
+
lifecycle.handleFailure(channel as unknown as RTCDataChannel, 1, error, event),
|
|
256
|
+
});
|
|
257
|
+
hs.accept();
|
|
258
|
+
await Promise.resolve();
|
|
259
|
+
failSend = true;
|
|
260
|
+
|
|
261
|
+
expect(() =>
|
|
262
|
+
handlers.get('branch_event')?.({
|
|
263
|
+
kind: 'checkpoint_created',
|
|
264
|
+
checkpointId: 'turn-0001',
|
|
265
|
+
branchId: 'main',
|
|
266
|
+
}),
|
|
267
|
+
).not.toThrow();
|
|
268
|
+
expect(onDeliveryError).toHaveBeenCalledWith(
|
|
269
|
+
expect.objectContaining({ message: 'paired channel closed synchronously' }),
|
|
270
|
+
'branch_event',
|
|
271
|
+
);
|
|
272
|
+
expect(cleanup).toHaveBeenCalledTimes(1);
|
|
273
|
+
expect(onDropped).toHaveBeenCalledTimes(1);
|
|
274
|
+
expect(handlers.size).toBe(0);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
|
|
2
|
+
|
|
3
|
+
import { extractDtlsFingerprint, startPairingHandshake } from '@robota-sdk/agent-remote-pairing';
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { RTCPeerConnection } from 'werift';
|
|
6
|
+
|
|
7
|
+
import { WebRtcTransport } from '../webrtc-transport.js';
|
|
8
|
+
import { createInMemorySignalingPair, type ISignalingClient } from '../signaling.js';
|
|
9
|
+
|
|
10
|
+
import type { IInteractiveSession } from '@robota-sdk/agent-interface-session';
|
|
11
|
+
import type { TPairingFrame } from '@robota-sdk/agent-remote-pairing';
|
|
12
|
+
import type { RTCDataChannel } from 'werift';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The pairing confirmation must bind to the certificate the DTLS layer actually verified. A relay that
|
|
16
|
+
* terminates DTLS on both sides and forwards the data channel must not be able to make the two honest
|
|
17
|
+
* peers compute the same binding.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
function createStubSession(): IInteractiveSession {
|
|
21
|
+
return Object.assign(createTestInteractiveSession(), {
|
|
22
|
+
getMessages: vi.fn().mockReturnValue([{ role: 'user', content: 'hi' }]),
|
|
23
|
+
getActiveDriverId: () => null,
|
|
24
|
+
on: vi.fn(),
|
|
25
|
+
off: vi.fn(),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Insert `a=fingerprint:sha-256 <value>` before the first existing fingerprint line. */
|
|
30
|
+
function prependFingerprint(sdp: string, value: string): string {
|
|
31
|
+
return sdp.replace(/^a=fingerprint:/m, `a=fingerprint:sha-256 ${value}\r\na=fingerprint:`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type TDescription = { type: 'offer' | 'answer'; sdp: string };
|
|
35
|
+
|
|
36
|
+
/** Pairing responder on the far side. Resolves true only if its own handshake accepts. */
|
|
37
|
+
function connectResponder(signaling: ISignalingClient, secret: string): Promise<boolean> {
|
|
38
|
+
return new Promise<boolean>((resolvePaired) => {
|
|
39
|
+
const peer = new RTCPeerConnection();
|
|
40
|
+
peer.onIceCandidate.subscribe((c) => {
|
|
41
|
+
if (c) signaling.send({ kind: 'ice', data: c.toJSON() });
|
|
42
|
+
});
|
|
43
|
+
let hostFingerprint: string | undefined;
|
|
44
|
+
let chain: Promise<void> = Promise.resolve();
|
|
45
|
+
signaling.onSignal((message) => {
|
|
46
|
+
chain = chain
|
|
47
|
+
.then(async () => {
|
|
48
|
+
if (message.kind === 'offer') {
|
|
49
|
+
const offer = message.data as TDescription;
|
|
50
|
+
hostFingerprint = extractDtlsFingerprint(offer.sdp);
|
|
51
|
+
await peer.setRemoteDescription(offer);
|
|
52
|
+
await peer.setLocalDescription(await peer.createAnswer());
|
|
53
|
+
signaling.send({ kind: 'answer', data: peer.localDescription });
|
|
54
|
+
} else if (message.kind === 'ice') {
|
|
55
|
+
await peer.addIceCandidate(message.data as Parameters<typeof peer.addIceCandidate>[0]);
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
.catch(() => resolvePaired(false));
|
|
59
|
+
});
|
|
60
|
+
peer.onDataChannel.subscribe((channel) => {
|
|
61
|
+
const controller = startPairingHandshake({
|
|
62
|
+
secret,
|
|
63
|
+
role: 'responder',
|
|
64
|
+
localFingerprint: extractDtlsFingerprint(peer.localDescription!.sdp),
|
|
65
|
+
remoteFingerprint: hostFingerprint!,
|
|
66
|
+
send: (frame: TPairingFrame) => {
|
|
67
|
+
try {
|
|
68
|
+
channel.send(JSON.stringify(frame));
|
|
69
|
+
} catch {
|
|
70
|
+
/* closing */
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
timeoutMs: 6000,
|
|
74
|
+
});
|
|
75
|
+
controller.result.then(
|
|
76
|
+
() => resolvePaired(true),
|
|
77
|
+
() => resolvePaired(false),
|
|
78
|
+
);
|
|
79
|
+
channel.onMessage.subscribe((data) => {
|
|
80
|
+
try {
|
|
81
|
+
controller.onFrame(JSON.parse(data.toString()) as TPairingFrame);
|
|
82
|
+
} catch {
|
|
83
|
+
/* ignore */
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A signaling relay that terminates DTLS with each side using its own certificates, forwards the data
|
|
92
|
+
* channel between them, and advertises the honest peer's fingerprint FIRST next to its own.
|
|
93
|
+
*/
|
|
94
|
+
function startRelay(toHost: ISignalingClient, toRemote: ISignalingClient): void {
|
|
95
|
+
const facingHost = new RTCPeerConnection();
|
|
96
|
+
const facingRemote = new RTCPeerConnection();
|
|
97
|
+
let hostChannel: RTCDataChannel | undefined;
|
|
98
|
+
const remoteChannel = facingRemote.createDataChannel('robota-session');
|
|
99
|
+
const toRemoteQueue: string[] = [];
|
|
100
|
+
const toHostQueue: string[] = [];
|
|
101
|
+
let hostFingerprint = '';
|
|
102
|
+
let pendingHostOffer: TDescription | undefined;
|
|
103
|
+
|
|
104
|
+
facingHost.onIceCandidate.subscribe((c) => {
|
|
105
|
+
if (c) toHost.send({ kind: 'ice', data: c.toJSON() });
|
|
106
|
+
});
|
|
107
|
+
facingRemote.onIceCandidate.subscribe((c) => {
|
|
108
|
+
if (c) toRemote.send({ kind: 'ice', data: c.toJSON() });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
facingHost.onDataChannel.subscribe((channel) => {
|
|
112
|
+
hostChannel = channel;
|
|
113
|
+
for (const m of toHostQueue.splice(0)) channel.send(m);
|
|
114
|
+
channel.onMessage.subscribe((data) => {
|
|
115
|
+
const text = data.toString();
|
|
116
|
+
if (remoteChannel.readyState === 'open') remoteChannel.send(text);
|
|
117
|
+
else toRemoteQueue.push(text);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
remoteChannel.stateChanged.subscribe((state) => {
|
|
121
|
+
if (state === 'open') for (const m of toRemoteQueue.splice(0)) remoteChannel.send(m);
|
|
122
|
+
});
|
|
123
|
+
remoteChannel.onMessage.subscribe((data) => {
|
|
124
|
+
const text = data.toString();
|
|
125
|
+
if (hostChannel) hostChannel.send(text);
|
|
126
|
+
else toHostQueue.push(text);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
let hostChain: Promise<void> = Promise.resolve();
|
|
130
|
+
toHost.onSignal((message) => {
|
|
131
|
+
hostChain = hostChain.then(async () => {
|
|
132
|
+
if (message.kind === 'offer') {
|
|
133
|
+
pendingHostOffer = message.data as TDescription;
|
|
134
|
+
hostFingerprint = extractDtlsFingerprint(pendingHostOffer.sdp);
|
|
135
|
+
await facingRemote.setLocalDescription(await facingRemote.createOffer());
|
|
136
|
+
const own = facingRemote.localDescription!;
|
|
137
|
+
toRemote.send({
|
|
138
|
+
kind: 'offer',
|
|
139
|
+
data: { type: 'offer', sdp: prependFingerprint(own.sdp, hostFingerprint) },
|
|
140
|
+
});
|
|
141
|
+
} else if (message.kind === 'ice') {
|
|
142
|
+
await facingHost.addIceCandidate(message.data as Parameters<typeof facingHost.addIceCandidate>[0]);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
let remoteChain: Promise<void> = Promise.resolve();
|
|
148
|
+
toRemote.onSignal((message) => {
|
|
149
|
+
remoteChain = remoteChain.then(async () => {
|
|
150
|
+
if (message.kind === 'answer') {
|
|
151
|
+
const answer = message.data as TDescription;
|
|
152
|
+
const remoteFingerprint = extractDtlsFingerprint(answer.sdp);
|
|
153
|
+
await facingRemote.setRemoteDescription(answer);
|
|
154
|
+
await facingHost.setRemoteDescription(pendingHostOffer!);
|
|
155
|
+
await facingHost.setLocalDescription(await facingHost.createAnswer());
|
|
156
|
+
const own = facingHost.localDescription!;
|
|
157
|
+
toHost.send({
|
|
158
|
+
kind: 'answer',
|
|
159
|
+
data: { type: 'answer', sdp: prependFingerprint(own.sdp, remoteFingerprint) },
|
|
160
|
+
});
|
|
161
|
+
} else if (message.kind === 'ice') {
|
|
162
|
+
await facingRemote.addIceCandidate(
|
|
163
|
+
message.data as Parameters<typeof facingRemote.addIceCandidate>[0],
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
describe('WebRTC pairing with a relay in the middle', () => {
|
|
171
|
+
it('refuses to pair when the relay advertises the honest fingerprint next to its own', async () => {
|
|
172
|
+
const secret = 'shared-secret-256bit-base64url-relay';
|
|
173
|
+
const [hostSig, relayHostSide] = createInMemorySignalingPair();
|
|
174
|
+
const [relayRemoteSide, remoteSig] = createInMemorySignalingPair();
|
|
175
|
+
const session = createStubSession();
|
|
176
|
+
const onPaired = vi.fn();
|
|
177
|
+
const transport = new WebRtcTransport({ signaling: hostSig, secret, onPaired });
|
|
178
|
+
transport.attach(session);
|
|
179
|
+
|
|
180
|
+
startRelay(relayHostSide, relayRemoteSide);
|
|
181
|
+
const remotePaired = connectResponder(remoteSig, secret);
|
|
182
|
+
await transport.start();
|
|
183
|
+
|
|
184
|
+
await expect(remotePaired).resolves.toBe(false);
|
|
185
|
+
expect(onPaired).not.toHaveBeenCalled();
|
|
186
|
+
await transport.stop();
|
|
187
|
+
}, 20000);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe('WebRTC transport answer handling', () => {
|
|
191
|
+
it('takes one answer per start and ignores any later one', async () => {
|
|
192
|
+
let remoteDescriptions = 0;
|
|
193
|
+
class CountingPeer extends RTCPeerConnection {
|
|
194
|
+
override async setRemoteDescription(
|
|
195
|
+
...args: Parameters<RTCPeerConnection['setRemoteDescription']>
|
|
196
|
+
): ReturnType<RTCPeerConnection['setRemoteDescription']> {
|
|
197
|
+
remoteDescriptions += 1;
|
|
198
|
+
return super.setRemoteDescription(...args);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const [hostSig, remoteSig] = createInMemorySignalingPair();
|
|
202
|
+
const transport = new WebRtcTransport({
|
|
203
|
+
signaling: hostSig,
|
|
204
|
+
secret: 'shared-secret-256bit-base64url-answers',
|
|
205
|
+
loadWerift: () => ({ RTCPeerConnection: CountingPeer }) as never,
|
|
206
|
+
});
|
|
207
|
+
transport.attach(createStubSession());
|
|
208
|
+
|
|
209
|
+
const remote = new RTCPeerConnection();
|
|
210
|
+
const answered = new Promise<void>((resolve) => {
|
|
211
|
+
remoteSig.onSignal((message) => {
|
|
212
|
+
if (message.kind !== 'offer') return;
|
|
213
|
+
void (async () => {
|
|
214
|
+
await remote.setRemoteDescription(message.data as TDescription);
|
|
215
|
+
await remote.setLocalDescription(await remote.createAnswer());
|
|
216
|
+
remoteSig.send({ kind: 'answer', data: remote.localDescription });
|
|
217
|
+
remoteSig.send({ kind: 'answer', data: remote.localDescription });
|
|
218
|
+
setTimeout(resolve, 200);
|
|
219
|
+
})();
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
await transport.start();
|
|
223
|
+
await answered;
|
|
224
|
+
|
|
225
|
+
expect(remoteDescriptions).toBe(1);
|
|
226
|
+
await transport.stop();
|
|
227
|
+
await remote.close();
|
|
228
|
+
}, 15000);
|
|
229
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ARCH-030 / issue #1734 — the replay path's backpressure budget REACHES production.
|
|
3
|
+
*
|
|
4
|
+
* `IAttachOptions.pendingBytes` existed with zero production callers: the bridge could take a
|
|
5
|
+
* carrier's backpressure reading and nothing gave it one, so the replay burst — the case the
|
|
6
|
+
* reopened scope names — ran unbudgeted while every other outbound path on the same connection was
|
|
7
|
+
* guarded.
|
|
8
|
+
*
|
|
9
|
+
* This case asserts the BEHAVIOUR, not the wiring. A test that checked the option reaches
|
|
10
|
+
* `bridge.attach` would have passed on the day the defect existed, because the option was reaching
|
|
11
|
+
* nothing on the way in. What can fail is a peer over budget going unclosed.
|
|
12
|
+
*/
|
|
13
|
+
import { DEFAULT_MAX_PENDING_BYTES, SessionResumeBridge } from '@robota-sdk/agent-transport';
|
|
14
|
+
import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
|
|
15
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
16
|
+
|
|
17
|
+
import { attachSession } from '../session-attachment.js';
|
|
18
|
+
|
|
19
|
+
import type { IPairingChannel } from '../pairing-gate-options.js';
|
|
20
|
+
import type { IInteractiveSession } from '@robota-sdk/agent-interface-session';
|
|
21
|
+
|
|
22
|
+
function fakeSession(): {
|
|
23
|
+
session: IInteractiveSession;
|
|
24
|
+
fire: (event: string, arg: unknown) => void;
|
|
25
|
+
} {
|
|
26
|
+
const handlers = new Map<string, (arg: unknown) => void>();
|
|
27
|
+
const session = createTestInteractiveSession({
|
|
28
|
+
on: ((event: string, handler: (arg: unknown) => void) =>
|
|
29
|
+
handlers.set(event, handler)) as IInteractiveSession['on'],
|
|
30
|
+
off: ((event: string) => {
|
|
31
|
+
handlers.delete(event);
|
|
32
|
+
}) as IInteractiveSession['off'],
|
|
33
|
+
});
|
|
34
|
+
return { session, fire: (event, arg) => handlers.get(event)?.(arg) };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A channel whose backpressure the case controls, matching the optional structural slice. */
|
|
38
|
+
function channelHolding(bytes: () => number): IPairingChannel & { sent: string[] } {
|
|
39
|
+
const sent: string[] = [];
|
|
40
|
+
return {
|
|
41
|
+
sent,
|
|
42
|
+
send: (data: string) => void sent.push(data),
|
|
43
|
+
close: () => undefined,
|
|
44
|
+
get bufferedAmount(): number {
|
|
45
|
+
return bytes();
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('attachSession + the replay budget (ARCH-030)', () => {
|
|
51
|
+
it('attributes direct admitted WebRTC submissions to the remote surface', async () => {
|
|
52
|
+
const base = fakeSession().session;
|
|
53
|
+
const submit = vi.fn().mockResolvedValue(undefined);
|
|
54
|
+
const session = Object.assign(base, { submit });
|
|
55
|
+
const channel = channelHolding(() => 0);
|
|
56
|
+
const attached = attachSession({ channel, session, surface: 'remote' }, false, () => undefined);
|
|
57
|
+
|
|
58
|
+
attached.onSessionMessage(JSON.stringify({ type: 'submit', prompt: 'remote turn' }));
|
|
59
|
+
await vi.waitFor(() => expect(submit).toHaveBeenCalled());
|
|
60
|
+
|
|
61
|
+
expect(submit).toHaveBeenCalledWith('remote turn', undefined, undefined, {
|
|
62
|
+
surface: 'remote',
|
|
63
|
+
});
|
|
64
|
+
attached.cleanup();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('exposes host usage reporting on the admitted direct handler path', async () => {
|
|
68
|
+
const { session } = fakeSession();
|
|
69
|
+
const channel = channelHolding(() => 0);
|
|
70
|
+
const report = { schemaVersion: 1 } as never;
|
|
71
|
+
const attached = attachSession(
|
|
72
|
+
{ channel, session, personalUsageReporter: async () => report },
|
|
73
|
+
false,
|
|
74
|
+
() => undefined,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
attached.onSessionMessage(
|
|
78
|
+
JSON.stringify({
|
|
79
|
+
type: 'get-personal-usage-report',
|
|
80
|
+
requestId: 'direct-usage',
|
|
81
|
+
period: '30d',
|
|
82
|
+
timezone: 'UTC',
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
await expect.poll(() => channel.sent.length).toBeGreaterThan(0);
|
|
86
|
+
|
|
87
|
+
expect(channel.sent.map((frame) => JSON.parse(frame))).toContainEqual({
|
|
88
|
+
type: 'personal_usage_report',
|
|
89
|
+
requestId: 'direct-usage',
|
|
90
|
+
report,
|
|
91
|
+
});
|
|
92
|
+
attached.cleanup();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('closes a peer that is over budget when the replay burst is attempted', () => {
|
|
96
|
+
const { session, fire } = fakeSession();
|
|
97
|
+
const bridge = new SessionResumeBridge({ session });
|
|
98
|
+
for (const delta of ['a', 'b', 'c']) fire('text_delta', delta);
|
|
99
|
+
|
|
100
|
+
let pending = 0;
|
|
101
|
+
const channel = channelHolding(() => pending);
|
|
102
|
+
const failures: string[] = [];
|
|
103
|
+
const attached = attachSession({ channel, session, resumeBridge: bridge }, true, (error) =>
|
|
104
|
+
failures.push(error.message),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
// The peer has accepted a backlog it is not reading.
|
|
108
|
+
pending = DEFAULT_MAX_PENDING_BYTES + 1;
|
|
109
|
+
attached.onSessionMessage(JSON.stringify({ type: 'resume', lastSeq: 0 }));
|
|
110
|
+
|
|
111
|
+
expect(channel.sent).toHaveLength(0);
|
|
112
|
+
expect(failures).toHaveLength(1);
|
|
113
|
+
expect(failures[0]).toContain('pending');
|
|
114
|
+
attached.cleanup();
|
|
115
|
+
bridge.dispose();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('replays normally while the peer is reading', () => {
|
|
119
|
+
// The companion the case above needs: without it, a budget that refused everything would pass.
|
|
120
|
+
const { session, fire } = fakeSession();
|
|
121
|
+
const bridge = new SessionResumeBridge({ session });
|
|
122
|
+
for (const delta of ['a', 'b', 'c']) fire('text_delta', delta);
|
|
123
|
+
|
|
124
|
+
const channel = channelHolding(() => 0);
|
|
125
|
+
const failures: string[] = [];
|
|
126
|
+
const attached = attachSession({ channel, session, resumeBridge: bridge }, true, (error) =>
|
|
127
|
+
failures.push(error.message),
|
|
128
|
+
);
|
|
129
|
+
attached.onSessionMessage(JSON.stringify({ type: 'resume', lastSeq: 0 }));
|
|
130
|
+
|
|
131
|
+
expect(channel.sent).toHaveLength(3);
|
|
132
|
+
expect(failures).toHaveLength(0);
|
|
133
|
+
attached.cleanup();
|
|
134
|
+
bridge.dispose();
|
|
135
|
+
});
|
|
136
|
+
});
|