@robota-sdk/agent-transport-link 0.0.0-stage → 3.1.2

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.
@@ -0,0 +1,389 @@
1
+ /**
2
+ * The owner vocabulary on the wire: the JSON inside `owner` frames, with a strict decoder for each
3
+ * direction.
4
+ *
5
+ * Owner frames are authenticated, so a malformed one is never an attack from outside the link; it is
6
+ * a peer that is broken or not what it claims, and the link closes. So every message has exactly its
7
+ * fields, every field its type, pattern and bound, and anything else decodes to nothing. Text a
8
+ * person will read (a notice, a question, a refusal) carries no control or bidirectional-override
9
+ * character, so it shows as it is in a terminal or a native dialog. Decoding builds fresh values,
10
+ * so nothing shaped by the peer (a prototype key, an extra field) survives into the domain value.
11
+ * Encoding checks its own output with the decoder, so an end never sends what the other must refuse.
12
+ */
13
+ import { Buffer } from 'node:buffer';
14
+
15
+ import type { ITrustRoot } from '@robota-sdk/agent-keyring';
16
+
17
+ import type {
18
+ IOwnerControlRequest,
19
+ IRuntimeCustodyState,
20
+ TGrantOutcome,
21
+ TOwnerControlResult,
22
+ TOwnerControlValue,
23
+ TOwnerSessionQuestion,
24
+ TRequestedLockReason,
25
+ } from './link-ports.js';
26
+
27
+ /** A request from a link's client. */
28
+ export type TOwnerRequest =
29
+ | { readonly op: 'install-trust-root'; readonly requestId: string; readonly root: ITrustRoot }
30
+ | { readonly op: 'lock'; readonly requestId: string; readonly reason: TRequestedLockReason }
31
+ | { readonly op: 'question'; readonly requestId: string; readonly sessionId: string }
32
+ | {
33
+ readonly op: 'open-accepted';
34
+ readonly requestId: string;
35
+ readonly sessionId: string;
36
+ readonly acceptedDigest: string;
37
+ }
38
+ | { readonly op: 'control'; readonly requestId: string; readonly request: IOwnerControlRequest };
39
+
40
+ /** A reply or a notice from the runtime. */
41
+ export type TRuntimeMessage =
42
+ | { readonly op: 'done'; readonly requestId: string }
43
+ | { readonly op: 'unlocked'; readonly requestId: string; readonly sealingKey: TGrantOutcome }
44
+ | {
45
+ readonly op: 'question';
46
+ readonly requestId: string;
47
+ readonly question: TOwnerSessionQuestion;
48
+ }
49
+ | {
50
+ readonly op: 'refused';
51
+ readonly requestId: string;
52
+ readonly code: string;
53
+ readonly message: string;
54
+ }
55
+ | { readonly op: 'control'; readonly requestId: string; readonly result: TOwnerControlResult }
56
+ | { readonly op: 'custody'; readonly state: IRuntimeCustodyState };
57
+
58
+ /** A client's own requests count `r1`, `r2`, ...; its read grants count `g1`, `g2`, ... */
59
+ export const REQUEST_ID_PATTERN = /^r[1-9][0-9]{0,15}$/u;
60
+ export const GRANT_ID_PATTERN = /^g[1-9][0-9]{0,15}$/u;
61
+
62
+ const SESSION_ID_PATTERN = /^[A-Za-z0-9._:-]{1,256}$/u;
63
+ const DIGEST_PATTERN = /^[A-Za-z0-9._:+/=-]{1,256}$/u;
64
+ const COMMAND_PATTERN = /^[a-z][a-z0-9-]{0,31}$/u;
65
+ const FIELD_KEY_PATTERN = /^[A-Za-z][A-Za-z0-9]{0,31}$/u;
66
+ const REFUSAL_CODE_PATTERN = /^[a-z][a-z0-9-]{0,63}$/u;
67
+ const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/u;
68
+ /* eslint-disable no-control-regex -- these patterns exist to find control characters */
69
+ const UNSAFE_TEXT =
70
+ /[\u0000-\u0009\u000b-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/u;
71
+ const UNSAFE_FIELD = /[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/u;
72
+ /* eslint-enable no-control-regex */
73
+ const MAX_TEXT_LENGTH = 4096;
74
+ const MAX_REFUSAL_MESSAGE_LENGTH = 1024;
75
+ const MAX_FIELDS = 16;
76
+ const MAX_FIELD_VALUE_LENGTH = 4096;
77
+ const MAX_RESULT_DEPTH = 8;
78
+ const MAX_RESULT_NODES = 4096;
79
+ const MAX_RESULT_STRING_LENGTH = 65_536;
80
+ const MAX_RESULT_KEY_LENGTH = 256;
81
+ const ATTACH_PUBLIC_KEY_BYTES = 32;
82
+ const CERTIFICATE_BYTES = 64;
83
+ const LOCK_REASONS: ReadonlySet<string> = new Set<TRequestedLockReason>([
84
+ 'explicit',
85
+ 'screen-lock',
86
+ 'suspend',
87
+ ]);
88
+ const GRANT_OUTCOMES: ReadonlySet<string> = new Set<TGrantOutcome>(['verified', 'mismatch']);
89
+
90
+ type TRecord = Readonly<Record<string, unknown>>;
91
+
92
+ const textDecoder = new TextDecoder('utf-8', { fatal: true });
93
+
94
+ function parse(bytes: Uint8Array): unknown {
95
+ try {
96
+ return JSON.parse(textDecoder.decode(bytes));
97
+ } catch {
98
+ return undefined;
99
+ }
100
+ }
101
+
102
+ /** `value` as a plain record with exactly `keys`, or `undefined`. */
103
+ function exactly(value: unknown, keys: readonly string[]): TRecord | undefined {
104
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
105
+ const own = Object.keys(value);
106
+ if (own.length !== keys.length || !keys.every((key) => Object.hasOwn(value, key)))
107
+ return undefined;
108
+ return value as TRecord;
109
+ }
110
+
111
+ const matches = (value: unknown, pattern: RegExp): value is string =>
112
+ typeof value === 'string' && pattern.test(value);
113
+
114
+ const isText = (value: unknown, maxLength: number): value is string =>
115
+ typeof value === 'string' &&
116
+ value.length > 0 &&
117
+ value.length <= maxLength &&
118
+ !UNSAFE_TEXT.test(value);
119
+
120
+ function bytesFrom(value: unknown, length: number): Uint8Array | undefined {
121
+ if (!matches(value, BASE64URL_PATTERN)) return undefined;
122
+ const decoded = Buffer.from(value, 'base64url');
123
+ return decoded.length === length && decoded.toString('base64url') === value
124
+ ? Uint8Array.from(decoded)
125
+ : undefined;
126
+ }
127
+
128
+ function fieldsFrom(value: unknown): Readonly<Record<string, string>> | undefined {
129
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
130
+ const entries = Object.entries(value);
131
+ if (entries.length > MAX_FIELDS) return undefined;
132
+ const fields: Record<string, string> = {};
133
+ for (const [key, field] of entries) {
134
+ if (
135
+ !FIELD_KEY_PATTERN.test(key) ||
136
+ typeof field !== 'string' ||
137
+ field.length > MAX_FIELD_VALUE_LENGTH ||
138
+ UNSAFE_FIELD.test(field)
139
+ ) {
140
+ return undefined;
141
+ }
142
+ fields[key] = field;
143
+ }
144
+ return fields;
145
+ }
146
+
147
+ /** A fresh copy of a JSON control result within its bounds, or `undefined`. */
148
+ function resultFrom(value: unknown): TOwnerControlResult | undefined {
149
+ let nodes = 0;
150
+ const copy = (node: unknown, depth: number): TOwnerControlValue | undefined => {
151
+ nodes += 1;
152
+ if (nodes > MAX_RESULT_NODES) return undefined;
153
+ if (node === null || typeof node === 'boolean') return node;
154
+ if (typeof node === 'number') return Number.isFinite(node) ? node : undefined;
155
+ if (typeof node === 'string') return node.length <= MAX_RESULT_STRING_LENGTH ? node : undefined;
156
+ if (depth >= MAX_RESULT_DEPTH || typeof node !== 'object') return undefined;
157
+ if (Array.isArray(node)) {
158
+ const items: TOwnerControlValue[] = [];
159
+ for (const item of node) {
160
+ const copied = copy(item, depth + 1);
161
+ if (copied === undefined) return undefined;
162
+ items.push(copied);
163
+ }
164
+ return items;
165
+ }
166
+ const record: Record<string, TOwnerControlValue> = {};
167
+ for (const [key, item] of Object.entries(node)) {
168
+ if (key === '__proto__' || key.length > MAX_RESULT_KEY_LENGTH) return undefined;
169
+ const copied = copy(item, depth + 1);
170
+ if (copied === undefined) return undefined;
171
+ record[key] = copied;
172
+ }
173
+ return record;
174
+ };
175
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
176
+ const copied = copy(value, 0);
177
+ return copied === undefined ? undefined : (copied as TOwnerControlResult);
178
+ }
179
+
180
+ function questionFrom(value: unknown): TOwnerSessionQuestion | undefined {
181
+ const kind = typeof value === 'object' && value !== null ? (value as TRecord)['kind'] : undefined;
182
+ if (kind === 'open') return exactly(value, ['kind']) ? { kind: 'open' } : undefined;
183
+ if (kind === 'accept') {
184
+ const record = exactly(value, ['kind', 'digest', 'notice', 'question']);
185
+ if (
186
+ record === undefined ||
187
+ !matches(record['digest'], DIGEST_PATTERN) ||
188
+ !isText(record['notice'], MAX_TEXT_LENGTH) ||
189
+ !isText(record['question'], MAX_TEXT_LENGTH)
190
+ ) {
191
+ return undefined;
192
+ }
193
+ return {
194
+ kind: 'accept',
195
+ digest: record['digest'],
196
+ notice: record['notice'],
197
+ question: record['question'],
198
+ };
199
+ }
200
+ if (kind === 'refused') {
201
+ const record = exactly(value, ['kind', 'message']);
202
+ return record !== undefined && isText(record['message'], MAX_TEXT_LENGTH)
203
+ ? { kind: 'refused', message: record['message'] }
204
+ : undefined;
205
+ }
206
+ return undefined;
207
+ }
208
+
209
+ function custodyFrom(value: unknown): IRuntimeCustodyState | undefined {
210
+ const record = exactly(value, ['unlocked', 'certified']);
211
+ return record !== undefined &&
212
+ typeof record['unlocked'] === 'boolean' &&
213
+ typeof record['certified'] === 'boolean'
214
+ ? { unlocked: record['unlocked'], certified: record['certified'] }
215
+ : undefined;
216
+ }
217
+
218
+ /** One request from a client, or `undefined` for anything that is not exactly one. */
219
+ export function decodeOwnerRequest(bytes: Uint8Array): TOwnerRequest | undefined {
220
+ const value = parse(bytes);
221
+ const op = typeof value === 'object' && value !== null ? (value as TRecord)['op'] : undefined;
222
+ switch (op) {
223
+ case 'install-trust-root': {
224
+ const record = exactly(value, ['op', 'requestId', 'attachPublicKey', 'certificate']);
225
+ const attachPublicKey = bytesFrom(record?.['attachPublicKey'], ATTACH_PUBLIC_KEY_BYTES);
226
+ const certificate = bytesFrom(record?.['certificate'], CERTIFICATE_BYTES);
227
+ if (!matches(record?.['requestId'], REQUEST_ID_PATTERN) || !attachPublicKey || !certificate) {
228
+ return undefined;
229
+ }
230
+ return { op, requestId: record['requestId'], root: { attachPublicKey, certificate } };
231
+ }
232
+ case 'lock': {
233
+ const record = exactly(value, ['op', 'requestId', 'reason']);
234
+ const reason = record?.['reason'];
235
+ if (
236
+ !matches(record?.['requestId'], REQUEST_ID_PATTERN) ||
237
+ typeof reason !== 'string' ||
238
+ !LOCK_REASONS.has(reason)
239
+ ) {
240
+ return undefined;
241
+ }
242
+ return { op, requestId: record['requestId'], reason: reason as TRequestedLockReason };
243
+ }
244
+ case 'question': {
245
+ const record = exactly(value, ['op', 'requestId', 'sessionId']);
246
+ if (
247
+ !matches(record?.['requestId'], REQUEST_ID_PATTERN) ||
248
+ !matches(record['sessionId'], SESSION_ID_PATTERN)
249
+ ) {
250
+ return undefined;
251
+ }
252
+ return { op, requestId: record['requestId'], sessionId: record['sessionId'] };
253
+ }
254
+ case 'open-accepted': {
255
+ const record = exactly(value, ['op', 'requestId', 'sessionId', 'acceptedDigest']);
256
+ if (
257
+ !matches(record?.['requestId'], REQUEST_ID_PATTERN) ||
258
+ !matches(record['sessionId'], SESSION_ID_PATTERN) ||
259
+ !matches(record['acceptedDigest'], DIGEST_PATTERN)
260
+ ) {
261
+ return undefined;
262
+ }
263
+ return {
264
+ op,
265
+ requestId: record['requestId'],
266
+ sessionId: record['sessionId'],
267
+ acceptedDigest: record['acceptedDigest'],
268
+ };
269
+ }
270
+ case 'control': {
271
+ const record = exactly(value, ['op', 'requestId', 'command', 'fields']);
272
+ const fields = fieldsFrom(record?.['fields']);
273
+ if (
274
+ !matches(record?.['requestId'], REQUEST_ID_PATTERN) ||
275
+ !matches(record['command'], COMMAND_PATTERN) ||
276
+ !fields
277
+ ) {
278
+ return undefined;
279
+ }
280
+ return {
281
+ op,
282
+ requestId: record['requestId'],
283
+ request: { command: record['command'], fields },
284
+ };
285
+ }
286
+ default:
287
+ return undefined;
288
+ }
289
+ }
290
+
291
+ /** One reply or notice from the runtime, or `undefined` for anything that is not exactly one. */
292
+ export function decodeRuntimeMessage(bytes: Uint8Array): TRuntimeMessage | undefined {
293
+ const value = parse(bytes);
294
+ const op = typeof value === 'object' && value !== null ? (value as TRecord)['op'] : undefined;
295
+ switch (op) {
296
+ case 'done': {
297
+ const record = exactly(value, ['op', 'requestId']);
298
+ return matches(record?.['requestId'], REQUEST_ID_PATTERN)
299
+ ? { op, requestId: record['requestId'] }
300
+ : undefined;
301
+ }
302
+ case 'unlocked': {
303
+ const record = exactly(value, ['op', 'requestId', 'sealingKey']);
304
+ const sealingKey = record?.['sealingKey'];
305
+ if (
306
+ !matches(record?.['requestId'], GRANT_ID_PATTERN) ||
307
+ typeof sealingKey !== 'string' ||
308
+ !GRANT_OUTCOMES.has(sealingKey)
309
+ ) {
310
+ return undefined;
311
+ }
312
+ return { op, requestId: record['requestId'], sealingKey: sealingKey as TGrantOutcome };
313
+ }
314
+ case 'question': {
315
+ const record = exactly(value, ['op', 'requestId', 'question']);
316
+ const question = questionFrom(record?.['question']);
317
+ return matches(record?.['requestId'], REQUEST_ID_PATTERN) && question
318
+ ? { op, requestId: record['requestId'], question }
319
+ : undefined;
320
+ }
321
+ case 'refused': {
322
+ const record = exactly(value, ['op', 'requestId', 'code', 'message']);
323
+ const requestId = record?.['requestId'];
324
+ if (
325
+ !(matches(requestId, REQUEST_ID_PATTERN) || matches(requestId, GRANT_ID_PATTERN)) ||
326
+ !matches(record?.['code'], REFUSAL_CODE_PATTERN) ||
327
+ !isText(record['message'], MAX_REFUSAL_MESSAGE_LENGTH)
328
+ ) {
329
+ return undefined;
330
+ }
331
+ return { op, requestId, code: record['code'], message: record['message'] };
332
+ }
333
+ case 'control': {
334
+ const record = exactly(value, ['op', 'requestId', 'result']);
335
+ const result = resultFrom(record?.['result']);
336
+ return matches(record?.['requestId'], REQUEST_ID_PATTERN) && result
337
+ ? { op, requestId: record['requestId'], result }
338
+ : undefined;
339
+ }
340
+ case 'custody': {
341
+ const record = exactly(value, ['op', 'state']);
342
+ const state = custodyFrom(record?.['state']);
343
+ return state ? { op, state } : undefined;
344
+ }
345
+ default:
346
+ return undefined;
347
+ }
348
+ }
349
+
350
+ function wireRequest(request: TOwnerRequest): Record<string, unknown> {
351
+ switch (request.op) {
352
+ case 'install-trust-root':
353
+ return {
354
+ op: request.op,
355
+ requestId: request.requestId,
356
+ attachPublicKey: Buffer.from(request.root.attachPublicKey).toString('base64url'),
357
+ certificate: Buffer.from(request.root.certificate).toString('base64url'),
358
+ };
359
+ case 'control':
360
+ return {
361
+ op: request.op,
362
+ requestId: request.requestId,
363
+ command: request.request.command,
364
+ fields: { ...request.request.fields },
365
+ };
366
+ default:
367
+ return { ...request };
368
+ }
369
+ }
370
+
371
+ function checked<TMessage>(
372
+ bytes: Uint8Array,
373
+ decode: (bytes: Uint8Array) => TMessage | undefined,
374
+ ): Uint8Array {
375
+ if (decode(bytes) === undefined) {
376
+ throw new RangeError('The message is outside the owner vocabulary.');
377
+ }
378
+ return bytes;
379
+ }
380
+
381
+ /** The bytes of `request`; throws `RangeError` for one the runtime would refuse to decode. */
382
+ export function encodeOwnerRequest(request: TOwnerRequest): Uint8Array {
383
+ return checked(Buffer.from(JSON.stringify(wireRequest(request)), 'utf8'), decodeOwnerRequest);
384
+ }
385
+
386
+ /** The bytes of `message`; throws `RangeError` for one a client would refuse to decode. */
387
+ export function encodeRuntimeMessage(message: TRuntimeMessage): Uint8Array {
388
+ return checked(Buffer.from(JSON.stringify(message), 'utf8'), decodeRuntimeMessage);
389
+ }
@@ -0,0 +1,303 @@
1
+ /**
2
+ * The runtime's end of an owner link: one connection the control endpoint routed here after the
3
+ * link request line.
4
+ *
5
+ * The handshake admits a spawner only with a spawner-key proof and an owner only with an attach-key
6
+ * proof; on any failure, a frame over its bound, or a handshake that does not finish in time, the
7
+ * stream is closed without a word, so a program probing the socket learns nothing about why. Once
8
+ * admitted, the link reports its standing to the runtime's `admit`, announces the custody state,
9
+ * and routes what arrives: session frames to the session sink, owner requests to the desk and the
10
+ * session decisions, grant frames (owner links only) to the desk as read keys. Decoding is strict;
11
+ * a frame that does not decode ends the link before any port sees it.
12
+ */
13
+ import type { Duplex } from 'node:stream';
14
+
15
+ import {
16
+ LINK_FRAME_OVERHEAD_BYTES,
17
+ LINK_GRANT_FRAME_BYTES,
18
+ LINK_HELLO_BYTES,
19
+ LINK_PROOF_MAX_BYTES,
20
+ createLinkResponder,
21
+ linkFrameKind,
22
+ type IReadKeys,
23
+ type IResponderLink,
24
+ type IRuntimeIdentity,
25
+ type ILinkBinding,
26
+ type TLinkMode,
27
+ } from '@robota-sdk/agent-keyring';
28
+
29
+ import {
30
+ OwnerRequestRefusedError,
31
+ type ILinkSessionSink,
32
+ type IOwnerLinkDesk,
33
+ type IOwnerLinkSessions,
34
+ type IRuntimeCustodyState,
35
+ } from './link-ports.js';
36
+ import { encodeLinkFrame, readLinkFrames } from './link-stream.js';
37
+ import {
38
+ MAX_REQUESTS_IN_FLIGHT,
39
+ OWNER_FRAME_MAX_BYTES,
40
+ SESSION_FRAME_MAX_BYTES,
41
+ SEALED_FRAME_MAX_BYTES,
42
+ } from './link-bounds.js';
43
+ import {
44
+ decodeOwnerRequest,
45
+ encodeRuntimeMessage,
46
+ type TOwnerRequest,
47
+ type TRuntimeMessage,
48
+ } from './owner-vocabulary.js';
49
+
50
+ /** A runtime gives up on a handshake that has not finished by then. */
51
+ export const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000;
52
+
53
+ /** An admitted link, as the runtime holds it. */
54
+ export interface IAdmittedRuntimeLink {
55
+ /** How the client was admitted: `spawner` by the spawner key, `owner` by the attach key. */
56
+ readonly standing: TLinkMode;
57
+ readonly binding: ILinkBinding;
58
+ readonly closed: boolean;
59
+ /** Sends one serialized session-protocol message; throws `RangeError` for one over the frame bound. */
60
+ sendSession(data: string): void;
61
+ /** Tells the client its runtime's custody state changed. */
62
+ notifyCustody(state: IRuntimeCustodyState): void;
63
+ close(): void;
64
+ }
65
+
66
+ /** The runtime's answers for one admitted link. */
67
+ export interface IRuntimeLinkPorts {
68
+ readonly desk: IOwnerLinkDesk;
69
+ readonly sessions: IOwnerLinkSessions;
70
+ readonly session: ILinkSessionSink;
71
+ }
72
+
73
+ export interface IRuntimeLinkDeps {
74
+ readonly identity: IRuntimeIdentity;
75
+ /** This runtime start, as its registration names it. */
76
+ readonly binding: ILinkBinding;
77
+ /** The bytes after the request line that arrived with it. */
78
+ readonly rest?: Uint8Array;
79
+ readonly handshakeTimeoutMs?: number;
80
+ /**
81
+ * Called once the handshake admitted the client; the ports it returns serve that link. The opening
82
+ * custody notice is always the first frame the client sees: whatever `admit` sends through the link
83
+ * goes out right after it, in the order it was sent.
84
+ */
85
+ readonly admit: (link: IAdmittedRuntimeLink) => IRuntimeLinkPorts;
86
+ }
87
+
88
+ const textDecoder = new TextDecoder('utf-8', { fatal: true });
89
+ const textEncoder = new TextEncoder();
90
+
91
+ function refusalOf(error: unknown): { readonly code: string; readonly message: string } {
92
+ return error instanceof OwnerRequestRefusedError
93
+ ? { code: error.code, message: error.message }
94
+ : { code: 'failed', message: 'The runtime could not do this.' };
95
+ }
96
+
97
+ /**
98
+ * Serves one owner link on `stream`, which the control endpoint has read the request line from and
99
+ * paused. Returns at once; the link lives until the stream closes.
100
+ */
101
+ export function serveRuntimeLink(stream: Duplex, deps: IRuntimeLinkDeps): void {
102
+ let responder: ReturnType<typeof createLinkResponder>;
103
+ try {
104
+ responder = createLinkResponder(deps.identity, deps.binding);
105
+ } catch (error) {
106
+ stream.destroy();
107
+ throw error;
108
+ }
109
+ let step: 'hello' | 'proof' | 'linked' = 'hello';
110
+ let link: IResponderLink | undefined;
111
+ let ports: IRuntimeLinkPorts | undefined;
112
+ let grants = 0;
113
+ let inFlight = 0;
114
+ const timer = setTimeout(
115
+ () => stream.destroy(),
116
+ deps.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS,
117
+ );
118
+ timer.unref?.();
119
+
120
+ /**
121
+ * What `admit` asked to send, as writes to run once the opening custody notice is out; `undefined`
122
+ * outside that window. A frame's counter is taken when it is sealed, so the notice is sealed first.
123
+ */
124
+ let held: Array<() => void> | undefined;
125
+
126
+ const sendSealed = (kind: 'session' | 'owner', bytes: Uint8Array): void => {
127
+ const write = (): void => {
128
+ if (link === undefined || link.closed || stream.destroyed) return;
129
+ stream.write(encodeLinkFrame(link.seal(kind, bytes)));
130
+ };
131
+ if (held !== undefined) held.push(write);
132
+ else write();
133
+ };
134
+
135
+ const sendOwner = (message: TRuntimeMessage): void => {
136
+ if (link === undefined || link.closed || stream.destroyed) return;
137
+ sendSealed('owner', encodeRuntimeMessage(message));
138
+ };
139
+
140
+ /** Whether one more request may wait; a peer that has more waiting than a client allows itself loses the link. */
141
+ const admitRequest = (): boolean => {
142
+ if (inFlight < MAX_REQUESTS_IN_FLIGHT) return true;
143
+ stream.destroy();
144
+ return false;
145
+ };
146
+
147
+ const reply = (requestId: string, work: () => Promise<TRuntimeMessage>): void => {
148
+ inFlight += 1;
149
+ void work()
150
+ .then(
151
+ (message) => message,
152
+ (error: unknown): TRuntimeMessage => ({ op: 'refused', requestId, ...refusalOf(error) }),
153
+ )
154
+ .then((message) => {
155
+ inFlight -= 1;
156
+ try {
157
+ sendOwner(message);
158
+ } catch {
159
+ // An answer outside the vocabulary (a too-long notice, say) reaches the client as a failure.
160
+ sendOwner({ op: 'refused', requestId, ...refusalOf(undefined) });
161
+ }
162
+ })
163
+ .catch(() => stream.destroy());
164
+ };
165
+
166
+ const dispatch = (request: TOwnerRequest, linked: IRuntimeLinkPorts): void => {
167
+ const { requestId } = request;
168
+ switch (request.op) {
169
+ case 'install-trust-root':
170
+ return reply(requestId, async () => {
171
+ await linked.desk.installTrustRoot(request.root);
172
+ return { op: 'done', requestId };
173
+ });
174
+ case 'lock':
175
+ return reply(requestId, async () => {
176
+ await linked.desk.lock(request.reason);
177
+ return { op: 'done', requestId };
178
+ });
179
+ case 'question':
180
+ return reply(requestId, async () => ({
181
+ op: 'question',
182
+ requestId,
183
+ question: await linked.sessions.question(request.sessionId),
184
+ }));
185
+ case 'open-accepted':
186
+ return reply(requestId, async () => {
187
+ await linked.sessions.openAccepted(request.sessionId, request.acceptedDigest);
188
+ return { op: 'done', requestId };
189
+ });
190
+ case 'control':
191
+ return reply(requestId, async () => ({
192
+ op: 'control',
193
+ requestId,
194
+ result: await linked.desk.control(request.request),
195
+ }));
196
+ }
197
+ };
198
+
199
+ const unlock = (keys: IReadKeys, linked: IRuntimeLinkPorts): void => {
200
+ grants += 1;
201
+ const requestId = `g${grants}`;
202
+ reply(requestId, async () => {
203
+ try {
204
+ return { op: 'unlocked', requestId, sealingKey: await linked.desk.unlock(keys) };
205
+ } catch (error) {
206
+ keys.lock();
207
+ throw error;
208
+ }
209
+ });
210
+ };
211
+
212
+ const onSealed = (frame: Uint8Array, linked: IRuntimeLinkPorts, open: IResponderLink): void => {
213
+ const kind = linkFrameKind(frame);
214
+ if (kind === 'grant') {
215
+ if (frame.length !== LINK_GRANT_FRAME_BYTES) throw new RangeError('A grant has one size.');
216
+ // Checked before the grant opens, so keys are never made for a request that cannot wait.
217
+ if (!admitRequest()) return;
218
+ unlock(open.openReadGrant(frame), linked);
219
+ return;
220
+ }
221
+ const bound = kind === 'owner' ? OWNER_FRAME_MAX_BYTES : SESSION_FRAME_MAX_BYTES;
222
+ if (frame.length > bound + LINK_FRAME_OVERHEAD_BYTES)
223
+ throw new RangeError('A frame over its bound.');
224
+ const { plaintext } = open.open(frame);
225
+ if (kind === 'session') {
226
+ linked.session.receive(textDecoder.decode(plaintext));
227
+ return;
228
+ }
229
+ const request = decodeOwnerRequest(plaintext);
230
+ if (request === undefined) throw new RangeError('A request outside the vocabulary.');
231
+ if (admitRequest()) dispatch(request, linked);
232
+ };
233
+
234
+ const admitted: IAdmittedRuntimeLink = {
235
+ get standing(): TLinkMode {
236
+ return link!.standing;
237
+ },
238
+ binding: deps.binding,
239
+ get closed(): boolean {
240
+ return link === undefined || link.closed || stream.destroyed;
241
+ },
242
+ sendSession(data: string): void {
243
+ const bytes = textEncoder.encode(data);
244
+ if (bytes.length > SESSION_FRAME_MAX_BYTES)
245
+ throw new RangeError('A session message over the frame bound.');
246
+ if (link === undefined || link.closed || stream.destroyed) return;
247
+ sendSealed('session', bytes);
248
+ },
249
+ notifyCustody(state: IRuntimeCustodyState): void {
250
+ sendOwner({ op: 'custody', state: { unlocked: state.unlocked, certified: state.certified } });
251
+ },
252
+ close(): void {
253
+ stream.destroy();
254
+ },
255
+ };
256
+
257
+ readLinkFrames(stream, {
258
+ ...(deps.rest !== undefined ? { rest: deps.rest } : {}),
259
+ maxBytes: () =>
260
+ step === 'hello'
261
+ ? LINK_HELLO_BYTES
262
+ : step === 'proof'
263
+ ? LINK_PROOF_MAX_BYTES
264
+ : SEALED_FRAME_MAX_BYTES,
265
+ onFrame: (frame) => {
266
+ try {
267
+ if (step === 'hello') {
268
+ stream.write(encodeLinkFrame(responder.challenge(frame)));
269
+ step = 'proof';
270
+ return;
271
+ }
272
+ if (step === 'proof') {
273
+ link = responder.accept(frame).link;
274
+ clearTimeout(timer);
275
+ step = 'linked';
276
+ const early: Array<() => void> = [];
277
+ held = early;
278
+ ports = deps.admit(admitted);
279
+ const custody = ports.desk.custody();
280
+ held = undefined;
281
+ admitted.notifyCustody(custody);
282
+ for (const write of early) write();
283
+ return;
284
+ }
285
+ onSealed(frame, ports!, link!);
286
+ } catch {
287
+ // Refused, malformed or failed: the other end is told nothing.
288
+ stream.destroy();
289
+ }
290
+ },
291
+ onEnd: () => {
292
+ clearTimeout(timer);
293
+ link?.close();
294
+ if (ports !== undefined) {
295
+ try {
296
+ ports.desk.closed();
297
+ } catch {
298
+ // allow-fallback: the link is already gone; a failing listener cannot bring it back.
299
+ }
300
+ }
301
+ },
302
+ });
303
+ }