@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,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A minimal TURN client over UDP for tests: it speaks just enough RFC 8656 to allocate, refresh,
|
|
3
|
+
* permit, bind channels and move data, and reports what the server answered.
|
|
4
|
+
*/
|
|
5
|
+
import { randomBytes } from 'node:crypto';
|
|
6
|
+
import { createSocket, type Socket } from 'node:dgram';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
StunAttr,
|
|
10
|
+
StunClass,
|
|
11
|
+
StunMethod,
|
|
12
|
+
attribute,
|
|
13
|
+
decodeChannelData,
|
|
14
|
+
decodeStun,
|
|
15
|
+
decodeXorAddress,
|
|
16
|
+
encodeChannelData,
|
|
17
|
+
encodeStun,
|
|
18
|
+
encodeXorAddress,
|
|
19
|
+
isChannelData,
|
|
20
|
+
longTermKey,
|
|
21
|
+
readErrorCode,
|
|
22
|
+
uint32,
|
|
23
|
+
type IStunMessage,
|
|
24
|
+
type ITransportAddress,
|
|
25
|
+
} from '../stun-message.js';
|
|
26
|
+
|
|
27
|
+
/** Like a real client's, it makes the first request as large as the challenge that answers it. */
|
|
28
|
+
const SOFTWARE = { type: 0x8022, value: Buffer.from('agent turn test client') };
|
|
29
|
+
|
|
30
|
+
export interface ITurnAnswer {
|
|
31
|
+
readonly ok: boolean;
|
|
32
|
+
readonly code?: number;
|
|
33
|
+
readonly message: IStunMessage;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class TurnTestClient {
|
|
37
|
+
private realm?: string;
|
|
38
|
+
private nonce?: string;
|
|
39
|
+
private readonly waiting = new Map<string, (message: IStunMessage) => void>();
|
|
40
|
+
private readonly data: { from: ITransportAddress; payload: Buffer }[] = [];
|
|
41
|
+
|
|
42
|
+
private constructor(
|
|
43
|
+
private readonly socket: Socket,
|
|
44
|
+
private readonly server: ITransportAddress,
|
|
45
|
+
private readonly username: string,
|
|
46
|
+
private readonly password: string,
|
|
47
|
+
) {
|
|
48
|
+
socket.on('message', (raw) => {
|
|
49
|
+
if (isChannelData(raw)) {
|
|
50
|
+
const frame = decodeChannelData(raw);
|
|
51
|
+
if (frame !== undefined) {
|
|
52
|
+
this.data.push({
|
|
53
|
+
from: { address: `channel`, port: frame.channel },
|
|
54
|
+
payload: frame.payload,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const message = decodeStun(raw);
|
|
60
|
+
if (message === undefined) return;
|
|
61
|
+
if (message.cls === StunClass.Indication && message.method === StunMethod.Data) {
|
|
62
|
+
const peer = attribute(message, StunAttr.XorPeerAddress);
|
|
63
|
+
const payload = attribute(message, StunAttr.Data);
|
|
64
|
+
const from = peer && decodeXorAddress(peer, message.transactionId);
|
|
65
|
+
if (from && payload) this.data.push({ from, payload: Buffer.from(payload) });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const key = message.transactionId.toString('hex');
|
|
69
|
+
this.waiting.get(key)?.(message);
|
|
70
|
+
this.waiting.delete(key);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
public static async connect(
|
|
75
|
+
server: ITransportAddress,
|
|
76
|
+
username: string,
|
|
77
|
+
password: string,
|
|
78
|
+
): Promise<TurnTestClient> {
|
|
79
|
+
const socket = createSocket('udp4');
|
|
80
|
+
await new Promise<void>((resolve) => socket.bind(0, '127.0.0.1', resolve));
|
|
81
|
+
return new TurnTestClient(socket, server, username, password);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Relayed data received so far. */
|
|
85
|
+
public received(): readonly { from: ITransportAddress; payload: Buffer }[] {
|
|
86
|
+
return this.data;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private exchange(message: Buffer, transactionId: Buffer): Promise<IStunMessage> {
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
const timer = setTimeout(() => reject(new Error('no TURN answer')), 2_000);
|
|
92
|
+
this.waiting.set(transactionId.toString('hex'), (answer) => {
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
resolve(answer);
|
|
95
|
+
});
|
|
96
|
+
this.socket.send(message, this.server.port, this.server.address);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Send an authenticated request, answering a 401/438 challenge once. */
|
|
101
|
+
public async request(
|
|
102
|
+
method: number,
|
|
103
|
+
attrs: { type: number; value: Buffer }[] = [],
|
|
104
|
+
transactionId: Buffer = randomBytes(12),
|
|
105
|
+
/** Attributes an on-path attacker appends after MESSAGE-INTEGRITY. */
|
|
106
|
+
appended: { type: number; value: Buffer }[] = [],
|
|
107
|
+
): Promise<ITurnAnswer> {
|
|
108
|
+
// One transaction id throughout: the peer addresses in `attrs` are XORed with it.
|
|
109
|
+
for (let round = 0; round < 3; round += 1) {
|
|
110
|
+
const auth =
|
|
111
|
+
this.realm !== undefined && this.nonce !== undefined
|
|
112
|
+
? [
|
|
113
|
+
{ type: StunAttr.Username, value: Buffer.from(this.username) },
|
|
114
|
+
{ type: StunAttr.Realm, value: Buffer.from(this.realm) },
|
|
115
|
+
{ type: StunAttr.Nonce, value: Buffer.from(this.nonce) },
|
|
116
|
+
]
|
|
117
|
+
: [];
|
|
118
|
+
const key =
|
|
119
|
+
this.realm !== undefined
|
|
120
|
+
? longTermKey(this.username, this.realm, this.password)
|
|
121
|
+
: undefined;
|
|
122
|
+
const answer = await this.exchange(
|
|
123
|
+
withAppended(
|
|
124
|
+
encodeStun(method, StunClass.Request, transactionId, [...attrs, SOFTWARE, ...auth], {
|
|
125
|
+
...(key !== undefined && auth.length > 0 ? { integrityKey: key } : {}),
|
|
126
|
+
fingerprint: appended.length === 0,
|
|
127
|
+
}),
|
|
128
|
+
appended,
|
|
129
|
+
),
|
|
130
|
+
transactionId,
|
|
131
|
+
);
|
|
132
|
+
const code = readErrorCode(attribute(answer, StunAttr.ErrorCode));
|
|
133
|
+
const challenged = code === 401 || code === 438;
|
|
134
|
+
const realm = attribute(answer, StunAttr.Realm)?.toString();
|
|
135
|
+
const nonce = attribute(answer, StunAttr.Nonce)?.toString();
|
|
136
|
+
if (challenged && realm !== undefined && nonce !== undefined) {
|
|
137
|
+
const first = this.nonce === undefined || code === 438;
|
|
138
|
+
this.realm = realm;
|
|
139
|
+
this.nonce = nonce;
|
|
140
|
+
if (first) continue;
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
ok: answer.cls === StunClass.Success,
|
|
144
|
+
...(code !== undefined ? { code } : {}),
|
|
145
|
+
message: answer,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
throw new Error('TURN challenge loop');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
public allocate(
|
|
152
|
+
extra: { type: number; value: Buffer }[] = [],
|
|
153
|
+
transport = 17,
|
|
154
|
+
): Promise<ITurnAnswer> {
|
|
155
|
+
return this.request(StunMethod.Allocate, [
|
|
156
|
+
{ type: StunAttr.RequestedTransport, value: Buffer.from([transport, 0, 0, 0]) },
|
|
157
|
+
...extra,
|
|
158
|
+
]);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
public refresh(lifetime: number): Promise<ITurnAnswer> {
|
|
162
|
+
return this.request(StunMethod.Refresh, [{ type: StunAttr.Lifetime, value: uint32(lifetime) }]);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Permit `peer`; `injected` is appended after MESSAGE-INTEGRITY, as an on-path attacker would. */
|
|
166
|
+
public permit(peer: ITransportAddress, injected?: ITransportAddress): Promise<ITurnAnswer> {
|
|
167
|
+
const transactionId = randomBytes(12);
|
|
168
|
+
return this.request(
|
|
169
|
+
StunMethod.CreatePermission,
|
|
170
|
+
[{ type: StunAttr.XorPeerAddress, value: encodeXorAddress(peer, transactionId) }],
|
|
171
|
+
transactionId,
|
|
172
|
+
injected === undefined
|
|
173
|
+
? []
|
|
174
|
+
: [{ type: StunAttr.XorPeerAddress, value: encodeXorAddress(injected, transactionId) }],
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
public bindChannel(channel: number, peer: ITransportAddress): Promise<ITurnAnswer> {
|
|
179
|
+
const transactionId = randomBytes(12);
|
|
180
|
+
const number = Buffer.alloc(4);
|
|
181
|
+
number.writeUInt16BE(channel, 0);
|
|
182
|
+
return this.request(
|
|
183
|
+
StunMethod.ChannelBind,
|
|
184
|
+
[
|
|
185
|
+
{ type: StunAttr.ChannelNumber, value: number },
|
|
186
|
+
{ type: StunAttr.XorPeerAddress, value: encodeXorAddress(peer, transactionId) },
|
|
187
|
+
],
|
|
188
|
+
transactionId,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Send data to `peer` through the relay in a Send indication. */
|
|
193
|
+
public sendTo(peer: ITransportAddress, payload: Buffer): void {
|
|
194
|
+
const transactionId = randomBytes(12);
|
|
195
|
+
this.socket.send(
|
|
196
|
+
encodeStun(StunMethod.Send, StunClass.Indication, transactionId, [
|
|
197
|
+
{ type: StunAttr.XorPeerAddress, value: encodeXorAddress(peer, transactionId) },
|
|
198
|
+
{ type: StunAttr.Data, value: payload },
|
|
199
|
+
]),
|
|
200
|
+
this.server.port,
|
|
201
|
+
this.server.address,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
public sendOnChannel(channel: number, payload: Buffer): void {
|
|
206
|
+
this.socket.send(encodeChannelData(channel, payload), this.server.port, this.server.address);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
public close(): void {
|
|
210
|
+
this.socket.close();
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** `message` with `attrs` appended and its length updated. */
|
|
215
|
+
function withAppended(message: Buffer, attrs: { type: number; value: Buffer }[]): Buffer {
|
|
216
|
+
if (attrs.length === 0) return message;
|
|
217
|
+
const parts = [message];
|
|
218
|
+
for (const { type, value } of attrs) {
|
|
219
|
+
const head = Buffer.alloc(4);
|
|
220
|
+
head.writeUInt16BE(type, 0);
|
|
221
|
+
head.writeUInt16BE(value.length, 2);
|
|
222
|
+
parts.push(head, value, Buffer.alloc((4 - (value.length % 4)) % 4));
|
|
223
|
+
}
|
|
224
|
+
const out = Buffer.concat(parts);
|
|
225
|
+
out.writeUInt16BE(out.length - 20, 2);
|
|
226
|
+
return out;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The relayed address in an Allocate success. */
|
|
230
|
+
export function relayedAddress(answer: ITurnAnswer): ITransportAddress | undefined {
|
|
231
|
+
const value = attribute(answer.message, StunAttr.XorRelayedAddress);
|
|
232
|
+
return value && decodeXorAddress(value, answer.message.transactionId);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function lifetimeOf(answer: ITurnAnswer): number | undefined {
|
|
236
|
+
return attribute(answer.message, StunAttr.Lifetime)?.readUInt32BE(0);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** A plain UDP peer on loopback that records what reaches it. */
|
|
240
|
+
export async function udpPeer(): Promise<{
|
|
241
|
+
readonly address: ITransportAddress;
|
|
242
|
+
readonly received: Buffer[];
|
|
243
|
+
send(to: ITransportAddress, payload: Buffer): void;
|
|
244
|
+
close(): void;
|
|
245
|
+
}> {
|
|
246
|
+
const socket = createSocket('udp4');
|
|
247
|
+
await new Promise<void>((resolve) => socket.bind(0, '127.0.0.1', resolve));
|
|
248
|
+
const received: Buffer[] = [];
|
|
249
|
+
socket.on('message', (data) => received.push(data));
|
|
250
|
+
return {
|
|
251
|
+
address: { address: '127.0.0.1', port: socket.address().port },
|
|
252
|
+
received,
|
|
253
|
+
send: (to, payload) => socket.send(payload, to.port, to.address),
|
|
254
|
+
close: () => socket.close(),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { createIdentityContext } from '@robota-sdk/agent-remote-pairing';
|
|
2
|
+
const testIdentity = createIdentityContext('test-product');
|
|
3
|
+
/**
|
|
4
|
+
* The Node transport runs on `node-datachannel` (libdatachannel, DTLS by OpenSSL) and nothing else:
|
|
5
|
+
* where it cannot load, the transport is unavailable, never backed by another implementation.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { createRequire } from 'node:module';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
12
|
+
|
|
13
|
+
import { loadDataChannel } from '../datachannel-loader.js';
|
|
14
|
+
import { RtcPeer } from '../rtc-peer.js';
|
|
15
|
+
import { createInMemorySignalingPair } from '../signaling.js';
|
|
16
|
+
import { WebRtcTransport } from '../webrtc-transport.js';
|
|
17
|
+
|
|
18
|
+
import type { IProtocolSession } from '@robota-sdk/agent-transport';
|
|
19
|
+
|
|
20
|
+
const UNAVAILABLE = /WebRTC transport unavailable/;
|
|
21
|
+
|
|
22
|
+
describe('the WebRTC implementation', () => {
|
|
23
|
+
it('is node-datachannel: the loader asks for that module only and returns it', () => {
|
|
24
|
+
const requested: string[] = [];
|
|
25
|
+
const real = createRequire(import.meta.url)('node-datachannel') as unknown;
|
|
26
|
+
const loaded = loadDataChannel((id) => {
|
|
27
|
+
requested.push(id);
|
|
28
|
+
return real;
|
|
29
|
+
});
|
|
30
|
+
expect(requested).toEqual(['node-datachannel']);
|
|
31
|
+
expect(loaded).toBe(real);
|
|
32
|
+
expect(loadDataChannel()).toBe(real);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('is the only WebRTC implementation the package declares', () => {
|
|
36
|
+
const manifest = JSON.parse(
|
|
37
|
+
readFileSync(join(import.meta.dirname, '..', '..', 'package.json'), 'utf8'),
|
|
38
|
+
) as {
|
|
39
|
+
dependencies?: Record<string, string>;
|
|
40
|
+
peerDependencies?: Record<string, string>;
|
|
41
|
+
};
|
|
42
|
+
expect(Object.keys(manifest.peerDependencies ?? {})).toEqual(['node-datachannel']);
|
|
43
|
+
expect(Object.keys(manifest.dependencies ?? {})).not.toContain('node-datachannel');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('backs a real connection', () => {
|
|
47
|
+
const peer = new RtcPeer();
|
|
48
|
+
expect(peer.state).toBe('new');
|
|
49
|
+
peer.close();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('when node-datachannel cannot load', () => {
|
|
54
|
+
const missing = (): never => {
|
|
55
|
+
throw new Error("Cannot find module 'node-datachannel'");
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
it('the loader refuses, whether the module is absent or has no binding for this platform', () => {
|
|
59
|
+
expect(() => loadDataChannel(missing)).toThrow(UNAVAILABLE);
|
|
60
|
+
expect(() => loadDataChannel(() => ({}))).toThrow(UNAVAILABLE);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('a connection refuses instead of being built on anything else', () => {
|
|
64
|
+
expect(() => new RtcPeer({ loadDataChannel: () => loadDataChannel(missing) })).toThrow(
|
|
65
|
+
UNAVAILABLE,
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('the transport start refuses and sends nothing', async () => {
|
|
70
|
+
const [signaling] = createInMemorySignalingPair();
|
|
71
|
+
const send = vi.spyOn(signaling, 'send');
|
|
72
|
+
const transport = new WebRtcTransport({cryptoContext: testIdentity,
|
|
73
|
+
signaling,
|
|
74
|
+
open: true,
|
|
75
|
+
openReason: 'implementation availability test',
|
|
76
|
+
loadDataChannel: () => loadDataChannel(missing),
|
|
77
|
+
});
|
|
78
|
+
transport.attach({} as IProtocolSession);
|
|
79
|
+
await expect(transport.start()).rejects.toThrow(UNAVAILABLE);
|
|
80
|
+
expect(send).not.toHaveBeenCalled();
|
|
81
|
+
await transport.stop();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { createIdentityContext } from '@robota-sdk/agent-remote-pairing';
|
|
2
|
+
const testIdentity = createIdentityContext('test-product');
|
|
3
|
+
import { runTransportLifecycleConformance } from '@robota-sdk/agent-interface-transport/testing';
|
|
4
|
+
import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
|
|
5
|
+
|
|
6
|
+
import { describe, expect, expectTypeOf, it, vi } from 'vitest';
|
|
7
|
+
import type { IConfigurableTransport } from '@robota-sdk/agent-interface-transport';
|
|
8
|
+
import type { IInteractiveSession } from '@robota-sdk/agent-interface-session';
|
|
9
|
+
import type { IProtocolSession } from '@robota-sdk/agent-transport';
|
|
10
|
+
|
|
11
|
+
import { HISTORY_PAGE_MAX_BYTES } from '@robota-sdk/agent-transport';
|
|
12
|
+
import { WebRtcTransport } from '../webrtc-transport.js';
|
|
13
|
+
import { createInMemorySignalingPair, type ISignalingClient } from '../signaling.js';
|
|
14
|
+
import { WebRtcDeliveryLifecycle } from '../webrtc-delivery-lifecycle.js';
|
|
15
|
+
import { RtcPeer } from '../rtc-peer.js';
|
|
16
|
+
import { fakeDataChannel } from './fake-datachannel.js';
|
|
17
|
+
|
|
18
|
+
/** Minimal stub session — only `getMessages` + no-op `on`/`off` are exercised by the get-messages round-trip. */
|
|
19
|
+
function createStubSession(): IInteractiveSession {
|
|
20
|
+
return Object.assign(createTestInteractiveSession(), {
|
|
21
|
+
getMessages: vi.fn().mockReturnValue([{ role: 'user', content: 'hi' }]),
|
|
22
|
+
// ARCH-012: required. This double feeds `subscribeSessionEvents`, which calls it on every
|
|
23
|
+
// turn-authored event — omitting it throws the moment a case emits one.
|
|
24
|
+
getActiveDriverId: () => null,
|
|
25
|
+
on: vi.fn(),
|
|
26
|
+
off: vi.fn(),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The remote peer (answerer): consumes the host's offer via the injected signaling, opens the data channel,
|
|
32
|
+
* and speaks the `TClientMessage`/`TServerMessage` protocol. Resolves with the first `TServerMessage` received.
|
|
33
|
+
*/
|
|
34
|
+
function connectRemote(
|
|
35
|
+
signaling: ISignalingClient,
|
|
36
|
+
request: Record<string, unknown> = { type: 'get-messages' },
|
|
37
|
+
): Promise<Record<string, unknown>> {
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
const peer = new RtcPeer();
|
|
40
|
+
peer.onLocalCandidate((c) =>
|
|
41
|
+
signaling.send({ kind: 'ice', data: { candidate: c.candidate, sdpMid: c.mid } }),
|
|
42
|
+
);
|
|
43
|
+
let chain: Promise<void> = Promise.resolve();
|
|
44
|
+
signaling.onSignal((message) => {
|
|
45
|
+
chain = chain
|
|
46
|
+
.then(async () => {
|
|
47
|
+
const data = message.data as { sdp?: string; candidate?: string; sdpMid?: string };
|
|
48
|
+
if (message.kind === 'offer') {
|
|
49
|
+
const sdp = await peer.acceptOffer(data.sdp!);
|
|
50
|
+
signaling.send({ kind: 'answer', data: { type: 'answer', sdp } });
|
|
51
|
+
} else if (message.kind === 'ice') {
|
|
52
|
+
peer.addRemoteCandidate({ candidate: data.candidate!, mid: data.sdpMid ?? '0' });
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
.catch(reject);
|
|
56
|
+
});
|
|
57
|
+
peer.onDataChannel((channel) => {
|
|
58
|
+
const ask = (): void => channel.send(JSON.stringify(request));
|
|
59
|
+
if (channel.readyState === 'open') ask();
|
|
60
|
+
else channel.onStateChange((state) => state === 'open' && ask());
|
|
61
|
+
channel.onMessage((text) => resolve(JSON.parse(text) as Record<string, unknown>));
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe('WebRtcTransport (REMOTE-002 Stage A — loopback)', () => {
|
|
67
|
+
it('accepts only the protocol session roles required by the carrier', () => {
|
|
68
|
+
const transport = new WebRtcTransport({cryptoContext: testIdentity,
|
|
69
|
+
signaling: createInMemorySignalingPair()[0],
|
|
70
|
+
open: true,
|
|
71
|
+
openReason: 'type compatibility test',
|
|
72
|
+
});
|
|
73
|
+
expectTypeOf(transport).toMatchTypeOf<IConfigurableTransport<IProtocolSession>>();
|
|
74
|
+
expectTypeOf(transport.attach).toEqualTypeOf<(session: IProtocolSession) => void>();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('has the collapsed webrtc metadata and is disabled by default', () => {
|
|
78
|
+
const [sig] = createInMemorySignalingPair();
|
|
79
|
+
const t = new WebRtcTransport({cryptoContext: testIdentity,
|
|
80
|
+
signaling: sig,
|
|
81
|
+
open: true,
|
|
82
|
+
openReason: 'SEC-008: Stage-A loopback — this case is about signalling, not pairing',
|
|
83
|
+
});
|
|
84
|
+
expect(t.name).toBe('webrtc');
|
|
85
|
+
expect(t.defaultEnabled).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('start() before attach() throws', async () => {
|
|
89
|
+
const [sig] = createInMemorySignalingPair();
|
|
90
|
+
const t = new WebRtcTransport({cryptoContext: testIdentity,
|
|
91
|
+
signaling: sig,
|
|
92
|
+
open: true,
|
|
93
|
+
openReason: 'SEC-008: Stage-A loopback — this case is about signalling, not pairing',
|
|
94
|
+
});
|
|
95
|
+
await expect(t.start()).rejects.toMatchObject({
|
|
96
|
+
name: 'TransportLifecycleError',
|
|
97
|
+
code: 'not-attached',
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('unpaired send failure closes the carrier and reports without escaping the session event', async () => {
|
|
102
|
+
const handlers = new Map<string, (value: unknown) => void>();
|
|
103
|
+
const session = createTestInteractiveSession({
|
|
104
|
+
on: ((event: string, handler: (value: unknown) => void) => {
|
|
105
|
+
handlers.set(event, handler);
|
|
106
|
+
}) as IInteractiveSession['on'],
|
|
107
|
+
off: ((event: string) => {
|
|
108
|
+
handlers.delete(event);
|
|
109
|
+
}) as IInteractiveSession['off'],
|
|
110
|
+
});
|
|
111
|
+
const closeChannel = vi.fn();
|
|
112
|
+
const fake = fakeDataChannel({
|
|
113
|
+
channel: {
|
|
114
|
+
sendMessage: () => {
|
|
115
|
+
throw new Error('unpaired channel closed');
|
|
116
|
+
},
|
|
117
|
+
close: closeChannel,
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
const onDeliveryError = vi.fn();
|
|
121
|
+
const onDropped = vi.fn();
|
|
122
|
+
const transport = new WebRtcTransport({cryptoContext: testIdentity,
|
|
123
|
+
signaling: { send: () => {}, onSignal: () => () => {}, close: () => {} },
|
|
124
|
+
open: true,
|
|
125
|
+
openReason: 'delivery lifecycle regression',
|
|
126
|
+
onDeliveryError,
|
|
127
|
+
onDropped,
|
|
128
|
+
loadDataChannel: () => fake.module,
|
|
129
|
+
});
|
|
130
|
+
transport.attach(session);
|
|
131
|
+
await transport.start();
|
|
132
|
+
|
|
133
|
+
expect(() =>
|
|
134
|
+
handlers.get('branch_event')?.({
|
|
135
|
+
kind: 'checkpoint_created',
|
|
136
|
+
checkpointId: 'turn-0001',
|
|
137
|
+
branchId: 'main',
|
|
138
|
+
}),
|
|
139
|
+
).not.toThrow();
|
|
140
|
+
// The stream is reset after the channel's close grace.
|
|
141
|
+
await vi.waitFor(() => expect(closeChannel).toHaveBeenCalledTimes(1));
|
|
142
|
+
expect(onDeliveryError).toHaveBeenCalledWith(
|
|
143
|
+
expect.objectContaining({ message: 'unpaired channel closed' }),
|
|
144
|
+
'branch_event',
|
|
145
|
+
);
|
|
146
|
+
expect(onDropped).not.toHaveBeenCalled();
|
|
147
|
+
expect(handlers.size).toBe(0);
|
|
148
|
+
await transport.stop();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('resets pairing and drop guards for every restart generation', () => {
|
|
152
|
+
const cleanup = vi.fn();
|
|
153
|
+
const onDropped = vi.fn();
|
|
154
|
+
const lifecycle = new WebRtcDeliveryLifecycle({
|
|
155
|
+
cleanup,
|
|
156
|
+
onDropped,
|
|
157
|
+
onDeliveryError: vi.fn(),
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
lifecycle.reset(1);
|
|
161
|
+
lifecycle.accept(1);
|
|
162
|
+
lifecycle.handleDrop(1);
|
|
163
|
+
lifecycle.handleDrop(1);
|
|
164
|
+
expect(cleanup).toHaveBeenCalledTimes(1);
|
|
165
|
+
expect(onDropped).toHaveBeenCalledTimes(1);
|
|
166
|
+
|
|
167
|
+
lifecycle.reset(2);
|
|
168
|
+
lifecycle.handleDrop(2);
|
|
169
|
+
lifecycle.accept(2);
|
|
170
|
+
lifecycle.handleDrop(1);
|
|
171
|
+
lifecycle.handleDrop(2);
|
|
172
|
+
expect(cleanup).toHaveBeenCalledTimes(2);
|
|
173
|
+
expect(onDropped).toHaveBeenCalledTimes(2);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('ignores queued signaling, ICE, and channel callbacks from an older generation', async () => {
|
|
177
|
+
const sent: Array<{ readonly kind: string }> = [];
|
|
178
|
+
const signalHandlers: Array<(message: { kind: 'answer'; data: object }) => void> = [];
|
|
179
|
+
const setRemoteDescription = vi.fn();
|
|
180
|
+
const onDropped = vi.fn();
|
|
181
|
+
const signaling: ISignalingClient = {
|
|
182
|
+
send: (message) => sent.push(message),
|
|
183
|
+
onSignal: (handler) => {
|
|
184
|
+
signalHandlers.push(handler as (message: { kind: 'answer'; data: object }) => void);
|
|
185
|
+
return () => {};
|
|
186
|
+
},
|
|
187
|
+
close: () => {},
|
|
188
|
+
};
|
|
189
|
+
const fake = fakeDataChannel({ setRemoteDescription });
|
|
190
|
+
const transport = new WebRtcTransport({cryptoContext: testIdentity,
|
|
191
|
+
signaling,
|
|
192
|
+
secret: 'pairing-secret',
|
|
193
|
+
onDropped,
|
|
194
|
+
loadDataChannel: () => fake.module,
|
|
195
|
+
});
|
|
196
|
+
transport.attach(createStubSession());
|
|
197
|
+
await transport.start();
|
|
198
|
+
const oldSignal = signalHandlers[0]!;
|
|
199
|
+
const oldIce = fake.connections[0]!.localCandidate!;
|
|
200
|
+
const oldState = fake.connections[0]!.channelClosed!;
|
|
201
|
+
|
|
202
|
+
oldSignal({ kind: 'answer', data: { sdp: 'a=fingerprint:sha-256 BB' } });
|
|
203
|
+
await transport.stop();
|
|
204
|
+
transport.attach(createStubSession());
|
|
205
|
+
await transport.start();
|
|
206
|
+
oldIce('a=candidate:stale', '0');
|
|
207
|
+
oldState();
|
|
208
|
+
await Promise.resolve();
|
|
209
|
+
|
|
210
|
+
expect(setRemoteDescription).not.toHaveBeenCalled();
|
|
211
|
+
expect(sent.filter(({ kind }) => kind === 'ice')).toEqual([]);
|
|
212
|
+
expect(onDropped).not.toHaveBeenCalled();
|
|
213
|
+
await transport.stop();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('invokes the shared lifecycle conformance suite', async () => {
|
|
217
|
+
const sent: Array<{ readonly kind: string }> = [];
|
|
218
|
+
const signaling: ISignalingClient = {
|
|
219
|
+
send: (message) => sent.push(message),
|
|
220
|
+
onSignal: () => () => {},
|
|
221
|
+
close: () => {},
|
|
222
|
+
};
|
|
223
|
+
const fake = fakeDataChannel();
|
|
224
|
+
|
|
225
|
+
await runTransportLifecycleConformance({
|
|
226
|
+
subjectId: '@robota-sdk/agent-transport-webrtc#WebRtcTransport',
|
|
227
|
+
kind: 'service',
|
|
228
|
+
createAdapter: () =>
|
|
229
|
+
new WebRtcTransport({cryptoContext: testIdentity,
|
|
230
|
+
signaling,
|
|
231
|
+
open: true,
|
|
232
|
+
openReason: 'ARCH-011 lifecycle conformance',
|
|
233
|
+
loadDataChannel: () => fake.module,
|
|
234
|
+
}),
|
|
235
|
+
createSession: createStubSession,
|
|
236
|
+
assertReady: () => {
|
|
237
|
+
if (!sent.some(({ kind }) => kind === 'offer')) throw new Error('offer not published');
|
|
238
|
+
},
|
|
239
|
+
assertStopped: () => {},
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('REMOTE-010: forceTurn → a relay-only connection, and a TURN url becomes a TURN server with its credentials', async () => {
|
|
244
|
+
// A fake module captures the configuration (a real relay-only peer with a dead TURN would never gather).
|
|
245
|
+
const fake = fakeDataChannel();
|
|
246
|
+
const [sig] = createInMemorySignalingPair();
|
|
247
|
+
const t = new WebRtcTransport({cryptoContext: testIdentity,
|
|
248
|
+
open: true,
|
|
249
|
+
openReason: 'SEC-008: Stage-A loopback — this case is about signalling, not pairing',
|
|
250
|
+
signaling: sig,
|
|
251
|
+
iceServers: [
|
|
252
|
+
{ urls: 'turn:relay.example:3478', username: 'u', credential: 'c' },
|
|
253
|
+
{ urls: 'stun:stun.example' },
|
|
254
|
+
],
|
|
255
|
+
forceTurn: true,
|
|
256
|
+
loadDataChannel: () => fake.module,
|
|
257
|
+
});
|
|
258
|
+
t.attach(createStubSession());
|
|
259
|
+
await t.start();
|
|
260
|
+
expect(fake.connections[0]!.config).toEqual({
|
|
261
|
+
iceServers: [
|
|
262
|
+
{
|
|
263
|
+
hostname: 'relay.example',
|
|
264
|
+
port: 3478,
|
|
265
|
+
username: 'u',
|
|
266
|
+
password: 'c',
|
|
267
|
+
relayType: 'TurnUdp',
|
|
268
|
+
},
|
|
269
|
+
'stun:stun.example:3478',
|
|
270
|
+
],
|
|
271
|
+
iceTransportPolicy: 'relay',
|
|
272
|
+
disableAutoNegotiation: true,
|
|
273
|
+
});
|
|
274
|
+
await t.stop();
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('contacts no ICE server unless one is configured', async () => {
|
|
278
|
+
const fake = fakeDataChannel();
|
|
279
|
+
const [sig] = createInMemorySignalingPair();
|
|
280
|
+
const t = new WebRtcTransport({cryptoContext: testIdentity,
|
|
281
|
+
open: true,
|
|
282
|
+
openReason: 'configuration test',
|
|
283
|
+
signaling: sig,
|
|
284
|
+
loadDataChannel: () => fake.module,
|
|
285
|
+
});
|
|
286
|
+
t.attach(createStubSession());
|
|
287
|
+
await t.start();
|
|
288
|
+
expect(fake.connections[0]!.config.iceServers).toEqual([]);
|
|
289
|
+
await t.stop();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('TC-03: establishes an RTCDataChannel between two peers and round-trips TClient→session→TServer through the shared handler', async () => {
|
|
293
|
+
const [hostSig, remoteSig] = createInMemorySignalingPair();
|
|
294
|
+
const session = createStubSession();
|
|
295
|
+
const host = new WebRtcTransport({cryptoContext: testIdentity,
|
|
296
|
+
signaling: hostSig,
|
|
297
|
+
open: true,
|
|
298
|
+
openReason: 'SEC-008: Stage-A loopback — this case is about signalling, not pairing',
|
|
299
|
+
});
|
|
300
|
+
host.attach(session);
|
|
301
|
+
|
|
302
|
+
const remoteReply = connectRemote(remoteSig);
|
|
303
|
+
await host.start();
|
|
304
|
+
|
|
305
|
+
const reply = await remoteReply;
|
|
306
|
+
// The remote's `get-messages` reached the session via the reused handler; the host streamed back a
|
|
307
|
+
// `messages` TServerMessage carrying the stub session's messages — a full P2P round-trip, no network.
|
|
308
|
+
expect(reply.type).toBe('messages');
|
|
309
|
+
expect(reply.messages).toEqual([{ role: 'user', content: 'hi' }]);
|
|
310
|
+
expect(session.getMessages).toHaveBeenCalled();
|
|
311
|
+
|
|
312
|
+
await host.stop();
|
|
313
|
+
}, 15000);
|
|
314
|
+
|
|
315
|
+
it('refuses an app view larger than a history page on the open path, before the data channel is asked to carry it', async () => {
|
|
316
|
+
const [hostSig, remoteSig] = createInMemorySignalingPair();
|
|
317
|
+
const session = Object.assign(createStubSession(), {
|
|
318
|
+
readToolAppView: vi.fn().mockReturnValue({
|
|
319
|
+
sourceId: 'files',
|
|
320
|
+
resourceUri: 'ui://files/list',
|
|
321
|
+
mimeType: 'text/html;profile=mcp-app',
|
|
322
|
+
html: 'x'.repeat(HISTORY_PAGE_MAX_BYTES),
|
|
323
|
+
callableTools: {},
|
|
324
|
+
}),
|
|
325
|
+
});
|
|
326
|
+
const host = new WebRtcTransport({
|
|
327
|
+
cryptoContext: testIdentity,
|
|
328
|
+
signaling: hostSig,
|
|
329
|
+
open: true,
|
|
330
|
+
openReason: 'SEC-008: loopback — this case is about the frame limit, not pairing',
|
|
331
|
+
});
|
|
332
|
+
host.attach(session);
|
|
333
|
+
|
|
334
|
+
const remoteReply = connectRemote(remoteSig, {
|
|
335
|
+
type: 'read-app-view',
|
|
336
|
+
requestId: 'v1',
|
|
337
|
+
viewId: 'view-1',
|
|
338
|
+
});
|
|
339
|
+
await host.start();
|
|
340
|
+
|
|
341
|
+
expect(await remoteReply).toEqual({
|
|
342
|
+
type: 'protocol_error',
|
|
343
|
+
message: 'This app view is too large to send.',
|
|
344
|
+
requestId: 'v1',
|
|
345
|
+
});
|
|
346
|
+
await host.stop();
|
|
347
|
+
}, 15000);
|
|
348
|
+
});
|
|
349
|
+
|