@robota-sdk/agent-transport 3.0.0-beta.78 → 3.0.0-beta.81
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +84 -26
- package/dist/browser/client.d.ts +2 -0
- package/dist/browser/client.js +1 -0
- package/dist/browser/index.d.ts +520 -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/message-decoders-BB8n-H2X.d.ts +252 -0
- package/dist/browser/message-decoders-BB8n-H2X.d.ts.map +1 -0
- package/dist/browser/message-decoders-CDmDoZPR.js +2 -0
- package/dist/browser/message-decoders-CDmDoZPR.js.map +1 -0
- package/dist/node/index.cjs +1 -1
- package/dist/node/index.d.cts +766 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +765 -27
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +1 -1
- package/dist/node/index.js.map +1 -1
- package/dist/node/node.cjs +1 -0
- package/dist/node/node.d.cts +109 -0
- package/dist/node/node.d.cts.map +1 -0
- package/dist/node/node.d.ts +109 -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 +68 -47
- package/src/__tests__/access-token-verifier.test.ts +672 -0
- package/src/__tests__/admission.test.ts +130 -0
- package/src/__tests__/channel-frames.test.ts +214 -0
- package/src/__tests__/driver-attribution-capability.test.ts +83 -0
- package/src/__tests__/handoff-chunking.test.ts +215 -0
- package/src/__tests__/handoff-manifest.test.ts +52 -0
- package/src/__tests__/message-decoders.test.ts +249 -0
- package/src/__tests__/outbound-delivery.test.ts +558 -0
- package/src/__tests__/peer-message-ledger.test.ts +200 -0
- package/src/__tests__/personal-usage-report.test.ts +193 -0
- package/src/__tests__/resume-buffer.test.ts +93 -0
- package/src/__tests__/session-event-delivery.test.ts +101 -0
- package/src/__tests__/session-message-handler-e5.test.ts +99 -0
- package/src/__tests__/session-message-handler.test.ts +590 -0
- package/src/__tests__/session-resume-bridge.test.ts +406 -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 +84 -0
- package/src/background-messages.ts +169 -0
- package/src/channel-frames.ts +182 -0
- package/src/client.ts +9 -0
- package/src/handoff-chunking.ts +235 -0
- package/src/index.ts +112 -3
- package/src/message-decoders.ts +229 -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/handoff-manifest.ts +77 -0
- package/src/node/index.ts +10 -0
- package/src/outbound-delivery.ts +231 -0
- package/src/peer-message-ledger.ts +193 -0
- package/src/protocol-session.ts +28 -0
- package/src/resume-buffer.ts +123 -0
- package/src/session-events.ts +198 -0
- package/src/session-message-handler.ts +282 -0
- package/src/session-query-messages.ts +42 -0
- package/src/session-resume-bridge.ts +252 -0
- package/src/usage-messages.ts +195 -0
- package/src/wire-messages.ts +165 -0
- package/dist/node/headless/index.cjs +0 -1
- package/dist/node/headless/index.d.ts +0 -2
- package/dist/node/headless/index.js +0 -1
- package/dist/node/headless-DG_MEbf0.js +0 -19
- package/dist/node/headless-DG_MEbf0.js.map +0 -1
- package/dist/node/headless-xq77JBq0.cjs +0 -18
- package/dist/node/index-CrJ2Lhn3.d.ts +0 -96
- package/dist/node/index-CrJ2Lhn3.d.ts.map +0 -1
- package/dist/node/index-JIvzrtaA.d.ts +0 -52
- package/dist/node/index-JIvzrtaA.d.ts.map +0 -1
- package/dist/node/index-JTD_xtEQ.d.ts +0 -96
- package/dist/node/index-JTD_xtEQ.d.ts.map +0 -1
- package/dist/node/index-y_HBOfzO.d.ts +0 -52
- package/dist/node/index-y_HBOfzO.d.ts.map +0 -1
- package/dist/node/programmatic/index.cjs +0 -1
- package/dist/node/programmatic/index.d.ts +0 -2
- package/dist/node/programmatic/index.js +0 -1
- package/dist/node/programmatic-6wLf68qJ.cjs +0 -1
- package/dist/node/programmatic-70dxIXtS.js +0 -2
- package/dist/node/programmatic-70dxIXtS.js.map +0 -1
- package/dist/node/testing/index.cjs +0 -1
- package/dist/node/testing/index.d.ts +0 -2
- package/dist/node/testing/index.js +0 -1
- package/src/__tests__/programmatic/programmatic-ask-user.test.ts +0 -32
- package/src/__tests__/programmatic/programmatic-driver.test.ts +0 -107
- package/src/__tests__/programmatic/programmatic-flows.test.ts +0 -140
- package/src/__tests__/transport-registry.stop-all.test.ts +0 -53
- package/src/headless/HeadlessInteractionChannel.ts +0 -129
- package/src/headless/__tests__/headless-channel-options.test.ts +0 -106
- package/src/headless/__tests__/headless-goal-runner.test.ts +0 -108
- package/src/headless/__tests__/headless-provider-failure.integration.test.ts +0 -143
- package/src/headless/__tests__/headless-runner-initialization.test.ts +0 -45
- package/src/headless/__tests__/headless-runner.test.ts +0 -508
- package/src/headless/__tests__/headless-skill-activation.integration.test.ts +0 -430
- package/src/headless/__tests__/headless-transport.test.ts +0 -267
- package/src/headless/cli-input.ts +0 -50
- package/src/headless/headless-runner.ts +0 -230
- package/src/headless/headless-stream-json.ts +0 -148
- package/src/headless/headless-transport.ts +0 -43
- package/src/headless/index.ts +0 -8
- package/src/headless/print-terminal.ts +0 -57
- package/src/programmatic/ProgrammaticInteractionChannel.ts +0 -82
- package/src/programmatic/createProgrammaticAgent.ts +0 -71
- package/src/programmatic/index.ts +0 -5
- package/src/testing/index.ts +0 -10
- package/src/transport-registry.ts +0 -103
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEC-008 — the admission seam's own cases.
|
|
3
|
+
*
|
|
4
|
+
* `resolveAdmission` had none. It was exercised only THROUGH the four transports that call it, and
|
|
5
|
+
* both defects review found here are ones that indirection hides: a transport passing a well-formed
|
|
6
|
+
* config never exhibits either.
|
|
7
|
+
*
|
|
8
|
+
* The seam's whole claim is "one place to read and one place to change". A seam nobody tests
|
|
9
|
+
* directly is one place to read and four places to find out you were wrong.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, expect, it } from 'vitest';
|
|
13
|
+
|
|
14
|
+
import { bearerCredential, resolveAdmission } from '../node/admission.js';
|
|
15
|
+
|
|
16
|
+
const OPEN_REASON = 'loopback only, the port is bound to 127.0.0.1';
|
|
17
|
+
|
|
18
|
+
describe('resolveAdmission is secure by default', () => {
|
|
19
|
+
it('mints when nothing is asked for', () => {
|
|
20
|
+
const admission = resolveAdmission();
|
|
21
|
+
|
|
22
|
+
expect(admission.token).toMatch(/^[0-9a-f]{64}$/);
|
|
23
|
+
expect(admission.openReason).toBeUndefined();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('keeps an explicit token', () => {
|
|
27
|
+
expect(resolveAdmission({ token: 'chosen-by-the-host' })).toEqual({
|
|
28
|
+
token: 'chosen-by-the-host',
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('opens only with a reason', () => {
|
|
33
|
+
expect(resolveAdmission({ open: true, openReason: OPEN_REASON })).toEqual({
|
|
34
|
+
token: null,
|
|
35
|
+
openReason: OPEN_REASON,
|
|
36
|
+
});
|
|
37
|
+
expect(() => resolveAdmission({ open: true })).toThrow(/requires `openReason`/);
|
|
38
|
+
expect(() => resolveAdmission({ open: true, openReason: ' ' })).toThrow(
|
|
39
|
+
/requires `openReason`/,
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('MINTS for an empty-string token rather than requiring the empty string', () => {
|
|
44
|
+
// The value behind the discriminator defect. `{ token: '' }` is documented as "mint a fresh
|
|
45
|
+
// one", and the HTTP route's `'token' in options.admission` read it as ALREADY RESOLVED and
|
|
46
|
+
// installed the empty string as the required credential — which a peer sending an empty bearer
|
|
47
|
+
// then matches. The route no longer discriminates; this pins the value it depends on.
|
|
48
|
+
expect(resolveAdmission({ token: '' }).token).toMatch(/^[0-9a-f]{64}$/);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('resolveAdmission is idempotent', () => {
|
|
53
|
+
// Why it must be: the HTTP route accepts either shape, and BOTH interfaces declare a `token`, so
|
|
54
|
+
// they cannot be told apart structurally. Making the function accept its own output deletes that
|
|
55
|
+
// question instead of answering it wrongly.
|
|
56
|
+
it('returns an already-resolved token admission unchanged', () => {
|
|
57
|
+
const resolved = resolveAdmission({ token: 'chosen-by-the-host' });
|
|
58
|
+
|
|
59
|
+
expect(resolveAdmission(resolved)).toEqual(resolved);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('returns an already-resolved OPEN admission unchanged, reason and all', () => {
|
|
63
|
+
// The case that made the old code wrong in the dangerous direction: re-resolving an open
|
|
64
|
+
// admission through a config-shaped path either minted a token nobody holds or dropped the
|
|
65
|
+
// reason and left an unexplained open transport.
|
|
66
|
+
const resolved = resolveAdmission({ open: true, openReason: OPEN_REASON });
|
|
67
|
+
|
|
68
|
+
expect(resolved).toEqual({ token: null, openReason: OPEN_REASON });
|
|
69
|
+
expect(resolveAdmission(resolved)).toEqual(resolved);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('REFUSES an open admission that arrives without its reason', () => {
|
|
73
|
+
// `token: null` is the recorded outcome of a decision. One reaching here with no reason was
|
|
74
|
+
// hand-built or corrupted, and both are the unexplained-open state the reason exists to
|
|
75
|
+
// prevent — so it is refused rather than passed through.
|
|
76
|
+
expect(() => resolveAdmission({ token: null })).toThrow(/requires `openReason`/);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('resolveAdmission refuses a contradiction', () => {
|
|
81
|
+
it('throws when a token and `open: true` are asked for together', () => {
|
|
82
|
+
// Review: `WebRtcTransport` already refused the analogous `secret` + `open` pair in its own
|
|
83
|
+
// constructor, so only WebRTC callers were protected. The old seam gave silent precedence to
|
|
84
|
+
// the token — the safe DIRECTION, but the wrong ANSWER: a caller that wrote both does not know
|
|
85
|
+
// what it is asking for, and one of the two things it believes is false.
|
|
86
|
+
expect(() =>
|
|
87
|
+
resolveAdmission({ token: 'a-credential', open: true, openReason: OPEN_REASON }),
|
|
88
|
+
).toThrow(/contradictory/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('still accepts `open: false` beside a token, which is not a contradiction', () => {
|
|
92
|
+
// `open: false` says the same thing the token does. Refusing it would turn a redundant but
|
|
93
|
+
// coherent config into an error, which is over-blocking for nothing.
|
|
94
|
+
expect(resolveAdmission({ token: 'a-credential', open: false })).toEqual({
|
|
95
|
+
token: 'a-credential',
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('bearerCredential reads the header the way RFC 7235 defines it', () => {
|
|
101
|
+
it('matches the SCHEME without regard to case', () => {
|
|
102
|
+
// `auth-scheme` is a token, and tokens compare case-insensitively. The exact-case match refused
|
|
103
|
+
// a conformant client with a 401 it could not act on — a refusal that reads as a server bug and
|
|
104
|
+
// gets worked around rather than fixed. Review found it.
|
|
105
|
+
expect(bearerCredential('bearer abc123')).toBe('abc123');
|
|
106
|
+
expect(bearerCredential('BEARER abc123')).toBe('abc123');
|
|
107
|
+
expect(bearerCredential('BeArEr abc123')).toBe('abc123');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('leaves the CREDENTIAL exactly as sent', () => {
|
|
111
|
+
// Only the scheme is case-insensitive. The credential is compared byte for byte, so folding it
|
|
112
|
+
// would make two different secrets the same secret.
|
|
113
|
+
expect(bearerCredential('Bearer AbC')).toBe('AbC');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('accepts more than one space between the two', () => {
|
|
117
|
+
expect(bearerCredential('Bearer abc123')).toBe('abc123');
|
|
118
|
+
expect(bearerCredential('Bearer\tabc123')).toBe('abc123');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('refuses a header that merely STARTS with the word', () => {
|
|
122
|
+
// `Bearerness xyz` is a different scheme, and reading it as a bearer token would admit a peer
|
|
123
|
+
// that never claimed to be one.
|
|
124
|
+
expect(bearerCredential('Bearerness xyz')).toBeUndefined();
|
|
125
|
+
expect(bearerCredential('Basic abc123')).toBeUndefined();
|
|
126
|
+
expect(bearerCredential('Bearer')).toBeUndefined();
|
|
127
|
+
expect(bearerCredential('Bearer ')).toBeUndefined();
|
|
128
|
+
expect(bearerCredential(undefined)).toBeUndefined();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
CHANNEL_FRAME_MAGIC,
|
|
5
|
+
decodeChannelFrame,
|
|
6
|
+
encodeBinaryFrame,
|
|
7
|
+
encodeChannelEventFrame,
|
|
8
|
+
} from '../channel-frames.js';
|
|
9
|
+
|
|
10
|
+
import type { IBinaryFrame, IChannelEventFrame } from '@robota-sdk/agent-interface-transport';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* TRANS-001 — payload-agnostic frame codec.
|
|
14
|
+
*
|
|
15
|
+
* The codec is the ONLY thing that knows the carrier envelope; it never interprets the opaque body.
|
|
16
|
+
* These tests pin round-trip integrity, ordering metadata (`seq`), and explicit malformed-input
|
|
17
|
+
* results (a result union, never a swallowed default).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
function bytes(...values: number[]): Uint8Array {
|
|
21
|
+
return Uint8Array.from(values);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('channel frame codec (TRANS-001)', () => {
|
|
25
|
+
it('round-trips an opaque binary payload byte-identically', () => {
|
|
26
|
+
// Deliberately includes bytes that are invalid UTF-8 and JSON-hostile — the carrier must not care.
|
|
27
|
+
const payload = bytes(0x00, 0xff, 0xfe, 0x7b, 0x22, 0x80, 0x0a, 0xc3, 0x28);
|
|
28
|
+
const encoded = encodeBinaryFrame({ kind: 'binary', channel: 'audio', seq: 7, payload });
|
|
29
|
+
|
|
30
|
+
const decoded = decodeChannelFrame(encoded);
|
|
31
|
+
expect(decoded.ok).toBe(true);
|
|
32
|
+
if (!decoded.ok) return;
|
|
33
|
+
expect(decoded.frame.kind).toBe('binary');
|
|
34
|
+
const frame = decoded.frame as IBinaryFrame;
|
|
35
|
+
expect(frame.channel).toBe('audio');
|
|
36
|
+
expect(frame.seq).toBe(7);
|
|
37
|
+
expect(Array.from(frame.payload)).toEqual(Array.from(payload));
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('round-trips an empty binary payload', () => {
|
|
41
|
+
const encoded = encodeBinaryFrame({
|
|
42
|
+
kind: 'binary',
|
|
43
|
+
channel: 'blob',
|
|
44
|
+
seq: 0,
|
|
45
|
+
payload: new Uint8Array(0),
|
|
46
|
+
});
|
|
47
|
+
const decoded = decodeChannelFrame(encoded);
|
|
48
|
+
expect(decoded.ok).toBe(true);
|
|
49
|
+
if (!decoded.ok) return;
|
|
50
|
+
expect((decoded.frame as IBinaryFrame).payload.byteLength).toBe(0);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('preserves per-channel ordering through seq on a chunked stream', () => {
|
|
54
|
+
const source = new Uint8Array(1024);
|
|
55
|
+
for (let i = 0; i < source.length; i += 1) source[i] = (i * 31) % 256;
|
|
56
|
+
|
|
57
|
+
const chunkSize = 100;
|
|
58
|
+
const wire: Uint8Array[] = [];
|
|
59
|
+
for (let offset = 0, seq = 0; offset < source.length; offset += chunkSize, seq += 1) {
|
|
60
|
+
wire.push(
|
|
61
|
+
encodeBinaryFrame({
|
|
62
|
+
kind: 'binary',
|
|
63
|
+
channel: 'file',
|
|
64
|
+
seq,
|
|
65
|
+
payload: source.subarray(offset, Math.min(offset + chunkSize, source.length)),
|
|
66
|
+
}),
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Deliver out of order; the receiver reassembles by `seq`, not by arrival order.
|
|
71
|
+
const shuffled = [...wire].reverse();
|
|
72
|
+
const received: IBinaryFrame[] = [];
|
|
73
|
+
for (const w of shuffled) {
|
|
74
|
+
const d = decodeChannelFrame(w);
|
|
75
|
+
expect(d.ok).toBe(true);
|
|
76
|
+
if (d.ok && d.frame.kind === 'binary') received.push(d.frame);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
received.sort((a, b) => a.seq - b.seq);
|
|
80
|
+
const reassembled = new Uint8Array(source.length);
|
|
81
|
+
let cursor = 0;
|
|
82
|
+
for (const frame of received) {
|
|
83
|
+
reassembled.set(frame.payload, cursor);
|
|
84
|
+
cursor += frame.payload.byteLength;
|
|
85
|
+
}
|
|
86
|
+
expect(cursor).toBe(source.length);
|
|
87
|
+
expect(Array.from(reassembled)).toEqual(Array.from(source));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('round-trips a consumer-declared structured event frame', () => {
|
|
91
|
+
const encoded = encodeChannelEventFrame({
|
|
92
|
+
kind: 'event',
|
|
93
|
+
channel: 'captions',
|
|
94
|
+
seq: 3,
|
|
95
|
+
event: 'caption',
|
|
96
|
+
payload: { text: 'hello', final: true },
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const decoded = decodeChannelFrame(encoded);
|
|
100
|
+
expect(decoded.ok).toBe(true);
|
|
101
|
+
if (!decoded.ok) return;
|
|
102
|
+
expect(decoded.frame.kind).toBe('event');
|
|
103
|
+
const frame = decoded.frame as IChannelEventFrame;
|
|
104
|
+
expect(frame.channel).toBe('captions');
|
|
105
|
+
expect(frame.seq).toBe(3);
|
|
106
|
+
expect(frame.event).toBe('caption');
|
|
107
|
+
expect(frame.payload).toEqual({ text: 'hello', final: true });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('supports multi-byte UTF-8 channel names', () => {
|
|
111
|
+
const encoded = encodeBinaryFrame({
|
|
112
|
+
kind: 'binary',
|
|
113
|
+
channel: '음성-채널',
|
|
114
|
+
seq: 1,
|
|
115
|
+
payload: bytes(1, 2, 3),
|
|
116
|
+
});
|
|
117
|
+
const decoded = decodeChannelFrame(encoded);
|
|
118
|
+
expect(decoded.ok).toBe(true);
|
|
119
|
+
if (!decoded.ok) return;
|
|
120
|
+
expect(decoded.frame.channel).toBe('음성-채널');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('rejects a channel name that does not fit the 255-byte header field', () => {
|
|
124
|
+
expect(() =>
|
|
125
|
+
encodeBinaryFrame({
|
|
126
|
+
kind: 'binary',
|
|
127
|
+
channel: 'x'.repeat(256),
|
|
128
|
+
seq: 0,
|
|
129
|
+
payload: new Uint8Array(0),
|
|
130
|
+
}),
|
|
131
|
+
).toThrow(/channel name/i);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('rejects an empty channel name', () => {
|
|
135
|
+
expect(() =>
|
|
136
|
+
encodeBinaryFrame({ kind: 'binary', channel: '', seq: 0, payload: new Uint8Array(0) }),
|
|
137
|
+
).toThrow(/channel name/i);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('rejects a seq outside the uint32 range', () => {
|
|
141
|
+
expect(() =>
|
|
142
|
+
encodeBinaryFrame({
|
|
143
|
+
kind: 'binary',
|
|
144
|
+
channel: 'c',
|
|
145
|
+
seq: 2 ** 32,
|
|
146
|
+
payload: new Uint8Array(0),
|
|
147
|
+
}),
|
|
148
|
+
).toThrow(/seq/i);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('reports a non-channel frame as a typed error result rather than throwing', () => {
|
|
152
|
+
const decoded = decodeChannelFrame(bytes(0x01, 0x02, 0x03, 0x04, 0x05));
|
|
153
|
+
expect(decoded.ok).toBe(false);
|
|
154
|
+
if (decoded.ok) return;
|
|
155
|
+
expect(decoded.error).toMatch(/magic|channel frame/i);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('reports a truncated frame as a typed error result', () => {
|
|
159
|
+
const full = encodeBinaryFrame({
|
|
160
|
+
kind: 'binary',
|
|
161
|
+
channel: 'audio',
|
|
162
|
+
seq: 1,
|
|
163
|
+
payload: bytes(9, 9, 9),
|
|
164
|
+
});
|
|
165
|
+
const decoded = decodeChannelFrame(full.subarray(0, 6));
|
|
166
|
+
expect(decoded.ok).toBe(false);
|
|
167
|
+
if (decoded.ok) return;
|
|
168
|
+
expect(decoded.error).toMatch(/truncated/i);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('reports an unknown frame kind as a typed error result', () => {
|
|
172
|
+
const full = encodeBinaryFrame({
|
|
173
|
+
kind: 'binary',
|
|
174
|
+
channel: 'audio',
|
|
175
|
+
seq: 1,
|
|
176
|
+
payload: bytes(9),
|
|
177
|
+
});
|
|
178
|
+
const tampered = Uint8Array.from(full);
|
|
179
|
+
tampered[4] = 0x7f; // unknown kind byte
|
|
180
|
+
const decoded = decodeChannelFrame(tampered);
|
|
181
|
+
expect(decoded.ok).toBe(false);
|
|
182
|
+
if (decoded.ok) return;
|
|
183
|
+
expect(decoded.error).toMatch(/kind/i);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('reports a malformed event body as a typed error result', () => {
|
|
187
|
+
const good = encodeChannelEventFrame({
|
|
188
|
+
kind: 'event',
|
|
189
|
+
channel: 'c',
|
|
190
|
+
seq: 0,
|
|
191
|
+
event: 'e',
|
|
192
|
+
payload: null,
|
|
193
|
+
});
|
|
194
|
+
const tampered = Uint8Array.from(good);
|
|
195
|
+
// Corrupt the JSON body (everything after the 10 + channelNameLength header bytes).
|
|
196
|
+
tampered[tampered.length - 1] = 0x7b; // '{'
|
|
197
|
+
const decoded = decodeChannelFrame(tampered);
|
|
198
|
+
expect(decoded.ok).toBe(false);
|
|
199
|
+
if (decoded.ok) return;
|
|
200
|
+
expect(decoded.error).toMatch(/body|json/i);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('stamps the shared magic so a carrier can route frames without parsing them', () => {
|
|
204
|
+
const encoded = encodeBinaryFrame({
|
|
205
|
+
kind: 'binary',
|
|
206
|
+
channel: 'c',
|
|
207
|
+
seq: 0,
|
|
208
|
+
payload: new Uint8Array(0),
|
|
209
|
+
});
|
|
210
|
+
expect(Array.from(encoded.subarray(0, CHANNEL_FRAME_MAGIC.length))).toEqual(
|
|
211
|
+
Array.from(CHANNEL_FRAME_MAGIC),
|
|
212
|
+
);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { createOutboundDelivery } from '../outbound-delivery.js';
|
|
4
|
+
import { subscribeSessionEvents } from '../session-events.js';
|
|
5
|
+
|
|
6
|
+
import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
|
|
7
|
+
|
|
8
|
+
import type { IInteractiveSession, TDriverId } from '@robota-sdk/agent-interface-session';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* ARCH-012 — the sharp edge of an optional contract member.
|
|
12
|
+
*
|
|
13
|
+
* `IInteractiveSession` declared `getActiveDriverId?()`, and the one consumer read it as
|
|
14
|
+
* `session.getActiveDriverId?.() ?? undefined`. Two entirely different situations arrived as the same
|
|
15
|
+
* `undefined`:
|
|
16
|
+
*
|
|
17
|
+
* - the host DOES attribute turns and no driver is active right now, and
|
|
18
|
+
* - the host does not implement attribution at all.
|
|
19
|
+
*
|
|
20
|
+
* The second silently loses every co-drive attribution, with no error, no log, and nothing a reader
|
|
21
|
+
* or a test could use to tell it from the first. The shipped `InteractiveSession` happens to
|
|
22
|
+
* implement all three optional members, so the defect was latent for it and live for the 51 casts
|
|
23
|
+
* that stand in for other implementations.
|
|
24
|
+
*
|
|
25
|
+
* The fix is not a better fallback. It is removing the optionality: a host either provides the
|
|
26
|
+
* capability or does not claim the contract. These cases pin that the member is REQUIRED, which is
|
|
27
|
+
* what makes "no active driver" the only thing `null` can mean.
|
|
28
|
+
*/
|
|
29
|
+
function hostWithAttribution(driverId: TDriverId | null): IInteractiveSession {
|
|
30
|
+
// The conformant double, not another hand-rolled partial — the cast ratchet objected to the first
|
|
31
|
+
// draft of this very file, which is the behaviour it exists for.
|
|
32
|
+
return createTestInteractiveSession({
|
|
33
|
+
getActiveDriverId: () => driverId,
|
|
34
|
+
on: vi.fn(),
|
|
35
|
+
off: vi.fn(),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('driver attribution is a declared capability, not an optional call (ARCH-012)', () => {
|
|
40
|
+
it('TYPE-LEVEL: a host that omits the attribution members does not satisfy the contract', () => {
|
|
41
|
+
// Judged by `pnpm typecheck`, not by this assertion — `@ts-expect-error` fails the BUILD when the
|
|
42
|
+
// error it expects does not occur.
|
|
43
|
+
//
|
|
44
|
+
// `Omit` rather than a hand-written object literal, deliberately. A literal missing the three
|
|
45
|
+
// members is also missing the forty required ones, so `@ts-expect-error` would be satisfied by
|
|
46
|
+
// the wrong error and the case would pass whether or not the optionality was ever removed. That
|
|
47
|
+
// is precisely the accidental-green shape; the first draft of this test had it. This type carries
|
|
48
|
+
// every other member and differs ONLY in the three.
|
|
49
|
+
type OmitsAttribution = Omit<
|
|
50
|
+
IInteractiveSession,
|
|
51
|
+
'getActiveDriverId' | 'getPendingCount' | 'isInitialized'
|
|
52
|
+
>;
|
|
53
|
+
const host = {} as OmitsAttribution;
|
|
54
|
+
// @ts-expect-error ARCH-012: the three are REQUIRED. If this ever compiles again the optionality
|
|
55
|
+
// is back, and `undefined` means both "no active driver" and "cannot answer" once more.
|
|
56
|
+
const asSession: IInteractiveSession = host;
|
|
57
|
+
expect(asSession).toBeDefined();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('stamps the active driver onto a turn-authored event', () => {
|
|
61
|
+
const send = vi.fn();
|
|
62
|
+
const session = hostWithAttribution('driver-7');
|
|
63
|
+
subscribeSessionEvents(session, createOutboundDelivery(send, vi.fn()));
|
|
64
|
+
|
|
65
|
+
const handler = vi.mocked(session.on).mock.calls.find(([name]) => name === 'user_message')?.[1];
|
|
66
|
+
(handler as (content: string) => void)('hello');
|
|
67
|
+
|
|
68
|
+
expect(send).toHaveBeenCalledWith(expect.objectContaining({ driverId: 'driver-7' }));
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('omits the driver when the host attributes turns and none is active', () => {
|
|
72
|
+
// `null` now means exactly one thing: nobody is driving. It used to be indistinguishable from
|
|
73
|
+
// "this host cannot answer the question".
|
|
74
|
+
const send = vi.fn();
|
|
75
|
+
const session = hostWithAttribution(null);
|
|
76
|
+
subscribeSessionEvents(session, createOutboundDelivery(send, vi.fn()));
|
|
77
|
+
|
|
78
|
+
const handler = vi.mocked(session.on).mock.calls.find(([name]) => name === 'user_message')?.[1];
|
|
79
|
+
(handler as (content: string) => void)('hello');
|
|
80
|
+
|
|
81
|
+
expect(send).toHaveBeenCalledWith(expect.not.objectContaining({ driverId: expect.anything() }));
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
chunkCountFor,
|
|
5
|
+
chunkHandoffPayload,
|
|
6
|
+
HandoffChunkAssembler,
|
|
7
|
+
type IHandoffChunk,
|
|
8
|
+
} from '../handoff-chunking.js';
|
|
9
|
+
import { sealHandoffRecord, verifyHandoffPayload } from '../node/handoff-manifest.js';
|
|
10
|
+
|
|
11
|
+
import type { IInteractiveSessionRecord } from '@robota-sdk/agent-interface-session';
|
|
12
|
+
|
|
13
|
+
const ID = 'handoff_1';
|
|
14
|
+
|
|
15
|
+
function assemble(chunks: readonly IHandoffChunk[], assembler = new HandoffChunkAssembler(ID)) {
|
|
16
|
+
let last;
|
|
17
|
+
for (const chunk of chunks) last = assembler.accept(chunk);
|
|
18
|
+
return { assembler, last };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('HANDOFF-001 — a payload larger than one frame travels in pieces', () => {
|
|
22
|
+
it('every chunk fits the byte budget', () => {
|
|
23
|
+
// The channel's limit is in bytes, so the budget has to be honoured in bytes. A payload of
|
|
24
|
+
// multi-byte characters is where a character-based cut overflows the frame.
|
|
25
|
+
const payload = '한글'.repeat(500);
|
|
26
|
+
|
|
27
|
+
const chunks = chunkHandoffPayload(ID, payload, 64);
|
|
28
|
+
|
|
29
|
+
for (const chunk of chunks) {
|
|
30
|
+
expect(Buffer.from(chunk.data, 'base64').byteLength).toBeLessThanOrEqual(64);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('round-trips a payload whose characters do not align to the cut', () => {
|
|
35
|
+
// The cut lands mid-character by construction here. Reassembly is on bytes, so the boundary
|
|
36
|
+
// is invisible to the result — which is the property the byte-based design buys.
|
|
37
|
+
const payload = `${'a'.repeat(30)}😀${'b'.repeat(30)}`;
|
|
38
|
+
|
|
39
|
+
const { last } = assemble(chunkHandoffPayload(ID, payload, 16));
|
|
40
|
+
|
|
41
|
+
expect(last?.outcome).toBe('complete');
|
|
42
|
+
expect(last?.serialized).toBe(payload);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('an empty payload is one chunk, not zero', () => {
|
|
46
|
+
// Zero chunks would leave a receiver unable to tell a completed empty transfer from one that
|
|
47
|
+
// never started.
|
|
48
|
+
const chunks = chunkHandoffPayload(ID, '', 64);
|
|
49
|
+
|
|
50
|
+
expect(chunks).toHaveLength(1);
|
|
51
|
+
expect(assemble(chunks).last?.serialized).toBe('');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('every chunk carries the total, so no single lost frame hides the count', () => {
|
|
55
|
+
const chunks = chunkHandoffPayload(ID, 'x'.repeat(100), 10);
|
|
56
|
+
|
|
57
|
+
expect(chunks.every((c) => c.total === chunks.length)).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('refuses a budget that would produce an endless sequence of empty chunks', () => {
|
|
61
|
+
expect(() => chunkHandoffPayload(ID, 'x', 0)).toThrow(/positive integer/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('predicts the chunk count from the manifest without building them', () => {
|
|
65
|
+
const { serialized, integrity } = sealHandoffRecord({
|
|
66
|
+
id: 's',
|
|
67
|
+
cwd: '/w',
|
|
68
|
+
createdAt: 'a',
|
|
69
|
+
updatedAt: 'b',
|
|
70
|
+
messages: [],
|
|
71
|
+
} as IInteractiveSessionRecord);
|
|
72
|
+
|
|
73
|
+
expect(chunkCountFor(integrity, 16)).toBe(chunkHandoffPayload(ID, serialized, 16).length);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('HANDOFF-001 — reordering and retries are normal; inconsistency is not', () => {
|
|
78
|
+
it('accepts chunks out of order', () => {
|
|
79
|
+
const chunks = [...chunkHandoffPayload(ID, 'x'.repeat(100), 10)].reverse();
|
|
80
|
+
|
|
81
|
+
const { last } = assemble(chunks);
|
|
82
|
+
|
|
83
|
+
expect(last?.outcome).toBe('complete');
|
|
84
|
+
expect(last?.serialized).toBe('x'.repeat(100));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('a re-sent chunk is a duplicate, not a problem', () => {
|
|
88
|
+
const chunks = chunkHandoffPayload(ID, 'x'.repeat(100), 10);
|
|
89
|
+
const assembler = new HandoffChunkAssembler(ID);
|
|
90
|
+
assembler.accept(chunks[0]);
|
|
91
|
+
|
|
92
|
+
const again = assembler.accept(chunks[0]);
|
|
93
|
+
|
|
94
|
+
expect(again.outcome).toBe('duplicate');
|
|
95
|
+
expect(again.received).toBe(1);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('different bytes for the same index is refused, not overwritten', () => {
|
|
99
|
+
const chunks = chunkHandoffPayload(ID, 'x'.repeat(100), 10);
|
|
100
|
+
const assembler = new HandoffChunkAssembler(ID);
|
|
101
|
+
assembler.accept(chunks[0]);
|
|
102
|
+
|
|
103
|
+
const conflicting = assembler.accept({
|
|
104
|
+
...chunks[0],
|
|
105
|
+
data: Buffer.from('zz').toString('base64'),
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
expect(conflicting.outcome).toBe('refused');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('a peer that changes its mind about the size is refused', () => {
|
|
112
|
+
// Either it restarted the transfer under the same id, or it is not the peer we started with.
|
|
113
|
+
// Both mean the pieces held so far can no longer be assumed to belong together.
|
|
114
|
+
const chunks = chunkHandoffPayload(ID, 'x'.repeat(100), 10);
|
|
115
|
+
const assembler = new HandoffChunkAssembler(ID);
|
|
116
|
+
assembler.accept(chunks[0]);
|
|
117
|
+
|
|
118
|
+
const result = assembler.accept({ ...chunks[1], total: 3 });
|
|
119
|
+
|
|
120
|
+
expect(result.rejection).toBe('inconsistent-total');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('refuses a chunk belonging to a different transfer rather than routing it', () => {
|
|
124
|
+
const [chunk] = chunkHandoffPayload('other_handoff', 'x', 10);
|
|
125
|
+
|
|
126
|
+
expect(new HandoffChunkAssembler(ID).accept(chunk).rejection).toBe('wrong-handoff');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it.each([
|
|
130
|
+
['a negative index', -1],
|
|
131
|
+
['an index beyond the total', 99],
|
|
132
|
+
])('refuses %s', (_label, index) => {
|
|
133
|
+
const [chunk] = chunkHandoffPayload(ID, 'x'.repeat(30), 10);
|
|
134
|
+
|
|
135
|
+
expect(new HandoffChunkAssembler(ID).accept({ ...chunk, index }).rejection).toBe(
|
|
136
|
+
'out-of-range',
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('reports which indices are still missing, so a caller can ask rather than restart', () => {
|
|
141
|
+
const chunks = chunkHandoffPayload(ID, 'x'.repeat(100), 10);
|
|
142
|
+
const assembler = new HandoffChunkAssembler(ID);
|
|
143
|
+
assembler.accept(chunks[0]);
|
|
144
|
+
assembler.accept(chunks[3]);
|
|
145
|
+
|
|
146
|
+
expect(assembler.missing()).toEqual([1, 2, 4, 5, 6, 7, 8, 9]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('knows nothing is missing before anything has declared a total', () => {
|
|
150
|
+
expect(new HandoffChunkAssembler(ID).missing()).toEqual([]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('discard drops what it held', () => {
|
|
154
|
+
const chunks = chunkHandoffPayload(ID, 'x'.repeat(100), 10);
|
|
155
|
+
const assembler = new HandoffChunkAssembler(ID);
|
|
156
|
+
assembler.accept(chunks[0]);
|
|
157
|
+
|
|
158
|
+
assembler.discard();
|
|
159
|
+
|
|
160
|
+
expect(assembler.received).toBe(0);
|
|
161
|
+
expect(assembler.expected).toBeUndefined();
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe('HANDOFF-001 TC-06 — a mangled chunk is caught where it arrives', () => {
|
|
166
|
+
it('refuses data that is not decodable base64', () => {
|
|
167
|
+
// Buffer.from is lenient: it drops what it cannot parse rather than throwing, so a mangled
|
|
168
|
+
// chunk would silently become a shorter one and corrupt the payload in a way only the final
|
|
169
|
+
// digest catches — after the whole transfer completed.
|
|
170
|
+
const [chunk] = chunkHandoffPayload(ID, 'x'.repeat(30), 10);
|
|
171
|
+
|
|
172
|
+
const result = new HandoffChunkAssembler(ID).accept({ ...chunk, data: 'not!valid!base64!' });
|
|
173
|
+
|
|
174
|
+
expect(result.rejection).toBe('undecodable');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('a corrupt-but-decodable chunk survives here and is caught by the digest', () => {
|
|
178
|
+
// The two checks are deliberately different questions. This one asks "did the pieces arrive";
|
|
179
|
+
// the digest asks "are they the pieces that were sent". Asking the second one per chunk would
|
|
180
|
+
// report each piece intact while the set is not the set that was sent.
|
|
181
|
+
const record = {
|
|
182
|
+
id: 's',
|
|
183
|
+
cwd: '/w',
|
|
184
|
+
createdAt: 'a',
|
|
185
|
+
updatedAt: 'b',
|
|
186
|
+
messages: [],
|
|
187
|
+
} as IInteractiveSessionRecord;
|
|
188
|
+
const { serialized, integrity } = sealHandoffRecord(record);
|
|
189
|
+
const chunks = chunkHandoffPayload(ID, serialized, 16);
|
|
190
|
+
const swapped = chunks.map((c, i) =>
|
|
191
|
+
i === 0 ? { ...c, data: Buffer.from('0'.repeat(16)).toString('base64') } : c,
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const { last } = assemble(swapped);
|
|
195
|
+
|
|
196
|
+
expect(last?.outcome).toBe('complete');
|
|
197
|
+
expect(verifyHandoffPayload(last?.serialized ?? '', integrity).intact).toBe(false);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('the whole path holds: seal, chunk, reassemble, verify', () => {
|
|
201
|
+
const record = {
|
|
202
|
+
id: 'session_1',
|
|
203
|
+
cwd: '/home/alice/project',
|
|
204
|
+
createdAt: '2026-08-17T00:00:00.000Z',
|
|
205
|
+
updatedAt: '2026-08-17T01:00:00.000Z',
|
|
206
|
+
messages: [{ role: 'user', content: '안녕하세요 😀' }],
|
|
207
|
+
} as unknown as IInteractiveSessionRecord;
|
|
208
|
+
const { serialized, integrity } = sealHandoffRecord(record);
|
|
209
|
+
|
|
210
|
+
const { last } = assemble(chunkHandoffPayload(ID, serialized, 24));
|
|
211
|
+
|
|
212
|
+
expect(verifyHandoffPayload(last?.serialized ?? '', integrity).intact).toBe(true);
|
|
213
|
+
expect(JSON.parse(last?.serialized ?? '{}')).toEqual(record);
|
|
214
|
+
});
|
|
215
|
+
});
|