@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,238 @@
|
|
|
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 { TPairingFrame } from '@robota-sdk/agent-remote-pairing';
|
|
11
|
+
import type { IInteractiveSession } from '@robota-sdk/agent-interface-session';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* REMOTE-008 Step 1/5 — end-to-end PAIRED handshake over a REAL werift data channel. The host is a
|
|
15
|
+
* `WebRtcTransport` with a `secret` (initiator ≡ offerer); the "remote device" here runs the RESPONDER
|
|
16
|
+
* side of the same pairing handshake, then speaks the session protocol only after its own accept. Proves
|
|
17
|
+
* the gate's routing switch works over a real channel: matching secrets → session exposed + round-trips;
|
|
18
|
+
* mismatched secrets → both reject, session never exposed (fail closed).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
function createStubSession(): IInteractiveSession {
|
|
22
|
+
return Object.assign(createTestInteractiveSession(), {
|
|
23
|
+
getMessages: vi.fn().mockReturnValue([{ role: 'user', content: 'hi' }]),
|
|
24
|
+
// ARCH-012: required. This double feeds `subscribeSessionEvents`, which calls it on every
|
|
25
|
+
// turn-authored event — omitting it throws the moment a case emits one.
|
|
26
|
+
getActiveDriverId: () => null,
|
|
27
|
+
on: vi.fn(),
|
|
28
|
+
off: vi.fn(),
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface IRemoteResult {
|
|
33
|
+
/** Resolves with the first session `TServerMessage` (only reachable if pairing accepted). */
|
|
34
|
+
session: Promise<Record<string, unknown>>;
|
|
35
|
+
/** Resolves true if the responder's pairing accepted, false if it rejected/closed. */
|
|
36
|
+
paired: Promise<boolean>;
|
|
37
|
+
/** Close the remote's data channel — the device going away. */
|
|
38
|
+
close: () => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The remote device (answerer + pairing RESPONDER). Extracts the host fingerprint from the offer and its
|
|
43
|
+
* own from the answer, runs the responder handshake over the data channel, and — on accept — sends a
|
|
44
|
+
* `get-messages` session frame. Session frames are only sent post-accept (phase separation).
|
|
45
|
+
*/
|
|
46
|
+
function connectRemotePaired(signaling: ISignalingClient, secret: string): IRemoteResult {
|
|
47
|
+
let resolveSession!: (v: Record<string, unknown>) => void;
|
|
48
|
+
let resolvePaired!: (v: boolean) => void;
|
|
49
|
+
const session = new Promise<Record<string, unknown>>((res) => (resolveSession = res));
|
|
50
|
+
const paired = new Promise<boolean>((res) => (resolvePaired = res));
|
|
51
|
+
|
|
52
|
+
const peer = new RTCPeerConnection();
|
|
53
|
+
peer.onIceCandidate.subscribe((c) => {
|
|
54
|
+
if (c) signaling.send({ kind: 'ice', data: c.toJSON() });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
let hostFingerprint: string | undefined;
|
|
58
|
+
let chain: Promise<void> = Promise.resolve();
|
|
59
|
+
signaling.onSignal((message) => {
|
|
60
|
+
chain = chain.then(async () => {
|
|
61
|
+
if (message.kind === 'offer') {
|
|
62
|
+
const offer = message.data as { sdp: string } & Parameters<
|
|
63
|
+
typeof peer.setRemoteDescription
|
|
64
|
+
>[0];
|
|
65
|
+
hostFingerprint = extractDtlsFingerprint(offer.sdp);
|
|
66
|
+
await peer.setRemoteDescription(offer);
|
|
67
|
+
const answer = await peer.createAnswer();
|
|
68
|
+
await peer.setLocalDescription(answer);
|
|
69
|
+
signaling.send({ kind: 'answer', data: peer.localDescription });
|
|
70
|
+
} else if (message.kind === 'ice') {
|
|
71
|
+
await peer.addIceCandidate(message.data as Parameters<typeof peer.addIceCandidate>[0]);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
let remoteChannel: { close(): void } | undefined;
|
|
77
|
+
peer.onDataChannel.subscribe((channel) => {
|
|
78
|
+
remoteChannel = channel;
|
|
79
|
+
let accepted = false;
|
|
80
|
+
const controller = startPairingHandshake({
|
|
81
|
+
secret,
|
|
82
|
+
role: 'responder',
|
|
83
|
+
localFingerprint: extractDtlsFingerprint(peer.localDescription!.sdp),
|
|
84
|
+
remoteFingerprint: hostFingerprint!,
|
|
85
|
+
send: (frame: TPairingFrame) => {
|
|
86
|
+
try {
|
|
87
|
+
channel.send(JSON.stringify(frame));
|
|
88
|
+
} catch {
|
|
89
|
+
/* channel closing */
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
timeoutMs: 8000,
|
|
93
|
+
});
|
|
94
|
+
controller.result.then(
|
|
95
|
+
() => {
|
|
96
|
+
accepted = true;
|
|
97
|
+
resolvePaired(true);
|
|
98
|
+
channel.send(JSON.stringify({ type: 'get-messages' }));
|
|
99
|
+
},
|
|
100
|
+
() => resolvePaired(false),
|
|
101
|
+
);
|
|
102
|
+
channel.onMessage.subscribe((data) => {
|
|
103
|
+
const text = typeof data === 'string' ? data : data.toString();
|
|
104
|
+
if (!accepted) {
|
|
105
|
+
// Pre-accept only pairing frames arrive; route them to the handshake.
|
|
106
|
+
try {
|
|
107
|
+
controller.onFrame(JSON.parse(text) as TPairingFrame);
|
|
108
|
+
} catch {
|
|
109
|
+
/* ignore */
|
|
110
|
+
}
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
resolveSession(JSON.parse(text) as Record<string, unknown>);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
return { session, paired, close: () => remoteChannel?.close() };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
describe('WebRtc pairing end-to-end (REMOTE-008)', () => {
|
|
121
|
+
it('matching secrets: both peers pair, the session is exposed, and get-messages round-trips', async () => {
|
|
122
|
+
const secret = 'shared-secret-256bit-base64url-xyz';
|
|
123
|
+
const [hostSig, remoteSig] = createInMemorySignalingPair();
|
|
124
|
+
const transport = new WebRtcTransport({ signaling: hostSig, secret });
|
|
125
|
+
transport.attach(createStubSession());
|
|
126
|
+
|
|
127
|
+
const remote = connectRemotePaired(remoteSig, secret);
|
|
128
|
+
await transport.start();
|
|
129
|
+
|
|
130
|
+
await expect(remote.paired).resolves.toBe(true);
|
|
131
|
+
const reply = await remote.session;
|
|
132
|
+
expect(reply.type).toBe('messages');
|
|
133
|
+
await transport.stop();
|
|
134
|
+
}, 20000);
|
|
135
|
+
|
|
136
|
+
it('mismatched secrets: both reject, the session is never exposed (fail closed)', async () => {
|
|
137
|
+
const [hostSig, remoteSig] = createInMemorySignalingPair();
|
|
138
|
+
const session = createStubSession();
|
|
139
|
+
const transport = new WebRtcTransport({ signaling: hostSig, secret: 'host-secret' });
|
|
140
|
+
transport.attach(session);
|
|
141
|
+
|
|
142
|
+
const remote = connectRemotePaired(remoteSig, 'different-secret');
|
|
143
|
+
await transport.start();
|
|
144
|
+
|
|
145
|
+
await expect(remote.paired).resolves.toBe(false);
|
|
146
|
+
// The host never built the session bridge → getMessages was never invoked over the channel.
|
|
147
|
+
expect(session.getMessages as ReturnType<typeof vi.fn>).not.toHaveBeenCalled();
|
|
148
|
+
await transport.stop();
|
|
149
|
+
}, 20000);
|
|
150
|
+
|
|
151
|
+
it('a driving connection the host operator does not approve never reaches the session', async () => {
|
|
152
|
+
const secret = 'shared-secret-256bit-base64url-xyz';
|
|
153
|
+
const [hostSig, remoteSig] = createInMemorySignalingPair();
|
|
154
|
+
const session = createStubSession();
|
|
155
|
+
const approve = vi.fn(async () => false);
|
|
156
|
+
const onPaired = vi.fn();
|
|
157
|
+
const transport = new WebRtcTransport({
|
|
158
|
+
signaling: hostSig,
|
|
159
|
+
secret,
|
|
160
|
+
connectionApproval: { approve },
|
|
161
|
+
onPaired,
|
|
162
|
+
});
|
|
163
|
+
transport.attach(session);
|
|
164
|
+
|
|
165
|
+
const remote = connectRemotePaired(remoteSig, secret);
|
|
166
|
+
await transport.start();
|
|
167
|
+
|
|
168
|
+
// The handshake itself succeeds — the device is who it says — and the operator still decides.
|
|
169
|
+
await expect(remote.paired).resolves.toBe(true);
|
|
170
|
+
await vi.waitFor(() => expect(approve).toHaveBeenCalledTimes(1));
|
|
171
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
172
|
+
expect(session.getMessages as ReturnType<typeof vi.fn>).not.toHaveBeenCalled();
|
|
173
|
+
expect(onPaired).not.toHaveBeenCalled();
|
|
174
|
+
await transport.stop();
|
|
175
|
+
}, 20000);
|
|
176
|
+
|
|
177
|
+
it('a device that goes away while the operator decides withdraws the question and is never admitted', async () => {
|
|
178
|
+
const secret = 'shared-secret-256bit-base64url-xyz';
|
|
179
|
+
const [hostSig, remoteSig] = createInMemorySignalingPair();
|
|
180
|
+
let signal: AbortSignal | undefined;
|
|
181
|
+
let answer!: (value: boolean) => void;
|
|
182
|
+
const approve = vi.fn((context: { signal: AbortSignal }) => {
|
|
183
|
+
signal = context.signal;
|
|
184
|
+
return new Promise<boolean>((resolve) => (answer = resolve));
|
|
185
|
+
});
|
|
186
|
+
const onPaired = vi.fn();
|
|
187
|
+
const onPairingFailed = vi.fn();
|
|
188
|
+
const session = createStubSession();
|
|
189
|
+
const transport = new WebRtcTransport({
|
|
190
|
+
signaling: hostSig,
|
|
191
|
+
secret,
|
|
192
|
+
connectionApproval: { approve },
|
|
193
|
+
onPaired,
|
|
194
|
+
onPairingFailed,
|
|
195
|
+
});
|
|
196
|
+
transport.attach(session);
|
|
197
|
+
|
|
198
|
+
const remote = connectRemotePaired(remoteSig, secret);
|
|
199
|
+
await transport.start();
|
|
200
|
+
await expect(remote.paired).resolves.toBe(true);
|
|
201
|
+
await vi.waitFor(() => expect(approve).toHaveBeenCalledTimes(1));
|
|
202
|
+
|
|
203
|
+
remote.close();
|
|
204
|
+
await vi.waitFor(() => expect(signal?.aborted).toBe(true), { timeout: 5000 });
|
|
205
|
+
expect(onPairingFailed).toHaveBeenCalledTimes(1);
|
|
206
|
+
answer(true);
|
|
207
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
208
|
+
expect(onPaired).not.toHaveBeenCalled();
|
|
209
|
+
expect(session.getMessages as ReturnType<typeof vi.fn>).not.toHaveBeenCalled();
|
|
210
|
+
await transport.stop();
|
|
211
|
+
}, 20000);
|
|
212
|
+
|
|
213
|
+
it('a driving connection the host operator approves reaches the session', async () => {
|
|
214
|
+
const secret = 'shared-secret-256bit-base64url-xyz';
|
|
215
|
+
const [hostSig, remoteSig] = createInMemorySignalingPair();
|
|
216
|
+
// The operator takes a moment: the device, already paired on its side, asks for the history
|
|
217
|
+
// meanwhile, and gets its answer once the operator says yes.
|
|
218
|
+
const approve = vi.fn(async () => {
|
|
219
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
220
|
+
return true;
|
|
221
|
+
});
|
|
222
|
+
const transport = new WebRtcTransport({
|
|
223
|
+
signaling: hostSig,
|
|
224
|
+
secret,
|
|
225
|
+
connectionApproval: { approve },
|
|
226
|
+
});
|
|
227
|
+
transport.attach(createStubSession());
|
|
228
|
+
|
|
229
|
+
const remote = connectRemotePaired(remoteSig, secret);
|
|
230
|
+
await transport.start();
|
|
231
|
+
|
|
232
|
+
await expect(remote.paired).resolves.toBe(true);
|
|
233
|
+
const reply = await remote.session;
|
|
234
|
+
expect(reply.type).toBe('messages');
|
|
235
|
+
expect(approve).toHaveBeenCalledTimes(1);
|
|
236
|
+
await transport.stop();
|
|
237
|
+
}, 20000);
|
|
238
|
+
});
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
deriveIdentityId,
|
|
5
|
+
exportPublicKey,
|
|
6
|
+
generateIdentityKeyPair,
|
|
7
|
+
importPublicKey,
|
|
8
|
+
startDeviceReconnect,
|
|
9
|
+
type TReconnectFrame,
|
|
10
|
+
} from '@robota-sdk/agent-remote-pairing';
|
|
11
|
+
import type { IInteractiveSession } from '@robota-sdk/agent-interface-session';
|
|
12
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
13
|
+
|
|
14
|
+
import { PairingGate, type IHostReconnectConfig } from '../pairing-gate.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* REMOTE-012 E3 TC-05 — the host gate's E3 admission paths, in isolation with a stub channel: first-pair
|
|
18
|
+
* ENROLLMENT (pin the device key, expose the session) and mutual RECONNECT (admit a pinned device, deny an
|
|
19
|
+
* unknown/revoked one) — all fail-closed.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
function stubSession(): IInteractiveSession {
|
|
23
|
+
return Object.assign(createTestInteractiveSession(), {
|
|
24
|
+
getMessages: vi.fn().mockReturnValue([]),
|
|
25
|
+
// ARCH-012: required. This double feeds `subscribeSessionEvents`, which calls it on every
|
|
26
|
+
// turn-authored event — omitting it throws the moment a case emits one.
|
|
27
|
+
getActiveDriverId: () => null,
|
|
28
|
+
on: vi.fn(),
|
|
29
|
+
off: vi.fn(),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A channel that captures sends and can route parsed frames to a sink (the counterpart controller). */
|
|
34
|
+
function stubChannel(sink?: (frame: unknown) => void): {
|
|
35
|
+
channel: { send: (d: string) => void; close: () => void };
|
|
36
|
+
sent: unknown[];
|
|
37
|
+
closed: () => boolean;
|
|
38
|
+
} {
|
|
39
|
+
const sent: unknown[] = [];
|
|
40
|
+
let closed = false;
|
|
41
|
+
return {
|
|
42
|
+
channel: {
|
|
43
|
+
send(d: string): void {
|
|
44
|
+
const parsed = JSON.parse(d);
|
|
45
|
+
sent.push(parsed);
|
|
46
|
+
sink?.(parsed);
|
|
47
|
+
},
|
|
48
|
+
close(): void {
|
|
49
|
+
closed = true;
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
sent,
|
|
53
|
+
closed: () => closed,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const FP = { localFingerprint: 'HOST:FP', remoteFingerprint: 'DEV:FP' };
|
|
58
|
+
|
|
59
|
+
async function hostConfig(over: Partial<IHostReconnectConfig> = {}): Promise<{
|
|
60
|
+
cfg: IHostReconnectConfig;
|
|
61
|
+
hostKeyPair: CryptoKeyPair;
|
|
62
|
+
}> {
|
|
63
|
+
const hostKeyPair = await generateIdentityKeyPair(true);
|
|
64
|
+
const hostPublicSpki = await exportPublicKey(hostKeyPair.publicKey);
|
|
65
|
+
return {
|
|
66
|
+
hostKeyPair,
|
|
67
|
+
cfg: {
|
|
68
|
+
hostIdentityId: await deriveIdentityId(hostPublicSpki),
|
|
69
|
+
hostPublicSpki,
|
|
70
|
+
hostPrivateKey: hostKeyPair.privateKey,
|
|
71
|
+
resolveDevicePublicKey: async () => undefined,
|
|
72
|
+
onEnroll: vi.fn(),
|
|
73
|
+
...over,
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe('PairingGate E3 first-pair enrollment (REMOTE-012 TC-05)', () => {
|
|
79
|
+
it('after B3 accept, advertises the host key, pins the device key, and exposes the session', async () => {
|
|
80
|
+
const { cfg } = await hostConfig();
|
|
81
|
+
const onAccept = vi.fn();
|
|
82
|
+
// Fake handshake that accepts immediately (isolates the enrollment path from real B3 crypto).
|
|
83
|
+
const fakeHandshake = (() => ({
|
|
84
|
+
result: Promise.resolve({ sessionKey: 'k' }),
|
|
85
|
+
onFrame: () => {},
|
|
86
|
+
})) as never;
|
|
87
|
+
|
|
88
|
+
const { channel, sent } = stubChannel();
|
|
89
|
+
const gate = new PairingGate({
|
|
90
|
+
channel,
|
|
91
|
+
session: stubSession(),
|
|
92
|
+
secret: 's',
|
|
93
|
+
role: 'initiator',
|
|
94
|
+
...FP,
|
|
95
|
+
reconnect: cfg,
|
|
96
|
+
onAccept,
|
|
97
|
+
startHandshake: fakeHandshake,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Client opens first-pair mode.
|
|
101
|
+
gate.onInbound(JSON.stringify({ t: 'pair-nonce', nonce: 'n' }));
|
|
102
|
+
await Promise.resolve();
|
|
103
|
+
await Promise.resolve();
|
|
104
|
+
// Host advertised its identity key.
|
|
105
|
+
expect(sent.some((f) => (f as { t?: string }).t === 'enroll-key')).toBe(true);
|
|
106
|
+
|
|
107
|
+
// Device replies with its public key → host pins it and exposes the session.
|
|
108
|
+
const deviceKey = await generateIdentityKeyPair(false);
|
|
109
|
+
const deviceSpki = await exportPublicKey(deviceKey.publicKey);
|
|
110
|
+
gate.onInbound(JSON.stringify({ t: 'enroll-key', spki: deviceSpki }));
|
|
111
|
+
await vi.waitFor(() => expect(onAccept).toHaveBeenCalledTimes(1));
|
|
112
|
+
expect(cfg.onEnroll).toHaveBeenCalledWith(await deriveIdentityId(deviceSpki), deviceSpki);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('PairingGate E3 reconnect (REMOTE-012 TC-05)', () => {
|
|
117
|
+
async function pinnedDevice(): Promise<{
|
|
118
|
+
keyPair: CryptoKeyPair;
|
|
119
|
+
deviceId: string;
|
|
120
|
+
publicKey: CryptoKey;
|
|
121
|
+
}> {
|
|
122
|
+
const keyPair = await generateIdentityKeyPair(false);
|
|
123
|
+
const spki = await exportPublicKey(keyPair.publicKey);
|
|
124
|
+
return {
|
|
125
|
+
keyPair,
|
|
126
|
+
deviceId: await deriveIdentityId(spki),
|
|
127
|
+
publicKey: await importPublicKey(spki),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
it('admits a pinned device via mutual reconnect and exposes the session (no re-enroll)', async () => {
|
|
132
|
+
const device = await pinnedDevice();
|
|
133
|
+
const { cfg } = await hostConfig({
|
|
134
|
+
resolveDevicePublicKey: async (id) => (id === device.deviceId ? device.publicKey : undefined),
|
|
135
|
+
});
|
|
136
|
+
const hostPublicKey = await importPublicKey(cfg.hostPublicSpki);
|
|
137
|
+
const onAccept = vi.fn();
|
|
138
|
+
const onReject = vi.fn();
|
|
139
|
+
|
|
140
|
+
let gate!: PairingGate;
|
|
141
|
+
let deviceCtrl!: ReturnType<typeof startDeviceReconnect>;
|
|
142
|
+
// Route host→device frames into the device controller.
|
|
143
|
+
const { channel } = stubChannel((frame) => deviceCtrl.onFrame(frame as TReconnectFrame));
|
|
144
|
+
gate = new PairingGate({
|
|
145
|
+
channel,
|
|
146
|
+
session: stubSession(),
|
|
147
|
+
secret: 's',
|
|
148
|
+
role: 'initiator',
|
|
149
|
+
...FP,
|
|
150
|
+
reconnect: cfg,
|
|
151
|
+
onAccept,
|
|
152
|
+
onReject,
|
|
153
|
+
});
|
|
154
|
+
// Device drives the reconnect; its frames go into the gate.
|
|
155
|
+
deviceCtrl = startDeviceReconnect({
|
|
156
|
+
deviceId: device.deviceId,
|
|
157
|
+
hostIdentityId: cfg.hostIdentityId,
|
|
158
|
+
localFingerprint: FP.remoteFingerprint,
|
|
159
|
+
remoteFingerprint: FP.localFingerprint,
|
|
160
|
+
devicePrivateKey: device.keyPair.privateKey,
|
|
161
|
+
pinnedHostPublicKey: hostPublicKey,
|
|
162
|
+
send: (frame) => gate.onInbound(JSON.stringify(frame)),
|
|
163
|
+
timeoutMs: 1000,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
await deviceCtrl.result;
|
|
167
|
+
await vi.waitFor(() => expect(onAccept).toHaveBeenCalledTimes(1));
|
|
168
|
+
expect(onReject).not.toHaveBeenCalled();
|
|
169
|
+
expect(cfg.onEnroll).not.toHaveBeenCalled();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('fail-closed: an unknown/revoked device is denied (channel closed, no session)', async () => {
|
|
173
|
+
const { cfg } = await hostConfig({ resolveDevicePublicKey: async () => undefined });
|
|
174
|
+
const onAccept = vi.fn();
|
|
175
|
+
const onReject = vi.fn();
|
|
176
|
+
const { channel, closed } = stubChannel();
|
|
177
|
+
const gate = new PairingGate({
|
|
178
|
+
channel,
|
|
179
|
+
session: stubSession(),
|
|
180
|
+
secret: 's',
|
|
181
|
+
role: 'initiator',
|
|
182
|
+
...FP,
|
|
183
|
+
reconnect: cfg,
|
|
184
|
+
onAccept,
|
|
185
|
+
onReject,
|
|
186
|
+
});
|
|
187
|
+
gate.onInbound(JSON.stringify({ t: 'rc-hello', deviceId: 'ghost', nonceDevice: 'ZGV2' }));
|
|
188
|
+
await vi.waitFor(() => expect(onReject).toHaveBeenCalledTimes(1));
|
|
189
|
+
expect(onAccept).not.toHaveBeenCalled();
|
|
190
|
+
expect(closed()).toBe(true);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { SessionResumeBridge } from '@robota-sdk/agent-transport';
|
|
2
|
+
import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
|
|
3
|
+
import type { IInteractiveSession } from '@robota-sdk/agent-interface-session';
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import { PairingGate } from '../pairing-gate.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* REMOTE-013 E4 — when a `resumeBridge` is supplied, the gate routes the paired session through the bridge
|
|
10
|
+
* (seq-stamped) instead of a fresh handler, and `cleanup()` DETACHES the bridge (session survives the drop).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
function fakeSession(): {
|
|
14
|
+
session: IInteractiveSession;
|
|
15
|
+
fire: (event: string, arg: unknown) => void;
|
|
16
|
+
} {
|
|
17
|
+
const handlers = new Map<string, (arg: unknown) => void>();
|
|
18
|
+
// ARCH-012: the conformant double. The three-member partial this replaces was accepted only by a
|
|
19
|
+
// cast, so the suite ran against a session shape no implementation could have.
|
|
20
|
+
const session = createTestInteractiveSession({
|
|
21
|
+
on: ((e: string, h: (arg: unknown) => void) => {
|
|
22
|
+
handlers.set(e, h);
|
|
23
|
+
}) as IInteractiveSession['on'],
|
|
24
|
+
off: ((e: string) => {
|
|
25
|
+
handlers.delete(e);
|
|
26
|
+
}) as IInteractiveSession['off'],
|
|
27
|
+
});
|
|
28
|
+
return { session, fire: (e, a) => handlers.get(e)?.(a) };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('PairingGate E4 resume bridge (REMOTE-013)', () => {
|
|
32
|
+
it('routes the paired session through the bridge (seq-stamped) and detaches on cleanup', async () => {
|
|
33
|
+
const { session, fire } = fakeSession();
|
|
34
|
+
const bridge = new SessionResumeBridge({ session });
|
|
35
|
+
const sent: string[] = [];
|
|
36
|
+
const channel = { send: (d: string) => sent.push(d), close: () => {} };
|
|
37
|
+
// Fake handshake that accepts immediately (isolates the bridge wiring from real B3 crypto).
|
|
38
|
+
const fakeHandshake = (() => ({
|
|
39
|
+
result: Promise.resolve({ sessionKey: 'k' }),
|
|
40
|
+
onFrame: () => {},
|
|
41
|
+
})) as never;
|
|
42
|
+
|
|
43
|
+
const gate = new PairingGate({
|
|
44
|
+
channel,
|
|
45
|
+
session,
|
|
46
|
+
secret: 's',
|
|
47
|
+
role: 'initiator',
|
|
48
|
+
localFingerprint: 'A',
|
|
49
|
+
remoteFingerprint: 'B',
|
|
50
|
+
resumeBridge: bridge,
|
|
51
|
+
startHandshake: fakeHandshake,
|
|
52
|
+
});
|
|
53
|
+
// Drive to accept (the fake handshake resolves on a microtask).
|
|
54
|
+
gate.onInbound(JSON.stringify({ t: 'pair-nonce', nonce: 'n' }));
|
|
55
|
+
await Promise.resolve();
|
|
56
|
+
await Promise.resolve();
|
|
57
|
+
|
|
58
|
+
// A session event now flows through the bridge → the channel, seq-stamped.
|
|
59
|
+
fire('text_delta', 'hi');
|
|
60
|
+
expect(sent).toHaveLength(1);
|
|
61
|
+
const frame = JSON.parse(sent[0]);
|
|
62
|
+
expect(frame).toMatchObject({ type: 'text_delta', delta: 'hi', seq: 1 });
|
|
63
|
+
|
|
64
|
+
// Cleanup DETACHES (does not dispose) — a subsequent event is buffered, not sent, and the session survives.
|
|
65
|
+
gate.cleanup();
|
|
66
|
+
fire('text_delta', 'gap'); // buffered at seq 2, not sent (detached)
|
|
67
|
+
expect(sent).toHaveLength(1);
|
|
68
|
+
|
|
69
|
+
// Re-attach a new channel + resume → the gap frame replays with its ORIGINAL seq 2 (continuity).
|
|
70
|
+
const sent2: string[] = [];
|
|
71
|
+
bridge.attach((d) => sent2.push(d), { onDeliveryError: vi.fn() });
|
|
72
|
+
bridge.onClientMessage(JSON.stringify({ type: 'resume', lastSeq: 1 }));
|
|
73
|
+
expect(sent2.map((s) => JSON.parse(s).seq)).toEqual([2]);
|
|
74
|
+
bridge.dispose();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('a resume/ack inbound frame post-accept is routed to the bridge (not the session)', async () => {
|
|
78
|
+
const { session, fire } = fakeSession();
|
|
79
|
+
const bridge = new SessionResumeBridge({ session });
|
|
80
|
+
const sent: string[] = [];
|
|
81
|
+
const channel = { send: (d: string) => sent.push(d), close: () => {} };
|
|
82
|
+
const fakeHandshake = (() => ({
|
|
83
|
+
result: Promise.resolve({ sessionKey: 'k' }),
|
|
84
|
+
onFrame: () => {},
|
|
85
|
+
})) as never;
|
|
86
|
+
const gate = new PairingGate({
|
|
87
|
+
channel,
|
|
88
|
+
session,
|
|
89
|
+
secret: 's',
|
|
90
|
+
role: 'initiator',
|
|
91
|
+
localFingerprint: 'A',
|
|
92
|
+
remoteFingerprint: 'B',
|
|
93
|
+
resumeBridge: bridge,
|
|
94
|
+
startHandshake: fakeHandshake,
|
|
95
|
+
});
|
|
96
|
+
gate.onInbound(JSON.stringify({ t: 'pair-nonce', nonce: 'n' }));
|
|
97
|
+
await Promise.resolve();
|
|
98
|
+
await Promise.resolve();
|
|
99
|
+
fire('text_delta', 'a'); // seq 1
|
|
100
|
+
fire('text_delta', 'b'); // seq 2
|
|
101
|
+
sent.length = 0;
|
|
102
|
+
// ack via the channel → bridge frees ≤1; resume lastSeq 1 → replays only seq 2.
|
|
103
|
+
gate.onInbound(JSON.stringify({ type: 'ack', seq: 1 }));
|
|
104
|
+
gate.onInbound(JSON.stringify({ type: 'resume', lastSeq: 1 }));
|
|
105
|
+
expect(sent.map((s) => JSON.parse(s).seq)).toEqual([2]);
|
|
106
|
+
bridge.dispose();
|
|
107
|
+
});
|
|
108
|
+
});
|