@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,147 @@
1
+ /**
2
+ * A program that took over the control socket and answers like a runtime. It may run a real runtime
3
+ * of its own, certified by its own keyring or by nobody, or forge an answer by hand. Every byte the
4
+ * client sends it is recorded: the client is refused before it sends anything but the request line
5
+ * and its hello, so the squatter never sees a proof, a grant or a session frame.
6
+ */
7
+ import { Buffer } from 'node:buffer';
8
+ import { randomBytes } from 'node:crypto';
9
+ import type { Socket } from 'node:net';
10
+
11
+ import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
12
+
13
+ import {
14
+ LINK_CHALLENGE_MAX_BYTES,
15
+ LINK_HELLO_BYTES,
16
+ LinkRefusedError,
17
+ createLinkResponder,
18
+ createRuntimeIdentity,
19
+ startSpawnerExchange,
20
+ type IOwnerHandover,
21
+ type IRuntimeIdentity,
22
+ } from '@robota-sdk/agent-keyring';
23
+
24
+ import {
25
+ LINK_REQUEST_LINE,
26
+ OwnerLinkClosedError,
27
+ openOwnerLink,
28
+ openSpawnerLink,
29
+ } from '../index.js';
30
+ import { BINDING, connect, frame, keyrings, listen, type IListening } from './harness.js';
31
+
32
+ let owner: IOwnerHandover;
33
+ let stranger: IOwnerHandover;
34
+ const servers: IListening[] = [];
35
+
36
+ beforeAll(async () => {
37
+ ({ owner, stranger } = await keyrings());
38
+ });
39
+ afterAll(() => {
40
+ owner.dispose();
41
+ stranger.dispose();
42
+ });
43
+ afterEach(async () => {
44
+ for (const server of servers.splice(0)) await server.close();
45
+ });
46
+
47
+ /** A squatter that answers each hello with `answer(hello)` and records everything it receives. */
48
+ async function squatter(answer: (hello: Uint8Array) => Uint8Array) {
49
+ const received: Buffer[] = [];
50
+ const server = await listen((socket: Socket) => {
51
+ let buffered = Buffer.alloc(0);
52
+ let answered = false;
53
+ socket.on('data', (chunk: Buffer) => {
54
+ received.push(chunk);
55
+ buffered = Buffer.concat([buffered, chunk]);
56
+ const helloStart = buffered.indexOf(0x0a) + 1;
57
+ if (!answered && helloStart > 0 && buffered.length >= helloStart + 4 + LINK_HELLO_BYTES) {
58
+ answered = true;
59
+ socket.write(
60
+ frame(answer(buffered.subarray(helloStart + 4, helloStart + 4 + LINK_HELLO_BYTES))),
61
+ );
62
+ }
63
+ });
64
+ });
65
+ servers.push(server);
66
+ return { port: server.port, received: () => Buffer.concat(received) };
67
+ }
68
+
69
+ /** What a client sends before it has checked the runtime: the request line and its hello, no more. */
70
+ function expectOnlyHello(received: Buffer): void {
71
+ const line = Buffer.from(LINK_REQUEST_LINE);
72
+ expect(received.subarray(0, line.length).toString()).toBe(LINK_REQUEST_LINE);
73
+ expect(received.readUInt32BE(line.length)).toBe(LINK_HELLO_BYTES);
74
+ expect(received.length).toBe(line.length + 4 + LINK_HELLO_BYTES);
75
+ }
76
+
77
+ function certifiedBy(handover: IOwnerHandover): IRuntimeIdentity {
78
+ const identity = createRuntimeIdentity();
79
+ const exchange = startSpawnerExchange();
80
+ exchange.complete(identity.answerSpawnerExchange(exchange.message)).spawnerKey.wipe();
81
+ identity.installTrustRoot(handover.certify(identity.instancePublicKey, BINDING), BINDING);
82
+ return identity;
83
+ }
84
+
85
+ async function refusal(opening: Promise<unknown>): Promise<string> {
86
+ const error = await opening.then(
87
+ () => undefined,
88
+ (reason: unknown) => reason,
89
+ );
90
+ expect(error).toBeInstanceOf(LinkRefusedError);
91
+ return (error as LinkRefusedError).reason;
92
+ }
93
+
94
+ describe('a squatter on the control socket', () => {
95
+ it('running a runtime another keyring certified gets no proof from the owner', async () => {
96
+ const identity = certifiedBy(stranger);
97
+ const { port, received } = await squatter((hello) =>
98
+ createLinkResponder(identity, BINDING).challenge(hello),
99
+ );
100
+ expect(await refusal(openOwnerLink(await connect(port), owner))).toBe('certificate-mismatch');
101
+ expectOnlyHello(received());
102
+ });
103
+
104
+ it('forging an owner-mode answer gets no proof from the owner', async () => {
105
+ const forged = (): Uint8Array =>
106
+ Buffer.concat([
107
+ randomBytes(96),
108
+ Buffer.from([0, 4]),
109
+ Buffer.from('fake'),
110
+ Buffer.from([0, 4]),
111
+ Buffer.from('fake'),
112
+ Buffer.from([0, 64]),
113
+ randomBytes(64),
114
+ randomBytes(64),
115
+ ]);
116
+ const { port, received } = await squatter(forged);
117
+ expect(await refusal(openOwnerLink(await connect(port), owner))).toBe('certificate-mismatch');
118
+ expectOnlyHello(received());
119
+ });
120
+
121
+ it('running a runtime of its own gets nothing from the real spawner', async () => {
122
+ const real = createRuntimeIdentity();
123
+ const exchange = startSpawnerExchange();
124
+ const { spawnerKey, instancePublicKey } = exchange.complete(
125
+ real.answerSpawnerExchange(exchange.message),
126
+ );
127
+ const own = createRuntimeIdentity();
128
+ const ownExchange = startSpawnerExchange();
129
+ ownExchange.complete(own.answerSpawnerExchange(ownExchange.message));
130
+ const { port, received } = await squatter((hello) =>
131
+ createLinkResponder(own, BINDING).challenge(hello),
132
+ );
133
+ expect(await refusal(openSpawnerLink(await connect(port), spawnerKey, instancePublicKey))).toBe(
134
+ 'instance-mismatch',
135
+ );
136
+ expectOnlyHello(received());
137
+ });
138
+
139
+ it('announcing a challenge over its bound is closed on, not read, and nothing more is sent', async () => {
140
+ const { port, received } = await squatter(() => randomBytes(LINK_CHALLENGE_MAX_BYTES + 1));
141
+ // A closed link, not a refusal: the client never read the challenge to judge it.
142
+ await expect(openOwnerLink(await connect(port), owner)).rejects.toBeInstanceOf(
143
+ OwnerLinkClosedError,
144
+ );
145
+ expectOnlyHello(received());
146
+ });
147
+ });
package/src/index.ts ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * `@robota-sdk/agent-transport-link` — the owner link carrier. It runs the `agent-keyring` owner
3
+ * link handshake and frame protection over a local byte stream (a supervised runtime's control
4
+ * socket or pipe), carries the unchanged session protocol of `agent-transport` in sealed frames,
5
+ * and owns the owner vocabulary: trust-root install, read grants, lock, the accept question and the
6
+ * runtime's existing control commands. Wire shapes stop here; a runtime answers through the
7
+ * domain-typed desk and session ports, and decides there what each standing may do.
8
+ */
9
+ export { LINK_PROTOCOL, LINK_REQUEST_LINE, isLinkRequest } from './link-request.js';
10
+ export { DEFAULT_HANDSHAKE_TIMEOUT_MS, serveRuntimeLink } from './serve-runtime-link.js';
11
+ export type {
12
+ IAdmittedRuntimeLink,
13
+ IRuntimeLinkDeps,
14
+ IRuntimeLinkPorts,
15
+ } from './serve-runtime-link.js';
16
+ export { DEFAULT_OPEN_TIMEOUT_MS, openOwnerLink, openSpawnerLink } from './open-runtime-link.js';
17
+ export type {
18
+ ILinkSessionChannel,
19
+ IOpenRuntimeLinkOptions,
20
+ IOwnerRuntimeLinkClient,
21
+ IRuntimeLinkClient,
22
+ } from './open-runtime-link.js';
23
+ export { OwnerLinkClosedError, OwnerRequestRefusedError } from './link-ports.js';
24
+ export type {
25
+ ILinkSessionSink,
26
+ IOwnerControlRequest,
27
+ IOwnerLinkDesk,
28
+ IOwnerLinkSessions,
29
+ IRuntimeCustodyState,
30
+ TGrantOutcome,
31
+ TOwnerControlResult,
32
+ TOwnerControlValue,
33
+ TOwnerSessionQuestion,
34
+ TRequestedLockReason,
35
+ } from './link-ports.js';
@@ -0,0 +1,21 @@
1
+ /**
2
+ * How large each kind of frame may be. A session frame carries one session-protocol message, and its
3
+ * bound is the protocol's own inbound limit counted in UTF-8 bytes, the way the local attach path
4
+ * counts it: a message within it is within the decoder's character limit too, so a sender never
5
+ * emits what the receiving decoder must refuse. Owner frames carry short requests and replies. Both
6
+ * ends bound what they send, and every frame they read before reading it, by these.
7
+ */
8
+ import { LINK_FRAME_OVERHEAD_BYTES } from '@robota-sdk/agent-keyring';
9
+ import { MAX_INBOUND_FRAME_BYTES } from '@robota-sdk/agent-transport';
10
+
11
+ export const SESSION_FRAME_MAX_BYTES = MAX_INBOUND_FRAME_BYTES;
12
+ export const OWNER_FRAME_MAX_BYTES = 1024 * 1024;
13
+ /** The largest sealed frame either end accepts, of any kind. */
14
+ export const SEALED_FRAME_MAX_BYTES =
15
+ Math.max(SESSION_FRAME_MAX_BYTES, OWNER_FRAME_MAX_BYTES) + LINK_FRAME_OVERHEAD_BYTES;
16
+ /**
17
+ * Owner requests and read grants one link may have waiting at once. A client refuses its own request
18
+ * beyond it, so a well-behaved caller never loses its link; the runtime ends a link whose peer sends
19
+ * one more.
20
+ */
21
+ export const MAX_REQUESTS_IN_FLIGHT = 64;
@@ -0,0 +1,118 @@
1
+ /**
2
+ * What the owner link asks of a runtime, and tells its clients, in domain terms.
3
+ *
4
+ * The owner vocabulary's wire shapes stop inside this package. A runtime answers an admitted link
5
+ * through two ports: the desk, for custody (the trust root, the read grant, locking and the existing
6
+ * control commands), and the session decisions, for the one owner decision that is not a session
7
+ * protocol message (accepting a record not verified as the owner's). Both are given the link's
8
+ * standing when the link is admitted, and decide what that standing may do: this package carries
9
+ * requests, it does not grant them.
10
+ */
11
+ import type { IReadKeys, ITrustRoot } from '@robota-sdk/agent-keyring';
12
+
13
+ /** What a client may ask a runtime to lock for; idle and last-link-closed are the runtime's own. */
14
+ export type TRequestedLockReason = 'explicit' | 'screen-lock' | 'suspend';
15
+
16
+ /**
17
+ * How a read grant went. `mismatch`: the keyring file's sealing public key is not the granted one;
18
+ * the runtime seals to the granted key, and the owner is told to run `secure verify`.
19
+ */
20
+ export type TGrantOutcome = 'verified' | 'mismatch';
21
+
22
+ /** What every linked client is told, once admitted and whenever it changes. */
23
+ export interface IRuntimeCustodyState {
24
+ readonly unlocked: boolean;
25
+ /** Whether owner links are possible: a spawner installed a trust root. */
26
+ readonly certified: boolean;
27
+ }
28
+
29
+ /**
30
+ * The runtime's answer to "may I open this session?". `accept` asks the owner, on a surface that
31
+ * shows the fixed `notice` and `question` and never the record, and `digest` binds the answer to
32
+ * the bytes the runtime read.
33
+ */
34
+ export type TOwnerSessionQuestion =
35
+ | { readonly kind: 'open' }
36
+ | {
37
+ readonly kind: 'accept';
38
+ readonly digest: string;
39
+ readonly notice: string;
40
+ readonly question: string;
41
+ }
42
+ | { readonly kind: 'refused'; readonly message: string };
43
+
44
+ /** One of the runtime's existing control commands, carried inside the link. */
45
+ export interface IOwnerControlRequest {
46
+ readonly command: string;
47
+ readonly fields: Readonly<Record<string, string>>;
48
+ }
49
+
50
+ export type TOwnerControlValue =
51
+ | null
52
+ | boolean
53
+ | number
54
+ | string
55
+ | readonly TOwnerControlValue[]
56
+ | { readonly [key: string]: TOwnerControlValue };
57
+
58
+ /** What a control command answered. */
59
+ export type TOwnerControlResult = { readonly [key: string]: TOwnerControlValue };
60
+
61
+ /** The runtime's custody answers for one admitted link. */
62
+ export interface IOwnerLinkDesk {
63
+ /** The state the link announces as soon as it is admitted. */
64
+ custody(): IRuntimeCustodyState;
65
+ installTrustRoot(root: ITrustRoot): void | Promise<void>;
66
+ /**
67
+ * Installs the granted read keys. The desk owns them once this returns; when it refuses, by
68
+ * throwing, the carrier locks them.
69
+ */
70
+ unlock(keys: IReadKeys): TGrantOutcome | Promise<TGrantOutcome>;
71
+ lock(reason: TRequestedLockReason): void | Promise<void>;
72
+ control(request: IOwnerControlRequest): TOwnerControlResult | Promise<TOwnerControlResult>;
73
+ /** The link ended, for whatever reason. Called once. */
74
+ closed(): void;
75
+ }
76
+
77
+ /** The runtime's session decisions for one admitted link. */
78
+ export interface IOwnerLinkSessions {
79
+ question(sessionId: string): TOwnerSessionQuestion | Promise<TOwnerSessionQuestion>;
80
+ openAccepted(sessionId: string, acceptedDigest: string): void | Promise<void>;
81
+ }
82
+
83
+ /** Where the session protocol of an admitted link goes: one serialized client message at a time. */
84
+ export interface ILinkSessionSink {
85
+ receive(data: string): void;
86
+ }
87
+
88
+ const REFUSAL_CODE = /^[a-z][a-z0-9-]{0,63}$/u;
89
+ const MAX_REFUSAL_MESSAGE_LENGTH = 1024;
90
+
91
+ /**
92
+ * A request the runtime refused, by decision: a desk throws it with a fixed code and fixed text, and
93
+ * the client rejects with it. Any other failure reaches the client as `failed`, never with its text.
94
+ */
95
+ export class OwnerRequestRefusedError extends Error {
96
+ readonly code: string;
97
+
98
+ constructor(code: string, message: string) {
99
+ if (
100
+ !REFUSAL_CODE.test(code) ||
101
+ message.length === 0 ||
102
+ message.length > MAX_REFUSAL_MESSAGE_LENGTH
103
+ ) {
104
+ throw new RangeError('A refusal has a lower-case code and a message of 1-1024 characters.');
105
+ }
106
+ super(message);
107
+ this.name = 'OwnerRequestRefusedError';
108
+ this.code = code;
109
+ }
110
+ }
111
+
112
+ /** The link closed, or never opened: the other end does not say why. */
113
+ export class OwnerLinkClosedError extends Error {
114
+ constructor() {
115
+ super('The owner link closed.');
116
+ this.name = 'OwnerLinkClosedError';
117
+ }
118
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The request line that opens an owner link, in the control endpoint's existing request shape so
3
+ * the endpoint routes it like any other command. It names no runtime and no start: the binding is
4
+ * proven inside the handshake, never taken from the request.
5
+ */
6
+ export const LINK_PROTOCOL = 1;
7
+
8
+ /** Exactly what a client writes first, newline included. */
9
+ export const LINK_REQUEST_LINE = `${JSON.stringify({ command: 'link', protocol: LINK_PROTOCOL })}\n`;
10
+
11
+ /** Whether a parsed request line is exactly the link request; anything more or less is not. */
12
+ export function isLinkRequest(value: unknown): boolean {
13
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
14
+ const record = value as Record<string, unknown>;
15
+ return (
16
+ Object.keys(record).length === 2 &&
17
+ record['command'] === 'link' &&
18
+ record['protocol'] === LINK_PROTOCOL
19
+ );
20
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * The owner link on a byte stream: after the request line, length-prefixed binary frames.
3
+ *
4
+ * Each frame is a 4-byte big-endian length and that many bytes. Every frame is bounded by what may
5
+ * come next (a hello, a challenge, a proof, a sealed frame), and the bound is checked as soon as the
6
+ * length arrives, so a peer can never make an end buffer more than one frame it is allowed to send.
7
+ * Chunks are kept as they arrive and joined once per frame, so a large frame costs one copy.
8
+ */
9
+ import { Buffer } from 'node:buffer';
10
+ import type { Duplex } from 'node:stream';
11
+
12
+ import { LINK_REQUEST_LINE } from './link-request.js';
13
+
14
+ const LENGTH_PREFIX_BYTES = 4;
15
+
16
+ /** One frame: its length, then its bytes. */
17
+ export function encodeLinkFrame(payload: Uint8Array): Buffer {
18
+ const prefix = Buffer.alloc(LENGTH_PREFIX_BYTES);
19
+ prefix.writeUInt32BE(payload.length);
20
+ return Buffer.concat([prefix, payload]);
21
+ }
22
+
23
+ /** The request line and the first frame, written together so the endpoint can route both at once. */
24
+ export function encodeLinkOpening(hello: Uint8Array): Buffer {
25
+ return Buffer.concat([Buffer.from(LINK_REQUEST_LINE, 'utf8'), encodeLinkFrame(hello)]);
26
+ }
27
+
28
+ /** Collects stream chunks and hands out whole frames within the bound the caller gives. */
29
+ class LinkFrameReader {
30
+ #chunks: Buffer[] = [];
31
+ #length = 0;
32
+
33
+ push(chunk: Uint8Array): void {
34
+ if (chunk.length === 0) return;
35
+ this.#chunks.push(Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength));
36
+ this.#length += chunk.length;
37
+ }
38
+
39
+ /** The next whole frame, or `undefined` until it has arrived; throws when one is announced over `maxBytes`. */
40
+ next(maxBytes: number): Buffer | undefined {
41
+ if (this.#length < LENGTH_PREFIX_BYTES) return undefined;
42
+ if (this.#chunks[0]!.length < LENGTH_PREFIX_BYTES) this.#join();
43
+ const length = this.#chunks[0]!.readUInt32BE(0);
44
+ if (length > maxBytes) throw new RangeError('A frame was announced over its bound.');
45
+ if (this.#length < LENGTH_PREFIX_BYTES + length) return undefined;
46
+ const all = this.#join();
47
+ const end = LENGTH_PREFIX_BYTES + length;
48
+ const payload = Buffer.from(all.subarray(LENGTH_PREFIX_BYTES, end));
49
+ const rest = all.subarray(end);
50
+ this.#chunks = rest.length > 0 ? [rest] : [];
51
+ this.#length = rest.length;
52
+ return payload;
53
+ }
54
+
55
+ #join(): Buffer {
56
+ const all =
57
+ this.#chunks.length === 1 ? this.#chunks[0]! : Buffer.concat(this.#chunks, this.#length);
58
+ this.#chunks = [all];
59
+ return all;
60
+ }
61
+ }
62
+
63
+ export interface IFrameReading {
64
+ /** The bytes after the request line that arrived with it. */
65
+ readonly rest?: Uint8Array;
66
+ /** The largest frame allowed next; asked before each frame. */
67
+ readonly maxBytes: () => number;
68
+ /**
69
+ * One whole frame. May end the stream; nothing more is read once it has. Returning `'yield'` stops
70
+ * the reading for the rest of this tick, so a caller woken by this frame runs before the next one.
71
+ */
72
+ readonly onFrame: (frame: Buffer) => void | 'yield';
73
+ /** The stream ended, errored, or sent a frame over its bound. Called once. */
74
+ readonly onEnd: () => void;
75
+ }
76
+
77
+ /**
78
+ * Reads frames off `stream` until it ends; a frame over its bound destroys the stream. A stream that
79
+ * is already destroyed or closed reads nothing and ends on the next tick.
80
+ */
81
+ export function readLinkFrames(stream: Duplex, reading: IFrameReading): void {
82
+ const reader = new LinkFrameReader();
83
+ let ended = false;
84
+ const end = (): void => {
85
+ if (ended) return;
86
+ ended = true;
87
+ reading.onEnd();
88
+ };
89
+ let yielded = false;
90
+ const drain = (): void => {
91
+ if (yielded) return;
92
+ try {
93
+ for (
94
+ let frame = reader.next(reading.maxBytes());
95
+ frame !== undefined;
96
+ frame = reader.next(reading.maxBytes())
97
+ ) {
98
+ if (stream.destroyed) return;
99
+ const outcome = reading.onFrame(frame);
100
+ if (stream.destroyed) return;
101
+ if (outcome === 'yield') {
102
+ // Paused, so nothing more arrives (and the stream cannot end) until the frames kept so far
103
+ // have been read: the pause is one tick, and holds no more than one chunk.
104
+ yielded = true;
105
+ stream.pause();
106
+ setImmediate(() => {
107
+ yielded = false;
108
+ drain();
109
+ if (!yielded) stream.resume();
110
+ });
111
+ return;
112
+ }
113
+ }
114
+ } catch {
115
+ // A frame over its bound, or a handler that failed: either way this link is over.
116
+ stream.destroy();
117
+ }
118
+ };
119
+ stream.on('data', (chunk: Buffer | string) => {
120
+ // A stream decoding to text has already lost the frames' bytes.
121
+ if (typeof chunk === 'string') {
122
+ stream.destroy();
123
+ return;
124
+ }
125
+ reader.push(chunk);
126
+ drain();
127
+ });
128
+ stream.on('error', () => stream.destroy());
129
+ stream.once('close', end);
130
+ stream.once('end', () => stream.destroy());
131
+ if (reading.rest !== undefined) reader.push(reading.rest);
132
+ drain();
133
+ stream.resume();
134
+ // A stream that is over before it is read emits no `close` to wait for, and a link must still end.
135
+ if (stream.destroyed) process.nextTick(end);
136
+ }