@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,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The contract a `PairingGate` is constructed with (REMOTE-008 / -012 / -013, SEC-010, SEC-011).
|
|
3
|
+
*
|
|
4
|
+
* Separated from the gate itself because they are different responsibilities: this file says what a
|
|
5
|
+
* caller must supply, and `pairing-gate.ts` says what the machine does with it. Keeping them
|
|
6
|
+
* together made one file that had to be read in full to answer either question.
|
|
7
|
+
*
|
|
8
|
+
* Re-exported from `pairing-gate.ts`, so every existing import keeps working — the split is a
|
|
9
|
+
* reading change, not a migration.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type {
|
|
13
|
+
IPairingResult,
|
|
14
|
+
startPairingHandshake,
|
|
15
|
+
TPairingRole,
|
|
16
|
+
} from '@robota-sdk/agent-remote-pairing';
|
|
17
|
+
import type {
|
|
18
|
+
IProtocolSession,
|
|
19
|
+
ISessionMessageHandlerOptions,
|
|
20
|
+
SessionResumeBridge,
|
|
21
|
+
createSessionMessageHandler,
|
|
22
|
+
} from '@robota-sdk/agent-transport';
|
|
23
|
+
|
|
24
|
+
import type { IHandoffGrantProof } from './handoff-grant-gate.js';
|
|
25
|
+
import type { ILocalPeerProof } from './local-peer-proof.js';
|
|
26
|
+
|
|
27
|
+
/** The minimal data-channel surface the gate drives (a werift `RTCDataChannel` satisfies it). */
|
|
28
|
+
export interface IPairingChannel {
|
|
29
|
+
send(data: string): void;
|
|
30
|
+
close(): void;
|
|
31
|
+
/**
|
|
32
|
+
* ARCH-030 / issue #1734: this channel's own count of what it has accepted and not yet written.
|
|
33
|
+
*
|
|
34
|
+
* Optional for the same reason `IDataChannelSink.bufferedAmount` is — the slice is structural and a
|
|
35
|
+
* test double may omit it — while a real `RTCDataChannel` always has it. It is here so the resume
|
|
36
|
+
* bridge's outbound boundary gets a reading: without one the replay path has no backpressure
|
|
37
|
+
* budget, which is a capability that exists and is never reached.
|
|
38
|
+
*/
|
|
39
|
+
readonly bufferedAmount?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** E3 host reconnect/enrollment config. When present, the gate runs reactive (first-frame) mode detection. */
|
|
43
|
+
export interface IHostReconnectConfig {
|
|
44
|
+
readonly hostIdentityId: string;
|
|
45
|
+
/** base64url SPKI advertised to a device at first-pair enrollment. */
|
|
46
|
+
readonly hostPublicSpki: string;
|
|
47
|
+
/** The host identity private key (signs reconnect challenges). */
|
|
48
|
+
readonly hostPrivateKey: CryptoKey;
|
|
49
|
+
/** Resolve a pinned device public key by id (undefined → unknown/revoked → fail closed). */
|
|
50
|
+
readonly resolveDevicePublicKey: (deviceId: string) => Promise<CryptoKey | undefined>;
|
|
51
|
+
/** Pin a device's public key on first-pair enrollment (deviceId, base64url SPKI). */
|
|
52
|
+
readonly onEnroll: (deviceId: string, deviceSpki: string) => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** What the receiving operator is told about a connection that would drive the session. */
|
|
56
|
+
export interface IConnectionApprovalContext {
|
|
57
|
+
/** The device the handshake proved, when the carrier has a stable id for it. */
|
|
58
|
+
readonly deviceId?: string;
|
|
59
|
+
/** A trusted device coming back, rather than one pairing now. */
|
|
60
|
+
readonly viaReconnect: boolean;
|
|
61
|
+
/** Aborted when the connection goes away before an answer: withdraw the question. */
|
|
62
|
+
readonly signal: AbortSignal;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The receiving operator's say over one connection. `approve` resolves `true` only on the operator's
|
|
67
|
+
* explicit yes; `false` or a rejection refuses the connection.
|
|
68
|
+
*/
|
|
69
|
+
export interface IConnectionApproval {
|
|
70
|
+
approve(context: IConnectionApprovalContext): Promise<boolean>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface IPairingGateOptions {
|
|
74
|
+
readonly channel: IPairingChannel;
|
|
75
|
+
readonly session: IProtocolSession;
|
|
76
|
+
readonly secret: string;
|
|
77
|
+
readonly role: TPairingRole;
|
|
78
|
+
readonly localFingerprint: string;
|
|
79
|
+
readonly remoteFingerprint: string;
|
|
80
|
+
/** Handshake timeout (ms); fail closed on expiry. */
|
|
81
|
+
readonly timeoutMs?: number;
|
|
82
|
+
/** REMOTE-008: fired once admission accepts + the session is exposed. Carries the first-pair result (E4). */
|
|
83
|
+
readonly onAccept?: (result?: IPairingResult) => void;
|
|
84
|
+
/** REMOTE-008: fired once admission rejects/times out + the channel closes (host lifecycle → teardown). */
|
|
85
|
+
readonly onReject?: () => void;
|
|
86
|
+
/** REMOTE-012 E3: host reconnect/enrollment config. Absent → B4 first-pair-only behavior (unchanged). */
|
|
87
|
+
readonly reconnect?: IHostReconnectConfig;
|
|
88
|
+
/**
|
|
89
|
+
* REMOTE-013 E4: a session-scoped {@link SessionResumeBridge}. When set, the paired session flows through the
|
|
90
|
+
* bridge (seq-stamped + buffered) instead of a fresh `createSessionMessageHandler`, so the session survives a channel
|
|
91
|
+
* drop and can replay on reconnect. Accept ATTACHES the channel as the bridge's sink; cleanup DETACHES it
|
|
92
|
+
* (never disposes — the bridge is owned by the transport across reconnects).
|
|
93
|
+
*/
|
|
94
|
+
readonly resumeBridge?: SessionResumeBridge;
|
|
95
|
+
/** Host-owned usage read models exposed only after this gate accepts. */
|
|
96
|
+
readonly personalUsageReporter?: ISessionMessageHandlerOptions['personalUsageReporter'];
|
|
97
|
+
readonly usageReporter?: ISessionMessageHandlerOptions['usageReporter'];
|
|
98
|
+
readonly storedSessionUsageReporter?: ISessionMessageHandlerOptions['storedSessionUsageReporter'];
|
|
99
|
+
/** Trusted carrier-owned surface; WebRTC assigns `remote`. */
|
|
100
|
+
readonly surface?: ISessionMessageHandlerOptions['surface'];
|
|
101
|
+
/**
|
|
102
|
+
* Post-accept session-frame delivery failure; owning transport performs drop cleanup.
|
|
103
|
+
*
|
|
104
|
+
* ARCH-030: was `TServerMessage['type'] | string`, which is just `string` — a union that reads as a
|
|
105
|
+
* narrowing and is not one. Stated as what it is.
|
|
106
|
+
*/
|
|
107
|
+
readonly onDeliveryError?: (error: Error, event: string) => void;
|
|
108
|
+
/** SEC-010: require a rendezvous nonce before exposing the session. Absent → unchanged behavior. */
|
|
109
|
+
readonly localPeer?: ILocalPeerProof;
|
|
110
|
+
/**
|
|
111
|
+
* SEC-011 (issue #1865): require a verified cross-device hand-off grant before exposing the
|
|
112
|
+
* session. Absent → unchanged behavior.
|
|
113
|
+
*
|
|
114
|
+
* Independent of `localPeer` rather than exclusive with it, and demanded AFTER it when both are
|
|
115
|
+
* set. They answer different questions — the rendezvous binds the environment, the grant binds the
|
|
116
|
+
* transfer — and a hand-off between two sessions on ONE machine legitimately has both. Requiring
|
|
117
|
+
* both is strictly more restrictive than requiring either, which is the safe direction.
|
|
118
|
+
*/
|
|
119
|
+
readonly handoffGrant?: IHandoffGrantProof;
|
|
120
|
+
/**
|
|
121
|
+
* Ask the receiving operator before exposing the session, after every proof above has run. Absent →
|
|
122
|
+
* unchanged behavior. A device first pairing is not pinned for reconnect until the operator allows
|
|
123
|
+
* it, so a refused device is not remembered.
|
|
124
|
+
*/
|
|
125
|
+
readonly connectionApproval?: IConnectionApproval;
|
|
126
|
+
/** Injection seams (default to the real implementations). */
|
|
127
|
+
readonly startHandshake?: typeof startPairingHandshake;
|
|
128
|
+
readonly createHandler?: typeof createSessionMessageHandler;
|
|
129
|
+
}
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pairing gate for the WebRTC data channel (REMOTE-008 Stage B4-2b; extended for REMOTE-012 Stage E3 TOFU
|
|
3
|
+
* reconnect).
|
|
4
|
+
*
|
|
5
|
+
* The data channel is phase-separated: pre-accept it carries pairing/reconnect frames, post-accept only
|
|
6
|
+
* session messages. An eager subscription feeds {@link PairingGate.onInbound}; the session bridge is not
|
|
7
|
+
* built until acceptance, so no pre-accept peer frame can reach the live session.
|
|
8
|
+
*
|
|
9
|
+
* E3 selects first-pair (`pair-nonce`, then identity-key enrollment) or reconnect (`rc-hello`, pinned
|
|
10
|
+
* identities) from the client's first frame and exposes the session only after mutual acceptance.
|
|
11
|
+
*
|
|
12
|
+
* Fail-closed: a non-admission frame pre-accept is DROPPED; the handshake `result` is the ONLY accept signal;
|
|
13
|
+
* on reject/timeout the channel is closed and no session bridge is ever created; a post-close frame is ignored.
|
|
14
|
+
*
|
|
15
|
+
* When no E3 `reconnect` config is supplied the gate is **exactly** the B4 first-pair-only gate (eager host
|
|
16
|
+
* `pair-nonce`, no enrollment, no reconnect) — preserving existing behavior.
|
|
17
|
+
*
|
|
18
|
+
* SEC-010 adds an optional `local-proof` step between handshake acceptance and session exposure; see
|
|
19
|
+
* `local-peer-proof.ts` for why both of those edges are load-bearing.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
deriveIdentityId,
|
|
24
|
+
importPublicKey,
|
|
25
|
+
startPairingHandshake,
|
|
26
|
+
type IPairingResult,
|
|
27
|
+
} from '@robota-sdk/agent-remote-pairing';
|
|
28
|
+
|
|
29
|
+
import { nextAdmissionStep } from './admission-steps.js';
|
|
30
|
+
import type {
|
|
31
|
+
IConnectionApproval,
|
|
32
|
+
IConnectionApprovalContext,
|
|
33
|
+
IHostReconnectConfig,
|
|
34
|
+
IPairingChannel,
|
|
35
|
+
IPairingGateOptions,
|
|
36
|
+
} from './pairing-gate-options.js';
|
|
37
|
+
|
|
38
|
+
// Re-exported so every existing import of these names keeps working: the split moved where they are
|
|
39
|
+
// DECLARED, and moving where they are imported from would be a migration this change is not.
|
|
40
|
+
export type {
|
|
41
|
+
IConnectionApproval,
|
|
42
|
+
IConnectionApprovalContext,
|
|
43
|
+
IHostReconnectConfig,
|
|
44
|
+
IPairingChannel,
|
|
45
|
+
IPairingGateOptions,
|
|
46
|
+
};
|
|
47
|
+
import { judgeHandoffGrant } from './handoff-grant-gate.js';
|
|
48
|
+
import { judgeLocalProof } from './local-peer-proof.js';
|
|
49
|
+
import { pairingChannel } from './pairing-channel-lifecycle.js';
|
|
50
|
+
import { startFirstPairController, startReconnectController } from './pairing-controllers.js';
|
|
51
|
+
import { isEnrollFrame, isPairingFrame, isReconnectFrame } from './pairing-frames.js';
|
|
52
|
+
import { attachSession } from './session-attachment.js';
|
|
53
|
+
|
|
54
|
+
import type { IEnrollFrame } from './pairing-frames.js';
|
|
55
|
+
|
|
56
|
+
/** How much a peer may say while the operator decides: a client's opening requests, not a stream. */
|
|
57
|
+
const HELD_FRAMES_MAX = 32;
|
|
58
|
+
const HELD_CHARS_MAX = 256 * 1024;
|
|
59
|
+
|
|
60
|
+
type TGateState =
|
|
61
|
+
| 'awaiting-mode'
|
|
62
|
+
| 'pairing'
|
|
63
|
+
| 'enrolling'
|
|
64
|
+
| 'reconnecting'
|
|
65
|
+
| 'local-proof'
|
|
66
|
+
| 'handoff-grant'
|
|
67
|
+
| 'operator-approval'
|
|
68
|
+
| 'accepted'
|
|
69
|
+
| 'closed';
|
|
70
|
+
|
|
71
|
+
export class PairingGate {
|
|
72
|
+
private state: TGateState;
|
|
73
|
+
/** Session message router — built ONLY on accept (nothing reaches the session before). */
|
|
74
|
+
private onSessionMessage?: (data: string) => void;
|
|
75
|
+
private handlerCleanup?: () => void;
|
|
76
|
+
private pairingController?: ReturnType<typeof startPairingHandshake>;
|
|
77
|
+
private reconnectController?: ReturnType<typeof startReconnectController>;
|
|
78
|
+
/** The first-pair result, held so it can be surfaced on accept (E4 uses its sessionKey). */
|
|
79
|
+
private pendingResult?: IPairingResult;
|
|
80
|
+
/** Held across the local-proof step so the reconnect ordering rule survives the detour. */
|
|
81
|
+
private pendingViaReconnect = false;
|
|
82
|
+
/** The device the handshake proved, for the operator's question. */
|
|
83
|
+
private pendingDeviceId?: string;
|
|
84
|
+
/** A first-pair device key, pinned only once every step has admitted the channel. */
|
|
85
|
+
private pendingEnrollment?: { readonly deviceId: string; readonly spki: string };
|
|
86
|
+
/**
|
|
87
|
+
* Session frames the peer sent while the operator was deciding. The peer's own handshake has
|
|
88
|
+
* already accepted, so it starts talking (a resume, a history request) at once; dropping those
|
|
89
|
+
* would leave an approved device waiting on answers to questions the host never saw. They reach
|
|
90
|
+
* the session only after a yes, and a peer that sends more than a bounded amount is refused.
|
|
91
|
+
*/
|
|
92
|
+
private heldFrames: string[] = [];
|
|
93
|
+
private heldChars = 0;
|
|
94
|
+
/** Withdraws the question put to the operator when the connection goes away first. */
|
|
95
|
+
private approvalAbort?: AbortController;
|
|
96
|
+
|
|
97
|
+
constructor(private readonly options: IPairingGateOptions) {
|
|
98
|
+
if (options.reconnect) {
|
|
99
|
+
// E3 mode: stay reactive — the client's first frame selects first-pair vs reconnect.
|
|
100
|
+
this.state = 'awaiting-mode';
|
|
101
|
+
} else {
|
|
102
|
+
// Legacy B4 mode: eagerly start the first-pair handshake (host sends pair-nonce immediately).
|
|
103
|
+
this.state = 'pairing';
|
|
104
|
+
this.startFirstPair();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Route one inbound channel frame. Pre-accept: admission frames → the active controller, everything else
|
|
110
|
+
* DROPPED. Post-accept: session messages → the session bridge. Post-close: ignored.
|
|
111
|
+
*/
|
|
112
|
+
onInbound(data: string): void {
|
|
113
|
+
if (this.state === 'closed') return;
|
|
114
|
+
if (this.state === 'accepted') {
|
|
115
|
+
this.onSessionMessage?.(data);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (this.state === 'operator-approval') {
|
|
119
|
+
this.holdForApproval(data);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let parsed: unknown;
|
|
124
|
+
try {
|
|
125
|
+
parsed = JSON.parse(data);
|
|
126
|
+
} catch {
|
|
127
|
+
return; // undecodable pre-accept frame → drop
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (this.state === 'awaiting-mode') {
|
|
131
|
+
// First frame selects the mode (E3).
|
|
132
|
+
if (isReconnectFrame(parsed) && parsed.t === 'rc-hello') {
|
|
133
|
+
this.state = 'reconnecting';
|
|
134
|
+
this.startReconnect();
|
|
135
|
+
this.reconnectController?.onFrame(parsed);
|
|
136
|
+
} else if (isPairingFrame(parsed) && parsed.t === 'pair-nonce') {
|
|
137
|
+
this.state = 'pairing';
|
|
138
|
+
this.startFirstPair();
|
|
139
|
+
this.pairingController?.onFrame(parsed);
|
|
140
|
+
}
|
|
141
|
+
// anything else pre-mode → drop
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (this.state === 'pairing') {
|
|
146
|
+
if (isPairingFrame(parsed)) this.pairingController?.onFrame(parsed);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (this.state === 'reconnecting') {
|
|
151
|
+
if (isReconnectFrame(parsed)) this.reconnectController?.onFrame(parsed);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (this.state === 'local-proof') {
|
|
156
|
+
const admission = judgeLocalProof(parsed, this.options.localPeer);
|
|
157
|
+
this.options.localPeer?.onAdmission?.(admission);
|
|
158
|
+
if (admission.admitted) this.accept(this.pendingResult, this.pendingViaReconnect);
|
|
159
|
+
else this.rejectAndClose();
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (this.state === 'handoff-grant') {
|
|
164
|
+
// Verifying a signature is async, and this handler is not. The promise is consumed here rather
|
|
165
|
+
// than returned so a rejection cannot escape into the channel's message subscription — an
|
|
166
|
+
// unhandled rejection would leave the gate parked in this state with the channel open, which
|
|
167
|
+
// is a hang. `judgeHandoffGrant` already converts a throw into a refusal; this is the second
|
|
168
|
+
// layer, because a gate that can hang is a gate that fails open by waiting.
|
|
169
|
+
void this.judgeGrantFrame(parsed);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (this.state === 'enrolling') {
|
|
174
|
+
// Awaiting the peer's identity public key to pin, then expose the session.
|
|
175
|
+
if (isEnrollFrame(parsed)) this.completeEnrollment(parsed.spki);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Tear down: cleanup the session bridge (if built) and mark closed. Idempotent. */
|
|
181
|
+
cleanup(): void {
|
|
182
|
+
this.state = 'closed';
|
|
183
|
+
this.withdrawApproval();
|
|
184
|
+
this.handlerCleanup?.();
|
|
185
|
+
this.handlerCleanup = undefined;
|
|
186
|
+
this.onSessionMessage = undefined;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The channel closed. Before acceptance that is a refusal: nobody is left to admit, so a question
|
|
191
|
+
* still open with the operator is withdrawn and a later answer admits nothing. After acceptance the
|
|
192
|
+
* owning transport handles the drop.
|
|
193
|
+
*/
|
|
194
|
+
onChannelClosed(): void {
|
|
195
|
+
if (this.state === 'accepted' || this.state === 'closed') return;
|
|
196
|
+
this.rejectAndClose();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private withdrawApproval(): void {
|
|
200
|
+
this.heldFrames = [];
|
|
201
|
+
this.heldChars = 0;
|
|
202
|
+
this.approvalAbort?.abort();
|
|
203
|
+
this.approvalAbort = undefined;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private startFirstPair(): void {
|
|
207
|
+
// `IPairingGateOptions` already carries every field `IControllerContext` asks for, so it is
|
|
208
|
+
// passed straight through — projecting it field by field would be a copy to keep in step.
|
|
209
|
+
this.pairingController = startFirstPairController(
|
|
210
|
+
this.options,
|
|
211
|
+
this.options.secret,
|
|
212
|
+
this.options.role,
|
|
213
|
+
(result) => this.onFirstPairAccepted(result),
|
|
214
|
+
() => this.rejectAndClose(),
|
|
215
|
+
this.options.startHandshake ?? startPairingHandshake,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private startReconnect(): void {
|
|
220
|
+
const cfg = this.options.reconnect;
|
|
221
|
+
if (!cfg) {
|
|
222
|
+
this.rejectAndClose();
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
this.reconnectController = startReconnectController(
|
|
226
|
+
this.options,
|
|
227
|
+
cfg,
|
|
228
|
+
// reconnect → hold live forwarding until the client's resume replays
|
|
229
|
+
(result) => {
|
|
230
|
+
this.pendingDeviceId = result.deviceId;
|
|
231
|
+
this.accept(undefined, true);
|
|
232
|
+
},
|
|
233
|
+
() => this.rejectAndClose(),
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** B3 handshake accepted. Without E3: expose immediately. With E3: run first-pair enrollment first. */
|
|
238
|
+
private onFirstPairAccepted(result: IPairingResult): void {
|
|
239
|
+
if (this.state !== 'pairing') return;
|
|
240
|
+
this.pendingResult = result;
|
|
241
|
+
const cfg = this.options.reconnect;
|
|
242
|
+
if (!cfg) {
|
|
243
|
+
this.accept(result);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
// E3 enrollment: advertise the host public key; the peer's `enroll-key` completes it (then expose).
|
|
247
|
+
this.state = 'enrolling';
|
|
248
|
+
pairingChannel.send(
|
|
249
|
+
this.options.channel,
|
|
250
|
+
JSON.stringify({ t: 'enroll-key', spki: cfg.hostPublicSpki } satisfies IEnrollFrame),
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private completeEnrollment(deviceSpki: string): void {
|
|
255
|
+
if (this.state !== 'enrolling') return;
|
|
256
|
+
const cfg = this.options.reconnect;
|
|
257
|
+
if (!cfg) {
|
|
258
|
+
this.rejectAndClose();
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
void (async (): Promise<void> => {
|
|
262
|
+
try {
|
|
263
|
+
// Validate the SPKI parses as a public key before pinning (fail closed on garbage).
|
|
264
|
+
await importPublicKey(deviceSpki);
|
|
265
|
+
const deviceId = await deriveIdentityId(deviceSpki);
|
|
266
|
+
if (this.state !== 'enrolling') return;
|
|
267
|
+
this.pendingDeviceId = deviceId;
|
|
268
|
+
this.pendingEnrollment = { deviceId, spki: deviceSpki };
|
|
269
|
+
this.accept(this.pendingResult);
|
|
270
|
+
} catch {
|
|
271
|
+
this.rejectAndClose();
|
|
272
|
+
}
|
|
273
|
+
})();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private accept(result?: IPairingResult, viaReconnect = false): void {
|
|
277
|
+
if (this.state === 'closed' || this.state === 'accepted') return;
|
|
278
|
+
// The steps this channel still owes. Demanded here rather than earlier because the handshake
|
|
279
|
+
// must already have bound the channel, and before anything below because that is where the
|
|
280
|
+
// session becomes reachable. The ordering argument lives in `admission-steps.ts`.
|
|
281
|
+
const owed = nextAdmissionStep(this.options, this.state);
|
|
282
|
+
if (owed !== null) {
|
|
283
|
+
this.pendingResult = result ?? this.pendingResult;
|
|
284
|
+
this.pendingViaReconnect = viaReconnect;
|
|
285
|
+
this.state = owed;
|
|
286
|
+
if (owed === 'operator-approval') void this.askOperator();
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
// Pin a first-pair device only now: a device a later step refused is not remembered.
|
|
290
|
+
const enrollment = this.pendingEnrollment;
|
|
291
|
+
this.pendingEnrollment = undefined;
|
|
292
|
+
if (enrollment !== undefined) {
|
|
293
|
+
try {
|
|
294
|
+
this.options.reconnect?.onEnroll(enrollment.deviceId, enrollment.spki);
|
|
295
|
+
} catch {
|
|
296
|
+
this.rejectAndClose();
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
const attached = attachSession(this.options, viaReconnect, (error, event) =>
|
|
301
|
+
this.handleSessionDeliveryError(error, event),
|
|
302
|
+
);
|
|
303
|
+
this.onSessionMessage = attached.onSessionMessage;
|
|
304
|
+
this.handlerCleanup = attached.cleanup;
|
|
305
|
+
this.state = 'accepted';
|
|
306
|
+
this.options.onAccept?.(result);
|
|
307
|
+
const held = this.heldFrames;
|
|
308
|
+
this.heldFrames = [];
|
|
309
|
+
this.heldChars = 0;
|
|
310
|
+
for (const frame of held) {
|
|
311
|
+
if (this.state !== 'accepted') return;
|
|
312
|
+
this.onSessionMessage?.(frame);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private holdForApproval(data: string): void {
|
|
317
|
+
this.heldChars += data.length;
|
|
318
|
+
this.heldFrames.push(data);
|
|
319
|
+
if (this.heldFrames.length > HELD_FRAMES_MAX || this.heldChars > HELD_CHARS_MAX) {
|
|
320
|
+
this.rejectAndClose();
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* The `operator-approval` state: ask, then admit only on an explicit yes. A failure to ask is a no,
|
|
326
|
+
* and an answer that arrives after the channel was torn down admits nothing.
|
|
327
|
+
*/
|
|
328
|
+
private async askOperator(): Promise<void> {
|
|
329
|
+
const abort = new AbortController();
|
|
330
|
+
this.approvalAbort = abort;
|
|
331
|
+
const context: IConnectionApprovalContext = {
|
|
332
|
+
...(this.pendingDeviceId !== undefined ? { deviceId: this.pendingDeviceId } : {}),
|
|
333
|
+
viaReconnect: this.pendingViaReconnect,
|
|
334
|
+
signal: abort.signal,
|
|
335
|
+
};
|
|
336
|
+
let allowed = false;
|
|
337
|
+
try {
|
|
338
|
+
allowed = (await this.options.connectionApproval?.approve(context)) === true;
|
|
339
|
+
} catch {
|
|
340
|
+
allowed = false;
|
|
341
|
+
}
|
|
342
|
+
if (this.state !== 'operator-approval' || abort.signal.aborted) return;
|
|
343
|
+
this.approvalAbort = undefined;
|
|
344
|
+
if (allowed) this.accept(this.pendingResult, this.pendingViaReconnect);
|
|
345
|
+
else this.rejectAndClose();
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** The async half of the `handoff-grant` state, kept off the synchronous message path. */
|
|
349
|
+
private async judgeGrantFrame(parsed: unknown): Promise<void> {
|
|
350
|
+
const admission = await judgeHandoffGrant(parsed, this.options.handoffGrant);
|
|
351
|
+
// A channel torn down while the signature was being checked must not be re-accepted by a verdict
|
|
352
|
+
// that arrives afterwards. Checked after the await, because that is the window it exists for.
|
|
353
|
+
if (this.state !== 'handoff-grant') return;
|
|
354
|
+
this.options.handoffGrant?.onAdmission?.(admission);
|
|
355
|
+
if (admission.admitted) this.accept(this.pendingResult, this.pendingViaReconnect);
|
|
356
|
+
else this.rejectAndClose();
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
private rejectAndClose(): void {
|
|
360
|
+
if (this.state === 'closed') return;
|
|
361
|
+
this.state = 'closed';
|
|
362
|
+
this.withdrawApproval();
|
|
363
|
+
pairingChannel.close(this.options.channel);
|
|
364
|
+
this.options.onReject?.();
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private handleSessionDeliveryError(error: Error, event: string): void {
|
|
368
|
+
if (this.state === 'closed') return;
|
|
369
|
+
this.state = 'closed';
|
|
370
|
+
this.handlerCleanup?.();
|
|
371
|
+
this.handlerCleanup = undefined;
|
|
372
|
+
this.onSessionMessage = undefined;
|
|
373
|
+
pairingChannel.reportDeliveryError(this.options.onDeliveryError, error, event);
|
|
374
|
+
pairingChannel.close(this.options.channel);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wiring an admitted channel to the session — the step immediately after the gate says yes.
|
|
3
|
+
*
|
|
4
|
+
* "How session frames travel once admission is settled" is a different subject from "what may reach
|
|
5
|
+
* the session at all", which is the gate's. Split out for the same reason the frame vocabulary and
|
|
6
|
+
* the controllers already were: `pairing-gate.ts` is at its size limit, and the rule there is to
|
|
7
|
+
* split rather than extend.
|
|
8
|
+
*
|
|
9
|
+
* Two carriers, chosen by whether a resume bridge exists. Neither decides anything about admission —
|
|
10
|
+
* by the time either runs, that question is closed.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
createSessionMessageHandler,
|
|
15
|
+
type ISessionMessageHandlerOptions,
|
|
16
|
+
type SessionResumeBridge,
|
|
17
|
+
} from '@robota-sdk/agent-transport';
|
|
18
|
+
|
|
19
|
+
import { createChannelDelivery } from './channel-delivery.js';
|
|
20
|
+
|
|
21
|
+
import type { IPairingChannel } from './pairing-gate-options.js';
|
|
22
|
+
import type { IProtocolSession } from '@robota-sdk/agent-transport';
|
|
23
|
+
|
|
24
|
+
export interface IAttachSessionOptions {
|
|
25
|
+
readonly channel: IPairingChannel;
|
|
26
|
+
readonly session: IProtocolSession;
|
|
27
|
+
readonly resumeBridge?: SessionResumeBridge;
|
|
28
|
+
readonly createHandler?: typeof createSessionMessageHandler;
|
|
29
|
+
readonly personalUsageReporter?: ISessionMessageHandlerOptions['personalUsageReporter'];
|
|
30
|
+
readonly usageReporter?: ISessionMessageHandlerOptions['usageReporter'];
|
|
31
|
+
readonly storedSessionUsageReporter?: ISessionMessageHandlerOptions['storedSessionUsageReporter'];
|
|
32
|
+
readonly surface?: ISessionMessageHandlerOptions['surface'];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface IAttachedSession {
|
|
36
|
+
/** Where post-accept inbound frames go. */
|
|
37
|
+
readonly onSessionMessage: (data: string) => void;
|
|
38
|
+
/** Detach (bridge) or dispose (handler). The gate calls this on teardown. */
|
|
39
|
+
readonly cleanup: () => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Attach the admitted channel to the session.
|
|
44
|
+
*
|
|
45
|
+
* `viaReconnect` is threaded through rather than inferred: on a reconnect the bridge must hold live
|
|
46
|
+
* forwarding until the client's `resume` replays the buffered tail, and getting that backwards
|
|
47
|
+
* delivers new frames ahead of the replay — an ordering bug that looks like data loss.
|
|
48
|
+
*/
|
|
49
|
+
export function attachSession(
|
|
50
|
+
options: IAttachSessionOptions,
|
|
51
|
+
viaReconnect: boolean,
|
|
52
|
+
onDeliveryError: (error: Error, event: string) => void,
|
|
53
|
+
): IAttachedSession {
|
|
54
|
+
const bridge = options.resumeBridge;
|
|
55
|
+
if (bridge) {
|
|
56
|
+
// REMOTE-013 E4: route the session through the persistent bridge. Attach this channel as the
|
|
57
|
+
// sink; post-accept inbound frames (incl. resume/ack) go to the bridge; cleanup DETACHES it
|
|
58
|
+
// (never disposes — the bridge is owned by the transport across reconnects).
|
|
59
|
+
bridge.attach((data) => options.channel.send(data), {
|
|
60
|
+
awaitResume: viaReconnect,
|
|
61
|
+
onDeliveryError,
|
|
62
|
+
// ARCH-030: without this the bridge's boundary has no backpressure reading, so the replay
|
|
63
|
+
// path — the burst this budget exists for — runs unbudgeted while every other outbound path
|
|
64
|
+
// on the same connection is guarded. Read at call time; `bufferedAmount` is live.
|
|
65
|
+
pendingBytes: () => options.channel.bufferedAmount,
|
|
66
|
+
});
|
|
67
|
+
return {
|
|
68
|
+
onSessionMessage: (data) => bridge.onClientMessage(data),
|
|
69
|
+
cleanup: () => bridge.detach(),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const create = options.createHandler ?? createSessionMessageHandler;
|
|
74
|
+
// ARCH-030: the gate is the carrier on this branch — its own channel sink, its own failure policy.
|
|
75
|
+
const { onMessage, cleanup } = create({
|
|
76
|
+
session: options.session,
|
|
77
|
+
deliver: createChannelDelivery(options.channel, onDeliveryError),
|
|
78
|
+
...(options.personalUsageReporter
|
|
79
|
+
? { personalUsageReporter: options.personalUsageReporter }
|
|
80
|
+
: {}),
|
|
81
|
+
...(options.usageReporter ? { usageReporter: options.usageReporter } : {}),
|
|
82
|
+
...(options.storedSessionUsageReporter
|
|
83
|
+
? { storedSessionUsageReporter: options.storedSessionUsageReporter }
|
|
84
|
+
: {}),
|
|
85
|
+
...(options.surface ? { surface: options.surface } : {}),
|
|
86
|
+
});
|
|
87
|
+
return { onSessionMessage: onMessage, cleanup };
|
|
88
|
+
}
|
package/src/signaling.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signaling port (REMOTE-002). The WebRTC transport is decoupled from any concrete signaling server via this
|
|
3
|
+
* port: it exchanges opaque SDP/ICE blobs by rendezvous id. The port carries **only** SDP offers/answers + ICE
|
|
4
|
+
* candidates — never session content — so a signaling server (or the in-memory test pair) is content-blind.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** The kinds of signal a peer exchanges through the relay. */
|
|
8
|
+
export type TSignalKind = 'offer' | 'answer' | 'ice';
|
|
9
|
+
|
|
10
|
+
/** An opaque signaling message relayed by rendezvous id. `data` is an SDP description or an ICE candidate. */
|
|
11
|
+
export interface ISignalMessage {
|
|
12
|
+
readonly kind: TSignalKind;
|
|
13
|
+
/** Opaque payload (serialized SDP or ICE candidate). The signaling layer never inspects it. */
|
|
14
|
+
readonly data: unknown;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Port a WebRTC peer uses to reach its counterpart at a rendezvous id. */
|
|
18
|
+
export interface ISignalingClient {
|
|
19
|
+
/** Send a signal to the counterpart at the rendezvous. */
|
|
20
|
+
send(message: ISignalMessage): void;
|
|
21
|
+
/** Subscribe to signals from the counterpart. Returns an unsubscribe function. */
|
|
22
|
+
onSignal(handler: (message: ISignalMessage) => void): () => void;
|
|
23
|
+
/** Tear down the signaling channel. */
|
|
24
|
+
close(): void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* An in-process pair of {@link ISignalingClient}s wired directly to each other — for tests + loopback only
|
|
29
|
+
* (no server, no network). Each side's `send` is delivered to the other side's handlers on a microtask.
|
|
30
|
+
*/
|
|
31
|
+
export function createInMemorySignalingPair(): [ISignalingClient, ISignalingClient] {
|
|
32
|
+
const handlersA: ((m: ISignalMessage) => void)[] = [];
|
|
33
|
+
const handlersB: ((m: ISignalMessage) => void)[] = [];
|
|
34
|
+
let open = true;
|
|
35
|
+
|
|
36
|
+
function make(
|
|
37
|
+
ownHandlers: ((m: ISignalMessage) => void)[],
|
|
38
|
+
peerHandlers: ((m: ISignalMessage) => void)[],
|
|
39
|
+
): ISignalingClient {
|
|
40
|
+
return {
|
|
41
|
+
send(message) {
|
|
42
|
+
if (!open) return;
|
|
43
|
+
queueMicrotask(() => {
|
|
44
|
+
for (const h of peerHandlers) h(message);
|
|
45
|
+
});
|
|
46
|
+
},
|
|
47
|
+
onSignal(handler) {
|
|
48
|
+
ownHandlers.push(handler);
|
|
49
|
+
return () => {
|
|
50
|
+
const i = ownHandlers.indexOf(handler);
|
|
51
|
+
if (i >= 0) ownHandlers.splice(i, 1);
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
close() {
|
|
55
|
+
open = false;
|
|
56
|
+
ownHandlers.length = 0;
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return [make(handlersA, handlersB), make(handlersB, handlersA)];
|
|
62
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ITransportLifecycleError } from '@robota-sdk/agent-interface-transport';
|
|
2
|
+
|
|
3
|
+
export function createTransportLifecycleError(
|
|
4
|
+
code: ITransportLifecycleError['code'],
|
|
5
|
+
): ITransportLifecycleError {
|
|
6
|
+
return Object.assign(new Error(`WebRtcTransport ${code}.`), {
|
|
7
|
+
name: 'TransportLifecycleError' as const,
|
|
8
|
+
code,
|
|
9
|
+
transportName: 'webrtc',
|
|
10
|
+
});
|
|
11
|
+
}
|