@robota-sdk/agent-transport-webrtc 3.0.0-beta.100
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/CHANGELOG.md +694 -0
- package/LICENSE +661 -0
- package/README.md +111 -0
- package/dist/node/index.cjs +1 -0
- package/dist/node/index.d.cts +1650 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +1650 -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 +93 -0
- package/src/__tests__/admission-secure-by-default.test.ts +63 -0
- package/src/__tests__/admission-steps.test.ts +46 -0
- package/src/__tests__/device-mesh-relay.test.ts +441 -0
- package/src/__tests__/device-mesh.test.ts +682 -0
- package/src/__tests__/discovering-mesh-relay.test.ts +234 -0
- package/src/__tests__/enrollment-link.test.ts +103 -0
- package/src/__tests__/fake-datachannel.ts +83 -0
- package/src/__tests__/handoff-grant-gate.test.ts +403 -0
- package/src/__tests__/local-peer-proof-gate.test.ts +215 -0
- package/src/__tests__/mesh-admission-contract.test.ts +38 -0
- package/src/__tests__/mesh-discovery.test.ts +820 -0
- package/src/__tests__/mesh-fixtures.ts +148 -0
- package/src/__tests__/mesh-internet.test.ts +942 -0
- package/src/__tests__/mesh-turn-relay.test.ts +161 -0
- package/src/__tests__/negotiated-certificate.test.ts +76 -0
- package/src/__tests__/nostr-source-condition.test.ts +27 -0
- package/src/__tests__/operator-approval-gate.test.ts +308 -0
- package/src/__tests__/outbound-delivery-carrier.test.ts +155 -0
- package/src/__tests__/pairing-e2e.test.ts +237 -0
- package/src/__tests__/pairing-gate-e3.test.ts +194 -0
- package/src/__tests__/pairing-gate-e4.test.ts +110 -0
- package/src/__tests__/pairing-gate.test.ts +296 -0
- package/src/__tests__/pairing-relay-in-the-middle.test.ts +225 -0
- package/src/__tests__/rtc-channel-close.test.ts +52 -0
- package/src/__tests__/rtc-peer-candidates.test.ts +61 -0
- package/src/__tests__/session-attachment-app-view.test.ts +66 -0
- package/src/__tests__/session-attachment-budget.test.ts +136 -0
- package/src/__tests__/turn-server-bind.test.ts +59 -0
- package/src/__tests__/turn-server.test.ts +335 -0
- package/src/__tests__/turn-test-client.ts +256 -0
- package/src/__tests__/webrtc-implementation.test.ts +83 -0
- package/src/__tests__/webrtc-transport.test.ts +349 -0
- package/src/__tests__/ws-mesh-relay-client.test.ts +95 -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/datachannel-loader.ts +86 -0
- package/src/device-mesh-node.ts +1019 -0
- package/src/discovering-mesh-relay.ts +675 -0
- package/src/enrollment-link.ts +465 -0
- package/src/handoff-grant-gate.ts +186 -0
- package/src/index.ts +139 -0
- package/src/local-peer-proof.ts +135 -0
- package/src/mainline-dht-store.ts +174 -0
- package/src/mesh-dht.ts +446 -0
- package/src/mesh-discovery.ts +87 -0
- package/src/mesh-item-store.ts +287 -0
- package/src/mesh-lan-listener.ts +193 -0
- package/src/mesh-mdns.ts +406 -0
- package/src/mesh-peer-link.ts +600 -0
- package/src/mesh-public-relays.ts +25 -0
- package/src/mesh-records.ts +357 -0
- package/src/mesh-relay.ts +98 -0
- package/src/mesh-signal.ts +116 -0
- package/src/mesh-turn-relay.ts +238 -0
- package/src/negotiated-certificate.ts +72 -0
- package/src/nostr-mesh-relay.ts +391 -0
- package/src/pairing-channel-lifecycle.ts +31 -0
- package/src/pairing-controllers.ts +81 -0
- package/src/pairing-frames.ts +34 -0
- package/src/pairing-gate-options.ts +133 -0
- package/src/pairing-gate.ts +397 -0
- package/src/rtc-peer.ts +429 -0
- package/src/session-attachment.ts +96 -0
- package/src/signaling.ts +62 -0
- package/src/stun-message.ts +341 -0
- package/src/transport-lifecycle-error.ts +11 -0
- package/src/turn-server.ts +1028 -0
- package/src/webrtc-delivery-lifecycle.ts +54 -0
- package/src/webrtc-transport-options.ts +87 -0
- package/src/webrtc-transport.ts +368 -0
- package/src/ws-mesh-relay-client.ts +117 -0
- package/src/ws-signaling-client.ts +139 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { createIdentityContext } from '@robota-sdk/agent-remote-pairing';
|
|
2
|
+
const testIdentity = createIdentityContext('test-product');
|
|
3
|
+
import { SessionResumeBridge } from '@robota-sdk/agent-transport';
|
|
4
|
+
import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
|
|
5
|
+
import type { IInteractiveSession } from '@robota-sdk/agent-interface-session';
|
|
6
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
7
|
+
|
|
8
|
+
import { PairingGate } from '../pairing-gate.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* REMOTE-013 E4 — when a `resumeBridge` is supplied, the gate routes the paired session through the bridge
|
|
12
|
+
* (seq-stamped) instead of a fresh handler, and `cleanup()` DETACHES the bridge (session survives the drop).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
function fakeSession(): {
|
|
16
|
+
session: IInteractiveSession;
|
|
17
|
+
fire: (event: string, arg: unknown) => void;
|
|
18
|
+
} {
|
|
19
|
+
const handlers = new Map<string, (arg: unknown) => void>();
|
|
20
|
+
// ARCH-012: the conformant double. The three-member partial this replaces was accepted only by a
|
|
21
|
+
// cast, so the suite ran against a session shape no implementation could have.
|
|
22
|
+
const session = createTestInteractiveSession({
|
|
23
|
+
on: ((e: string, h: (arg: unknown) => void) => {
|
|
24
|
+
handlers.set(e, h);
|
|
25
|
+
}) as IInteractiveSession['on'],
|
|
26
|
+
off: ((e: string) => {
|
|
27
|
+
handlers.delete(e);
|
|
28
|
+
}) as IInteractiveSession['off'],
|
|
29
|
+
});
|
|
30
|
+
return { session, fire: (e, a) => handlers.get(e)?.(a) };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('PairingGate E4 resume bridge (REMOTE-013)', () => {
|
|
34
|
+
it('routes the paired session through the bridge (seq-stamped) and detaches on cleanup', async () => {
|
|
35
|
+
const { session, fire } = fakeSession();
|
|
36
|
+
const bridge = new SessionResumeBridge({ session });
|
|
37
|
+
const sent: string[] = [];
|
|
38
|
+
const channel = { send: (d: string) => sent.push(d), close: () => {} };
|
|
39
|
+
// Fake handshake that accepts immediately (isolates the bridge wiring from real B3 crypto).
|
|
40
|
+
const fakeHandshake = (() => ({
|
|
41
|
+
result: Promise.resolve({ sessionKey: 'k' }),
|
|
42
|
+
onFrame: () => {},
|
|
43
|
+
})) as never;
|
|
44
|
+
|
|
45
|
+
const gate = new PairingGate({cryptoContext: testIdentity,
|
|
46
|
+
channel,
|
|
47
|
+
session,
|
|
48
|
+
secret: 's',
|
|
49
|
+
role: 'initiator',
|
|
50
|
+
localFingerprint: 'A',
|
|
51
|
+
remoteFingerprint: 'B',
|
|
52
|
+
resumeBridge: bridge,
|
|
53
|
+
startHandshake: fakeHandshake,
|
|
54
|
+
});
|
|
55
|
+
// Drive to accept (the fake handshake resolves on a microtask).
|
|
56
|
+
gate.onInbound(JSON.stringify({ t: 'pair-nonce', nonce: 'n' }));
|
|
57
|
+
await Promise.resolve();
|
|
58
|
+
await Promise.resolve();
|
|
59
|
+
|
|
60
|
+
// A session event now flows through the bridge → the channel, seq-stamped.
|
|
61
|
+
fire('text_delta', 'hi');
|
|
62
|
+
expect(sent).toHaveLength(1);
|
|
63
|
+
const frame = JSON.parse(sent[0]);
|
|
64
|
+
expect(frame).toMatchObject({ type: 'text_delta', delta: 'hi', seq: 1 });
|
|
65
|
+
|
|
66
|
+
// Cleanup DETACHES (does not dispose) — a subsequent event is buffered, not sent, and the session survives.
|
|
67
|
+
gate.cleanup();
|
|
68
|
+
fire('text_delta', 'gap'); // buffered at seq 2, not sent (detached)
|
|
69
|
+
expect(sent).toHaveLength(1);
|
|
70
|
+
|
|
71
|
+
// Re-attach a new channel + resume → the gap frame replays with its ORIGINAL seq 2 (continuity).
|
|
72
|
+
const sent2: string[] = [];
|
|
73
|
+
bridge.attach((d) => sent2.push(d), { onDeliveryError: vi.fn() });
|
|
74
|
+
bridge.onClientMessage(JSON.stringify({ type: 'resume', lastSeq: 1 }));
|
|
75
|
+
expect(sent2.map((s) => JSON.parse(s).seq)).toEqual([2]);
|
|
76
|
+
bridge.dispose();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('a resume/ack inbound frame post-accept is routed to the bridge (not the session)', async () => {
|
|
80
|
+
const { session, fire } = fakeSession();
|
|
81
|
+
const bridge = new SessionResumeBridge({ session });
|
|
82
|
+
const sent: string[] = [];
|
|
83
|
+
const channel = { send: (d: string) => sent.push(d), close: () => {} };
|
|
84
|
+
const fakeHandshake = (() => ({
|
|
85
|
+
result: Promise.resolve({ sessionKey: 'k' }),
|
|
86
|
+
onFrame: () => {},
|
|
87
|
+
})) as never;
|
|
88
|
+
const gate = new PairingGate({cryptoContext: testIdentity,
|
|
89
|
+
channel,
|
|
90
|
+
session,
|
|
91
|
+
secret: 's',
|
|
92
|
+
role: 'initiator',
|
|
93
|
+
localFingerprint: 'A',
|
|
94
|
+
remoteFingerprint: 'B',
|
|
95
|
+
resumeBridge: bridge,
|
|
96
|
+
startHandshake: fakeHandshake,
|
|
97
|
+
});
|
|
98
|
+
gate.onInbound(JSON.stringify({ t: 'pair-nonce', nonce: 'n' }));
|
|
99
|
+
await Promise.resolve();
|
|
100
|
+
await Promise.resolve();
|
|
101
|
+
fire('text_delta', 'a'); // seq 1
|
|
102
|
+
fire('text_delta', 'b'); // seq 2
|
|
103
|
+
sent.length = 0;
|
|
104
|
+
// ack via the channel → bridge frees ≤1; resume lastSeq 1 → replays only seq 2.
|
|
105
|
+
gate.onInbound(JSON.stringify({ type: 'ack', seq: 1 }));
|
|
106
|
+
gate.onInbound(JSON.stringify({ type: 'resume', lastSeq: 1 }));
|
|
107
|
+
expect(sent.map((s) => JSON.parse(s).seq)).toEqual([2]);
|
|
108
|
+
bridge.dispose();
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { createIdentityContext } from '@robota-sdk/agent-remote-pairing';
|
|
2
|
+
const testIdentity = createIdentityContext('test-product');
|
|
3
|
+
import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
|
|
4
|
+
|
|
5
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import { PairingGate, type IPairingGateOptions } from '../pairing-gate.js';
|
|
8
|
+
import { WebRtcDeliveryLifecycle } from '../webrtc-delivery-lifecycle.js';
|
|
9
|
+
|
|
10
|
+
import type { startPairingHandshake, TPairingFrame } from '@robota-sdk/agent-remote-pairing';
|
|
11
|
+
import type { createSessionMessageHandler } from '@robota-sdk/agent-transport';
|
|
12
|
+
import type { IInteractiveSession } from '@robota-sdk/agent-interface-session';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* REMOTE-008 Step 1 (SECURITY milestone) — the pairing gate's fail-closed routing switch, driven with a stub
|
|
16
|
+
* channel + injected handshake + injected session-bridge (no real peer/crypto/session).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** A controllable stub of the pairing handshake: capture frames, resolve/reject on demand. */
|
|
20
|
+
function makeHandshakeStub() {
|
|
21
|
+
const sent: TPairingFrame[] = [];
|
|
22
|
+
const received: TPairingFrame[] = [];
|
|
23
|
+
let resolveResult!: (value: { sessionKey: string }) => void;
|
|
24
|
+
let rejectResult!: (error: Error) => void;
|
|
25
|
+
const start: typeof startPairingHandshake = (_cryptoContext, options) => {
|
|
26
|
+
// Record what the gate asked us to send (it wraps this over the channel).
|
|
27
|
+
const controller = {
|
|
28
|
+
result: new Promise<{ sessionKey: string }>((res, rej) => {
|
|
29
|
+
resolveResult = res;
|
|
30
|
+
rejectResult = rej;
|
|
31
|
+
}),
|
|
32
|
+
onFrame: (frame: TPairingFrame) => received.push(frame),
|
|
33
|
+
};
|
|
34
|
+
// Expose the send seam so a test can assert the gate serialized a frame.
|
|
35
|
+
options.send({ t: 'pair-nonce', nonce: 'stub' });
|
|
36
|
+
return controller;
|
|
37
|
+
};
|
|
38
|
+
return {
|
|
39
|
+
start,
|
|
40
|
+
received,
|
|
41
|
+
sentByGate: sent,
|
|
42
|
+
accept: () => resolveResult({ sessionKey: 'k' }),
|
|
43
|
+
reject: () => rejectResult(new Error('pairing rejected')),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function makeGate(over: Partial<IPairingGateOptions> = {}) {
|
|
48
|
+
const channelSends: string[] = [];
|
|
49
|
+
const channel = { send: (d: string) => channelSends.push(d), close: vi.fn() };
|
|
50
|
+
const session = Object.assign(createTestInteractiveSession(), {});
|
|
51
|
+
const sessionOnMessage = vi.fn();
|
|
52
|
+
const handlerCleanup = vi.fn();
|
|
53
|
+
const createHandler: typeof createSessionMessageHandler = () => ({
|
|
54
|
+
onMessage: sessionOnMessage,
|
|
55
|
+
cleanup: handlerCleanup,
|
|
56
|
+
});
|
|
57
|
+
const hs = makeHandshakeStub();
|
|
58
|
+
const gate = new PairingGate({cryptoContext: testIdentity,
|
|
59
|
+
channel,
|
|
60
|
+
session,
|
|
61
|
+
secret: 's',
|
|
62
|
+
role: 'initiator',
|
|
63
|
+
localFingerprint: 'AA',
|
|
64
|
+
remoteFingerprint: 'BB',
|
|
65
|
+
startHandshake: hs.start,
|
|
66
|
+
createHandler,
|
|
67
|
+
...over,
|
|
68
|
+
});
|
|
69
|
+
return { gate, channel, channelSends, sessionOnMessage, handlerCleanup, hs };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe('PairingGate (REMOTE-008 Step 1 — fail-closed routing switch)', () => {
|
|
73
|
+
it('does not expose the session before the handshake accepts (no session bridge built)', () => {
|
|
74
|
+
const { sessionOnMessage } = makeGate();
|
|
75
|
+
// The handshake sent its nonce, but no session handler exists yet.
|
|
76
|
+
expect(sessionOnMessage).not.toHaveBeenCalled();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('serializes the handshake frames it sends over the channel', () => {
|
|
80
|
+
const { channelSends } = makeGate();
|
|
81
|
+
// The stub handshake emits a pair-nonce on start; the gate must have serialized it.
|
|
82
|
+
expect(channelSends).toContain(JSON.stringify({ t: 'pair-nonce', nonce: 'stub' }));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('pre-accept: routes a pairing frame to the handshake and keeps a non-pairing frame from the session', () => {
|
|
86
|
+
const { gate, hs, sessionOnMessage } = makeGate();
|
|
87
|
+
gate.onInbound(JSON.stringify({ t: 'pair-confirm', mac: 'm' }));
|
|
88
|
+
expect(hs.received).toEqual([{ t: 'pair-confirm', mac: 'm' }]);
|
|
89
|
+
// A well-formed but non-pairing frame pre-accept must never reach the (nonexistent) session.
|
|
90
|
+
gate.onInbound(JSON.stringify({ type: 'submit', prompt: 'p' }));
|
|
91
|
+
expect(sessionOnMessage).not.toHaveBeenCalled();
|
|
92
|
+
// Undecodable garbage pre-accept is dropped without throwing.
|
|
93
|
+
expect(() => gate.onInbound('not json')).not.toThrow();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it.each([
|
|
97
|
+
['an empty mac', { t: 'pair-confirm', mac: '' }],
|
|
98
|
+
['a numeric nonce', { t: 'pair-nonce', nonce: 7 }],
|
|
99
|
+
['a padded (non-base64url) mac', { t: 'pair-confirm', mac: 'bWFj==' }],
|
|
100
|
+
['a mac over the ceiling', { t: 'pair-confirm', mac: 'A'.repeat(129) }],
|
|
101
|
+
])(
|
|
102
|
+
'issue #2046: a pairing frame with the right discriminator but %s never reaches the handshake',
|
|
103
|
+
(_label, frame) => {
|
|
104
|
+
// Against the old discriminator-only predicate every one of these was handed to the controller.
|
|
105
|
+
const { gate, hs } = makeGate();
|
|
106
|
+
gate.onInbound(JSON.stringify(frame));
|
|
107
|
+
expect(hs.received).toEqual([]);
|
|
108
|
+
},
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
it('on ACCEPT: builds the session bridge and routes subsequent frames to it', async () => {
|
|
112
|
+
const { gate, hs, sessionOnMessage } = makeGate();
|
|
113
|
+
hs.accept();
|
|
114
|
+
await Promise.resolve(); // let the result.then microtask run
|
|
115
|
+
gate.onInbound(JSON.stringify({ type: 'submit', prompt: 'hi' }));
|
|
116
|
+
expect(sessionOnMessage).toHaveBeenCalledWith(JSON.stringify({ type: 'submit', prompt: 'hi' }));
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('a session frame the peer sends before this side accepts is held, then delivered after acceptance', async () => {
|
|
120
|
+
// The peer finishes its half of the handshake first and asks for the history at once.
|
|
121
|
+
const { gate, hs, sessionOnMessage } = makeGate();
|
|
122
|
+
const early = JSON.stringify({ type: 'get-messages' });
|
|
123
|
+
gate.onInbound(early);
|
|
124
|
+
expect(sessionOnMessage).not.toHaveBeenCalled();
|
|
125
|
+
hs.accept();
|
|
126
|
+
await Promise.resolve();
|
|
127
|
+
expect(sessionOnMessage).toHaveBeenCalledWith(early);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('frames held before a rejection are discarded, never delivered', async () => {
|
|
131
|
+
const { gate, hs, sessionOnMessage } = makeGate();
|
|
132
|
+
gate.onInbound(JSON.stringify({ type: 'get-messages' }));
|
|
133
|
+
hs.reject();
|
|
134
|
+
await Promise.resolve();
|
|
135
|
+
await Promise.resolve();
|
|
136
|
+
expect(sessionOnMessage).not.toHaveBeenCalled();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('on REJECT: closes the channel and NEVER exposes the session', async () => {
|
|
140
|
+
const { gate, channel, hs, sessionOnMessage } = makeGate();
|
|
141
|
+
hs.reject();
|
|
142
|
+
await Promise.resolve();
|
|
143
|
+
expect(channel.close).toHaveBeenCalledTimes(1);
|
|
144
|
+
// Any frame after a failed pairing is ignored — the session is never reachable.
|
|
145
|
+
gate.onInbound(JSON.stringify({ type: 'submit', prompt: 'hi' }));
|
|
146
|
+
expect(sessionOnMessage).not.toHaveBeenCalled();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('post-close (cleanup): ignores further inbound frames', async () => {
|
|
150
|
+
const { gate, hs, sessionOnMessage } = makeGate();
|
|
151
|
+
hs.accept();
|
|
152
|
+
await Promise.resolve();
|
|
153
|
+
gate.cleanup();
|
|
154
|
+
gate.onInbound(JSON.stringify({ type: 'submit', prompt: 'hi' }));
|
|
155
|
+
expect(sessionOnMessage).not.toHaveBeenCalled();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('fires onAccept on accept (not onReject)', async () => {
|
|
159
|
+
const onAccept = vi.fn();
|
|
160
|
+
const onReject = vi.fn();
|
|
161
|
+
const { hs } = makeGate({ onAccept, onReject });
|
|
162
|
+
hs.accept();
|
|
163
|
+
await Promise.resolve();
|
|
164
|
+
expect(onAccept).toHaveBeenCalledTimes(1);
|
|
165
|
+
expect(onReject).not.toHaveBeenCalled();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('fires onReject on reject (not onAccept)', async () => {
|
|
169
|
+
const onAccept = vi.fn();
|
|
170
|
+
const onReject = vi.fn();
|
|
171
|
+
const { hs } = makeGate({ onAccept, onReject });
|
|
172
|
+
hs.reject();
|
|
173
|
+
await Promise.resolve();
|
|
174
|
+
expect(onReject).toHaveBeenCalledTimes(1);
|
|
175
|
+
expect(onAccept).not.toHaveBeenCalled();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('cleanup tears down the session bridge when one was built', async () => {
|
|
179
|
+
const { gate, hs, handlerCleanup } = makeGate();
|
|
180
|
+
hs.accept();
|
|
181
|
+
await Promise.resolve();
|
|
182
|
+
gate.cleanup();
|
|
183
|
+
expect(handlerCleanup).toHaveBeenCalledTimes(1);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('post-accept send failure closes the carrier and reports without escaping the session event', async () => {
|
|
187
|
+
const handlers = new Map<string, (value: unknown) => void>();
|
|
188
|
+
const session = createTestInteractiveSession({
|
|
189
|
+
on: ((event: string, handler: (value: unknown) => void) => {
|
|
190
|
+
handlers.set(event, handler);
|
|
191
|
+
}) as IInteractiveSession['on'],
|
|
192
|
+
off: ((event: string) => {
|
|
193
|
+
handlers.delete(event);
|
|
194
|
+
}) as IInteractiveSession['off'],
|
|
195
|
+
});
|
|
196
|
+
let failSend = false;
|
|
197
|
+
const channel = {
|
|
198
|
+
send: vi.fn(() => {
|
|
199
|
+
if (failSend) throw new Error('paired channel closed');
|
|
200
|
+
}),
|
|
201
|
+
close: vi.fn(),
|
|
202
|
+
};
|
|
203
|
+
const onDeliveryError = vi.fn();
|
|
204
|
+
const hs = makeHandshakeStub();
|
|
205
|
+
new PairingGate({cryptoContext: testIdentity,
|
|
206
|
+
channel,
|
|
207
|
+
session,
|
|
208
|
+
secret: 's',
|
|
209
|
+
role: 'initiator',
|
|
210
|
+
localFingerprint: 'AA',
|
|
211
|
+
remoteFingerprint: 'BB',
|
|
212
|
+
startHandshake: hs.start,
|
|
213
|
+
onDeliveryError,
|
|
214
|
+
});
|
|
215
|
+
hs.accept();
|
|
216
|
+
await Promise.resolve();
|
|
217
|
+
failSend = true;
|
|
218
|
+
|
|
219
|
+
expect(() =>
|
|
220
|
+
handlers.get('branch_event')?.({
|
|
221
|
+
kind: 'checkpoint_created',
|
|
222
|
+
checkpointId: 'turn-0001',
|
|
223
|
+
branchId: 'main',
|
|
224
|
+
}),
|
|
225
|
+
).not.toThrow();
|
|
226
|
+
expect(channel.close).toHaveBeenCalledTimes(1);
|
|
227
|
+
expect(onDeliveryError).toHaveBeenCalledWith(
|
|
228
|
+
expect.objectContaining({ message: 'paired channel closed' }),
|
|
229
|
+
'branch_event',
|
|
230
|
+
);
|
|
231
|
+
expect(handlers.size).toBe(0);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('reports a paired delivery failure before a synchronous closing transition drops the carrier', async () => {
|
|
235
|
+
const handlers = new Map<string, (value: unknown) => void>();
|
|
236
|
+
const session = createTestInteractiveSession({
|
|
237
|
+
on: ((event: string, handler: (value: unknown) => void) => {
|
|
238
|
+
handlers.set(event, handler);
|
|
239
|
+
}) as IInteractiveSession['on'],
|
|
240
|
+
off: ((event: string) => {
|
|
241
|
+
handlers.delete(event);
|
|
242
|
+
}) as IInteractiveSession['off'],
|
|
243
|
+
});
|
|
244
|
+
const cleanup = vi.fn();
|
|
245
|
+
const onDropped = vi.fn();
|
|
246
|
+
const onDeliveryError = vi.fn();
|
|
247
|
+
let gate!: PairingGate;
|
|
248
|
+
let lifecycle!: WebRtcDeliveryLifecycle;
|
|
249
|
+
let failSend = false;
|
|
250
|
+
const channel = {
|
|
251
|
+
send: vi.fn(() => {
|
|
252
|
+
if (failSend) throw new Error('paired channel closed synchronously');
|
|
253
|
+
}),
|
|
254
|
+
close: vi.fn(() => lifecycle.handleDrop(1)),
|
|
255
|
+
};
|
|
256
|
+
lifecycle = new WebRtcDeliveryLifecycle({
|
|
257
|
+
cleanup: () => {
|
|
258
|
+
cleanup();
|
|
259
|
+
gate.cleanup();
|
|
260
|
+
},
|
|
261
|
+
onDropped,
|
|
262
|
+
onDeliveryError,
|
|
263
|
+
});
|
|
264
|
+
lifecycle.reset(1);
|
|
265
|
+
const hs = makeHandshakeStub();
|
|
266
|
+
gate = new PairingGate({cryptoContext: testIdentity,
|
|
267
|
+
channel,
|
|
268
|
+
session,
|
|
269
|
+
secret: 's',
|
|
270
|
+
role: 'initiator',
|
|
271
|
+
localFingerprint: 'AA',
|
|
272
|
+
remoteFingerprint: 'BB',
|
|
273
|
+
startHandshake: hs.start,
|
|
274
|
+
onAccept: () => lifecycle.accept(1),
|
|
275
|
+
onDeliveryError: (error, event) => lifecycle.handleFailure(channel, 1, error, event),
|
|
276
|
+
});
|
|
277
|
+
hs.accept();
|
|
278
|
+
await Promise.resolve();
|
|
279
|
+
failSend = true;
|
|
280
|
+
|
|
281
|
+
expect(() =>
|
|
282
|
+
handlers.get('branch_event')?.({
|
|
283
|
+
kind: 'checkpoint_created',
|
|
284
|
+
checkpointId: 'turn-0001',
|
|
285
|
+
branchId: 'main',
|
|
286
|
+
}),
|
|
287
|
+
).not.toThrow();
|
|
288
|
+
expect(onDeliveryError).toHaveBeenCalledWith(
|
|
289
|
+
expect.objectContaining({ message: 'paired channel closed synchronously' }),
|
|
290
|
+
'branch_event',
|
|
291
|
+
);
|
|
292
|
+
expect(cleanup).toHaveBeenCalledTimes(1);
|
|
293
|
+
expect(onDropped).toHaveBeenCalledTimes(1);
|
|
294
|
+
expect(handlers.size).toBe(0);
|
|
295
|
+
});
|
|
296
|
+
});
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { createIdentityContext } from '@robota-sdk/agent-remote-pairing';
|
|
2
|
+
const testIdentity = createIdentityContext('test-product');
|
|
3
|
+
import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
|
|
4
|
+
|
|
5
|
+
import { extractDtlsFingerprint, startPairingHandshake } from '@robota-sdk/agent-remote-pairing';
|
|
6
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
7
|
+
|
|
8
|
+
import { WebRtcTransport } from '../webrtc-transport.js';
|
|
9
|
+
import { RtcPeer, type RtcChannel } from '../rtc-peer.js';
|
|
10
|
+
import { createInMemorySignalingPair, type ISignalingClient } from '../signaling.js';
|
|
11
|
+
import { fakeDataChannel } from './fake-datachannel.js';
|
|
12
|
+
|
|
13
|
+
import type { IInteractiveSession } from '@robota-sdk/agent-interface-session';
|
|
14
|
+
import type { TPairingFrame } from '@robota-sdk/agent-remote-pairing';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The pairing confirmation must bind to the certificate the DTLS layer actually verified. A relay that
|
|
18
|
+
* terminates DTLS on both sides and forwards the data channel must not be able to make the two honest
|
|
19
|
+
* peers compute the same binding.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
function createStubSession(): IInteractiveSession {
|
|
23
|
+
return Object.assign(createTestInteractiveSession(), {
|
|
24
|
+
getMessages: vi.fn().mockReturnValue([{ role: 'user', content: 'hi' }]),
|
|
25
|
+
getActiveDriverId: () => null,
|
|
26
|
+
on: vi.fn(),
|
|
27
|
+
off: vi.fn(),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Insert `a=fingerprint:sha-256 <value>` before the first existing fingerprint line. */
|
|
32
|
+
function prependFingerprint(sdp: string, value: string): string {
|
|
33
|
+
return sdp.replace(/^a=fingerprint:/m, `a=fingerprint:sha-256 ${value}\r\na=fingerprint:`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type TDescription = { type: 'offer' | 'answer'; sdp: string };
|
|
37
|
+
type TCandidate = { candidate: string; sdpMid?: string };
|
|
38
|
+
|
|
39
|
+
function sendCandidates(peer: RtcPeer, signaling: ISignalingClient): void {
|
|
40
|
+
peer.onLocalCandidate((c) =>
|
|
41
|
+
signaling.send({ kind: 'ice', data: { candidate: c.candidate, sdpMid: c.mid } }),
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function addCandidate(peer: RtcPeer, data: unknown): void {
|
|
46
|
+
const c = data as TCandidate;
|
|
47
|
+
peer.addRemoteCandidate({ candidate: c.candidate, mid: c.sdpMid ?? '0' });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Pairing responder on the far side. Resolves true only if its own handshake accepts. */
|
|
51
|
+
function connectResponder(signaling: ISignalingClient, secret: string): Promise<boolean> {
|
|
52
|
+
return new Promise<boolean>((resolvePaired) => {
|
|
53
|
+
const peer = new RtcPeer();
|
|
54
|
+
sendCandidates(peer, signaling);
|
|
55
|
+
let hostFingerprint: string | undefined;
|
|
56
|
+
let localFingerprint: string | undefined;
|
|
57
|
+
let chain: Promise<void> = Promise.resolve();
|
|
58
|
+
signaling.onSignal((message) => {
|
|
59
|
+
chain = chain
|
|
60
|
+
.then(async () => {
|
|
61
|
+
if (message.kind === 'offer') {
|
|
62
|
+
const offer = message.data as TDescription;
|
|
63
|
+
hostFingerprint = extractDtlsFingerprint(offer.sdp);
|
|
64
|
+
const answer = await peer.acceptOffer(offer.sdp);
|
|
65
|
+
localFingerprint = extractDtlsFingerprint(answer);
|
|
66
|
+
signaling.send({ kind: 'answer', data: { type: 'answer', sdp: answer } });
|
|
67
|
+
} else if (message.kind === 'ice') {
|
|
68
|
+
addCandidate(peer, message.data);
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
.catch(() => resolvePaired(false));
|
|
72
|
+
});
|
|
73
|
+
peer.onDataChannel((channel) => {
|
|
74
|
+
const controller = startPairingHandshake(testIdentity, {
|
|
75
|
+
secret,
|
|
76
|
+
role: 'responder',
|
|
77
|
+
localFingerprint: localFingerprint!,
|
|
78
|
+
remoteFingerprint: hostFingerprint!,
|
|
79
|
+
send: (frame: TPairingFrame) => {
|
|
80
|
+
try {
|
|
81
|
+
channel.send(JSON.stringify(frame));
|
|
82
|
+
} catch {
|
|
83
|
+
/* closing */
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
timeoutMs: 6000,
|
|
87
|
+
});
|
|
88
|
+
controller.result.then(
|
|
89
|
+
() => resolvePaired(true),
|
|
90
|
+
() => resolvePaired(false),
|
|
91
|
+
);
|
|
92
|
+
channel.onMessage((text) => {
|
|
93
|
+
try {
|
|
94
|
+
controller.onFrame(JSON.parse(text) as TPairingFrame);
|
|
95
|
+
} catch {
|
|
96
|
+
/* ignore */
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A signaling relay that terminates DTLS with each side using its own certificates, forwards the data
|
|
105
|
+
* channel between them, and advertises the honest peer's fingerprint FIRST next to its own.
|
|
106
|
+
*/
|
|
107
|
+
function startRelay(toHost: ISignalingClient, toRemote: ISignalingClient): void {
|
|
108
|
+
const facingHost = new RtcPeer();
|
|
109
|
+
const facingRemote = new RtcPeer();
|
|
110
|
+
let hostChannel: RtcChannel | undefined;
|
|
111
|
+
const remoteChannel = facingRemote.createDataChannel('agent-session');
|
|
112
|
+
const toRemoteQueue: string[] = [];
|
|
113
|
+
const toHostQueue: string[] = [];
|
|
114
|
+
let pendingHostOffer: TDescription | undefined;
|
|
115
|
+
|
|
116
|
+
sendCandidates(facingHost, toHost);
|
|
117
|
+
sendCandidates(facingRemote, toRemote);
|
|
118
|
+
|
|
119
|
+
facingHost.onDataChannel((channel) => {
|
|
120
|
+
hostChannel = channel;
|
|
121
|
+
for (const m of toHostQueue.splice(0)) channel.send(m);
|
|
122
|
+
channel.onMessage((text) => {
|
|
123
|
+
if (remoteChannel.readyState === 'open') remoteChannel.send(text);
|
|
124
|
+
else toRemoteQueue.push(text);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
remoteChannel.onStateChange((state) => {
|
|
128
|
+
if (state === 'open') for (const m of toRemoteQueue.splice(0)) remoteChannel.send(m);
|
|
129
|
+
});
|
|
130
|
+
remoteChannel.onMessage((text) => {
|
|
131
|
+
if (hostChannel) hostChannel.send(text);
|
|
132
|
+
else toHostQueue.push(text);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
let hostChain: Promise<void> = Promise.resolve();
|
|
136
|
+
toHost.onSignal((message) => {
|
|
137
|
+
hostChain = hostChain
|
|
138
|
+
.then(async () => {
|
|
139
|
+
if (message.kind === 'offer') {
|
|
140
|
+
pendingHostOffer = message.data as TDescription;
|
|
141
|
+
const hostFingerprint = extractDtlsFingerprint(pendingHostOffer.sdp);
|
|
142
|
+
const own = await facingRemote.createOffer();
|
|
143
|
+
toRemote.send({
|
|
144
|
+
kind: 'offer',
|
|
145
|
+
data: { type: 'offer', sdp: prependFingerprint(own, hostFingerprint) },
|
|
146
|
+
});
|
|
147
|
+
} else if (message.kind === 'ice') {
|
|
148
|
+
addCandidate(facingHost, message.data);
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
.catch(() => undefined);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
let remoteChain: Promise<void> = Promise.resolve();
|
|
155
|
+
toRemote.onSignal((message) => {
|
|
156
|
+
remoteChain = remoteChain
|
|
157
|
+
.then(async () => {
|
|
158
|
+
if (message.kind === 'answer') {
|
|
159
|
+
const answer = message.data as TDescription;
|
|
160
|
+
const remoteFingerprint = extractDtlsFingerprint(answer.sdp);
|
|
161
|
+
facingRemote.acceptAnswer(answer.sdp);
|
|
162
|
+
const own = await facingHost.acceptOffer(pendingHostOffer!.sdp);
|
|
163
|
+
toHost.send({
|
|
164
|
+
kind: 'answer',
|
|
165
|
+
data: { type: 'answer', sdp: prependFingerprint(own, remoteFingerprint) },
|
|
166
|
+
});
|
|
167
|
+
} else if (message.kind === 'ice') {
|
|
168
|
+
addCandidate(facingRemote, message.data);
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
.catch(() => undefined);
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
describe('WebRTC pairing with a relay in the middle', () => {
|
|
176
|
+
it('refuses to pair when the relay advertises the honest fingerprint next to its own', async () => {
|
|
177
|
+
const secret = 'shared-secret-256bit-base64url-relay';
|
|
178
|
+
const [hostSig, relayHostSide] = createInMemorySignalingPair();
|
|
179
|
+
const [relayRemoteSide, remoteSig] = createInMemorySignalingPair();
|
|
180
|
+
const session = createStubSession();
|
|
181
|
+
const onPaired = vi.fn();
|
|
182
|
+
const transport = new WebRtcTransport({cryptoContext: testIdentity, signaling: hostSig, secret, onPaired });
|
|
183
|
+
transport.attach(session);
|
|
184
|
+
|
|
185
|
+
startRelay(relayHostSide, relayRemoteSide);
|
|
186
|
+
const remotePaired = connectResponder(remoteSig, secret);
|
|
187
|
+
await transport.start();
|
|
188
|
+
|
|
189
|
+
await expect(remotePaired).resolves.toBe(false);
|
|
190
|
+
expect(onPaired).not.toHaveBeenCalled();
|
|
191
|
+
await transport.stop();
|
|
192
|
+
}, 20000);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe('WebRTC transport answer handling', () => {
|
|
196
|
+
it('takes one answer per start and ignores any later one', async () => {
|
|
197
|
+
const remoteDescriptions: string[] = [];
|
|
198
|
+
const fake = fakeDataChannel({
|
|
199
|
+
offerSdp: 'a=fingerprint:sha-256 AA:AA',
|
|
200
|
+
setRemoteDescription: (_sdp, type) => remoteDescriptions.push(type),
|
|
201
|
+
});
|
|
202
|
+
const [hostSig, remoteSig] = createInMemorySignalingPair();
|
|
203
|
+
const transport = new WebRtcTransport({cryptoContext: testIdentity,
|
|
204
|
+
signaling: hostSig,
|
|
205
|
+
secret: 'shared-secret-256bit-base64url-answers',
|
|
206
|
+
loadDataChannel: () => fake.module,
|
|
207
|
+
});
|
|
208
|
+
transport.attach(createStubSession());
|
|
209
|
+
|
|
210
|
+
const answer = { type: 'answer', sdp: 'a=fingerprint:sha-256 BB:BB' };
|
|
211
|
+
const answered = new Promise<void>((resolve) => {
|
|
212
|
+
remoteSig.onSignal((message) => {
|
|
213
|
+
if (message.kind !== 'offer') return;
|
|
214
|
+
remoteSig.send({ kind: 'answer', data: answer });
|
|
215
|
+
remoteSig.send({ kind: 'answer', data: answer });
|
|
216
|
+
setTimeout(resolve, 200);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
await transport.start();
|
|
220
|
+
await answered;
|
|
221
|
+
|
|
222
|
+
expect(remoteDescriptions).toEqual(['answer']);
|
|
223
|
+
await transport.stop();
|
|
224
|
+
}, 15000);
|
|
225
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Closing a channel right after a send must not lose that send. The implementation resets the
|
|
3
|
+
* channel's stream as soon as it is told to close, and a message sent just before can then never
|
|
4
|
+
* reach the peer; so the channel reads closed at once and its stream is reset only after a grace.
|
|
5
|
+
*/
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
7
|
+
|
|
8
|
+
import { RtcPeer } from '../rtc-peer.js';
|
|
9
|
+
|
|
10
|
+
import { fakeDataChannel } from './fake-datachannel.js';
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
vi.useFakeTimers();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
vi.useRealTimers();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('RtcChannel close', () => {
|
|
21
|
+
it('reads closed at once and resets the stream only after what was sent had time to leave', () => {
|
|
22
|
+
const nativeClose = vi.fn();
|
|
23
|
+
const fake = fakeDataChannel({ channel: { close: nativeClose } });
|
|
24
|
+
const channel = new RtcPeer({ loadDataChannel: () => fake.module }).createDataChannel('x');
|
|
25
|
+
const states: string[] = [];
|
|
26
|
+
channel.onStateChange((state) => states.push(state));
|
|
27
|
+
|
|
28
|
+
channel.send('the last word');
|
|
29
|
+
channel.close();
|
|
30
|
+
expect(channel.readyState).toBe('closed');
|
|
31
|
+
expect(states).toEqual(['closed']);
|
|
32
|
+
expect(nativeClose).not.toHaveBeenCalled();
|
|
33
|
+
|
|
34
|
+
vi.advanceTimersByTime(1_000);
|
|
35
|
+
expect(nativeClose).toHaveBeenCalledTimes(1);
|
|
36
|
+
channel.close();
|
|
37
|
+
vi.advanceTimersByTime(1_000);
|
|
38
|
+
expect(nativeClose).toHaveBeenCalledTimes(1);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('hands out nothing that arrives after this side closed it', () => {
|
|
42
|
+
const fake = fakeDataChannel();
|
|
43
|
+
const channel = new RtcPeer({ loadDataChannel: () => fake.module }).createDataChannel('x');
|
|
44
|
+
const received: string[] = [];
|
|
45
|
+
channel.onMessage((text) => received.push(text));
|
|
46
|
+
|
|
47
|
+
fake.connections[0]!.channelMessage!('before');
|
|
48
|
+
channel.close();
|
|
49
|
+
fake.connections[0]!.channelMessage!('after');
|
|
50
|
+
expect(received).toEqual(['before']);
|
|
51
|
+
});
|
|
52
|
+
});
|