@robota-sdk/agent-transport 3.0.0-beta.100
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1137 -0
- package/LICENSE +661 -0
- package/README.md +121 -0
- package/dist/browser/client.d.ts +2 -0
- package/dist/browser/client.js +1 -0
- package/dist/browser/index.d.ts +624 -0
- package/dist/browser/index.d.ts.map +1 -0
- package/dist/browser/index.js +2 -0
- package/dist/browser/index.js.map +1 -0
- package/dist/browser/observer-messages-4fRQZU2D.d.ts +499 -0
- package/dist/browser/observer-messages-4fRQZU2D.d.ts.map +1 -0
- package/dist/browser/observer-messages-O8jTPEQO.js +2 -0
- package/dist/browser/observer-messages-O8jTPEQO.js.map +1 -0
- package/dist/node/index.cjs +1 -0
- package/dist/node/index.d.cts +1115 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +1115 -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/dist/node/node.cjs +1 -0
- package/dist/node/node.d.cts +317 -0
- package/dist/node/node.d.cts.map +1 -0
- package/dist/node/node.d.ts +317 -0
- package/dist/node/node.d.ts.map +1 -0
- package/dist/node/node.js +2 -0
- package/dist/node/node.js.map +1 -0
- package/package.json +122 -0
- package/src/__tests__/__fixtures__/korean-text.json +6 -0
- package/src/__tests__/access-token-verifier.test.ts +672 -0
- package/src/__tests__/admission.test.ts +130 -0
- package/src/__tests__/app-tool-messages.test.ts +463 -0
- package/src/__tests__/bearer-resource-gate.test.ts +120 -0
- package/src/__tests__/channel-frames.test.ts +215 -0
- package/src/__tests__/driver-attribution-capability.test.ts +83 -0
- package/src/__tests__/file-transfer.test.ts +281 -0
- package/src/__tests__/handoff-chunking.test.ts +261 -0
- package/src/__tests__/handoff-manifest.test.ts +52 -0
- package/src/__tests__/history-attached-budget.test.ts +216 -0
- package/src/__tests__/message-decoders.test.ts +870 -0
- package/src/__tests__/outbound-delivery.test.ts +558 -0
- package/src/__tests__/peer-message-ledger.test.ts +200 -0
- package/src/__tests__/permission-summary.test.ts +18 -0
- package/src/__tests__/personal-usage-report.test.ts +193 -0
- package/src/__tests__/project-read-messages.test.ts +118 -0
- package/src/__tests__/resume-buffer.test.ts +93 -0
- package/src/__tests__/session-detail-loop-status.test.ts +169 -0
- package/src/__tests__/session-directory-messages.test.ts +322 -0
- package/src/__tests__/session-error-classification.test.ts +113 -0
- package/src/__tests__/session-event-delivery.test.ts +101 -0
- package/src/__tests__/session-history-wire.test.ts +364 -0
- package/src/__tests__/session-message-handler-e5.test.ts +99 -0
- package/src/__tests__/session-message-handler-observe.test.ts +223 -0
- package/src/__tests__/session-message-handler.test.ts +726 -0
- package/src/__tests__/session-resume-bridge.test.ts +472 -0
- package/src/__tests__/settings-messages.test.ts +193 -0
- package/src/__tests__/stdio-carrier.test.ts +85 -0
- package/src/__tests__/submit-prompt-shape.test.ts +78 -0
- package/src/__tests__/usage-report-carrier.test.ts +98 -0
- package/src/__tests__/ws-broadcast-events.test.ts +94 -0
- package/src/app-tool-messages.ts +140 -0
- package/src/background-messages.ts +169 -0
- package/src/channel-frames.ts +182 -0
- package/src/client.ts +17 -0
- package/src/handoff-chunking.ts +275 -0
- package/src/index.ts +135 -0
- package/src/loop-control-messages.ts +34 -0
- package/src/message-decoders.ts +485 -0
- package/src/message-parser.ts +33 -0
- package/src/node/access-token-verifier.ts +363 -0
- package/src/node/admission.ts +153 -0
- package/src/node/bearer-resource-gate.ts +331 -0
- package/src/node/file-transfer.ts +530 -0
- package/src/node/handoff-manifest.ts +77 -0
- package/src/node/index.ts +45 -0
- package/src/node/stdio-carrier.ts +59 -0
- package/src/observer-messages.ts +47 -0
- package/src/open-prompts.ts +76 -0
- package/src/outbound-delivery.ts +240 -0
- package/src/peer-message-ledger.ts +193 -0
- package/src/permission-summary.ts +18 -0
- package/src/project-read-messages.ts +74 -0
- package/src/protocol-session.ts +56 -0
- package/src/resume-buffer.ts +123 -0
- package/src/session-directory-messages.ts +162 -0
- package/src/session-error-classification.ts +91 -0
- package/src/session-events.ts +290 -0
- package/src/session-message-handler.ts +413 -0
- package/src/session-query-messages.ts +160 -0
- package/src/session-resume-bridge.ts +275 -0
- package/src/settings-messages.ts +103 -0
- package/src/usage-messages.ts +195 -0
- package/src/wire-messages.ts +436 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Payload-agnostic channel frame codec (TRANS-001).
|
|
3
|
+
*
|
|
4
|
+
* The single place that knows the channel envelope. Pure and transport-neutral: a WebSocket binary
|
|
5
|
+
* frame, a WebRTC data-channel message, or any other byte carrier can use it unchanged. It NEVER
|
|
6
|
+
* interprets a binary body — that is the whole point of the payload-agnostic profile.
|
|
7
|
+
*
|
|
8
|
+
* Wire layout (all multi-byte integers big-endian):
|
|
9
|
+
*
|
|
10
|
+
* ```
|
|
11
|
+
* 0..2 magic 'R','B','F' ← lets a carrier route a frame without parsing it
|
|
12
|
+
* 3 version (1)
|
|
13
|
+
* 4 kind: 0x01 = opaque binary, 0x02 = structured event (UTF-8 JSON body)
|
|
14
|
+
* 5 channel-name length in bytes (1..255)
|
|
15
|
+
* 6.. channel name (UTF-8)
|
|
16
|
+
* +4 seq (uint32)
|
|
17
|
+
* ... body — opaque bytes (kind 0x01) or UTF-8 JSON `{ "event": string, "payload": … }` (0x02)
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* The envelope deliberately carries NO length prefix for the body: the carrier already delimits the
|
|
21
|
+
* message (a WebSocket frame is a message boundary), so a length field would only be a second source
|
|
22
|
+
* of truth that can disagree with the first.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type {
|
|
26
|
+
IBinaryFrame,
|
|
27
|
+
IChannelEventFrame,
|
|
28
|
+
TChannelReceiveResult,
|
|
29
|
+
} from '@robota-sdk/agent-interface-transport';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The JSON-serializable payload type an event frame carries. Read off the contract (rather than
|
|
33
|
+
* re-importing `agent-core`'s `TUniversalValue`) so this package keeps its single contract edge.
|
|
34
|
+
*/
|
|
35
|
+
type TEventPayload = IChannelEventFrame['payload'];
|
|
36
|
+
|
|
37
|
+
const encoder = new TextEncoder();
|
|
38
|
+
const decoder = new TextDecoder();
|
|
39
|
+
|
|
40
|
+
/** `R`, `B`, `F` — the shared prefix every channel frame starts with. */
|
|
41
|
+
export const CHANNEL_FRAME_MAGIC: Uint8Array = encoder.encode('RBF');
|
|
42
|
+
|
|
43
|
+
/** Envelope version. Bumped only on an incompatible layout change. */
|
|
44
|
+
export const CHANNEL_FRAME_VERSION = 1;
|
|
45
|
+
|
|
46
|
+
const KIND_BINARY = 0x01;
|
|
47
|
+
const KIND_EVENT = 0x02;
|
|
48
|
+
|
|
49
|
+
/** Bytes before the channel name: magic(3) + version(1) + kind(1) + name length(1). */
|
|
50
|
+
const PREFIX_BYTES = 6;
|
|
51
|
+
/** Bytes after the channel name and before the body: seq(4). */
|
|
52
|
+
const SEQ_BYTES = 4;
|
|
53
|
+
|
|
54
|
+
const MAX_CHANNEL_NAME_BYTES = 255;
|
|
55
|
+
const MAX_SEQ = 0xffff_ffff;
|
|
56
|
+
|
|
57
|
+
/** Validate the envelope fields both encoders share, then return the encoded channel name. */
|
|
58
|
+
function encodeChannelName(channel: string, seq: number): Uint8Array {
|
|
59
|
+
const name = encoder.encode(channel);
|
|
60
|
+
if (name.byteLength === 0 || name.byteLength > MAX_CHANNEL_NAME_BYTES) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`channel name must be 1..${MAX_CHANNEL_NAME_BYTES} UTF-8 bytes (got ${name.byteLength})`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
if (!Number.isInteger(seq) || seq < 0 || seq > MAX_SEQ) {
|
|
66
|
+
throw new Error(`seq must be an integer in 0..${MAX_SEQ} (got ${seq})`);
|
|
67
|
+
}
|
|
68
|
+
return name;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Write the shared header into `out` and return the offset where the body starts. */
|
|
72
|
+
function writeHeader(out: Uint8Array, kind: number, name: Uint8Array, seq: number): number {
|
|
73
|
+
out.set(CHANNEL_FRAME_MAGIC, 0);
|
|
74
|
+
out[3] = CHANNEL_FRAME_VERSION;
|
|
75
|
+
out[4] = kind;
|
|
76
|
+
out[5] = name.byteLength;
|
|
77
|
+
out.set(name, PREFIX_BYTES);
|
|
78
|
+
const seqAt = PREFIX_BYTES + name.byteLength;
|
|
79
|
+
new DataView(out.buffer, out.byteOffset, out.byteLength).setUint32(seqAt, seq, false);
|
|
80
|
+
return seqAt + SEQ_BYTES;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Encode an opaque binary frame. The payload bytes are copied verbatim. */
|
|
84
|
+
export function encodeBinaryFrame(frame: IBinaryFrame): Uint8Array {
|
|
85
|
+
const name = encodeChannelName(frame.channel, frame.seq);
|
|
86
|
+
const out = new Uint8Array(PREFIX_BYTES + name.byteLength + SEQ_BYTES + frame.payload.byteLength);
|
|
87
|
+
const bodyAt = writeHeader(out, KIND_BINARY, name, frame.seq);
|
|
88
|
+
out.set(frame.payload, bodyAt);
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Encode a structured, consumer-declared event frame (UTF-8 JSON body). */
|
|
93
|
+
export function encodeChannelEventFrame(frame: IChannelEventFrame): Uint8Array {
|
|
94
|
+
const name = encodeChannelName(frame.channel, frame.seq);
|
|
95
|
+
const body = encoder.encode(JSON.stringify({ event: frame.event, payload: frame.payload }));
|
|
96
|
+
const out = new Uint8Array(PREFIX_BYTES + name.byteLength + SEQ_BYTES + body.byteLength);
|
|
97
|
+
const bodyAt = writeHeader(out, KIND_EVENT, name, frame.seq);
|
|
98
|
+
out.set(body, bodyAt);
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** True when `bytes` starts with the channel-frame magic — a cheap, non-parsing routing check. */
|
|
103
|
+
export function isChannelFrame(bytes: Uint8Array): boolean {
|
|
104
|
+
if (bytes.byteLength < CHANNEL_FRAME_MAGIC.length) return false;
|
|
105
|
+
return CHANNEL_FRAME_MAGIC.every((b, i) => bytes[i] === b);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The decoded shape of an event frame's JSON body. */
|
|
109
|
+
interface IEventBody {
|
|
110
|
+
readonly event?: TEventPayload;
|
|
111
|
+
readonly payload?: TEventPayload;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Parse the JSON body of an event frame into its `event` + `payload` fields, or state why not. */
|
|
115
|
+
function parseEventBody(
|
|
116
|
+
body: Uint8Array,
|
|
117
|
+
): { ok: true; event: string; payload: TEventPayload } | { ok: false; error: string } {
|
|
118
|
+
const text = decoder.decode(body);
|
|
119
|
+
let parsed: TEventPayload;
|
|
120
|
+
try {
|
|
121
|
+
parsed = JSON.parse(text) as TEventPayload;
|
|
122
|
+
} catch {
|
|
123
|
+
// An error RESULT, not a fallback: the caller reports it to the peer as a protocol error.
|
|
124
|
+
return { ok: false, error: 'channel frame: malformed JSON body' };
|
|
125
|
+
}
|
|
126
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
127
|
+
return { ok: false, error: 'channel frame: event body must be a JSON object' };
|
|
128
|
+
}
|
|
129
|
+
const { event, payload } = parsed as IEventBody;
|
|
130
|
+
if (typeof event !== 'string' || event.length === 0) {
|
|
131
|
+
return { ok: false, error: 'channel frame: event body is missing a non-empty `event` name' };
|
|
132
|
+
}
|
|
133
|
+
return { ok: true, event, payload: payload ?? null };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Decode a channel frame. Returns an explicit result union — a non-channel, truncated, or malformed
|
|
138
|
+
* input is a stated error the carrier reports to the peer, never a thrown exception on the hot path
|
|
139
|
+
* and never a silently dropped frame.
|
|
140
|
+
*/
|
|
141
|
+
export function decodeChannelFrame(bytes: Uint8Array): TChannelReceiveResult {
|
|
142
|
+
if (!isChannelFrame(bytes)) {
|
|
143
|
+
return { ok: false, error: 'channel frame: bad magic (not a channel frame)' };
|
|
144
|
+
}
|
|
145
|
+
if (bytes.byteLength < PREFIX_BYTES) {
|
|
146
|
+
return { ok: false, error: 'channel frame: truncated header' };
|
|
147
|
+
}
|
|
148
|
+
if (bytes[3] !== CHANNEL_FRAME_VERSION) {
|
|
149
|
+
return { ok: false, error: `channel frame: unsupported version ${String(bytes[3])}` };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const kind = bytes[4];
|
|
153
|
+
const nameLength = bytes[5] ?? 0;
|
|
154
|
+
if (nameLength === 0) return { ok: false, error: 'channel frame: empty channel name' };
|
|
155
|
+
|
|
156
|
+
const seqAt = PREFIX_BYTES + nameLength;
|
|
157
|
+
const bodyAt = seqAt + SEQ_BYTES;
|
|
158
|
+
if (bytes.byteLength < bodyAt) {
|
|
159
|
+
return { ok: false, error: 'channel frame: truncated (header longer than the frame)' };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const channel = decoder.decode(bytes.subarray(PREFIX_BYTES, seqAt));
|
|
163
|
+
const seq = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(
|
|
164
|
+
seqAt,
|
|
165
|
+
false,
|
|
166
|
+
);
|
|
167
|
+
const body = bytes.subarray(bodyAt);
|
|
168
|
+
|
|
169
|
+
if (kind === KIND_BINARY) {
|
|
170
|
+
// Copy so the frame does not alias the carrier's (possibly pooled/reused) receive buffer.
|
|
171
|
+
return { ok: true, frame: { kind: 'binary', channel, seq, payload: Uint8Array.from(body) } };
|
|
172
|
+
}
|
|
173
|
+
if (kind === KIND_EVENT) {
|
|
174
|
+
const parsed = parseEventBody(body);
|
|
175
|
+
if (!parsed.ok) return { ok: false, error: parsed.error };
|
|
176
|
+
return {
|
|
177
|
+
ok: true,
|
|
178
|
+
frame: { kind: 'event', channel, seq, event: parsed.event, payload: parsed.payload },
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
return { ok: false, error: `channel frame: unknown kind ${String(kind)}` };
|
|
182
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Browser-safe protocol surface: wire unions plus runtime frame decoders, with no Node admission code. */
|
|
2
|
+
export {
|
|
3
|
+
MAX_INBOUND_FRAME_BYTES,
|
|
4
|
+
decodeClientMessage,
|
|
5
|
+
decodeFrame,
|
|
6
|
+
decodeServerMessage,
|
|
7
|
+
} from './message-decoders.js';
|
|
8
|
+
export type { TMessageDecodeResult } from './message-decoders.js';
|
|
9
|
+
// The one list of messages an observer may send: the host refuses the rest, a client sends no others.
|
|
10
|
+
export { isObserverMessageType } from './observer-messages.js';
|
|
11
|
+
export type {
|
|
12
|
+
IWireHistoryEntry,
|
|
13
|
+
TClientMessage,
|
|
14
|
+
TServerMessage,
|
|
15
|
+
TSeqServerMessage,
|
|
16
|
+
TWireExecutionResult,
|
|
17
|
+
} from './wire-messages.js';
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HANDOFF-001 (#1811): moving a session record that does not fit in one frame.
|
|
3
|
+
*
|
|
4
|
+
* A data channel has a maximum message size; a session record is a whole conversation. So the
|
|
5
|
+
* payload travels in pieces, and this owns the two halves of that: cutting it up, and putting it
|
|
6
|
+
* back together in the face of retries, reordering and a peer that says something inconsistent.
|
|
7
|
+
*
|
|
8
|
+
* ## Why the cut is made on BYTES rather than on the string
|
|
9
|
+
*
|
|
10
|
+
* The channel's limit is in bytes, and the serialized record is a JavaScript string — UTF-16 code
|
|
11
|
+
* units, where one character can be one byte or four. Slicing by characters and hoping the pieces
|
|
12
|
+
* fit means either measuring each slice's encoded length anyway, or shipping frames that overflow
|
|
13
|
+
* the channel for anyone whose conversation contains an emoji or a non-Latin script.
|
|
14
|
+
*
|
|
15
|
+
* Cutting the encoded bytes makes the budget exact. It costs base64's third: a byte slice is not a
|
|
16
|
+
* valid string on its own — it can end mid-character — so it cannot ride in a JSON frame as text.
|
|
17
|
+
* That cost is deliberate and is the smaller risk. The alternative works only because
|
|
18
|
+
* `JSON.stringify` has escaped lone surrogates since ES2019; a transfer whose correctness rests on
|
|
19
|
+
* that, and which fails as silent corruption when something in the chain does not, is not a trade
|
|
20
|
+
* worth a third of the bandwidth.
|
|
21
|
+
*
|
|
22
|
+
* ## What this does NOT decide
|
|
23
|
+
*
|
|
24
|
+
* It does not verify the digest. `verifyHandoffPayload` owns that, and asking the same question in
|
|
25
|
+
* two places is how the two answers drift. This reports that all the pieces arrived; whether they
|
|
26
|
+
* are the RIGHT pieces is the next check, and it deliberately runs on the whole reassembled payload
|
|
27
|
+
* rather than per chunk — a per-chunk digest would say each piece is intact while the set is not the
|
|
28
|
+
* set that was sent.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import type { IHandoffIntegrity } from '@robota-sdk/agent-interface-session-mobility';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* One piece of a payload in flight.
|
|
35
|
+
*
|
|
36
|
+
* `total` travels on every chunk rather than in a separate header frame: a header can be the frame
|
|
37
|
+
* that gets lost, and a receiver that learned the count from a message it never got would wait
|
|
38
|
+
* forever for a number it cannot ask for again.
|
|
39
|
+
*/
|
|
40
|
+
export interface IHandoffChunk {
|
|
41
|
+
readonly handoffId: string;
|
|
42
|
+
/** 0-based position in the sequence. */
|
|
43
|
+
readonly index: number;
|
|
44
|
+
/** How many chunks make up the whole payload. */
|
|
45
|
+
readonly total: number;
|
|
46
|
+
/** base64 of this slice of the UTF-8 encoded payload. */
|
|
47
|
+
readonly data: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Default frame budget, in bytes of encoded chunk data.
|
|
52
|
+
*
|
|
53
|
+
* 16 KiB because that is the size every SCTP implementation carries without negotiation. Larger
|
|
54
|
+
* works between two peers that both support it and fails between two that do not — and it fails at
|
|
55
|
+
* transfer time, on the user's session, not in a test.
|
|
56
|
+
*/
|
|
57
|
+
export const DEFAULT_MAX_CHUNK_BYTES = 16 * 1024;
|
|
58
|
+
|
|
59
|
+
// This module is exported from the package root, which also builds for the browser, so it uses the
|
|
60
|
+
// web platform's bytes, text codecs and base64 (`btoa`/`atob`) rather than Node's `Buffer`.
|
|
61
|
+
|
|
62
|
+
/** How many bytes go through `String.fromCharCode` at once, well under the argument-count limit. */
|
|
63
|
+
const BINARY_STRING_STEP = 0x8000;
|
|
64
|
+
|
|
65
|
+
function toBase64(bytes: Uint8Array): string {
|
|
66
|
+
let binary = '';
|
|
67
|
+
for (let offset = 0; offset < bytes.length; offset += BINARY_STRING_STEP) {
|
|
68
|
+
binary += String.fromCharCode(...bytes.subarray(offset, offset + BINARY_STRING_STEP));
|
|
69
|
+
}
|
|
70
|
+
return btoa(binary);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The bytes `data` encodes, or `undefined` when it is not base64 at all. */
|
|
74
|
+
function fromBase64(data: string): Uint8Array | undefined {
|
|
75
|
+
let binary: string;
|
|
76
|
+
try {
|
|
77
|
+
binary = atob(data);
|
|
78
|
+
} catch {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
const bytes = new Uint8Array(binary.length);
|
|
82
|
+
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
|
83
|
+
return bytes;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function sameBytes(a: Uint8Array, b: Uint8Array): boolean {
|
|
87
|
+
if (a.length !== b.length) return false;
|
|
88
|
+
for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false;
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Cut a sealed payload into chunks.
|
|
94
|
+
*
|
|
95
|
+
* Takes the serialized string the seal produced, not the record — the digest is over those exact
|
|
96
|
+
* bytes, and re-serializing here would let a chunked payload differ from the one the manifest
|
|
97
|
+
* describes.
|
|
98
|
+
*/
|
|
99
|
+
export function chunkHandoffPayload(
|
|
100
|
+
handoffId: string,
|
|
101
|
+
serialized: string,
|
|
102
|
+
maxChunkBytes: number = DEFAULT_MAX_CHUNK_BYTES,
|
|
103
|
+
): readonly IHandoffChunk[] {
|
|
104
|
+
if (!Number.isInteger(maxChunkBytes) || maxChunkBytes < 1) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`handoff chunking: maxChunkBytes must be a positive integer, got ${maxChunkBytes}. ` +
|
|
107
|
+
'A zero or negative budget produces an infinite sequence of empty chunks, which presents ' +
|
|
108
|
+
'as a hang rather than as the configuration error it is.',
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
const bytes = new TextEncoder().encode(serialized);
|
|
112
|
+
// An empty payload is still one chunk. Zero chunks would leave the receiver unable to tell a
|
|
113
|
+
// completed empty transfer from one that never started.
|
|
114
|
+
const total = Math.max(1, Math.ceil(bytes.byteLength / maxChunkBytes));
|
|
115
|
+
const chunks: IHandoffChunk[] = [];
|
|
116
|
+
for (let index = 0; index < total; index += 1) {
|
|
117
|
+
const slice = bytes.subarray(index * maxChunkBytes, (index + 1) * maxChunkBytes);
|
|
118
|
+
chunks.push({ handoffId, index, total, data: toBase64(slice) });
|
|
119
|
+
}
|
|
120
|
+
return chunks;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Why a chunk was not accepted. */
|
|
124
|
+
export type TChunkRejection =
|
|
125
|
+
/** Belongs to a different transfer. */
|
|
126
|
+
| 'wrong-handoff'
|
|
127
|
+
/** `index` is negative, or beyond the declared `total`. */
|
|
128
|
+
| 'out-of-range'
|
|
129
|
+
/** Its `total` disagrees with what earlier chunks declared. */
|
|
130
|
+
| 'inconsistent-total'
|
|
131
|
+
/** Its `data` is not decodable base64. */
|
|
132
|
+
| 'undecodable';
|
|
133
|
+
|
|
134
|
+
export type TChunkOutcome =
|
|
135
|
+
/** Stored; more are still needed. */
|
|
136
|
+
| 'accepted'
|
|
137
|
+
/** Already had this index, and it matched. A retry, not a problem. */
|
|
138
|
+
| 'duplicate'
|
|
139
|
+
/** Stored, and that was the last one missing. */
|
|
140
|
+
| 'complete'
|
|
141
|
+
| 'refused';
|
|
142
|
+
|
|
143
|
+
export interface IChunkResult {
|
|
144
|
+
readonly outcome: TChunkOutcome;
|
|
145
|
+
readonly rejection?: TChunkRejection;
|
|
146
|
+
/** Present when complete: the reassembled payload, ready for the integrity check. */
|
|
147
|
+
readonly serialized?: string;
|
|
148
|
+
/** How many distinct chunks are held. */
|
|
149
|
+
readonly received: number;
|
|
150
|
+
/** How many are expected, once anything has declared it. */
|
|
151
|
+
readonly expected?: number;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Collects the chunks of ONE transfer.
|
|
156
|
+
*
|
|
157
|
+
* One assembler per `handoffId`, because a shared one would have to key everything by transfer and
|
|
158
|
+
* would then be a place where two transfers can be confused for each other. A chunk naming a
|
|
159
|
+
* different transfer is refused rather than routed — routing is the caller's job and doing it here
|
|
160
|
+
* would make this a dispatcher as well as a buffer.
|
|
161
|
+
*/
|
|
162
|
+
export class HandoffChunkAssembler {
|
|
163
|
+
private readonly chunks = new Map<number, Uint8Array>();
|
|
164
|
+
private declaredTotal: number | undefined;
|
|
165
|
+
|
|
166
|
+
constructor(private readonly handoffId: string) {}
|
|
167
|
+
|
|
168
|
+
get received(): number {
|
|
169
|
+
return this.chunks.size;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
get expected(): number | undefined {
|
|
173
|
+
return this.declaredTotal;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Take one chunk. Reordering and retries are normal; inconsistency is not. */
|
|
177
|
+
accept(chunk: IHandoffChunk): IChunkResult {
|
|
178
|
+
const refuse = (rejection: TChunkRejection): IChunkResult => ({
|
|
179
|
+
outcome: 'refused',
|
|
180
|
+
rejection,
|
|
181
|
+
received: this.chunks.size,
|
|
182
|
+
...(this.declaredTotal !== undefined ? { expected: this.declaredTotal } : {}),
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
if (chunk.handoffId !== this.handoffId) return refuse('wrong-handoff');
|
|
186
|
+
if (!Number.isInteger(chunk.total) || chunk.total < 1) return refuse('inconsistent-total');
|
|
187
|
+
if (this.declaredTotal !== undefined && chunk.total !== this.declaredTotal) {
|
|
188
|
+
// A peer that changes its mind about the size has either restarted the transfer under the
|
|
189
|
+
// same id or is not the peer we started with. Either way the pieces held so far can no longer
|
|
190
|
+
// be assumed to belong together.
|
|
191
|
+
return refuse('inconsistent-total');
|
|
192
|
+
}
|
|
193
|
+
if (!Number.isInteger(chunk.index) || chunk.index < 0 || chunk.index >= chunk.total) {
|
|
194
|
+
return refuse('out-of-range');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const decoded = fromBase64(chunk.data);
|
|
198
|
+
// Base64 decoding is lenient — it skips whitespace and accepts non-canonical padding — so a
|
|
199
|
+
// mangled chunk could silently become a different one and corrupt the payload in a way only the
|
|
200
|
+
// final digest would catch, after the whole transfer completed. Re-encoding is how the leniency
|
|
201
|
+
// is detected here instead.
|
|
202
|
+
if (decoded === undefined || toBase64(decoded) !== chunk.data) return refuse('undecodable');
|
|
203
|
+
|
|
204
|
+
this.declaredTotal = chunk.total;
|
|
205
|
+
const held = this.chunks.get(chunk.index);
|
|
206
|
+
if (held !== undefined) {
|
|
207
|
+
// A retry re-sending the same bytes is normal. Different bytes for the same index is the
|
|
208
|
+
// inconsistent case again, and the same reasoning applies.
|
|
209
|
+
if (sameBytes(held, decoded)) {
|
|
210
|
+
return { outcome: 'duplicate', received: this.chunks.size, expected: chunk.total };
|
|
211
|
+
}
|
|
212
|
+
return refuse('inconsistent-total');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
this.chunks.set(chunk.index, decoded);
|
|
216
|
+
if (this.chunks.size < chunk.total) {
|
|
217
|
+
return { outcome: 'accepted', received: this.chunks.size, expected: chunk.total };
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
outcome: 'complete',
|
|
221
|
+
serialized: this.reassemble(chunk.total),
|
|
222
|
+
received: this.chunks.size,
|
|
223
|
+
expected: chunk.total,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Which indices are still missing, so a caller can ask for them rather than restarting. */
|
|
228
|
+
missing(): readonly number[] {
|
|
229
|
+
if (this.declaredTotal === undefined) return [];
|
|
230
|
+
const gaps: number[] = [];
|
|
231
|
+
for (let i = 0; i < this.declaredTotal; i += 1) if (!this.chunks.has(i)) gaps.push(i);
|
|
232
|
+
return gaps;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Drop everything held. The transfer was abandoned, or its integrity check failed. */
|
|
236
|
+
discard(): void {
|
|
237
|
+
this.chunks.clear();
|
|
238
|
+
this.declaredTotal = undefined;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private reassemble(total: number): string {
|
|
242
|
+
const ordered: Uint8Array[] = [];
|
|
243
|
+
for (let i = 0; i < total; i += 1) {
|
|
244
|
+
const part = this.chunks.get(i);
|
|
245
|
+
if (part === undefined) {
|
|
246
|
+
// Unreachable: size equalled total and every index was range-checked on the way in. Stated
|
|
247
|
+
// as an error rather than a non-null assertion, because the assertion would be the thing
|
|
248
|
+
// that hid it if the invariant ever broke.
|
|
249
|
+
throw new Error(`handoff chunking: chunk ${i} of ${total} is missing at reassembly.`);
|
|
250
|
+
}
|
|
251
|
+
ordered.push(part);
|
|
252
|
+
}
|
|
253
|
+
const joined = new Uint8Array(ordered.reduce((size, part) => size + part.length, 0));
|
|
254
|
+
let offset = 0;
|
|
255
|
+
for (const part of ordered) {
|
|
256
|
+
joined.set(part, offset);
|
|
257
|
+
offset += part.length;
|
|
258
|
+
}
|
|
259
|
+
// Keep a leading byte-order mark: it is part of the payload, as it was with `Buffer`.
|
|
260
|
+
return new TextDecoder('utf-8', { ignoreBOM: true }).decode(joined);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* How many chunks a payload of this size will take, without building them.
|
|
266
|
+
*
|
|
267
|
+
* For a progress indicator at the sending end, and for a receiver deciding whether it is willing to
|
|
268
|
+
* hold that much before the first chunk arrives.
|
|
269
|
+
*/
|
|
270
|
+
export function chunkCountFor(
|
|
271
|
+
integrity: IHandoffIntegrity,
|
|
272
|
+
maxChunkBytes: number = DEFAULT_MAX_CHUNK_BYTES,
|
|
273
|
+
): number {
|
|
274
|
+
return Math.max(1, Math.ceil(integrity.byteLength / maxChunkBytes));
|
|
275
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-safe entry point for `@robota-sdk/agent-transport`.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this file exists
|
|
5
|
+
*
|
|
6
|
+
* The package's `./node` entry owns `admission.ts` and `handoff-manifest.ts`, the only modules
|
|
7
|
+
* that import `node:crypto`. Keeping those modules out of this root prevents a browser consumer
|
|
8
|
+
* from pulling a Node builtin into its bundle.
|
|
9
|
+
*
|
|
10
|
+
* ## What is in, and why
|
|
11
|
+
*
|
|
12
|
+
* Everything in `src/index.ts` EXCEPT the two modules that touch `node:crypto`. Read off the actual
|
|
13
|
+
* import graph, not off intent:
|
|
14
|
+
*
|
|
15
|
+
* node:crypto reachers `admission.ts`, `handoff-manifest.ts` — and nothing inside the package
|
|
16
|
+
* imports either of them, so removing both from this barrel severs the
|
|
17
|
+
* builtin from this entry's graph entirely.
|
|
18
|
+
* everything else `session-message-handler` → {`background-messages`, `message-decoders`,
|
|
19
|
+
* `session-events`}, `session-resume-bridge` → {`outbound-delivery`,
|
|
20
|
+
* `resume-buffer`, `session-message-handler`, `session-events`}, `channel-frames`,
|
|
21
|
+
* `peer-message-ledger`, `handoff-chunking`,
|
|
22
|
+
* `protocol-session`, `wire-messages` — no builtin on any path.
|
|
23
|
+
*
|
|
24
|
+
* Handoff authority, offer refusal, and inventory decisions live in
|
|
25
|
+
* `agent-interface-session-mobility`, outside this wire package.
|
|
26
|
+
*
|
|
27
|
+
* The handoff MANIFEST types are omitted along with their functions. Keeping the `export type` lines
|
|
28
|
+
* would be free at runtime, but it would put `node/handoff-manifest.ts` back on this entry's import graph
|
|
29
|
+
* — and the `browser-package-node-subpath` scan that guards this file walks EVERY import edge rather
|
|
30
|
+
* than trying to tell a type edge from a value one. A check that has to be clever about which edges
|
|
31
|
+
* count is a check that can be fooled; a browser consumer that needs those types is asking for the
|
|
32
|
+
* Node surface and should say so by importing `./node`.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
export { createSessionMessageHandler } from './session-message-handler.js';
|
|
36
|
+
export type {
|
|
37
|
+
ISessionMessageHandlerOptions,
|
|
38
|
+
TSessionSurfaceRole,
|
|
39
|
+
} from './session-message-handler.js';
|
|
40
|
+
// ARCH-030: the connection-scoped outbound delivery boundary every carrier builds and passes down.
|
|
41
|
+
export {
|
|
42
|
+
ATTACHED_SURFACE_MAX_PENDING_BYTES,
|
|
43
|
+
createOutboundDelivery,
|
|
44
|
+
createPendingStallClock,
|
|
45
|
+
isOverPendingBudget,
|
|
46
|
+
DEFAULT_MAX_PENDING_BYTES,
|
|
47
|
+
DEFAULT_MAX_PENDING_MS,
|
|
48
|
+
} from './outbound-delivery.js';
|
|
49
|
+
export type {
|
|
50
|
+
IPendingStallClock,
|
|
51
|
+
TDeliveryErrorHandler,
|
|
52
|
+
TOutboundDeliver,
|
|
53
|
+
} from './outbound-delivery.js';
|
|
54
|
+
export { PROTOCOL_SESSION_EVENT_CLASSIFICATION } from './session-events.js';
|
|
55
|
+
// The one list of messages an observer may send: the host refuses the rest, a client sends no others.
|
|
56
|
+
export { isObserverMessageType } from './observer-messages.js';
|
|
57
|
+
// The most history one frame carries, sized for the tightest channel; a carrier whose channel is
|
|
58
|
+
// small (a WebRTC data channel) bounds an app view's frame by the same number.
|
|
59
|
+
export { HISTORY_PAGE_MAX_BYTES } from './session-query-messages.js';
|
|
60
|
+
export type { TProtocolSessionEventClassification } from './session-events.js';
|
|
61
|
+
export type { IProtocolSession } from './protocol-session.js';
|
|
62
|
+
export type {
|
|
63
|
+
IWireAgentDefinitionSummary,
|
|
64
|
+
IWireHistoryEntry,
|
|
65
|
+
TClientMessage,
|
|
66
|
+
TServerMessage,
|
|
67
|
+
TSeqServerMessage,
|
|
68
|
+
TWireExecutionResult,
|
|
69
|
+
} from './wire-messages.js';
|
|
70
|
+
// Issue #2045: the owner-side runtime decoders; carriers implement `raw → decodeFrame → typed`.
|
|
71
|
+
export {
|
|
72
|
+
MAX_INBOUND_FRAME_BYTES,
|
|
73
|
+
decodeClientMessage,
|
|
74
|
+
decodeFrame,
|
|
75
|
+
decodeServerMessage,
|
|
76
|
+
} from './message-decoders.js';
|
|
77
|
+
export type { TMessageDecodeResult } from './message-decoders.js';
|
|
78
|
+
export { ResumeBuffer } from './resume-buffer.js';
|
|
79
|
+
export type { IResumeBufferOptions, IBufferedFrame, TResumeTail } from './resume-buffer.js';
|
|
80
|
+
// TRANS-001 — payload-agnostic channel frame codec (transport-neutral, body-opaque).
|
|
81
|
+
export {
|
|
82
|
+
CHANNEL_FRAME_MAGIC,
|
|
83
|
+
CHANNEL_FRAME_VERSION,
|
|
84
|
+
decodeChannelFrame,
|
|
85
|
+
encodeBinaryFrame,
|
|
86
|
+
encodeChannelEventFrame,
|
|
87
|
+
isChannelFrame,
|
|
88
|
+
} from './channel-frames.js';
|
|
89
|
+
export { SessionResumeBridge } from './session-resume-bridge.js';
|
|
90
|
+
export type {
|
|
91
|
+
ISessionResumeBridgeOptions,
|
|
92
|
+
TResumeSink,
|
|
93
|
+
IAttachOptions,
|
|
94
|
+
} from './session-resume-bridge.js';
|
|
95
|
+
// #3282 §4a: the Settings screen's host-owned read/write seam (parallels `IUsageQueryReporters`).
|
|
96
|
+
export { handleSettingsMessage, isSettingsMessage } from './settings-messages.js';
|
|
97
|
+
export type { ISettingsReporter, TSettingsUpdateOutcome } from './settings-messages.js';
|
|
98
|
+
|
|
99
|
+
// SEC-008 admission is NOT here: `src/node/admission.ts` mints and compares transport tokens with
|
|
100
|
+
// `node:crypto`. A browser never mints one — it presents one it was handed — so the exclusion costs
|
|
101
|
+
// the browser surface nothing it could have used.
|
|
102
|
+
|
|
103
|
+
// PEER-001 (#1809): the receiver's record of what it has already taken responsibility for. Lives
|
|
104
|
+
// here rather than in a carrier because duplicate, retry and gap are questions about what was SEEN
|
|
105
|
+
// BEFORE, and no socket or frame codec has the memory to answer them.
|
|
106
|
+
export {
|
|
107
|
+
acknowledgePeerMessage,
|
|
108
|
+
admitPeerMessage,
|
|
109
|
+
createPeerMessageLedger,
|
|
110
|
+
forgetPeerOrigin,
|
|
111
|
+
} from './peer-message-ledger.js';
|
|
112
|
+
export type {
|
|
113
|
+
IPeerMessageLedger,
|
|
114
|
+
IPeerMessageRejection,
|
|
115
|
+
IPeerMessageVerdict,
|
|
116
|
+
} from './peer-message-ledger.js';
|
|
117
|
+
|
|
118
|
+
// The argument a person recognises a tool call by, shared by every transport that asks a person
|
|
119
|
+
// about a permission in its own wire form, so they name the same call the same way.
|
|
120
|
+
export { permissionSummaryArgument } from './permission-summary.js';
|
|
121
|
+
|
|
122
|
+
// The handoff integrity helpers (`sealHandoffRecord`, `verifyHandoffPayload`) are NOT
|
|
123
|
+
// here: it hashes with `node:crypto`. Chunking is, because it never hashes anything.
|
|
124
|
+
export {
|
|
125
|
+
chunkCountFor,
|
|
126
|
+
chunkHandoffPayload,
|
|
127
|
+
DEFAULT_MAX_CHUNK_BYTES,
|
|
128
|
+
HandoffChunkAssembler,
|
|
129
|
+
} from './handoff-chunking.js';
|
|
130
|
+
export type {
|
|
131
|
+
IChunkResult,
|
|
132
|
+
IHandoffChunk,
|
|
133
|
+
TChunkOutcome,
|
|
134
|
+
TChunkRejection,
|
|
135
|
+
} from './handoff-chunking.js';
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { TOutboundDeliver } from './outbound-delivery.js';
|
|
2
|
+
import type { IProtocolSession } from './protocol-session.js';
|
|
3
|
+
import type { TClientMessage } from './wire-messages.js';
|
|
4
|
+
|
|
5
|
+
type TLoopControlMessage = Extract<TClientMessage, { type: 'stop-waiting-loop' }>;
|
|
6
|
+
|
|
7
|
+
export function isLoopControlMessage(msg: TClientMessage): msg is TLoopControlMessage {
|
|
8
|
+
return msg.type === 'stop-waiting-loop';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Stop the self-paced loop that is waiting for its next wake. The session decides which loop, if
|
|
13
|
+
* any; the reply carries its outcome, and a session that throws is reported as a failed stop.
|
|
14
|
+
*/
|
|
15
|
+
export function handleLoopControlMessage(
|
|
16
|
+
session: IProtocolSession,
|
|
17
|
+
deliver: TOutboundDeliver,
|
|
18
|
+
msg: TLoopControlMessage,
|
|
19
|
+
): void {
|
|
20
|
+
const { requestId } = msg;
|
|
21
|
+
session.stopWaitingSelfPacedLoop().then(
|
|
22
|
+
(outcome) => deliver({ type: 'waiting_loop_stop', requestId, outcome }),
|
|
23
|
+
(error: unknown) =>
|
|
24
|
+
deliver({
|
|
25
|
+
type: 'waiting_loop_stop',
|
|
26
|
+
requestId,
|
|
27
|
+
outcome: { kind: 'failed', message: errorMessage(error) },
|
|
28
|
+
}),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function errorMessage(error: unknown): string {
|
|
33
|
+
return error instanceof Error ? error.message : String(error);
|
|
34
|
+
}
|