@robota-sdk/agent-transport-link 0.0.0-stage → 3.1.1
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 +37 -0
- package/LICENSE +661 -0
- package/README.md +73 -2
- package/dist/node/index.cjs +1 -0
- package/dist/node/index.d.cts +195 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +195 -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 +77 -3
- package/src/__tests__/harness.ts +322 -0
- package/src/__tests__/link-stream.test.ts +52 -0
- package/src/__tests__/owner-vocabulary.test.ts +140 -0
- package/src/__tests__/runtime-link.test.ts +616 -0
- package/src/__tests__/squatter.test.ts +147 -0
- package/src/index.ts +35 -0
- package/src/link-bounds.ts +21 -0
- package/src/link-ports.ts +118 -0
- package/src/link-request.ts +20 -0
- package/src/link-stream.ts +136 -0
- package/src/open-runtime-link.ts +441 -0
- package/src/owner-vocabulary.ts +389 -0
- package/src/serve-runtime-link.ts +303 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":["MAX_REFUSAL_MESSAGE_LENGTH","#chunks","#length","#join","textDecoder","textDecoder","textEncoder","#stream","#link","#handover","#pending","#custodyListeners","#sessionListeners","#custody","#resolveClosed","#sendSession","#request","#grants","#await","#write","#ended","#requests"],"sources":["../../src/link-request.ts","../../src/link-ports.ts","../../src/link-stream.ts","../../src/link-bounds.ts","../../src/owner-vocabulary.ts","../../src/serve-runtime-link.ts","../../src/open-runtime-link.ts"],"sourcesContent":["/**\n * The request line that opens an owner link, in the control endpoint's existing request shape so\n * the endpoint routes it like any other command. It names no runtime and no start: the binding is\n * proven inside the handshake, never taken from the request.\n */\nexport const LINK_PROTOCOL = 1;\n\n/** Exactly what a client writes first, newline included. */\nexport const LINK_REQUEST_LINE = `${JSON.stringify({ command: 'link', protocol: LINK_PROTOCOL })}\\n`;\n\n/** Whether a parsed request line is exactly the link request; anything more or less is not. */\nexport function isLinkRequest(value: unknown): boolean {\n if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;\n const record = value as Record<string, unknown>;\n return (\n Object.keys(record).length === 2 &&\n record['command'] === 'link' &&\n record['protocol'] === LINK_PROTOCOL\n );\n}\n","/**\n * What the owner link asks of a runtime, and tells its clients, in domain terms.\n *\n * The owner vocabulary's wire shapes stop inside this package. A runtime answers an admitted link\n * through two ports: the desk, for custody (the trust root, the read grant, locking and the existing\n * control commands), and the session decisions, for the one owner decision that is not a session\n * protocol message (accepting a record not verified as the owner's). Both are given the link's\n * standing when the link is admitted, and decide what that standing may do: this package carries\n * requests, it does not grant them.\n */\nimport type { IReadKeys, ITrustRoot } from '@robota-sdk/agent-keyring';\n\n/** What a client may ask a runtime to lock for; idle and last-link-closed are the runtime's own. */\nexport type TRequestedLockReason = 'explicit' | 'screen-lock' | 'suspend';\n\n/**\n * How a read grant went. `mismatch`: the keyring file's sealing public key is not the granted one;\n * the runtime seals to the granted key, and the owner is told to run `secure verify`.\n */\nexport type TGrantOutcome = 'verified' | 'mismatch';\n\n/** What every linked client is told, once admitted and whenever it changes. */\nexport interface IRuntimeCustodyState {\n readonly unlocked: boolean;\n /** Whether owner links are possible: a spawner installed a trust root. */\n readonly certified: boolean;\n}\n\n/**\n * The runtime's answer to \"may I open this session?\". `accept` asks the owner, on a surface that\n * shows the fixed `notice` and `question` and never the record, and `digest` binds the answer to\n * the bytes the runtime read.\n */\nexport type TOwnerSessionQuestion =\n | { readonly kind: 'open' }\n | {\n readonly kind: 'accept';\n readonly digest: string;\n readonly notice: string;\n readonly question: string;\n }\n | { readonly kind: 'refused'; readonly message: string };\n\n/** One of the runtime's existing control commands, carried inside the link. */\nexport interface IOwnerControlRequest {\n readonly command: string;\n readonly fields: Readonly<Record<string, string>>;\n}\n\nexport type TOwnerControlValue =\n | null\n | boolean\n | number\n | string\n | readonly TOwnerControlValue[]\n | { readonly [key: string]: TOwnerControlValue };\n\n/** What a control command answered. */\nexport type TOwnerControlResult = { readonly [key: string]: TOwnerControlValue };\n\n/** The runtime's custody answers for one admitted link. */\nexport interface IOwnerLinkDesk {\n /** The state the link announces as soon as it is admitted. */\n custody(): IRuntimeCustodyState;\n installTrustRoot(root: ITrustRoot): void | Promise<void>;\n /**\n * Installs the granted read keys. The desk owns them once this returns; when it refuses, by\n * throwing, the carrier locks them.\n */\n unlock(keys: IReadKeys): TGrantOutcome | Promise<TGrantOutcome>;\n lock(reason: TRequestedLockReason): void | Promise<void>;\n control(request: IOwnerControlRequest): TOwnerControlResult | Promise<TOwnerControlResult>;\n /** The link ended, for whatever reason. Called once. */\n closed(): void;\n}\n\n/** The runtime's session decisions for one admitted link. */\nexport interface IOwnerLinkSessions {\n question(sessionId: string): TOwnerSessionQuestion | Promise<TOwnerSessionQuestion>;\n openAccepted(sessionId: string, acceptedDigest: string): void | Promise<void>;\n}\n\n/** Where the session protocol of an admitted link goes: one serialized client message at a time. */\nexport interface ILinkSessionSink {\n receive(data: string): void;\n}\n\nconst REFUSAL_CODE = /^[a-z][a-z0-9-]{0,63}$/u;\nconst MAX_REFUSAL_MESSAGE_LENGTH = 1024;\n\n/**\n * A request the runtime refused, by decision: a desk throws it with a fixed code and fixed text, and\n * the client rejects with it. Any other failure reaches the client as `failed`, never with its text.\n */\nexport class OwnerRequestRefusedError extends Error {\n readonly code: string;\n\n constructor(code: string, message: string) {\n if (\n !REFUSAL_CODE.test(code) ||\n message.length === 0 ||\n message.length > MAX_REFUSAL_MESSAGE_LENGTH\n ) {\n throw new RangeError('A refusal has a lower-case code and a message of 1-1024 characters.');\n }\n super(message);\n this.name = 'OwnerRequestRefusedError';\n this.code = code;\n }\n}\n\n/** The link closed, or never opened: the other end does not say why. */\nexport class OwnerLinkClosedError extends Error {\n constructor() {\n super('The owner link closed.');\n this.name = 'OwnerLinkClosedError';\n }\n}\n","/**\n * The owner link on a byte stream: after the request line, length-prefixed binary frames.\n *\n * Each frame is a 4-byte big-endian length and that many bytes. Every frame is bounded by what may\n * come next (a hello, a challenge, a proof, a sealed frame), and the bound is checked as soon as the\n * length arrives, so a peer can never make an end buffer more than one frame it is allowed to send.\n * Chunks are kept as they arrive and joined once per frame, so a large frame costs one copy.\n */\nimport { Buffer } from 'node:buffer';\nimport type { Duplex } from 'node:stream';\n\nimport { LINK_REQUEST_LINE } from './link-request.js';\n\nconst LENGTH_PREFIX_BYTES = 4;\n\n/** One frame: its length, then its bytes. */\nexport function encodeLinkFrame(payload: Uint8Array): Buffer {\n const prefix = Buffer.alloc(LENGTH_PREFIX_BYTES);\n prefix.writeUInt32BE(payload.length);\n return Buffer.concat([prefix, payload]);\n}\n\n/** The request line and the first frame, written together so the endpoint can route both at once. */\nexport function encodeLinkOpening(hello: Uint8Array): Buffer {\n return Buffer.concat([Buffer.from(LINK_REQUEST_LINE, 'utf8'), encodeLinkFrame(hello)]);\n}\n\n/** Collects stream chunks and hands out whole frames within the bound the caller gives. */\nclass LinkFrameReader {\n #chunks: Buffer[] = [];\n #length = 0;\n\n push(chunk: Uint8Array): void {\n if (chunk.length === 0) return;\n this.#chunks.push(Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength));\n this.#length += chunk.length;\n }\n\n /** The next whole frame, or `undefined` until it has arrived; throws when one is announced over `maxBytes`. */\n next(maxBytes: number): Buffer | undefined {\n if (this.#length < LENGTH_PREFIX_BYTES) return undefined;\n if (this.#chunks[0]!.length < LENGTH_PREFIX_BYTES) this.#join();\n const length = this.#chunks[0]!.readUInt32BE(0);\n if (length > maxBytes) throw new RangeError('A frame was announced over its bound.');\n if (this.#length < LENGTH_PREFIX_BYTES + length) return undefined;\n const all = this.#join();\n const end = LENGTH_PREFIX_BYTES + length;\n const payload = Buffer.from(all.subarray(LENGTH_PREFIX_BYTES, end));\n const rest = all.subarray(end);\n this.#chunks = rest.length > 0 ? [rest] : [];\n this.#length = rest.length;\n return payload;\n }\n\n #join(): Buffer {\n const all =\n this.#chunks.length === 1 ? this.#chunks[0]! : Buffer.concat(this.#chunks, this.#length);\n this.#chunks = [all];\n return all;\n }\n}\n\nexport interface IFrameReading {\n /** The bytes after the request line that arrived with it. */\n readonly rest?: Uint8Array;\n /** The largest frame allowed next; asked before each frame. */\n readonly maxBytes: () => number;\n /**\n * One whole frame. May end the stream; nothing more is read once it has. Returning `'yield'` stops\n * the reading for the rest of this tick, so a caller woken by this frame runs before the next one.\n */\n readonly onFrame: (frame: Buffer) => void | 'yield';\n /** The stream ended, errored, or sent a frame over its bound. Called once. */\n readonly onEnd: () => void;\n}\n\n/**\n * Reads frames off `stream` until it ends; a frame over its bound destroys the stream. A stream that\n * is already destroyed or closed reads nothing and ends on the next tick.\n */\nexport function readLinkFrames(stream: Duplex, reading: IFrameReading): void {\n const reader = new LinkFrameReader();\n let ended = false;\n const end = (): void => {\n if (ended) return;\n ended = true;\n reading.onEnd();\n };\n let yielded = false;\n const drain = (): void => {\n if (yielded) return;\n try {\n for (\n let frame = reader.next(reading.maxBytes());\n frame !== undefined;\n frame = reader.next(reading.maxBytes())\n ) {\n if (stream.destroyed) return;\n const outcome = reading.onFrame(frame);\n if (stream.destroyed) return;\n if (outcome === 'yield') {\n // Paused, so nothing more arrives (and the stream cannot end) until the frames kept so far\n // have been read: the pause is one tick, and holds no more than one chunk.\n yielded = true;\n stream.pause();\n setImmediate(() => {\n yielded = false;\n drain();\n if (!yielded) stream.resume();\n });\n return;\n }\n }\n } catch {\n // A frame over its bound, or a handler that failed: either way this link is over.\n stream.destroy();\n }\n };\n stream.on('data', (chunk: Buffer | string) => {\n // A stream decoding to text has already lost the frames' bytes.\n if (typeof chunk === 'string') {\n stream.destroy();\n return;\n }\n reader.push(chunk);\n drain();\n });\n stream.on('error', () => stream.destroy());\n stream.once('close', end);\n stream.once('end', () => stream.destroy());\n if (reading.rest !== undefined) reader.push(reading.rest);\n drain();\n stream.resume();\n // A stream that is over before it is read emits no `close` to wait for, and a link must still end.\n if (stream.destroyed) process.nextTick(end);\n}\n","/**\n * How large each kind of frame may be. A session frame carries one session-protocol message, and its\n * bound is the protocol's own inbound limit counted in UTF-8 bytes, the way the local attach path\n * counts it: a message within it is within the decoder's character limit too, so a sender never\n * emits what the receiving decoder must refuse. Owner frames carry short requests and replies. Both\n * ends bound what they send, and every frame they read before reading it, by these.\n */\nimport { LINK_FRAME_OVERHEAD_BYTES } from '@robota-sdk/agent-keyring';\nimport { MAX_INBOUND_FRAME_BYTES } from '@robota-sdk/agent-transport';\n\nexport const SESSION_FRAME_MAX_BYTES = MAX_INBOUND_FRAME_BYTES;\nexport const OWNER_FRAME_MAX_BYTES = 1024 * 1024;\n/** The largest sealed frame either end accepts, of any kind. */\nexport const SEALED_FRAME_MAX_BYTES =\n Math.max(SESSION_FRAME_MAX_BYTES, OWNER_FRAME_MAX_BYTES) + LINK_FRAME_OVERHEAD_BYTES;\n/**\n * Owner requests and read grants one link may have waiting at once. A client refuses its own request\n * beyond it, so a well-behaved caller never loses its link; the runtime ends a link whose peer sends\n * one more.\n */\nexport const MAX_REQUESTS_IN_FLIGHT = 64;\n","/**\n * The owner vocabulary on the wire: the JSON inside `owner` frames, with a strict decoder for each\n * direction.\n *\n * Owner frames are authenticated, so a malformed one is never an attack from outside the link; it is\n * a peer that is broken or not what it claims, and the link closes. So every message has exactly its\n * fields, every field its type, pattern and bound, and anything else decodes to nothing. Text a\n * person will read (a notice, a question, a refusal) carries no control or bidirectional-override\n * character, so it shows as it is in a terminal or a native dialog. Decoding builds fresh values,\n * so nothing shaped by the peer (a prototype key, an extra field) survives into the domain value.\n * Encoding checks its own output with the decoder, so an end never sends what the other must refuse.\n */\nimport { Buffer } from 'node:buffer';\n\nimport type { ITrustRoot } from '@robota-sdk/agent-keyring';\n\nimport type {\n IOwnerControlRequest,\n IRuntimeCustodyState,\n TGrantOutcome,\n TOwnerControlResult,\n TOwnerControlValue,\n TOwnerSessionQuestion,\n TRequestedLockReason,\n} from './link-ports.js';\n\n/** A request from a link's client. */\nexport type TOwnerRequest =\n | { readonly op: 'install-trust-root'; readonly requestId: string; readonly root: ITrustRoot }\n | { readonly op: 'lock'; readonly requestId: string; readonly reason: TRequestedLockReason }\n | { readonly op: 'question'; readonly requestId: string; readonly sessionId: string }\n | {\n readonly op: 'open-accepted';\n readonly requestId: string;\n readonly sessionId: string;\n readonly acceptedDigest: string;\n }\n | { readonly op: 'control'; readonly requestId: string; readonly request: IOwnerControlRequest };\n\n/** A reply or a notice from the runtime. */\nexport type TRuntimeMessage =\n | { readonly op: 'done'; readonly requestId: string }\n | { readonly op: 'unlocked'; readonly requestId: string; readonly sealingKey: TGrantOutcome }\n | {\n readonly op: 'question';\n readonly requestId: string;\n readonly question: TOwnerSessionQuestion;\n }\n | {\n readonly op: 'refused';\n readonly requestId: string;\n readonly code: string;\n readonly message: string;\n }\n | { readonly op: 'control'; readonly requestId: string; readonly result: TOwnerControlResult }\n | { readonly op: 'custody'; readonly state: IRuntimeCustodyState };\n\n/** A client's own requests count `r1`, `r2`, ...; its read grants count `g1`, `g2`, ... */\nexport const REQUEST_ID_PATTERN = /^r[1-9][0-9]{0,15}$/u;\nexport const GRANT_ID_PATTERN = /^g[1-9][0-9]{0,15}$/u;\n\nconst SESSION_ID_PATTERN = /^[A-Za-z0-9._:-]{1,256}$/u;\nconst DIGEST_PATTERN = /^[A-Za-z0-9._:+/=-]{1,256}$/u;\nconst COMMAND_PATTERN = /^[a-z][a-z0-9-]{0,31}$/u;\nconst FIELD_KEY_PATTERN = /^[A-Za-z][A-Za-z0-9]{0,31}$/u;\nconst REFUSAL_CODE_PATTERN = /^[a-z][a-z0-9-]{0,63}$/u;\nconst BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/u;\n/* eslint-disable no-control-regex -- these patterns exist to find control characters */\nconst UNSAFE_TEXT =\n /[\\u0000-\\u0009\\u000b-\\u001f\\u007f-\\u009f\\u200e\\u200f\\u202a-\\u202e\\u2066-\\u2069]/u;\nconst UNSAFE_FIELD = /[\\u0000-\\u001f\\u007f-\\u009f\\u200e\\u200f\\u202a-\\u202e\\u2066-\\u2069]/u;\n/* eslint-enable no-control-regex */\nconst MAX_TEXT_LENGTH = 4096;\nconst MAX_REFUSAL_MESSAGE_LENGTH = 1024;\nconst MAX_FIELDS = 16;\nconst MAX_FIELD_VALUE_LENGTH = 4096;\nconst MAX_RESULT_DEPTH = 8;\nconst MAX_RESULT_NODES = 4096;\nconst MAX_RESULT_STRING_LENGTH = 65_536;\nconst MAX_RESULT_KEY_LENGTH = 256;\nconst ATTACH_PUBLIC_KEY_BYTES = 32;\nconst CERTIFICATE_BYTES = 64;\nconst LOCK_REASONS: ReadonlySet<string> = new Set<TRequestedLockReason>([\n 'explicit',\n 'screen-lock',\n 'suspend',\n]);\nconst GRANT_OUTCOMES: ReadonlySet<string> = new Set<TGrantOutcome>(['verified', 'mismatch']);\n\ntype TRecord = Readonly<Record<string, unknown>>;\n\nconst textDecoder = new TextDecoder('utf-8', { fatal: true });\n\nfunction parse(bytes: Uint8Array): unknown {\n try {\n return JSON.parse(textDecoder.decode(bytes));\n } catch {\n return undefined;\n }\n}\n\n/** `value` as a plain record with exactly `keys`, or `undefined`. */\nfunction exactly(value: unknown, keys: readonly string[]): TRecord | undefined {\n if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;\n const own = Object.keys(value);\n if (own.length !== keys.length || !keys.every((key) => Object.hasOwn(value, key)))\n return undefined;\n return value as TRecord;\n}\n\nconst matches = (value: unknown, pattern: RegExp): value is string =>\n typeof value === 'string' && pattern.test(value);\n\nconst isText = (value: unknown, maxLength: number): value is string =>\n typeof value === 'string' &&\n value.length > 0 &&\n value.length <= maxLength &&\n !UNSAFE_TEXT.test(value);\n\nfunction bytesFrom(value: unknown, length: number): Uint8Array | undefined {\n if (!matches(value, BASE64URL_PATTERN)) return undefined;\n const decoded = Buffer.from(value, 'base64url');\n return decoded.length === length && decoded.toString('base64url') === value\n ? Uint8Array.from(decoded)\n : undefined;\n}\n\nfunction fieldsFrom(value: unknown): Readonly<Record<string, string>> | undefined {\n if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;\n const entries = Object.entries(value);\n if (entries.length > MAX_FIELDS) return undefined;\n const fields: Record<string, string> = {};\n for (const [key, field] of entries) {\n if (\n !FIELD_KEY_PATTERN.test(key) ||\n typeof field !== 'string' ||\n field.length > MAX_FIELD_VALUE_LENGTH ||\n UNSAFE_FIELD.test(field)\n ) {\n return undefined;\n }\n fields[key] = field;\n }\n return fields;\n}\n\n/** A fresh copy of a JSON control result within its bounds, or `undefined`. */\nfunction resultFrom(value: unknown): TOwnerControlResult | undefined {\n let nodes = 0;\n const copy = (node: unknown, depth: number): TOwnerControlValue | undefined => {\n nodes += 1;\n if (nodes > MAX_RESULT_NODES) return undefined;\n if (node === null || typeof node === 'boolean') return node;\n if (typeof node === 'number') return Number.isFinite(node) ? node : undefined;\n if (typeof node === 'string') return node.length <= MAX_RESULT_STRING_LENGTH ? node : undefined;\n if (depth >= MAX_RESULT_DEPTH || typeof node !== 'object') return undefined;\n if (Array.isArray(node)) {\n const items: TOwnerControlValue[] = [];\n for (const item of node) {\n const copied = copy(item, depth + 1);\n if (copied === undefined) return undefined;\n items.push(copied);\n }\n return items;\n }\n const record: Record<string, TOwnerControlValue> = {};\n for (const [key, item] of Object.entries(node)) {\n if (key === '__proto__' || key.length > MAX_RESULT_KEY_LENGTH) return undefined;\n const copied = copy(item, depth + 1);\n if (copied === undefined) return undefined;\n record[key] = copied;\n }\n return record;\n };\n if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;\n const copied = copy(value, 0);\n return copied === undefined ? undefined : (copied as TOwnerControlResult);\n}\n\nfunction questionFrom(value: unknown): TOwnerSessionQuestion | undefined {\n const kind = typeof value === 'object' && value !== null ? (value as TRecord)['kind'] : undefined;\n if (kind === 'open') return exactly(value, ['kind']) ? { kind: 'open' } : undefined;\n if (kind === 'accept') {\n const record = exactly(value, ['kind', 'digest', 'notice', 'question']);\n if (\n record === undefined ||\n !matches(record['digest'], DIGEST_PATTERN) ||\n !isText(record['notice'], MAX_TEXT_LENGTH) ||\n !isText(record['question'], MAX_TEXT_LENGTH)\n ) {\n return undefined;\n }\n return {\n kind: 'accept',\n digest: record['digest'],\n notice: record['notice'],\n question: record['question'],\n };\n }\n if (kind === 'refused') {\n const record = exactly(value, ['kind', 'message']);\n return record !== undefined && isText(record['message'], MAX_TEXT_LENGTH)\n ? { kind: 'refused', message: record['message'] }\n : undefined;\n }\n return undefined;\n}\n\nfunction custodyFrom(value: unknown): IRuntimeCustodyState | undefined {\n const record = exactly(value, ['unlocked', 'certified']);\n return record !== undefined &&\n typeof record['unlocked'] === 'boolean' &&\n typeof record['certified'] === 'boolean'\n ? { unlocked: record['unlocked'], certified: record['certified'] }\n : undefined;\n}\n\n/** One request from a client, or `undefined` for anything that is not exactly one. */\nexport function decodeOwnerRequest(bytes: Uint8Array): TOwnerRequest | undefined {\n const value = parse(bytes);\n const op = typeof value === 'object' && value !== null ? (value as TRecord)['op'] : undefined;\n switch (op) {\n case 'install-trust-root': {\n const record = exactly(value, ['op', 'requestId', 'attachPublicKey', 'certificate']);\n const attachPublicKey = bytesFrom(record?.['attachPublicKey'], ATTACH_PUBLIC_KEY_BYTES);\n const certificate = bytesFrom(record?.['certificate'], CERTIFICATE_BYTES);\n if (!matches(record?.['requestId'], REQUEST_ID_PATTERN) || !attachPublicKey || !certificate) {\n return undefined;\n }\n return { op, requestId: record['requestId'], root: { attachPublicKey, certificate } };\n }\n case 'lock': {\n const record = exactly(value, ['op', 'requestId', 'reason']);\n const reason = record?.['reason'];\n if (\n !matches(record?.['requestId'], REQUEST_ID_PATTERN) ||\n typeof reason !== 'string' ||\n !LOCK_REASONS.has(reason)\n ) {\n return undefined;\n }\n return { op, requestId: record['requestId'], reason: reason as TRequestedLockReason };\n }\n case 'question': {\n const record = exactly(value, ['op', 'requestId', 'sessionId']);\n if (\n !matches(record?.['requestId'], REQUEST_ID_PATTERN) ||\n !matches(record['sessionId'], SESSION_ID_PATTERN)\n ) {\n return undefined;\n }\n return { op, requestId: record['requestId'], sessionId: record['sessionId'] };\n }\n case 'open-accepted': {\n const record = exactly(value, ['op', 'requestId', 'sessionId', 'acceptedDigest']);\n if (\n !matches(record?.['requestId'], REQUEST_ID_PATTERN) ||\n !matches(record['sessionId'], SESSION_ID_PATTERN) ||\n !matches(record['acceptedDigest'], DIGEST_PATTERN)\n ) {\n return undefined;\n }\n return {\n op,\n requestId: record['requestId'],\n sessionId: record['sessionId'],\n acceptedDigest: record['acceptedDigest'],\n };\n }\n case 'control': {\n const record = exactly(value, ['op', 'requestId', 'command', 'fields']);\n const fields = fieldsFrom(record?.['fields']);\n if (\n !matches(record?.['requestId'], REQUEST_ID_PATTERN) ||\n !matches(record['command'], COMMAND_PATTERN) ||\n !fields\n ) {\n return undefined;\n }\n return {\n op,\n requestId: record['requestId'],\n request: { command: record['command'], fields },\n };\n }\n default:\n return undefined;\n }\n}\n\n/** One reply or notice from the runtime, or `undefined` for anything that is not exactly one. */\nexport function decodeRuntimeMessage(bytes: Uint8Array): TRuntimeMessage | undefined {\n const value = parse(bytes);\n const op = typeof value === 'object' && value !== null ? (value as TRecord)['op'] : undefined;\n switch (op) {\n case 'done': {\n const record = exactly(value, ['op', 'requestId']);\n return matches(record?.['requestId'], REQUEST_ID_PATTERN)\n ? { op, requestId: record['requestId'] }\n : undefined;\n }\n case 'unlocked': {\n const record = exactly(value, ['op', 'requestId', 'sealingKey']);\n const sealingKey = record?.['sealingKey'];\n if (\n !matches(record?.['requestId'], GRANT_ID_PATTERN) ||\n typeof sealingKey !== 'string' ||\n !GRANT_OUTCOMES.has(sealingKey)\n ) {\n return undefined;\n }\n return { op, requestId: record['requestId'], sealingKey: sealingKey as TGrantOutcome };\n }\n case 'question': {\n const record = exactly(value, ['op', 'requestId', 'question']);\n const question = questionFrom(record?.['question']);\n return matches(record?.['requestId'], REQUEST_ID_PATTERN) && question\n ? { op, requestId: record['requestId'], question }\n : undefined;\n }\n case 'refused': {\n const record = exactly(value, ['op', 'requestId', 'code', 'message']);\n const requestId = record?.['requestId'];\n if (\n !(matches(requestId, REQUEST_ID_PATTERN) || matches(requestId, GRANT_ID_PATTERN)) ||\n !matches(record?.['code'], REFUSAL_CODE_PATTERN) ||\n !isText(record['message'], MAX_REFUSAL_MESSAGE_LENGTH)\n ) {\n return undefined;\n }\n return { op, requestId, code: record['code'], message: record['message'] };\n }\n case 'control': {\n const record = exactly(value, ['op', 'requestId', 'result']);\n const result = resultFrom(record?.['result']);\n return matches(record?.['requestId'], REQUEST_ID_PATTERN) && result\n ? { op, requestId: record['requestId'], result }\n : undefined;\n }\n case 'custody': {\n const record = exactly(value, ['op', 'state']);\n const state = custodyFrom(record?.['state']);\n return state ? { op, state } : undefined;\n }\n default:\n return undefined;\n }\n}\n\nfunction wireRequest(request: TOwnerRequest): Record<string, unknown> {\n switch (request.op) {\n case 'install-trust-root':\n return {\n op: request.op,\n requestId: request.requestId,\n attachPublicKey: Buffer.from(request.root.attachPublicKey).toString('base64url'),\n certificate: Buffer.from(request.root.certificate).toString('base64url'),\n };\n case 'control':\n return {\n op: request.op,\n requestId: request.requestId,\n command: request.request.command,\n fields: { ...request.request.fields },\n };\n default:\n return { ...request };\n }\n}\n\nfunction checked<TMessage>(\n bytes: Uint8Array,\n decode: (bytes: Uint8Array) => TMessage | undefined,\n): Uint8Array {\n if (decode(bytes) === undefined) {\n throw new RangeError('The message is outside the owner vocabulary.');\n }\n return bytes;\n}\n\n/** The bytes of `request`; throws `RangeError` for one the runtime would refuse to decode. */\nexport function encodeOwnerRequest(request: TOwnerRequest): Uint8Array {\n return checked(Buffer.from(JSON.stringify(wireRequest(request)), 'utf8'), decodeOwnerRequest);\n}\n\n/** The bytes of `message`; throws `RangeError` for one a client would refuse to decode. */\nexport function encodeRuntimeMessage(message: TRuntimeMessage): Uint8Array {\n return checked(Buffer.from(JSON.stringify(message), 'utf8'), decodeRuntimeMessage);\n}\n","/**\n * The runtime's end of an owner link: one connection the control endpoint routed here after the\n * link request line.\n *\n * The handshake admits a spawner only with a spawner-key proof and an owner only with an attach-key\n * proof; on any failure, a frame over its bound, or a handshake that does not finish in time, the\n * stream is closed without a word, so a program probing the socket learns nothing about why. Once\n * admitted, the link reports its standing to the runtime's `admit`, announces the custody state,\n * and routes what arrives: session frames to the session sink, owner requests to the desk and the\n * session decisions, grant frames (owner links only) to the desk as read keys. Decoding is strict;\n * a frame that does not decode ends the link before any port sees it.\n */\nimport type { Duplex } from 'node:stream';\n\nimport {\n LINK_FRAME_OVERHEAD_BYTES,\n LINK_GRANT_FRAME_BYTES,\n LINK_HELLO_BYTES,\n LINK_PROOF_MAX_BYTES,\n createLinkResponder,\n linkFrameKind,\n type IReadKeys,\n type IResponderLink,\n type IRuntimeIdentity,\n type ILinkBinding,\n type TLinkMode,\n} from '@robota-sdk/agent-keyring';\n\nimport {\n OwnerRequestRefusedError,\n type ILinkSessionSink,\n type IOwnerLinkDesk,\n type IOwnerLinkSessions,\n type IRuntimeCustodyState,\n} from './link-ports.js';\nimport { encodeLinkFrame, readLinkFrames } from './link-stream.js';\nimport {\n MAX_REQUESTS_IN_FLIGHT,\n OWNER_FRAME_MAX_BYTES,\n SESSION_FRAME_MAX_BYTES,\n SEALED_FRAME_MAX_BYTES,\n} from './link-bounds.js';\nimport {\n decodeOwnerRequest,\n encodeRuntimeMessage,\n type TOwnerRequest,\n type TRuntimeMessage,\n} from './owner-vocabulary.js';\n\n/** A runtime gives up on a handshake that has not finished by then. */\nexport const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000;\n\n/** An admitted link, as the runtime holds it. */\nexport interface IAdmittedRuntimeLink {\n /** How the client was admitted: `spawner` by the spawner key, `owner` by the attach key. */\n readonly standing: TLinkMode;\n readonly binding: ILinkBinding;\n readonly closed: boolean;\n /** Sends one serialized session-protocol message; throws `RangeError` for one over the frame bound. */\n sendSession(data: string): void;\n /** Tells the client its runtime's custody state changed. */\n notifyCustody(state: IRuntimeCustodyState): void;\n close(): void;\n}\n\n/** The runtime's answers for one admitted link. */\nexport interface IRuntimeLinkPorts {\n readonly desk: IOwnerLinkDesk;\n readonly sessions: IOwnerLinkSessions;\n readonly session: ILinkSessionSink;\n}\n\nexport interface IRuntimeLinkDeps {\n readonly identity: IRuntimeIdentity;\n /** This runtime start, as its registration names it. */\n readonly binding: ILinkBinding;\n /** The bytes after the request line that arrived with it. */\n readonly rest?: Uint8Array;\n readonly handshakeTimeoutMs?: number;\n /**\n * Called once the handshake admitted the client; the ports it returns serve that link. The opening\n * custody notice is always the first frame the client sees: whatever `admit` sends through the link\n * goes out right after it, in the order it was sent.\n */\n readonly admit: (link: IAdmittedRuntimeLink) => IRuntimeLinkPorts;\n}\n\nconst textDecoder = new TextDecoder('utf-8', { fatal: true });\nconst textEncoder = new TextEncoder();\n\nfunction refusalOf(error: unknown): { readonly code: string; readonly message: string } {\n return error instanceof OwnerRequestRefusedError\n ? { code: error.code, message: error.message }\n : { code: 'failed', message: 'The runtime could not do this.' };\n}\n\n/**\n * Serves one owner link on `stream`, which the control endpoint has read the request line from and\n * paused. Returns at once; the link lives until the stream closes.\n */\nexport function serveRuntimeLink(stream: Duplex, deps: IRuntimeLinkDeps): void {\n let responder: ReturnType<typeof createLinkResponder>;\n try {\n responder = createLinkResponder(deps.identity, deps.binding);\n } catch (error) {\n stream.destroy();\n throw error;\n }\n let step: 'hello' | 'proof' | 'linked' = 'hello';\n let link: IResponderLink | undefined;\n let ports: IRuntimeLinkPorts | undefined;\n let grants = 0;\n let inFlight = 0;\n const timer = setTimeout(\n () => stream.destroy(),\n deps.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS,\n );\n timer.unref?.();\n\n /**\n * What `admit` asked to send, as writes to run once the opening custody notice is out; `undefined`\n * outside that window. A frame's counter is taken when it is sealed, so the notice is sealed first.\n */\n let held: Array<() => void> | undefined;\n\n const sendSealed = (kind: 'session' | 'owner', bytes: Uint8Array): void => {\n const write = (): void => {\n if (link === undefined || link.closed || stream.destroyed) return;\n stream.write(encodeLinkFrame(link.seal(kind, bytes)));\n };\n if (held !== undefined) held.push(write);\n else write();\n };\n\n const sendOwner = (message: TRuntimeMessage): void => {\n if (link === undefined || link.closed || stream.destroyed) return;\n sendSealed('owner', encodeRuntimeMessage(message));\n };\n\n /** Whether one more request may wait; a peer that has more waiting than a client allows itself loses the link. */\n const admitRequest = (): boolean => {\n if (inFlight < MAX_REQUESTS_IN_FLIGHT) return true;\n stream.destroy();\n return false;\n };\n\n const reply = (requestId: string, work: () => Promise<TRuntimeMessage>): void => {\n inFlight += 1;\n void work()\n .then(\n (message) => message,\n (error: unknown): TRuntimeMessage => ({ op: 'refused', requestId, ...refusalOf(error) }),\n )\n .then((message) => {\n inFlight -= 1;\n try {\n sendOwner(message);\n } catch {\n // An answer outside the vocabulary (a too-long notice, say) reaches the client as a failure.\n sendOwner({ op: 'refused', requestId, ...refusalOf(undefined) });\n }\n })\n .catch(() => stream.destroy());\n };\n\n const dispatch = (request: TOwnerRequest, linked: IRuntimeLinkPorts): void => {\n const { requestId } = request;\n switch (request.op) {\n case 'install-trust-root':\n return reply(requestId, async () => {\n await linked.desk.installTrustRoot(request.root);\n return { op: 'done', requestId };\n });\n case 'lock':\n return reply(requestId, async () => {\n await linked.desk.lock(request.reason);\n return { op: 'done', requestId };\n });\n case 'question':\n return reply(requestId, async () => ({\n op: 'question',\n requestId,\n question: await linked.sessions.question(request.sessionId),\n }));\n case 'open-accepted':\n return reply(requestId, async () => {\n await linked.sessions.openAccepted(request.sessionId, request.acceptedDigest);\n return { op: 'done', requestId };\n });\n case 'control':\n return reply(requestId, async () => ({\n op: 'control',\n requestId,\n result: await linked.desk.control(request.request),\n }));\n }\n };\n\n const unlock = (keys: IReadKeys, linked: IRuntimeLinkPorts): void => {\n grants += 1;\n const requestId = `g${grants}`;\n reply(requestId, async () => {\n try {\n return { op: 'unlocked', requestId, sealingKey: await linked.desk.unlock(keys) };\n } catch (error) {\n keys.lock();\n throw error;\n }\n });\n };\n\n const onSealed = (frame: Uint8Array, linked: IRuntimeLinkPorts, open: IResponderLink): void => {\n const kind = linkFrameKind(frame);\n if (kind === 'grant') {\n if (frame.length !== LINK_GRANT_FRAME_BYTES) throw new RangeError('A grant has one size.');\n // Checked before the grant opens, so keys are never made for a request that cannot wait.\n if (!admitRequest()) return;\n unlock(open.openReadGrant(frame), linked);\n return;\n }\n const bound = kind === 'owner' ? OWNER_FRAME_MAX_BYTES : SESSION_FRAME_MAX_BYTES;\n if (frame.length > bound + LINK_FRAME_OVERHEAD_BYTES)\n throw new RangeError('A frame over its bound.');\n const { plaintext } = open.open(frame);\n if (kind === 'session') {\n linked.session.receive(textDecoder.decode(plaintext));\n return;\n }\n const request = decodeOwnerRequest(plaintext);\n if (request === undefined) throw new RangeError('A request outside the vocabulary.');\n if (admitRequest()) dispatch(request, linked);\n };\n\n const admitted: IAdmittedRuntimeLink = {\n get standing(): TLinkMode {\n return link!.standing;\n },\n binding: deps.binding,\n get closed(): boolean {\n return link === undefined || link.closed || stream.destroyed;\n },\n sendSession(data: string): void {\n const bytes = textEncoder.encode(data);\n if (bytes.length > SESSION_FRAME_MAX_BYTES)\n throw new RangeError('A session message over the frame bound.');\n if (link === undefined || link.closed || stream.destroyed) return;\n sendSealed('session', bytes);\n },\n notifyCustody(state: IRuntimeCustodyState): void {\n sendOwner({ op: 'custody', state: { unlocked: state.unlocked, certified: state.certified } });\n },\n close(): void {\n stream.destroy();\n },\n };\n\n readLinkFrames(stream, {\n ...(deps.rest !== undefined ? { rest: deps.rest } : {}),\n maxBytes: () =>\n step === 'hello'\n ? LINK_HELLO_BYTES\n : step === 'proof'\n ? LINK_PROOF_MAX_BYTES\n : SEALED_FRAME_MAX_BYTES,\n onFrame: (frame) => {\n try {\n if (step === 'hello') {\n stream.write(encodeLinkFrame(responder.challenge(frame)));\n step = 'proof';\n return;\n }\n if (step === 'proof') {\n link = responder.accept(frame).link;\n clearTimeout(timer);\n step = 'linked';\n const early: Array<() => void> = [];\n held = early;\n ports = deps.admit(admitted);\n const custody = ports.desk.custody();\n held = undefined;\n admitted.notifyCustody(custody);\n for (const write of early) write();\n return;\n }\n onSealed(frame, ports!, link!);\n } catch {\n // Refused, malformed or failed: the other end is told nothing.\n stream.destroy();\n }\n },\n onEnd: () => {\n clearTimeout(timer);\n link?.close();\n if (ports !== undefined) {\n try {\n ports.desk.closed();\n } catch {\n // allow-fallback: the link is already gone; a failing listener cannot bring it back.\n }\n }\n },\n });\n}\n","/**\n * The client's end of an owner link: the spawner of a runtime, or its owner.\n *\n * A client writes the request line and its hello, checks the runtime's challenge (the keyring's\n * initiator decides), and only then sends its proof; a runtime that is not the one it must be gets\n * nothing more, and opening rejects with the keyring's `LinkRefusedError`. The link is open once\n * the runtime's first sealed frame, its custody notice, arrives: a runtime that refused the proof\n * closes instead. After that the client asks the owner vocabulary, sends read grants (owner links\n * only) and carries the session protocol, and everything it receives is strictly decoded; anything\n * that does not decode, or answers a request it did not ask, ends the link.\n */\nimport type { Duplex } from 'node:stream';\n\nimport {\n KeyringError,\n LINK_CHALLENGE_MAX_BYTES,\n LINK_FRAME_OVERHEAD_BYTES,\n createLinkInitiator,\n linkFrameKind,\n type IInitiatorLink,\n type ILinkBinding,\n type IOwnerHandover,\n type ITrustRoot,\n type SpawnerKey,\n type TLinkInitiatorAuth,\n type TLinkMode,\n} from '@robota-sdk/agent-keyring';\nimport {\n decodeFrame,\n decodeServerMessage,\n type TClientMessage,\n type TServerMessage,\n} from '@robota-sdk/agent-transport';\n\nimport {\n OwnerLinkClosedError,\n OwnerRequestRefusedError,\n type IOwnerControlRequest,\n type IRuntimeCustodyState,\n type TGrantOutcome,\n type TOwnerControlResult,\n type TOwnerSessionQuestion,\n type TRequestedLockReason,\n} from './link-ports.js';\nimport {\n MAX_REQUESTS_IN_FLIGHT,\n OWNER_FRAME_MAX_BYTES,\n SEALED_FRAME_MAX_BYTES,\n SESSION_FRAME_MAX_BYTES,\n} from './link-bounds.js';\nimport { encodeLinkFrame, encodeLinkOpening, readLinkFrames } from './link-stream.js';\nimport {\n decodeRuntimeMessage,\n encodeOwnerRequest,\n type TOwnerRequest,\n type TRuntimeMessage,\n} from './owner-vocabulary.js';\n\n/** A client gives up on a runtime that has not admitted it by then. */\nexport const DEFAULT_OPEN_TIMEOUT_MS = 10_000;\n\nexport interface IOpenRuntimeLinkOptions {\n readonly timeoutMs?: number;\n}\n\n/** The session protocol over the link: what an attach client or a desktop relay sends and hears. */\nexport interface ILinkSessionChannel {\n send(message: TClientMessage): void;\n onMessage(listener: (message: TServerMessage) => void): () => void;\n}\n\n/**\n * One open link, as its client holds it. Only so many requests (read grants included) may wait for\n * the runtime's answer at once; one more is refused at once with an `OwnerRequestRefusedError` coded\n * `busy`, is not sent, and leaves the link open.\n */\nexport interface IRuntimeLinkClient {\n readonly standing: TLinkMode;\n /** The runtime start the handshake proved. */\n readonly binding: ILinkBinding;\n /** The runtime's instance key: the exchange's (spawner) or the certified one (owner). */\n readonly instancePublicKey: Uint8Array;\n /** The custody state the runtime announced last. */\n readonly custody: IRuntimeCustodyState;\n onCustody(listener: (state: IRuntimeCustodyState) => void): () => void;\n installTrustRoot(root: ITrustRoot): Promise<void>;\n lock(reason: TRequestedLockReason): Promise<void>;\n question(sessionId: string): Promise<TOwnerSessionQuestion>;\n openAccepted(sessionId: string, acceptedDigest: string): Promise<void>;\n control(request: IOwnerControlRequest): Promise<TOwnerControlResult>;\n readonly session: ILinkSessionChannel;\n /** Settles once the link has closed, for whatever reason. */\n readonly closed: Promise<void>;\n close(): void;\n}\n\n/** An owner link, which alone can unlock the runtime. */\nexport interface IOwnerRuntimeLinkClient extends IRuntimeLinkClient {\n /** Seals the owner's read keys for this runtime and sends them. */\n unlock(): Promise<TGrantOutcome>;\n}\n\ntype TExpectedReply = TRuntimeMessage['op'];\n\ninterface IPendingRequest {\n readonly expects: TExpectedReply;\n readonly resolve: (message: TRuntimeMessage) => void;\n readonly reject: (error: Error) => void;\n}\n\nconst textDecoder = new TextDecoder('utf-8', { fatal: true });\nconst textEncoder = new TextEncoder();\n\n/** Calls every listener; one that throws is the caller's bug and must not end the link for the rest. */\nfunction notify<TValue>(listeners: ReadonlySet<(value: TValue) => void>, value: TValue): void {\n for (const listener of [...listeners]) {\n try {\n listener(value);\n } catch {\n // allow-fallback: the frame was valid; a failing listener cannot make the runtime's message wrong.\n }\n }\n}\n\nclass RuntimeLinkClient implements IOwnerRuntimeLinkClient {\n readonly closed: Promise<void>;\n readonly session: ILinkSessionChannel;\n readonly #stream: Duplex;\n readonly #link: IInitiatorLink;\n readonly #handover: IOwnerHandover | undefined;\n readonly #pending = new Map<string, IPendingRequest>();\n readonly #custodyListeners = new Set<(state: IRuntimeCustodyState) => void>();\n readonly #sessionListeners = new Set<(message: TServerMessage) => void>();\n #custody: IRuntimeCustodyState;\n #requests = 0;\n #grants = 0;\n #ended = false;\n #resolveClosed!: () => void;\n\n constructor(\n stream: Duplex,\n link: IInitiatorLink,\n custody: IRuntimeCustodyState,\n handover: IOwnerHandover | undefined,\n ) {\n this.#stream = stream;\n this.#link = link;\n this.#custody = custody;\n this.#handover = handover;\n this.closed = new Promise((resolve) => {\n this.#resolveClosed = resolve;\n });\n this.session = {\n send: (message) => this.#sendSession(message),\n onMessage: (listener) => {\n this.#sessionListeners.add(listener);\n return () => {\n this.#sessionListeners.delete(listener);\n };\n },\n };\n }\n\n get standing(): TLinkMode {\n return this.#link.standing;\n }\n\n get binding(): ILinkBinding {\n return this.#link.binding;\n }\n\n get instancePublicKey(): Uint8Array {\n return this.#link.instancePublicKey;\n }\n\n get custody(): IRuntimeCustodyState {\n return this.#custody;\n }\n\n onCustody(listener: (state: IRuntimeCustodyState) => void): () => void {\n this.#custodyListeners.add(listener);\n return () => {\n this.#custodyListeners.delete(listener);\n };\n }\n\n async installTrustRoot(root: ITrustRoot): Promise<void> {\n await this.#request((requestId) => ({ op: 'install-trust-root', requestId, root }), 'done');\n }\n\n async lock(reason: TRequestedLockReason): Promise<void> {\n await this.#request((requestId) => ({ op: 'lock', requestId, reason }), 'done');\n }\n\n async question(sessionId: string): Promise<TOwnerSessionQuestion> {\n const reply = await this.#request(\n (requestId) => ({ op: 'question', requestId, sessionId }),\n 'question',\n );\n return (reply as Extract<TRuntimeMessage, { op: 'question' }>).question;\n }\n\n async openAccepted(sessionId: string, acceptedDigest: string): Promise<void> {\n await this.#request(\n (requestId) => ({ op: 'open-accepted', requestId, sessionId, acceptedDigest }),\n 'done',\n );\n }\n\n async control(request: IOwnerControlRequest): Promise<TOwnerControlResult> {\n const reply = await this.#request(\n (requestId) => ({ op: 'control', requestId, request }),\n 'control',\n );\n return (reply as Extract<TRuntimeMessage, { op: 'control' }>).result;\n }\n\n async unlock(): Promise<TGrantOutcome> {\n if (this.#handover === undefined) {\n throw new OwnerRequestRefusedError('not-owner', 'Only an owner link can unlock the runtime.');\n }\n const handover = this.#handover;\n const requestId = `g${this.#grants + 1}`;\n const reply = await this.#await(requestId, 'unlocked', () => {\n this.#grants += 1;\n this.#write(this.#link.sealReadGrant(handover));\n });\n return (reply as Extract<TRuntimeMessage, { op: 'unlocked' }>).sealingKey;\n }\n\n close(): void {\n this.#stream.destroy();\n }\n\n /** One frame from the runtime; anything that is not exactly what it may send ends the link. */\n receive(frame: Uint8Array): void {\n const kind = linkFrameKind(frame);\n const bound = kind === 'owner' ? OWNER_FRAME_MAX_BYTES : SESSION_FRAME_MAX_BYTES;\n if (kind === 'grant' || frame.length > bound + LINK_FRAME_OVERHEAD_BYTES) {\n throw new RangeError('The runtime sent a frame it may not.');\n }\n const { plaintext } = this.#link.open(frame);\n if (kind === 'session') {\n const decoded = decodeFrame(textDecoder.decode(plaintext), decodeServerMessage);\n if (!decoded.ok)\n throw new RangeError('The runtime sent a session message that does not decode.');\n notify(this.#sessionListeners, decoded.message);\n return;\n }\n const message = decodeRuntimeMessage(plaintext);\n if (message === undefined)\n throw new RangeError('The runtime sent a message outside the vocabulary.');\n if (message.op === 'custody') {\n this.#custody = message.state;\n notify(this.#custodyListeners, message.state);\n return;\n }\n const pending = this.#pending.get(message.requestId);\n if (pending === undefined || (message.op !== pending.expects && message.op !== 'refused')) {\n throw new RangeError('The runtime answered a request that was not asked.');\n }\n this.#pending.delete(message.requestId);\n if (message.op === 'refused')\n pending.reject(new OwnerRequestRefusedError(message.code, message.message));\n else pending.resolve(message);\n }\n\n /** The link ended: everything still waiting is rejected. */\n end(): void {\n if (this.#ended) return;\n this.#ended = true;\n this.#link.close();\n for (const pending of this.#pending.values()) pending.reject(new OwnerLinkClosedError());\n this.#pending.clear();\n this.#resolveClosed();\n }\n\n #sendSession(message: TClientMessage): void {\n const bytes = textEncoder.encode(JSON.stringify(message));\n if (bytes.length > SESSION_FRAME_MAX_BYTES) {\n throw new RangeError('A session message over the frame bound.');\n }\n if (this.#ended) throw new OwnerLinkClosedError();\n this.#write(this.#link.seal('session', bytes));\n }\n\n #request(\n build: (requestId: string) => TOwnerRequest,\n expects: TExpectedReply,\n ): Promise<TRuntimeMessage> {\n const requestId = `r${this.#requests + 1}`;\n // Encoding first, so a request outside the vocabulary is the caller's error and never reaches the link.\n const bytes = encodeOwnerRequest(build(requestId));\n return this.#await(requestId, expects, () => {\n this.#requests += 1;\n this.#write(this.#link.seal('owner', bytes));\n });\n }\n\n #await(requestId: string, expects: TExpectedReply, send: () => void): Promise<TRuntimeMessage> {\n if (this.#ended) return Promise.reject(new OwnerLinkClosedError());\n // Refused here, not queued: a runtime ends a link whose peer has more waiting than it allows.\n if (this.#pending.size >= MAX_REQUESTS_IN_FLIGHT) {\n return Promise.reject(\n new OwnerRequestRefusedError('busy', 'The link has as many requests waiting as it allows.'),\n );\n }\n return new Promise((resolve, reject) => {\n this.#pending.set(requestId, { expects, resolve, reject });\n try {\n send();\n } catch (error) {\n this.#pending.delete(requestId);\n reject(error);\n }\n });\n }\n\n #write(sealed: Uint8Array): void {\n this.#stream.write(encodeLinkFrame(sealed));\n }\n}\n\nfunction openRuntimeLink(\n stream: Duplex,\n auth: TLinkInitiatorAuth,\n options: IOpenRuntimeLinkOptions,\n): Promise<RuntimeLinkClient> {\n return new Promise((resolve, reject) => {\n let initiator: ReturnType<typeof createLinkInitiator>;\n try {\n initiator = createLinkInitiator(auth);\n } catch (error) {\n stream.destroy();\n reject(error);\n return;\n }\n let link: IInitiatorLink | undefined;\n let client: RuntimeLinkClient | undefined;\n /** Why the initiator refused the runtime's answer, if it did; any other end says nothing. */\n let refusal: unknown;\n const fail = (): void => {\n stream.destroy();\n };\n // Rejects itself: a stream that does not report its own close must not leave the opening waiting.\n const timer = setTimeout(() => {\n fail();\n link?.close();\n reject(new OwnerLinkClosedError());\n }, options.timeoutMs ?? DEFAULT_OPEN_TIMEOUT_MS);\n timer.unref?.();\n\n readLinkFrames(stream, {\n maxBytes: () => (link === undefined ? LINK_CHALLENGE_MAX_BYTES : SEALED_FRAME_MAX_BYTES),\n onFrame: (frame): void | 'yield' => {\n if (client !== undefined) {\n try {\n client.receive(frame);\n } catch {\n stream.destroy();\n }\n return;\n }\n if (link === undefined) {\n try {\n const answered = initiator.answer(frame);\n link = answered.link;\n stream.write(encodeLinkFrame(answered.proof));\n } catch (error) {\n refusal = error;\n fail();\n }\n return;\n }\n // The runtime's first frame once it admitted the proof: its custody notice.\n try {\n const opened = link.open(frame);\n const notice =\n opened.kind === 'owner' ? decodeRuntimeMessage(opened.plaintext) : undefined;\n if (notice?.op !== 'custody')\n throw new RangeError('The runtime did not announce its custody.');\n clearTimeout(timer);\n client = new RuntimeLinkClient(\n stream,\n link,\n notice.state,\n auth.mode === 'owner' ? auth.handover : undefined,\n );\n resolve(client);\n // The caller attaches its listeners as the open resolves; frames behind the notice wait for that.\n return 'yield';\n } catch {\n fail();\n }\n },\n onEnd: () => {\n clearTimeout(timer);\n if (client !== undefined) {\n client.end();\n return;\n }\n link?.close();\n // A refusal of the runtime's answer says which check failed; a close says nothing.\n reject(refusal instanceof KeyringError ? refusal : new OwnerLinkClosedError());\n },\n });\n stream.write(encodeLinkOpening(initiator.hello()));\n });\n}\n\n/**\n * Opens a spawner link on `stream`, already connected to the runtime's control endpoint, with the\n * key and instance key the spawner exchange gave. Rejects with `LinkRefusedError` when the runtime\n * is not the one the exchange started, before sending anything secret-derived, and with\n * `OwnerLinkClosedError` when the runtime closes or does not admit the link in time. Listeners\n * attached in the same tick the open resolves receive everything the runtime sends after its\n * custody notice.\n */\nexport async function openSpawnerLink(\n stream: Duplex,\n spawnerKey: SpawnerKey,\n instancePublicKey: Uint8Array,\n options: IOpenRuntimeLinkOptions = {},\n): Promise<IRuntimeLinkClient> {\n return openRuntimeLink(stream, { mode: 'spawner', spawnerKey, instancePublicKey }, options);\n}\n\n/**\n * Opens an owner link on `stream` with the owner's handover. Rejects with `LinkRefusedError` when the\n * runtime shows no certificate the handover's attach key made for it (an impostor, a runtime nobody\n * certified, or one another keyring certified), before sending anything secret-derived. Listeners\n * attached in the same tick the open resolves receive everything the runtime sends after its\n * custody notice.\n */\nexport async function openOwnerLink(\n stream: Duplex,\n handover: IOwnerHandover,\n options: IOpenRuntimeLinkOptions = {},\n): Promise<IOwnerRuntimeLinkClient> {\n return openRuntimeLink(stream, { mode: 'owner', handover }, options);\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,80 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@robota-sdk/agent-transport-link",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "3.1.1",
|
|
4
|
+
"description": "Owner-link carrier for the Robota SDK: the owner link handshake, sealed frames and the owner vocabulary over a local byte stream",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/node/index.js",
|
|
7
|
+
"types": "dist/node/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"source": "./src/index.ts",
|
|
11
|
+
"browser": null,
|
|
12
|
+
"node": {
|
|
13
|
+
"import": {
|
|
14
|
+
"types": "./dist/node/index.d.ts",
|
|
15
|
+
"default": "./dist/node/index.js"
|
|
16
|
+
},
|
|
17
|
+
"require": {
|
|
18
|
+
"types": "./dist/node/index.d.cts",
|
|
19
|
+
"default": "./dist/node/index.cjs"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"default": {
|
|
23
|
+
"import": {
|
|
24
|
+
"types": "./dist/node/index.d.ts",
|
|
25
|
+
"default": "./dist/node/index.js"
|
|
26
|
+
},
|
|
27
|
+
"require": {
|
|
28
|
+
"types": "./dist/node/index.d.cts",
|
|
29
|
+
"default": "./dist/node/index.cjs"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"./package.json": "./package.json"
|
|
34
|
+
},
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=22.12.0"
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"dist",
|
|
40
|
+
"src",
|
|
41
|
+
"CHANGELOG.md"
|
|
42
|
+
],
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@robota-sdk/agent-keyring": "3.1.1",
|
|
45
|
+
"@robota-sdk/agent-transport": "3.1.1"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/node": "^20.19.43",
|
|
49
|
+
"rimraf": "^5.0.10",
|
|
50
|
+
"tsdown": "^0.22.14",
|
|
51
|
+
"vitest": "^3.2.6"
|
|
52
|
+
},
|
|
53
|
+
"license": "AGPL-3.0-only OR LicenseRef-Commercial",
|
|
54
|
+
"publishConfig": {
|
|
55
|
+
"access": "public"
|
|
56
|
+
},
|
|
57
|
+
"volta": {
|
|
58
|
+
"extends": "../../package.json"
|
|
59
|
+
},
|
|
60
|
+
"repository": {
|
|
61
|
+
"type": "git",
|
|
62
|
+
"url": "https://github.com/woojubb/robota"
|
|
63
|
+
},
|
|
64
|
+
"bugs": {
|
|
65
|
+
"url": "https://github.com/woojubb/robota/issues"
|
|
66
|
+
},
|
|
67
|
+
"homepage": "https://robota.io/",
|
|
68
|
+
"scripts": {
|
|
69
|
+
"build": "rimraf dist && tsdown",
|
|
70
|
+
"build:js": "pnpm run build",
|
|
71
|
+
"build:types": "pnpm run build",
|
|
72
|
+
"typecheck": "tsgo --noEmit",
|
|
73
|
+
"test": "vitest run --passWithNoTests",
|
|
74
|
+
"test:coverage": "vitest run --coverage --passWithNoTests",
|
|
75
|
+
"lint": "eslint src/ --ext .ts",
|
|
76
|
+
"clean": "rimraf dist",
|
|
77
|
+
"smoke": "node scripts/smoke.mjs",
|
|
78
|
+
"smoke:bun": "bun scripts/smoke.mjs"
|
|
79
|
+
}
|
|
6
80
|
}
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real loopback sockets for the carrier tests, and the pieces around them a product supplies: a
|
|
3
|
+
* control endpoint that routes the link request line to `serveRuntimeLink`, a recording runtime
|
|
4
|
+
* desk, and raw clients and servers that speak the framing by hand, so a test can send what no
|
|
5
|
+
* well-behaved end would.
|
|
6
|
+
*/
|
|
7
|
+
import { Buffer } from 'node:buffer';
|
|
8
|
+
import { createServer, connect as connectSocket, type Server, type Socket } from 'node:net';
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
createKeyring,
|
|
12
|
+
createLinkResponder,
|
|
13
|
+
openOwnerHandover,
|
|
14
|
+
type IResponderLink,
|
|
15
|
+
type ILinkBinding,
|
|
16
|
+
type IOwnerHandover,
|
|
17
|
+
type IReadKeys,
|
|
18
|
+
type IRuntimeIdentity,
|
|
19
|
+
} from '@robota-sdk/agent-keyring';
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
OwnerRequestRefusedError,
|
|
23
|
+
isLinkRequest,
|
|
24
|
+
serveRuntimeLink,
|
|
25
|
+
type IAdmittedRuntimeLink,
|
|
26
|
+
type IOwnerControlRequest,
|
|
27
|
+
type IRuntimeLinkPorts,
|
|
28
|
+
type TRequestedLockReason,
|
|
29
|
+
} from '../index.js';
|
|
30
|
+
|
|
31
|
+
export const BINDING: ILinkBinding = {
|
|
32
|
+
runtimeId: '6f1c2d3e-4b5a-4c6d-8e9f-0a1b2c3d4e5f',
|
|
33
|
+
generation: 'AAECAwQFBgcICQoLDA0ODw',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export interface IListening {
|
|
37
|
+
readonly port: number;
|
|
38
|
+
readonly server: Server;
|
|
39
|
+
close(): Promise<void>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A loopback server; every connection it accepted is destroyed on close. */
|
|
43
|
+
export async function listen(onConnection: (socket: Socket) => void): Promise<IListening> {
|
|
44
|
+
const sockets = new Set<Socket>();
|
|
45
|
+
const server = createServer((socket) => {
|
|
46
|
+
sockets.add(socket);
|
|
47
|
+
socket.once('close', () => sockets.delete(socket));
|
|
48
|
+
socket.on('error', () => socket.destroy());
|
|
49
|
+
onConnection(socket);
|
|
50
|
+
});
|
|
51
|
+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
52
|
+
const address = server.address();
|
|
53
|
+
if (address === null || typeof address === 'string') throw new Error('No port was bound.');
|
|
54
|
+
return {
|
|
55
|
+
port: address.port,
|
|
56
|
+
server,
|
|
57
|
+
close: async () => {
|
|
58
|
+
for (const socket of sockets) socket.destroy();
|
|
59
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function connect(port: number): Promise<Socket> {
|
|
65
|
+
const socket = connectSocket({ host: '127.0.0.1', port });
|
|
66
|
+
await new Promise<void>((resolve, reject) => {
|
|
67
|
+
socket.once('connect', resolve);
|
|
68
|
+
socket.once('error', reject);
|
|
69
|
+
});
|
|
70
|
+
return socket;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Reads the request line as raw bytes, the way a control endpoint routes it: the rest stays bytes. */
|
|
74
|
+
export function readRequestLine(
|
|
75
|
+
socket: Socket,
|
|
76
|
+
): Promise<{ line: string; rest: Buffer } | undefined> {
|
|
77
|
+
return new Promise((resolve) => {
|
|
78
|
+
let buffered = Buffer.alloc(0);
|
|
79
|
+
const onData = (chunk: Buffer): void => {
|
|
80
|
+
buffered = Buffer.concat([buffered, chunk]);
|
|
81
|
+
const end = buffered.indexOf(0x0a);
|
|
82
|
+
if (end === -1) {
|
|
83
|
+
if (buffered.length > 4096) finish(undefined);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
finish({
|
|
87
|
+
line: buffered.subarray(0, end).toString('utf8'),
|
|
88
|
+
rest: buffered.subarray(end + 1),
|
|
89
|
+
});
|
|
90
|
+
};
|
|
91
|
+
const finish = (value: { line: string; rest: Buffer } | undefined): void => {
|
|
92
|
+
socket.off('data', onData);
|
|
93
|
+
socket.pause();
|
|
94
|
+
resolve(value);
|
|
95
|
+
};
|
|
96
|
+
socket.on('data', onData);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A control endpoint that serves only owner links. */
|
|
101
|
+
export function controlEndpoint(
|
|
102
|
+
identity: IRuntimeIdentity,
|
|
103
|
+
admit: (link: IAdmittedRuntimeLink) => IRuntimeLinkPorts,
|
|
104
|
+
options: { readonly handshakeTimeoutMs?: number } = {},
|
|
105
|
+
): (socket: Socket) => void {
|
|
106
|
+
return (socket) => {
|
|
107
|
+
void readRequestLine(socket).then((request) => {
|
|
108
|
+
let value: unknown;
|
|
109
|
+
try {
|
|
110
|
+
value = request === undefined ? undefined : JSON.parse(request.line);
|
|
111
|
+
} catch {
|
|
112
|
+
value = undefined;
|
|
113
|
+
}
|
|
114
|
+
if (request === undefined || !isLinkRequest(value)) {
|
|
115
|
+
socket.destroy();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
serveRuntimeLink(socket, {
|
|
119
|
+
identity,
|
|
120
|
+
binding: BINDING,
|
|
121
|
+
rest: request.rest,
|
|
122
|
+
admit,
|
|
123
|
+
...(options.handshakeTimeoutMs !== undefined
|
|
124
|
+
? { handshakeTimeoutMs: options.handshakeTimeoutMs }
|
|
125
|
+
: {}),
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** A runtime's answers that record what each link asked; spawner links alone install a root. */
|
|
132
|
+
export function recordingRuntime(identity: IRuntimeIdentity) {
|
|
133
|
+
const record = {
|
|
134
|
+
links: [] as IAdmittedRuntimeLink[],
|
|
135
|
+
calls: [] as unknown[],
|
|
136
|
+
sessionData: [] as string[],
|
|
137
|
+
grantedKeys: [] as IReadKeys[],
|
|
138
|
+
closed: 0,
|
|
139
|
+
/** What waits for the test to let it go: a `gated` question, and an unlock while `gateUnlock` is set. */
|
|
140
|
+
gates: [] as Array<() => void>,
|
|
141
|
+
gateUnlock: false,
|
|
142
|
+
};
|
|
143
|
+
const admit = (link: IAdmittedRuntimeLink): IRuntimeLinkPorts => {
|
|
144
|
+
record.links.push(link);
|
|
145
|
+
return {
|
|
146
|
+
desk: {
|
|
147
|
+
custody: () => ({ unlocked: record.grantedKeys.length > 0, certified: identity.certified }),
|
|
148
|
+
installTrustRoot: (root) => {
|
|
149
|
+
if (link.standing !== 'spawner') {
|
|
150
|
+
throw new OwnerRequestRefusedError(
|
|
151
|
+
'not-allowed',
|
|
152
|
+
'Only the spawner installs a trust root.',
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
identity.installTrustRoot(root, link.binding);
|
|
156
|
+
record.calls.push('install-trust-root');
|
|
157
|
+
},
|
|
158
|
+
unlock: async (keys) => {
|
|
159
|
+
record.grantedKeys.push(keys);
|
|
160
|
+
record.calls.push('unlock');
|
|
161
|
+
if (record.gateUnlock) await new Promise<void>((resolve) => record.gates.push(resolve));
|
|
162
|
+
return 'verified' as const;
|
|
163
|
+
},
|
|
164
|
+
lock: (reason: TRequestedLockReason) => {
|
|
165
|
+
record.calls.push(['lock', reason]);
|
|
166
|
+
},
|
|
167
|
+
control: (request: IOwnerControlRequest) => {
|
|
168
|
+
record.calls.push(['control', request.command]);
|
|
169
|
+
return { status: 'renamed', name: request.fields['name'] ?? null };
|
|
170
|
+
},
|
|
171
|
+
closed: () => {
|
|
172
|
+
record.closed += 1;
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
sessions: {
|
|
176
|
+
// `held` is never answered, so a test can close the link while a request waits; `gated` is
|
|
177
|
+
// answered when the test opens its gate.
|
|
178
|
+
question: (sessionId) =>
|
|
179
|
+
sessionId === 'held'
|
|
180
|
+
? new Promise<never>(() => undefined)
|
|
181
|
+
: sessionId === 'gated'
|
|
182
|
+
? new Promise<{ readonly kind: 'open' }>((resolve) =>
|
|
183
|
+
record.gates.push(() => resolve({ kind: 'open' })),
|
|
184
|
+
)
|
|
185
|
+
: Promise.resolve({
|
|
186
|
+
kind: 'accept',
|
|
187
|
+
digest: 'd'.repeat(64),
|
|
188
|
+
notice: 'This session was not verified as yours.',
|
|
189
|
+
question: `Resume ${sessionId} and mark it as yours?`,
|
|
190
|
+
} as const),
|
|
191
|
+
openAccepted: (sessionId, digest) => {
|
|
192
|
+
record.calls.push(['open-accepted', sessionId, digest]);
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
session: {
|
|
196
|
+
receive: (data) => {
|
|
197
|
+
record.sessionData.push(data);
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
};
|
|
202
|
+
return { record, admit };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The owner's keyring and a second one another program made. */
|
|
206
|
+
export async function keyrings(): Promise<{
|
|
207
|
+
owner: IOwnerHandover;
|
|
208
|
+
stranger: IOwnerHandover;
|
|
209
|
+
sealingPublicKey: Uint8Array;
|
|
210
|
+
}> {
|
|
211
|
+
const mine = await createKeyring({ passphrase: 'the owner passphrase for these tests' });
|
|
212
|
+
const theirs = await createKeyring({ passphrase: 'another program made this keyring' });
|
|
213
|
+
const owner = await openOwnerHandover(mine.keyring, {
|
|
214
|
+
type: 'recoveryKey',
|
|
215
|
+
recoveryKey: mine.recoveryKey,
|
|
216
|
+
});
|
|
217
|
+
const stranger = await openOwnerHandover(theirs.keyring, {
|
|
218
|
+
type: 'recoveryKey',
|
|
219
|
+
recoveryKey: theirs.recoveryKey,
|
|
220
|
+
});
|
|
221
|
+
const sealingPublicKey = Uint8Array.from(mine.unlocked.sealing.publicKey);
|
|
222
|
+
mine.unlocked.lock();
|
|
223
|
+
theirs.unlocked.lock();
|
|
224
|
+
return { owner, stranger, sealingPublicKey };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** One length-prefixed frame, written by hand. */
|
|
228
|
+
export function frame(payload: Uint8Array): Buffer {
|
|
229
|
+
const prefix = Buffer.alloc(4);
|
|
230
|
+
prefix.writeUInt32BE(payload.length);
|
|
231
|
+
return Buffer.concat([prefix, payload]);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Reads length-prefixed frames off a socket by hand, and everything else it receives. */
|
|
235
|
+
export function frameReader(socket: Socket) {
|
|
236
|
+
let buffered = Buffer.alloc(0);
|
|
237
|
+
let received = Buffer.alloc(0);
|
|
238
|
+
const waiters: Array<() => void> = [];
|
|
239
|
+
let ended = false;
|
|
240
|
+
const wake = (): void => {
|
|
241
|
+
for (const waiter of waiters.splice(0)) waiter();
|
|
242
|
+
};
|
|
243
|
+
socket.on('data', (chunk: Buffer) => {
|
|
244
|
+
buffered = Buffer.concat([buffered, chunk]);
|
|
245
|
+
received = Buffer.concat([received, chunk]);
|
|
246
|
+
wake();
|
|
247
|
+
});
|
|
248
|
+
socket.on('close', () => {
|
|
249
|
+
ended = true;
|
|
250
|
+
wake();
|
|
251
|
+
});
|
|
252
|
+
socket.resume();
|
|
253
|
+
return {
|
|
254
|
+
/** The next frame, or `undefined` once the socket closed without one. */
|
|
255
|
+
async next(): Promise<Buffer | undefined> {
|
|
256
|
+
for (;;) {
|
|
257
|
+
if (buffered.length >= 4) {
|
|
258
|
+
const length = buffered.readUInt32BE(0);
|
|
259
|
+
if (buffered.length >= 4 + length) {
|
|
260
|
+
const payload = buffered.subarray(4, 4 + length);
|
|
261
|
+
buffered = buffered.subarray(4 + length);
|
|
262
|
+
return payload;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (ended) return undefined;
|
|
266
|
+
await new Promise<void>((resolve) => waiters.push(resolve));
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
/** Every byte the socket received so far. */
|
|
270
|
+
received: () => received,
|
|
271
|
+
closed: () => ended,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Resolves once `condition` holds, polling on the event loop (never a fixed sleep). */
|
|
276
|
+
export async function until(condition: () => boolean, timeoutMs = 5_000): Promise<void> {
|
|
277
|
+
const started = Date.now();
|
|
278
|
+
while (!condition()) {
|
|
279
|
+
if (Date.now() - started > timeoutMs) throw new Error('The condition did not hold in time.');
|
|
280
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* A runtime that runs the real handshake by hand and then does whatever `linked` says with the
|
|
286
|
+
* established link: a way to make a runtime send what `serveRuntimeLink` never would.
|
|
287
|
+
*/
|
|
288
|
+
export function handRuntime(
|
|
289
|
+
identity: IRuntimeIdentity,
|
|
290
|
+
linked: (socket: Socket, link: IResponderLink) => void,
|
|
291
|
+
): (socket: Socket) => void {
|
|
292
|
+
return (socket) => {
|
|
293
|
+
const responder = createLinkResponder(identity, BINDING);
|
|
294
|
+
let buffered = Buffer.alloc(0);
|
|
295
|
+
let lineRead = false;
|
|
296
|
+
let step: 'hello' | 'proof' | 'done' = 'hello';
|
|
297
|
+
socket.on('data', (chunk: Buffer) => {
|
|
298
|
+
buffered = Buffer.concat([buffered, chunk]);
|
|
299
|
+
if (!lineRead) {
|
|
300
|
+
const end = buffered.indexOf(0x0a);
|
|
301
|
+
if (end === -1) return;
|
|
302
|
+
buffered = buffered.subarray(end + 1);
|
|
303
|
+
lineRead = true;
|
|
304
|
+
}
|
|
305
|
+
while (
|
|
306
|
+
step !== 'done' &&
|
|
307
|
+
buffered.length >= 4 &&
|
|
308
|
+
buffered.length >= 4 + buffered.readUInt32BE(0)
|
|
309
|
+
) {
|
|
310
|
+
const payload = buffered.subarray(4, 4 + buffered.readUInt32BE(0));
|
|
311
|
+
buffered = buffered.subarray(4 + payload.length);
|
|
312
|
+
if (step === 'hello') {
|
|
313
|
+
socket.write(frame(responder.challenge(payload)));
|
|
314
|
+
step = 'proof';
|
|
315
|
+
} else {
|
|
316
|
+
step = 'done';
|
|
317
|
+
linked(socket, responder.accept(payload).link);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
};
|
|
322
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The frame reader on streams that are over before it starts reading: a stream that is already
|
|
3
|
+
* destroyed or closed never emits `close` again, so the reader itself has to report its end, and
|
|
4
|
+
* must not read a frame off it.
|
|
5
|
+
*/
|
|
6
|
+
import { once } from 'node:events';
|
|
7
|
+
import { PassThrough } from 'node:stream';
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from 'vitest';
|
|
10
|
+
|
|
11
|
+
import { encodeLinkFrame, readLinkFrames } from '../link-stream.js';
|
|
12
|
+
import { until } from './harness.js';
|
|
13
|
+
|
|
14
|
+
function reading(rest?: Uint8Array) {
|
|
15
|
+
const seen = { frames: 0, ends: 0 };
|
|
16
|
+
return {
|
|
17
|
+
seen,
|
|
18
|
+
reading: {
|
|
19
|
+
...(rest !== undefined ? { rest } : {}),
|
|
20
|
+
maxBytes: () => 64,
|
|
21
|
+
onFrame: () => {
|
|
22
|
+
seen.frames += 1;
|
|
23
|
+
},
|
|
24
|
+
onEnd: () => {
|
|
25
|
+
seen.ends += 1;
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('readLinkFrames on a stream that is already over', () => {
|
|
32
|
+
it('reports the end of a stream that closed before it was read, and reads nothing from it', async () => {
|
|
33
|
+
const stream = new PassThrough();
|
|
34
|
+
stream.destroy();
|
|
35
|
+
await once(stream, 'close');
|
|
36
|
+
const { seen, reading: options } = reading(encodeLinkFrame(Buffer.from('late')));
|
|
37
|
+
readLinkFrames(stream, options);
|
|
38
|
+
await until(() => seen.ends > 0);
|
|
39
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
40
|
+
expect(seen).toEqual({ frames: 0, ends: 1 });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('reports the end of a stream destroyed whose close has not come yet, once', async () => {
|
|
44
|
+
const stream = new PassThrough();
|
|
45
|
+
stream.destroy();
|
|
46
|
+
const { seen, reading: options } = reading();
|
|
47
|
+
readLinkFrames(stream, options);
|
|
48
|
+
await once(stream, 'close');
|
|
49
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
50
|
+
expect(seen).toEqual({ frames: 0, ends: 1 });
|
|
51
|
+
});
|
|
52
|
+
});
|