mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
Merge pull request #7344 from earendil-works/feat/protocol-package
feat(protocol): add remote session wire protocol
This commit is contained in:
commit
c066d01a40
25 changed files with 2047 additions and 39 deletions
19
package-lock.json
generated
19
package-lock.json
generated
|
|
@ -798,6 +798,10 @@
|
|||
"resolved": "packages/evals",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@earendil-works/pi-protocol": {
|
||||
"resolved": "packages/protocol",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@earendil-works/pi-server": {
|
||||
"resolved": "packages/server",
|
||||
"link": true
|
||||
|
|
@ -5669,6 +5673,21 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@earendil-works/pi-protocol",
|
||||
"version": "0.83.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"typebox": "1.3.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"shx": "0.4.0",
|
||||
"vitest": "4.1.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
}
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@earendil-works/pi-server",
|
||||
"version": "0.83.0",
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@
|
|||
],
|
||||
"scripts": {
|
||||
"clean": "npm run clean --workspaces",
|
||||
"build": "cd packages/tui && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../storage/sqlite-node && npm run build && cd ../../coding-agent && npm run build && cd ../server && npm run build",
|
||||
"build:offline": "cd packages/tui && npm run build && cd ../ai && npm run build:offline && cd ../agent && npm run build && cd ../storage/sqlite-node && npm run build && cd ../../coding-agent && npm run build && cd ../server && npm run build",
|
||||
"build": "cd packages/tui && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../storage/sqlite-node && npm run build && cd ../../protocol && npm run build && cd ../coding-agent && npm run build && cd ../server && npm run build",
|
||||
"build:offline": "cd packages/tui && npm run build && cd ../ai && npm run build:offline && cd ../agent && npm run build && cd ../storage/sqlite-node && npm run build && cd ../../protocol && npm run build && cd ../coding-agent && npm run build && cd ../server && npm run build",
|
||||
"check": "biome check --write --error-on-warnings . && npm run check:pinned-deps && npm run check:ts-imports && npm run check:shrinkwrap && npm run check:install-lock:coding-agent && tsgo --noEmit && npm run check:browser-smoke",
|
||||
"check:browser-smoke": "node scripts/check-browser-smoke.mjs",
|
||||
"check:pinned-deps": "node scripts/check-pinned-deps.mjs",
|
||||
|
|
|
|||
|
|
@ -137,6 +137,8 @@ const SUBSCRIBER_EVENT_TYPE = "*";
|
|||
|
||||
type AgentHarnessHandler = (event: any, signal?: AbortSignal) => Promise<any> | any;
|
||||
|
||||
type TrackedTaskKind = "operation" | "mutation";
|
||||
|
||||
function normalizeHarnessError(error: unknown, fallbackCode: AgentHarnessError["code"]): AgentHarnessError {
|
||||
if (error instanceof AgentHarnessError) return error;
|
||||
const cause = toError(error);
|
||||
|
|
@ -178,7 +180,7 @@ export class AgentHarness<
|
|||
readonly models: Models;
|
||||
private phase: AgentHarnessPhase = "idle";
|
||||
private activeAbortController?: AbortController;
|
||||
private activeOperationPromise?: Promise<void>;
|
||||
private readonly activeTasks = new Map<Promise<void>, TrackedTaskKind>();
|
||||
private shutdownPromise?: Promise<void>;
|
||||
private isShutdown = false;
|
||||
private pendingSessionWrites: PendingSessionWrite[] = [];
|
||||
|
|
@ -336,19 +338,46 @@ export class AgentHarness<
|
|||
const abortController = new AbortController();
|
||||
let finish = () => {};
|
||||
this.activeAbortController = abortController;
|
||||
this.activeOperationPromise = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
void this.track(
|
||||
"operation",
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
return {
|
||||
signal: abortController.signal,
|
||||
finish: () => {
|
||||
this.activeAbortController = undefined;
|
||||
this.activeOperationPromise = undefined;
|
||||
finish();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async track<T>(kind: TrackedTaskKind, operation: () => Promise<T>): Promise<T> {
|
||||
let settle = () => {};
|
||||
const settled = new Promise<void>((resolve) => {
|
||||
settle = resolve;
|
||||
});
|
||||
this.activeTasks.set(settled, kind);
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
this.activeTasks.delete(settled);
|
||||
settle();
|
||||
}
|
||||
}
|
||||
|
||||
private async waitForTasks(kind?: TrackedTaskKind): Promise<void> {
|
||||
while (true) {
|
||||
const tasks = [...this.activeTasks].flatMap(([task, taskKind]) =>
|
||||
kind === undefined || kind === taskKind ? [task] : [],
|
||||
);
|
||||
if (tasks.length === 0) return;
|
||||
await Promise.all(tasks);
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveToolContext(): Promise<TContext> {
|
||||
if (typeof this.toolContext === "function") {
|
||||
return await (this.toolContext as () => TContext | Promise<TContext>)();
|
||||
|
|
@ -738,15 +767,17 @@ export class AgentHarness<
|
|||
|
||||
async appendMessage(message: AgentMessage): Promise<void> {
|
||||
this.assertNotShutDown();
|
||||
try {
|
||||
if (this.phase === "idle") {
|
||||
await this.session.appendMessage(message);
|
||||
} else {
|
||||
this.pendingSessionWrites.push({ type: "message", message });
|
||||
return this.track("mutation", async () => {
|
||||
try {
|
||||
if (this.phase === "idle") {
|
||||
await this.session.appendMessage(message);
|
||||
} else {
|
||||
this.pendingSessionWrites.push({ type: "message", message });
|
||||
}
|
||||
} catch (error) {
|
||||
throw normalizeHarnessError(error, "session");
|
||||
}
|
||||
} catch (error) {
|
||||
throw normalizeHarnessError(error, "session");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async compact(customInstructions?: string): Promise<CompactResult> {
|
||||
|
|
@ -914,18 +945,20 @@ export class AgentHarness<
|
|||
|
||||
async setModel(model: Model<any>): Promise<void> {
|
||||
this.assertNotShutDown();
|
||||
try {
|
||||
const previousModel = this.model;
|
||||
if (this.phase === "idle") {
|
||||
await this.session.appendModelChange(model.provider, model.id);
|
||||
} else {
|
||||
this.pendingSessionWrites.push({ type: "model_change", provider: model.provider, modelId: model.id });
|
||||
return this.track("mutation", async () => {
|
||||
try {
|
||||
const previousModel = this.model;
|
||||
if (this.phase === "idle") {
|
||||
await this.session.appendModelChange(model.provider, model.id);
|
||||
} else {
|
||||
this.pendingSessionWrites.push({ type: "model_change", provider: model.provider, modelId: model.id });
|
||||
}
|
||||
this.model = model;
|
||||
await this.emitOwn({ type: "model_update", model, previousModel, source: "set" });
|
||||
} catch (error) {
|
||||
throw normalizeHarnessError(error, "session");
|
||||
}
|
||||
this.model = model;
|
||||
await this.emitOwn({ type: "model_update", model, previousModel, source: "set" });
|
||||
} catch (error) {
|
||||
throw normalizeHarnessError(error, "session");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getThinkingLevel(): ThinkingLevel {
|
||||
|
|
@ -934,18 +967,20 @@ export class AgentHarness<
|
|||
|
||||
async setThinkingLevel(level: ThinkingLevel): Promise<void> {
|
||||
this.assertNotShutDown();
|
||||
try {
|
||||
const previousLevel = this.thinkingLevel;
|
||||
if (this.phase === "idle") {
|
||||
await this.session.appendThinkingLevelChange(level);
|
||||
} else {
|
||||
this.pendingSessionWrites.push({ type: "thinking_level_change", thinkingLevel: level });
|
||||
return this.track("mutation", async () => {
|
||||
try {
|
||||
const previousLevel = this.thinkingLevel;
|
||||
if (this.phase === "idle") {
|
||||
await this.session.appendThinkingLevelChange(level);
|
||||
} else {
|
||||
this.pendingSessionWrites.push({ type: "thinking_level_change", thinkingLevel: level });
|
||||
}
|
||||
this.thinkingLevel = level;
|
||||
await this.emitOwn({ type: "thinking_level_update", level, previousLevel });
|
||||
} catch (error) {
|
||||
throw normalizeHarnessError(error, "session");
|
||||
}
|
||||
this.thinkingLevel = level;
|
||||
await this.emitOwn({ type: "thinking_level_update", level, previousLevel });
|
||||
} catch (error) {
|
||||
throw normalizeHarnessError(error, "session");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getTools(): TTool[] {
|
||||
|
|
@ -954,6 +989,10 @@ export class AgentHarness<
|
|||
|
||||
async setTools(tools: TTool[], activeToolNames?: string[]): Promise<void> {
|
||||
this.assertNotShutDown();
|
||||
return this.track("mutation", () => this.applyTools(tools, activeToolNames));
|
||||
}
|
||||
|
||||
private async applyTools(tools: TTool[], activeToolNames?: string[]): Promise<void> {
|
||||
try {
|
||||
this.validateUniqueNames(
|
||||
tools.map((tool) => tool.name),
|
||||
|
|
@ -990,6 +1029,10 @@ export class AgentHarness<
|
|||
|
||||
async setActiveTools(toolNames: string[]): Promise<void> {
|
||||
this.assertNotShutDown();
|
||||
return this.track("mutation", () => this.applyActiveTools(toolNames));
|
||||
}
|
||||
|
||||
private async applyActiveTools(toolNames: string[]): Promise<void> {
|
||||
try {
|
||||
this.validateToolNames(toolNames);
|
||||
const previousToolNames = [...this.tools.keys()];
|
||||
|
|
@ -1069,7 +1112,7 @@ export class AgentHarness<
|
|||
this.followUpQueue = [];
|
||||
this.nextTurnQueue = [];
|
||||
this.activeAbortController?.abort();
|
||||
this.shutdownPromise = this.waitForIdle();
|
||||
this.shutdownPromise = this.waitForTasks();
|
||||
return this.shutdownPromise;
|
||||
}
|
||||
|
||||
|
|
@ -1104,7 +1147,7 @@ export class AgentHarness<
|
|||
}
|
||||
|
||||
async waitForIdle(): Promise<void> {
|
||||
await this.activeOperationPromise;
|
||||
await this.waitForTasks("operation");
|
||||
}
|
||||
|
||||
subscribe(
|
||||
|
|
|
|||
|
|
@ -89,6 +89,25 @@ function createAssistantMessage(text: string): AgentMessage {
|
|||
};
|
||||
}
|
||||
|
||||
class BlockingSessionStorage extends InMemorySessionStorage {
|
||||
readonly allWritesStarted = deferred();
|
||||
readonly releaseWrites = deferred();
|
||||
private readonly expectedWrites: number;
|
||||
private writesStarted = 0;
|
||||
|
||||
constructor(expectedWrites: number) {
|
||||
super();
|
||||
this.expectedWrites = expectedWrites;
|
||||
}
|
||||
|
||||
override async appendEntry(entry: Parameters<InMemorySessionStorage["appendEntry"]>[0]): Promise<void> {
|
||||
this.writesStarted++;
|
||||
if (this.writesStarted === this.expectedWrites) this.allWritesStarted.resolve();
|
||||
await this.releaseWrites.promise;
|
||||
await super.appendEntry(entry);
|
||||
}
|
||||
}
|
||||
|
||||
describe("AgentHarness", () => {
|
||||
it("constructs directly and exposes queue modes", () => {
|
||||
const session = new Session(new InMemorySessionStorage());
|
||||
|
|
@ -265,6 +284,53 @@ describe("AgentHarness", () => {
|
|||
expect(await session.getLeafId()).toBe(originalLeafId);
|
||||
});
|
||||
|
||||
it("does not treat concurrent mutations as active operations", async () => {
|
||||
const storage = new BlockingSessionStorage(1);
|
||||
const harness = new AgentHarness({
|
||||
models,
|
||||
session: new Session(storage),
|
||||
model: getModel("anthropic", "claude-sonnet-4-5"),
|
||||
});
|
||||
const mutation = harness.appendMessage(createUserMessage("concurrent"));
|
||||
await storage.allWritesStarted.promise;
|
||||
|
||||
const firstSettlement = await Promise.race([
|
||||
harness.waitForIdle().then(() => "idle" as const),
|
||||
new Promise<"mutation-pending">((resolve) => setImmediate(() => resolve("mutation-pending"))),
|
||||
]);
|
||||
|
||||
expect(firstSettlement).toBe("idle");
|
||||
storage.releaseWrites.resolve();
|
||||
await mutation;
|
||||
});
|
||||
|
||||
it("awaits concurrent idle session mutations before shutdown resolves", async () => {
|
||||
const storage = new BlockingSessionStorage(3);
|
||||
const harness = new AgentHarness({
|
||||
models,
|
||||
session: new Session(storage),
|
||||
model: getModel("anthropic", "claude-sonnet-4-5"),
|
||||
});
|
||||
const nextModel = getModel("anthropic", "claude-haiku-4-5");
|
||||
const mutations = [
|
||||
harness.appendMessage(createUserMessage("concurrent")),
|
||||
harness.setModel(nextModel),
|
||||
harness.setThinkingLevel("high"),
|
||||
];
|
||||
await storage.allWritesStarted.promise;
|
||||
|
||||
const shutdown = harness.shutdown();
|
||||
const firstSettlement = await Promise.race([
|
||||
shutdown.then(() => "shutdown" as const),
|
||||
new Promise<"writes-pending">((resolve) => setImmediate(() => resolve("writes-pending"))),
|
||||
]);
|
||||
|
||||
expect(firstSettlement).toBe("writes-pending");
|
||||
storage.releaseWrites.resolve();
|
||||
await Promise.all([...mutations, shutdown]);
|
||||
expect(await storage.getEntries()).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("shuts down an idle harness without modifying its durable session", async () => {
|
||||
const session = new Session(new InMemorySessionStorage());
|
||||
await session.appendMessage(createUserMessage("existing"));
|
||||
|
|
|
|||
7
packages/protocol/CHANGELOG.md
Normal file
7
packages/protocol/CHANGELOG.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Added transport-neutral CBOR protocol schemas, codecs, and length-prefixed framing for remote pi sessions.
|
||||
68
packages/protocol/README.md
Normal file
68
packages/protocol/README.md
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
# @earendil-works/pi-protocol
|
||||
|
||||
Runtime-neutral schemas, types, CBOR encoding, and byte-stream framing for the experimental pi protocol.
|
||||
|
||||
Protocol version `2` uses binary messages with this wire layout:
|
||||
|
||||
1. A four-byte unsigned big-endian payload length.
|
||||
2. One definite-length CBOR item containing the message.
|
||||
|
||||
The first client message is always `hello`, containing `PROTOCOL_VERSION` and a bearer token. Subsequent messages use correlated request/response envelopes and server event envelopes. Session and server snapshots are authoritative. Progress events are transient UI hints and must not be reduced into authoritative state.
|
||||
|
||||
## Validated message API
|
||||
|
||||
`encodeClientMessage()` and `encodeServerMessage()` validate a message and return a complete framed `Uint8Array`. The incremental decoders accept arbitrary fragmentation or coalescing, so they work with streams, sockets, and custom byte transports.
|
||||
|
||||
```ts
|
||||
import {
|
||||
PROTOCOL_VERSION,
|
||||
createServerMessageDecoder,
|
||||
encodeClientMessage,
|
||||
type ClientHello,
|
||||
} from "@earendil-works/pi-protocol";
|
||||
|
||||
const hello: ClientHello = {
|
||||
type: "hello",
|
||||
version: PROTOCOL_VERSION,
|
||||
token: bearerToken,
|
||||
};
|
||||
|
||||
transport.send(encodeClientMessage(hello));
|
||||
|
||||
const decoder = createServerMessageDecoder({ maxFrameLength: 1024 * 1024 });
|
||||
for (const message of decoder.push(incomingChunk)) {
|
||||
handleServerMessage(message);
|
||||
}
|
||||
decoder.end(); // Call when the byte stream closes to detect truncation.
|
||||
```
|
||||
|
||||
`ClientMessageDecoder` and `ServerMessageDecoder` are also available directly. Schema violations, malformed CBOR, and invalid framing throw `ProtocolValidationError`. Validation errors do not retain rejected payloads.
|
||||
|
||||
`parseClientMessage()` and `parseServerMessage()` only validate already-decoded values. They do not parse JSON strings.
|
||||
|
||||
## Transport support
|
||||
|
||||
Every transport carries the same complete bytes: `[uint32-be CBOR length][CBOR payload]`. Transports may split or coalesce those bytes arbitrarily.
|
||||
|
||||
This package does not bundle a transport. Consumers provide a byte-stream transport that preserves byte order and reports stream closure. Custom transports must handle arbitrary frame fragmentation and coalescing.
|
||||
|
||||
All transports are untrusted. Configure matching frame limits and enforce the authentication and access controls appropriate for the transport.
|
||||
|
||||
## Encoding and framing
|
||||
|
||||
`encodeCbor()` and `decodeCbor()` implement the protocol's strict RFC 8949 subset. `encodeFrame()` and `FrameDecoder` handle framing independently of schemas and CBOR.
|
||||
|
||||
The CBOR subset supports:
|
||||
|
||||
- `null` and booleans
|
||||
- finite numbers, with integers restricted to JavaScript's safe range and non-integers encoded as float64
|
||||
- UTF-8 strings
|
||||
- `Uint8Array` byte strings
|
||||
- definite-length arrays
|
||||
- definite-length maps represented by objects with unique string keys
|
||||
|
||||
Undefined object properties are omitted. JSON-valued protocol fields reject CBOR byte strings and non-plain objects. Top-level undefined, undefined array entries, sparse arrays, non-finite or unsafe numbers, tags, indefinite-length items, malformed UTF-8, trailing data, excessive nesting, and oversized values are rejected.
|
||||
|
||||
Default limits are 16 MiB per CBOR payload/frame, 1,000,000 array elements or map entries, and 64 nested item levels. Options can configure these limits. A frame decoder validates the declared length before buffering payload bytes.
|
||||
|
||||
All schemas reject unknown object properties. The protocol is experimental and has no compatibility guarantees.
|
||||
32
packages/protocol/package.json
Normal file
32
packages/protocol/package.json
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"name": "@earendil-works/pi-protocol",
|
||||
"version": "0.83.0",
|
||||
"description": "Transport-neutral CBOR protocol for remote pi sessions",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": ["dist", "README.md"],
|
||||
"scripts": {
|
||||
"clean": "shx rm -rf dist",
|
||||
"build": "tsgo -p tsconfig.build.json",
|
||||
"test": "vitest --run",
|
||||
"prepublishOnly": "npm run clean && npm run build"
|
||||
},
|
||||
"keywords": ["pi", "protocol", "cbor", "binary", "framing"],
|
||||
"author": "Earendil Works",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/earendil-works/pi.git",
|
||||
"directory": "packages/protocol"
|
||||
},
|
||||
"engines": { "node": ">=22.19.0" },
|
||||
"dependencies": { "typebox": "1.3.7" },
|
||||
"devDependencies": { "shx": "0.4.0", "vitest": "4.1.9" }
|
||||
}
|
||||
168
packages/protocol/src/cbor/decoder.ts
Normal file
168
packages/protocol/src/cbor/decoder.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import {
|
||||
CborError,
|
||||
type CborOptions,
|
||||
type ResolvedCborOptions,
|
||||
resolveOptions,
|
||||
textDecoder,
|
||||
UINT32_BASE,
|
||||
} from "./options.ts";
|
||||
|
||||
class CborReader {
|
||||
private readonly bytes: Uint8Array;
|
||||
private offset = 0;
|
||||
private readonly options: ResolvedCborOptions;
|
||||
|
||||
constructor(bytes: Uint8Array, options: ResolvedCborOptions) {
|
||||
this.bytes = bytes;
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
decode(): unknown {
|
||||
const value = this.readItem(0);
|
||||
if (this.offset !== this.bytes.byteLength) throw new CborError("CBOR payload contains trailing data");
|
||||
return value;
|
||||
}
|
||||
|
||||
private readItem(depth: number): unknown {
|
||||
if (depth > this.options.maxDepth) {
|
||||
throw new CborError(`CBOR nesting depth exceeds configured limit of ${this.options.maxDepth}`);
|
||||
}
|
||||
const initial = this.readByte();
|
||||
const majorType = initial >>> 5;
|
||||
const additionalInformation = initial & 0x1f;
|
||||
|
||||
switch (majorType) {
|
||||
case 0:
|
||||
return this.readArgument(additionalInformation);
|
||||
case 1: {
|
||||
const value = -1 - this.readArgument(additionalInformation);
|
||||
if (!Number.isSafeInteger(value)) throw new CborError("Decoded CBOR integer is outside the safe range");
|
||||
return value;
|
||||
}
|
||||
case 2: {
|
||||
const length = this.readLength(additionalInformation, "byte string", this.options.maxByteLength);
|
||||
return new Uint8Array(this.readBytes(length));
|
||||
}
|
||||
case 3: {
|
||||
const length = this.readLength(additionalInformation, "text string", this.options.maxByteLength);
|
||||
const bytes = this.readBytes(length);
|
||||
try {
|
||||
return textDecoder.decode(bytes);
|
||||
} catch (_error) {
|
||||
throw new CborError("CBOR text string contains invalid UTF-8");
|
||||
}
|
||||
}
|
||||
case 4: {
|
||||
const length = this.readLength(additionalInformation, "array", this.options.maxContainerLength);
|
||||
const result: unknown[] = [];
|
||||
for (let index = 0; index < length; index++) result.push(this.readItem(depth + 1));
|
||||
return result;
|
||||
}
|
||||
case 5: {
|
||||
const length = this.readLength(additionalInformation, "map", this.options.maxContainerLength);
|
||||
const result: Record<string, unknown> = {};
|
||||
const keys = new Set<string>();
|
||||
for (let index = 0; index < length; index++) {
|
||||
const key = this.readItem(depth + 1);
|
||||
if (typeof key !== "string") throw new CborError("CBOR map keys must be strings");
|
||||
if (keys.has(key)) throw new CborError("CBOR map contains a duplicate key");
|
||||
keys.add(key);
|
||||
Object.defineProperty(result, key, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: this.readItem(depth + 1),
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
case 6:
|
||||
throw new CborError("CBOR tags are not supported");
|
||||
case 7:
|
||||
return this.readSimple(additionalInformation);
|
||||
default:
|
||||
throw new CborError("Malformed CBOR major type");
|
||||
}
|
||||
}
|
||||
|
||||
private readSimple(additionalInformation: number): unknown {
|
||||
switch (additionalInformation) {
|
||||
case 20:
|
||||
return false;
|
||||
case 21:
|
||||
return true;
|
||||
case 22:
|
||||
return null;
|
||||
case 27: {
|
||||
const bytes = this.readBytes(8);
|
||||
const value = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getFloat64(0, false);
|
||||
if (!Number.isFinite(value)) throw new CborError("Decoded CBOR number must be finite");
|
||||
if (Number.isInteger(value) && !Number.isSafeInteger(value)) {
|
||||
throw new CborError("Decoded CBOR integer is outside the safe range");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
case 31:
|
||||
throw new CborError("CBOR break marker is not supported");
|
||||
default:
|
||||
throw new CborError("Unsupported CBOR simple value or floating-point width");
|
||||
}
|
||||
}
|
||||
|
||||
private readLength(additionalInformation: number, kind: string, limit: number): number {
|
||||
if (additionalInformation === 31) throw new CborError(`Indefinite-length CBOR ${kind}s are not supported`);
|
||||
const length = this.readArgument(additionalInformation);
|
||||
if (length > limit) throw new CborError(`CBOR ${kind} length exceeds configured limit of ${limit}`);
|
||||
return length;
|
||||
}
|
||||
|
||||
private readArgument(additionalInformation: number): number {
|
||||
if (additionalInformation < 24) return additionalInformation;
|
||||
switch (additionalInformation) {
|
||||
case 24:
|
||||
return this.readByte();
|
||||
case 25: {
|
||||
const bytes = this.readBytes(2);
|
||||
return bytes[0]! * 0x100 + bytes[1]!;
|
||||
}
|
||||
case 26: {
|
||||
const bytes = this.readBytes(4);
|
||||
return bytes[0]! * 0x1_000_000 + bytes[1]! * 0x1_0000 + bytes[2]! * 0x100 + bytes[3]!;
|
||||
}
|
||||
case 27: {
|
||||
const high = this.readArgument(26);
|
||||
const low = this.readArgument(26);
|
||||
if (high > 0x1f_ffff) throw new CborError("Decoded CBOR integer or length is outside the safe range");
|
||||
return high * UINT32_BASE + low;
|
||||
}
|
||||
case 31:
|
||||
throw new CborError("Indefinite-length CBOR items are not supported");
|
||||
default:
|
||||
throw new CborError("Malformed CBOR additional information");
|
||||
}
|
||||
}
|
||||
|
||||
private readByte(): number {
|
||||
if (this.offset >= this.bytes.byteLength) throw new CborError("Truncated CBOR payload");
|
||||
const value = this.bytes[this.offset]!;
|
||||
this.offset++;
|
||||
return value;
|
||||
}
|
||||
|
||||
private readBytes(length: number): Uint8Array {
|
||||
if (length > this.bytes.byteLength - this.offset) throw new CborError("Truncated CBOR payload");
|
||||
const value = this.bytes.subarray(this.offset, this.offset + length);
|
||||
this.offset += length;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Decodes exactly one item from the protocol's strict RFC 8949 subset. */
|
||||
export function decodeCbor(bytes: Uint8Array, options?: CborOptions): unknown {
|
||||
if (!(bytes instanceof Uint8Array)) throw new TypeError("CBOR input must be a Uint8Array");
|
||||
const resolved = resolveOptions(options);
|
||||
if (bytes.byteLength > resolved.maxByteLength) {
|
||||
throw new CborError(`CBOR byte length exceeds configured limit of ${resolved.maxByteLength}`);
|
||||
}
|
||||
return new CborReader(bytes, resolved).decode();
|
||||
}
|
||||
216
packages/protocol/src/cbor/encoder.ts
Normal file
216
packages/protocol/src/cbor/encoder.ts
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
import {
|
||||
CborError,
|
||||
type CborOptions,
|
||||
MAX_UINT32,
|
||||
type ResolvedCborOptions,
|
||||
resolveOptions,
|
||||
textDecoder,
|
||||
textEncoder,
|
||||
UINT32_BASE,
|
||||
} from "./options.ts";
|
||||
|
||||
class CborWriter {
|
||||
private buffer: Uint8Array;
|
||||
private offset = 0;
|
||||
private readonly maxByteLength: number;
|
||||
|
||||
constructor(maxByteLength: number) {
|
||||
this.maxByteLength = maxByteLength;
|
||||
this.buffer = new Uint8Array(Math.min(256, maxByteLength));
|
||||
}
|
||||
|
||||
writeByte(value: number): void {
|
||||
this.ensureCapacity(1);
|
||||
this.buffer[this.offset] = value;
|
||||
this.offset++;
|
||||
}
|
||||
|
||||
writeBytes(bytes: Uint8Array): void {
|
||||
this.ensureCapacity(bytes.byteLength);
|
||||
this.buffer.set(bytes, this.offset);
|
||||
this.offset += bytes.byteLength;
|
||||
}
|
||||
|
||||
writeUint16(value: number): void {
|
||||
this.ensureCapacity(2);
|
||||
this.buffer[this.offset] = value >>> 8;
|
||||
this.buffer[this.offset + 1] = value;
|
||||
this.offset += 2;
|
||||
}
|
||||
|
||||
writeUint32(value: number): void {
|
||||
this.ensureCapacity(4);
|
||||
this.buffer[this.offset] = value >>> 24;
|
||||
this.buffer[this.offset + 1] = value >>> 16;
|
||||
this.buffer[this.offset + 2] = value >>> 8;
|
||||
this.buffer[this.offset + 3] = value;
|
||||
this.offset += 4;
|
||||
}
|
||||
|
||||
writeUint64(value: number): void {
|
||||
const high = Math.floor(value / UINT32_BASE);
|
||||
const low = value - high * UINT32_BASE;
|
||||
this.writeUint32(high);
|
||||
this.writeUint32(low);
|
||||
}
|
||||
|
||||
writeFloat64(value: number): void {
|
||||
this.ensureCapacity(9);
|
||||
this.buffer[this.offset] = 0xfb;
|
||||
new DataView(this.buffer.buffer).setFloat64(this.offset + 1, value, false);
|
||||
this.offset += 9;
|
||||
}
|
||||
|
||||
finish(): Uint8Array {
|
||||
return this.buffer.slice(0, this.offset);
|
||||
}
|
||||
|
||||
private ensureCapacity(additionalBytes: number): void {
|
||||
const required = this.offset + additionalBytes;
|
||||
if (required > this.maxByteLength) {
|
||||
throw new CborError(`CBOR byte length exceeds configured limit of ${this.maxByteLength}`);
|
||||
}
|
||||
if (required <= this.buffer.byteLength) return;
|
||||
|
||||
let capacity = Math.max(1, this.buffer.byteLength);
|
||||
while (capacity < required) capacity = Math.min(this.maxByteLength, Math.max(required, capacity * 2));
|
||||
const expanded = new Uint8Array(capacity);
|
||||
expanded.set(this.buffer);
|
||||
this.buffer = expanded;
|
||||
}
|
||||
}
|
||||
|
||||
function writeArgument(writer: CborWriter, majorType: number, value: number): void {
|
||||
const prefix = majorType << 5;
|
||||
if (value < 24) {
|
||||
writer.writeByte(prefix | value);
|
||||
} else if (value <= 0xff) {
|
||||
writer.writeByte(prefix | 24);
|
||||
writer.writeByte(value);
|
||||
} else if (value <= 0xffff) {
|
||||
writer.writeByte(prefix | 25);
|
||||
writer.writeUint16(value);
|
||||
} else if (value <= MAX_UINT32) {
|
||||
writer.writeByte(prefix | 26);
|
||||
writer.writeUint32(value);
|
||||
} else {
|
||||
writer.writeByte(prefix | 27);
|
||||
writer.writeUint64(value);
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
|
||||
function encodeText(writer: CborWriter, value: string, options: ResolvedCborOptions): void {
|
||||
const bytes = textEncoder.encode(value);
|
||||
if (bytes.byteLength > options.maxByteLength) {
|
||||
throw new CborError(`CBOR text string length exceeds configured limit of ${options.maxByteLength}`);
|
||||
}
|
||||
if (textDecoder.decode(bytes) !== value)
|
||||
throw new CborError("CBOR text strings must contain valid Unicode scalar values");
|
||||
writeArgument(writer, 3, bytes.byteLength);
|
||||
writer.writeBytes(bytes);
|
||||
}
|
||||
|
||||
function encodeValue(
|
||||
writer: CborWriter,
|
||||
value: unknown,
|
||||
options: ResolvedCborOptions,
|
||||
depth: number,
|
||||
ancestors: Set<object>,
|
||||
): void {
|
||||
if (depth > options.maxDepth)
|
||||
throw new CborError(`CBOR nesting depth exceeds configured limit of ${options.maxDepth}`);
|
||||
|
||||
if (value === null) {
|
||||
writer.writeByte(0xf6);
|
||||
return;
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
writer.writeByte(value ? 0xf5 : 0xf4);
|
||||
return;
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
if (!Number.isFinite(value)) throw new CborError("CBOR numbers must be finite");
|
||||
if (Number.isInteger(value) && !Object.is(value, -0)) {
|
||||
if (!Number.isSafeInteger(value)) throw new CborError("CBOR integers must be safe JavaScript integers");
|
||||
if (value >= 0) writeArgument(writer, 0, value);
|
||||
else writeArgument(writer, 1, -1 - value);
|
||||
} else {
|
||||
writer.writeFloat64(value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
encodeText(writer, value, options);
|
||||
return;
|
||||
}
|
||||
if (value instanceof Uint8Array) {
|
||||
if (value.byteLength > options.maxByteLength) {
|
||||
throw new CborError(`CBOR byte string length exceeds configured limit of ${options.maxByteLength}`);
|
||||
}
|
||||
writeArgument(writer, 2, value.byteLength);
|
||||
writer.writeBytes(value);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (ancestors.has(value)) throw new CborError("CBOR values must not contain cycles");
|
||||
if (value.length > options.maxContainerLength) {
|
||||
throw new CborError(`CBOR array length exceeds configured limit of ${options.maxContainerLength}`);
|
||||
}
|
||||
ancestors.add(value);
|
||||
try {
|
||||
writeArgument(writer, 4, value.length);
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
if (!Object.hasOwn(value, index) || value[index] === undefined) {
|
||||
throw new CborError("CBOR arrays must not contain holes or undefined values");
|
||||
}
|
||||
encodeValue(writer, value[index], options, depth + 1, ancestors);
|
||||
}
|
||||
} finally {
|
||||
ancestors.delete(value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isPlainObject(value)) {
|
||||
if (ancestors.has(value)) throw new CborError("CBOR values must not contain cycles");
|
||||
for (const symbol of Object.getOwnPropertySymbols(value)) {
|
||||
if (Object.prototype.propertyIsEnumerable.call(value, symbol)) {
|
||||
throw new CborError("CBOR map keys must be strings");
|
||||
}
|
||||
}
|
||||
const entries: Array<readonly [string, unknown]> = [];
|
||||
for (const key of Object.keys(value)) {
|
||||
const entryValue = value[key];
|
||||
if (entryValue !== undefined) entries.push([key, entryValue]);
|
||||
}
|
||||
if (entries.length > options.maxContainerLength) {
|
||||
throw new CborError(`CBOR map length exceeds configured limit of ${options.maxContainerLength}`);
|
||||
}
|
||||
ancestors.add(value);
|
||||
try {
|
||||
writeArgument(writer, 5, entries.length);
|
||||
for (const [key, entryValue] of entries) {
|
||||
encodeText(writer, key, options);
|
||||
encodeValue(writer, entryValue, options, depth + 1, ancestors);
|
||||
}
|
||||
} finally {
|
||||
ancestors.delete(value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
throw new CborError(`Unsupported CBOR value type: ${typeof value}`);
|
||||
}
|
||||
|
||||
/** Encodes the protocol's strict, definite-length RFC 8949 subset. */
|
||||
export function encodeCbor(value: unknown, options?: CborOptions): Uint8Array {
|
||||
const resolved = resolveOptions(options);
|
||||
const writer = new CborWriter(resolved.maxByteLength);
|
||||
encodeValue(writer, value, resolved, 0, new Set<object>());
|
||||
return writer.finish();
|
||||
}
|
||||
9
packages/protocol/src/cbor/index.ts
Normal file
9
packages/protocol/src/cbor/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export { decodeCbor } from "./decoder.ts";
|
||||
export { encodeCbor } from "./encoder.ts";
|
||||
export {
|
||||
CborError,
|
||||
type CborOptions,
|
||||
DEFAULT_MAX_CBOR_BYTE_LENGTH,
|
||||
DEFAULT_MAX_CBOR_CONTAINER_LENGTH,
|
||||
DEFAULT_MAX_CBOR_DEPTH,
|
||||
} from "./options.ts";
|
||||
52
packages/protocol/src/cbor/options.ts
Normal file
52
packages/protocol/src/cbor/options.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
export const UINT32_BASE = 0x1_0000_0000;
|
||||
export const MAX_UINT32 = 0xffff_ffff;
|
||||
const MAX_CONFIGURED_DEPTH = 512;
|
||||
|
||||
/** Safe defaults for untrusted protocol payloads. */
|
||||
export const DEFAULT_MAX_CBOR_BYTE_LENGTH = 16 * 1024 * 1024;
|
||||
export const DEFAULT_MAX_CBOR_CONTAINER_LENGTH = 1_000_000;
|
||||
export const DEFAULT_MAX_CBOR_DEPTH = 64;
|
||||
|
||||
export interface CborOptions {
|
||||
/** Maximum encoded input/output bytes and maximum byte/text string length. */
|
||||
maxByteLength?: number;
|
||||
/** Maximum number of elements in an array or entries in a map. */
|
||||
maxContainerLength?: number;
|
||||
/** Maximum recursive item depth. */
|
||||
maxDepth?: number;
|
||||
}
|
||||
|
||||
export interface ResolvedCborOptions {
|
||||
maxByteLength: number;
|
||||
maxContainerLength: number;
|
||||
maxDepth: number;
|
||||
}
|
||||
|
||||
export class CborError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "CborError";
|
||||
}
|
||||
}
|
||||
|
||||
export const textEncoder = new TextEncoder();
|
||||
export const textDecoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
||||
|
||||
function resolveLimit(name: string, value: number, maximum: number): number {
|
||||
if (!Number.isSafeInteger(value) || value < 0 || value > maximum) {
|
||||
throw new RangeError(`${name} must be an integer between 0 and ${maximum}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function resolveOptions(options: CborOptions | undefined): ResolvedCborOptions {
|
||||
return {
|
||||
maxByteLength: resolveLimit("maxByteLength", options?.maxByteLength ?? DEFAULT_MAX_CBOR_BYTE_LENGTH, MAX_UINT32),
|
||||
maxContainerLength: resolveLimit(
|
||||
"maxContainerLength",
|
||||
options?.maxContainerLength ?? DEFAULT_MAX_CBOR_CONTAINER_LENGTH,
|
||||
MAX_UINT32,
|
||||
),
|
||||
maxDepth: resolveLimit("maxDepth", options?.maxDepth ?? DEFAULT_MAX_CBOR_DEPTH, MAX_CONFIGURED_DEPTH),
|
||||
};
|
||||
}
|
||||
172
packages/protocol/src/codec.ts
Normal file
172
packages/protocol/src/codec.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import { Check } from "typebox/value";
|
||||
import { decodeCbor, encodeCbor } from "./cbor/index.ts";
|
||||
import {
|
||||
assertCompleteFrame,
|
||||
DEFAULT_MAX_FRAME_LENGTH,
|
||||
encodeFrame,
|
||||
FrameDecoder,
|
||||
type FrameDecoderOptions,
|
||||
} from "./framing.ts";
|
||||
import {
|
||||
type ClientMessage,
|
||||
ClientMessageSchema,
|
||||
PROTOCOL_VERSION,
|
||||
type ServerMessage,
|
||||
ServerMessageSchema,
|
||||
} from "./schemas.ts";
|
||||
|
||||
export class ProtocolValidationError extends Error {
|
||||
constructor(message: string, _value?: unknown) {
|
||||
super(message);
|
||||
this.name = "ProtocolValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
function isProtocolValue(value: unknown, optionalProperty = false, ancestors = new Set<object>()): boolean {
|
||||
if (value === undefined) return optionalProperty;
|
||||
if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
|
||||
return true;
|
||||
}
|
||||
if (typeof value !== "object" || ancestors.has(value)) return false;
|
||||
ancestors.add(value);
|
||||
try {
|
||||
if (Array.isArray(value)) return value.every((item) => isProtocolValue(item, false, ancestors));
|
||||
if (Object.getPrototypeOf(value) !== Object.prototype) return false;
|
||||
return Object.values(value).every((item) => isProtocolValue(item, true, ancestors));
|
||||
} finally {
|
||||
ancestors.delete(value);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseClientMessage(value: unknown): ClientMessage {
|
||||
if (!isProtocolValue(value) || !Check(ClientMessageSchema, value)) {
|
||||
throw new ProtocolValidationError("Invalid client protocol message");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseServerMessage(value: unknown): ServerMessage {
|
||||
if (!isProtocolValue(value) || !Check(ServerMessageSchema, value)) {
|
||||
throw new ProtocolValidationError("Invalid server protocol message");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function boundedErrorMessage(error: unknown): string {
|
||||
if (!(error instanceof Error)) return "Unknown codec error";
|
||||
return error.message.length <= 500 ? error.message : `${error.message.slice(0, 497)}...`;
|
||||
}
|
||||
|
||||
function encodeProtocolMessage<T>(
|
||||
value: T,
|
||||
parse: (candidate: unknown) => T,
|
||||
kind: string,
|
||||
options?: FrameDecoderOptions,
|
||||
): Uint8Array {
|
||||
const validated = parse(value);
|
||||
try {
|
||||
const maxFrameLength = options?.maxFrameLength ?? DEFAULT_MAX_FRAME_LENGTH;
|
||||
const frame = encodeFrame(encodeCbor(validated, { maxByteLength: maxFrameLength }));
|
||||
assertCompleteFrame(frame, { maxFrameLength });
|
||||
return frame;
|
||||
} catch (error) {
|
||||
if (error instanceof ProtocolValidationError) throw error;
|
||||
throw new ProtocolValidationError(`Unable to encode ${kind} protocol message: ${boundedErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Validates and encodes one complete length-prefixed client message. */
|
||||
export function encodeClientMessage(message: ClientMessage, options?: FrameDecoderOptions): Uint8Array {
|
||||
return encodeProtocolMessage(message, parseClientMessage, "client", options);
|
||||
}
|
||||
|
||||
/** Validates and encodes one complete length-prefixed server message. */
|
||||
export function encodeServerMessage(message: ServerMessage, options?: FrameDecoderOptions): Uint8Array {
|
||||
return encodeProtocolMessage(message, parseServerMessage, "server", options);
|
||||
}
|
||||
|
||||
class ValidatedMessageDecoder<T> {
|
||||
private failed = false;
|
||||
private readonly frames: FrameDecoder;
|
||||
private readonly kind: string;
|
||||
private readonly maxFrameLength: number;
|
||||
private readonly parse: (candidate: unknown) => T;
|
||||
|
||||
constructor(kind: string, parse: (candidate: unknown) => T, options?: FrameDecoderOptions) {
|
||||
this.frames = new FrameDecoder(options);
|
||||
this.kind = kind;
|
||||
this.maxFrameLength = options?.maxFrameLength ?? DEFAULT_MAX_FRAME_LENGTH;
|
||||
this.parse = parse;
|
||||
}
|
||||
|
||||
push(chunk: Uint8Array): T[] {
|
||||
if (this.failed) throw new ProtocolValidationError(`${this.kind} message decoder has failed`);
|
||||
try {
|
||||
const messages: T[] = [];
|
||||
for (const frame of this.frames.push(chunk)) {
|
||||
messages.push(this.parse(decodeCbor(frame, { maxByteLength: this.maxFrameLength })));
|
||||
}
|
||||
return messages;
|
||||
} catch (error) {
|
||||
this.failed = true;
|
||||
if (error instanceof ProtocolValidationError) throw error;
|
||||
throw new ProtocolValidationError(`Invalid ${this.kind} protocol frame: ${boundedErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
end(): void {
|
||||
if (this.failed) throw new ProtocolValidationError(`${this.kind} message decoder has failed`);
|
||||
try {
|
||||
this.frames.end();
|
||||
} catch (error) {
|
||||
this.failed = true;
|
||||
throw new ProtocolValidationError(`Invalid ${this.kind} protocol framing: ${boundedErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Incrementally decodes and validates framed client messages. */
|
||||
export class ClientMessageDecoder {
|
||||
private readonly decoder: ValidatedMessageDecoder<ClientMessage>;
|
||||
|
||||
constructor(options?: FrameDecoderOptions) {
|
||||
this.decoder = new ValidatedMessageDecoder("client", parseClientMessage, options);
|
||||
}
|
||||
|
||||
push(chunk: Uint8Array): ClientMessage[] {
|
||||
return this.decoder.push(chunk);
|
||||
}
|
||||
|
||||
end(): void {
|
||||
this.decoder.end();
|
||||
}
|
||||
}
|
||||
|
||||
/** Incrementally decodes and validates framed server messages. */
|
||||
export class ServerMessageDecoder {
|
||||
private readonly decoder: ValidatedMessageDecoder<ServerMessage>;
|
||||
|
||||
constructor(options?: FrameDecoderOptions) {
|
||||
this.decoder = new ValidatedMessageDecoder("server", parseServerMessage, options);
|
||||
}
|
||||
|
||||
push(chunk: Uint8Array): ServerMessage[] {
|
||||
return this.decoder.push(chunk);
|
||||
}
|
||||
|
||||
end(): void {
|
||||
this.decoder.end();
|
||||
}
|
||||
}
|
||||
|
||||
export function createClientMessageDecoder(options?: FrameDecoderOptions): ClientMessageDecoder {
|
||||
return new ClientMessageDecoder(options);
|
||||
}
|
||||
|
||||
export function createServerMessageDecoder(options?: FrameDecoderOptions): ServerMessageDecoder {
|
||||
return new ServerMessageDecoder(options);
|
||||
}
|
||||
|
||||
export function isSupportedProtocolVersion(version: number): version is typeof PROTOCOL_VERSION {
|
||||
return Number.isInteger(version) && version === PROTOCOL_VERSION;
|
||||
}
|
||||
165
packages/protocol/src/framing.ts
Normal file
165
packages/protocol/src/framing.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
const FRAME_HEADER_LENGTH = 4;
|
||||
const MAX_UINT32 = 0xffff_ffff;
|
||||
const PAYLOAD_BLOCK_SIZE = 64 * 1024;
|
||||
|
||||
/** Default upper bound for one framed CBOR payload. */
|
||||
export const DEFAULT_MAX_FRAME_LENGTH = 16 * 1024 * 1024;
|
||||
|
||||
export interface FrameDecoderOptions {
|
||||
maxFrameLength?: number;
|
||||
}
|
||||
|
||||
export class FrameError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "FrameError";
|
||||
}
|
||||
}
|
||||
|
||||
function resolveMaxFrameLength(options: FrameDecoderOptions | undefined): number {
|
||||
const value = options?.maxFrameLength ?? DEFAULT_MAX_FRAME_LENGTH;
|
||||
if (!Number.isSafeInteger(value) || value < 0 || value > MAX_UINT32) {
|
||||
throw new RangeError(`maxFrameLength must be an integer between 0 and ${MAX_UINT32}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Prefixes a payload with its unsigned 32-bit big-endian byte length. */
|
||||
export function encodeFrame(payload: Uint8Array): Uint8Array {
|
||||
if (!(payload instanceof Uint8Array)) throw new TypeError("Frame payload must be a Uint8Array");
|
||||
if (payload.byteLength > MAX_UINT32) throw new RangeError("Frame payload exceeds the unsigned 32-bit length limit");
|
||||
const frame = new Uint8Array(FRAME_HEADER_LENGTH + payload.byteLength);
|
||||
const length = payload.byteLength;
|
||||
frame[0] = length >>> 24;
|
||||
frame[1] = length >>> 16;
|
||||
frame[2] = length >>> 8;
|
||||
frame[3] = length;
|
||||
frame.set(payload, FRAME_HEADER_LENGTH);
|
||||
return frame;
|
||||
}
|
||||
|
||||
/** Validates that bytes contain exactly one complete frame within the configured limit. */
|
||||
export function assertCompleteFrame(frame: Uint8Array, options?: FrameDecoderOptions): void {
|
||||
if (!(frame instanceof Uint8Array)) throw new TypeError("Frame must be a Uint8Array");
|
||||
if (frame.byteLength < FRAME_HEADER_LENGTH) throw new FrameError("Frame does not contain a complete length prefix");
|
||||
const length = frame[0]! * 0x1_000_000 + frame[1]! * 0x1_0000 + frame[2]! * 0x100 + frame[3]!;
|
||||
const maxFrameLength = resolveMaxFrameLength(options);
|
||||
if (length > maxFrameLength) {
|
||||
throw new FrameError(`Frame length ${length} exceeds configured limit of ${maxFrameLength}`);
|
||||
}
|
||||
if (frame.byteLength !== FRAME_HEADER_LENGTH + length) {
|
||||
throw new FrameError("Frame must contain exactly one complete payload");
|
||||
}
|
||||
}
|
||||
|
||||
type DecoderState = "open" | "ended" | "failed";
|
||||
|
||||
/** Incrementally splits arbitrary byte chunks into length-prefixed payloads. */
|
||||
export class FrameDecoder {
|
||||
private readonly header = new Uint8Array(FRAME_HEADER_LENGTH);
|
||||
private headerLength = 0;
|
||||
private readonly maxFrameLength: number;
|
||||
private payloadBlocks: Uint8Array[] = [];
|
||||
private currentPayloadBlock: Uint8Array | undefined;
|
||||
private currentPayloadBlockLength = 0;
|
||||
private expectedPayloadLength: number | undefined;
|
||||
private payloadLength = 0;
|
||||
private state: DecoderState = "open";
|
||||
|
||||
constructor(options?: FrameDecoderOptions) {
|
||||
this.maxFrameLength = resolveMaxFrameLength(options);
|
||||
}
|
||||
|
||||
push(chunk: Uint8Array): Uint8Array[] {
|
||||
if (this.state === "ended") throw new FrameError("Frame decoder has ended");
|
||||
if (this.state === "failed") throw new FrameError("Frame decoder has failed");
|
||||
if (!(chunk instanceof Uint8Array)) throw new TypeError("Frame chunk must be a Uint8Array");
|
||||
|
||||
const frames: Uint8Array[] = [];
|
||||
let chunkOffset = 0;
|
||||
while (chunkOffset < chunk.byteLength) {
|
||||
if (this.expectedPayloadLength === undefined) {
|
||||
const headerBytes = Math.min(FRAME_HEADER_LENGTH - this.headerLength, chunk.byteLength - chunkOffset);
|
||||
this.header.set(chunk.subarray(chunkOffset, chunkOffset + headerBytes), this.headerLength);
|
||||
this.headerLength += headerBytes;
|
||||
chunkOffset += headerBytes;
|
||||
if (this.headerLength < FRAME_HEADER_LENGTH) continue;
|
||||
|
||||
const frameLength =
|
||||
this.header[0]! * 0x1_000_000 + this.header[1]! * 0x1_0000 + this.header[2]! * 0x100 + this.header[3]!;
|
||||
this.headerLength = 0;
|
||||
if (frameLength > this.maxFrameLength) {
|
||||
this.fail(`Frame length ${frameLength} exceeds configured limit of ${this.maxFrameLength}`);
|
||||
}
|
||||
if (frameLength === 0) {
|
||||
frames.push(new Uint8Array());
|
||||
continue;
|
||||
}
|
||||
this.expectedPayloadLength = frameLength;
|
||||
this.payloadBlocks = [];
|
||||
this.currentPayloadBlock = undefined;
|
||||
this.currentPayloadBlockLength = 0;
|
||||
this.payloadLength = 0;
|
||||
}
|
||||
|
||||
const expectedPayloadLength = this.expectedPayloadLength;
|
||||
if (expectedPayloadLength === undefined) continue;
|
||||
while (chunkOffset < chunk.byteLength && this.payloadLength < expectedPayloadLength) {
|
||||
let block = this.currentPayloadBlock;
|
||||
if (!block || this.currentPayloadBlockLength === block.byteLength) {
|
||||
block = new Uint8Array(Math.min(PAYLOAD_BLOCK_SIZE, expectedPayloadLength - this.payloadLength));
|
||||
this.payloadBlocks.push(block);
|
||||
this.currentPayloadBlock = block;
|
||||
this.currentPayloadBlockLength = 0;
|
||||
}
|
||||
const payloadBytes = Math.min(
|
||||
block.byteLength - this.currentPayloadBlockLength,
|
||||
chunk.byteLength - chunkOffset,
|
||||
);
|
||||
block.set(chunk.subarray(chunkOffset, chunkOffset + payloadBytes), this.currentPayloadBlockLength);
|
||||
this.currentPayloadBlockLength += payloadBytes;
|
||||
this.payloadLength += payloadBytes;
|
||||
chunkOffset += payloadBytes;
|
||||
}
|
||||
if (this.payloadLength === expectedPayloadLength) {
|
||||
if (this.payloadBlocks.length === 1) {
|
||||
frames.push(this.payloadBlocks[0]!);
|
||||
} else {
|
||||
const payload = new Uint8Array(expectedPayloadLength);
|
||||
let offset = 0;
|
||||
for (const payloadBlock of this.payloadBlocks) {
|
||||
payload.set(payloadBlock, offset);
|
||||
offset += payloadBlock.byteLength;
|
||||
}
|
||||
frames.push(payload);
|
||||
}
|
||||
this.payloadBlocks = [];
|
||||
this.currentPayloadBlock = undefined;
|
||||
this.currentPayloadBlockLength = 0;
|
||||
this.expectedPayloadLength = undefined;
|
||||
this.payloadLength = 0;
|
||||
}
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
end(): void {
|
||||
if (this.state === "ended") throw new FrameError("Frame decoder has ended");
|
||||
if (this.state === "failed") throw new FrameError("Frame decoder has failed");
|
||||
if (this.headerLength !== 0 || this.expectedPayloadLength !== undefined) {
|
||||
this.fail("Truncated frame at end of stream");
|
||||
}
|
||||
this.state = "ended";
|
||||
}
|
||||
|
||||
private fail(message: string): never {
|
||||
this.state = "failed";
|
||||
this.headerLength = 0;
|
||||
this.payloadBlocks = [];
|
||||
this.currentPayloadBlock = undefined;
|
||||
this.currentPayloadBlockLength = 0;
|
||||
this.expectedPayloadLength = undefined;
|
||||
this.payloadLength = 0;
|
||||
throw new FrameError(message);
|
||||
}
|
||||
}
|
||||
4
packages/protocol/src/index.ts
Normal file
4
packages/protocol/src/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from "./cbor/index.ts";
|
||||
export * from "./codec.ts";
|
||||
export * from "./framing.ts";
|
||||
export * from "./schemas.ts";
|
||||
412
packages/protocol/src/schemas.ts
Normal file
412
packages/protocol/src/schemas.ts
Normal file
|
|
@ -0,0 +1,412 @@
|
|||
import Type, { type Static } from "typebox";
|
||||
|
||||
export const PROTOCOL_VERSION = 2 as const;
|
||||
|
||||
const IdSchema = Type.String({ minLength: 1 });
|
||||
const TimestampSchema = Type.Integer({ minimum: 0 });
|
||||
const StrictObject = <const T extends Parameters<typeof Type.Object>[0]>(properties: T) =>
|
||||
Type.Object(properties, { additionalProperties: false });
|
||||
|
||||
export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
|
||||
const JsonValueRecursiveSchema = Type.Cyclic(
|
||||
{
|
||||
JsonValue: Type.Union([
|
||||
Type.Null(),
|
||||
Type.Boolean(),
|
||||
Type.Number(),
|
||||
Type.String(),
|
||||
Type.Array(Type.Ref("JsonValue")),
|
||||
Type.Record(Type.String(), Type.Ref("JsonValue")),
|
||||
]),
|
||||
},
|
||||
"JsonValue",
|
||||
);
|
||||
export const JsonValueSchema = Type.Unsafe<JsonValue>(JsonValueRecursiveSchema);
|
||||
|
||||
export const ThinkingLevelSchema = Type.Union([
|
||||
Type.Literal("off"),
|
||||
Type.Literal("minimal"),
|
||||
Type.Literal("low"),
|
||||
Type.Literal("medium"),
|
||||
Type.Literal("high"),
|
||||
Type.Literal("xhigh"),
|
||||
Type.Literal("max"),
|
||||
]);
|
||||
export type ThinkingLevel = Static<typeof ThinkingLevelSchema>;
|
||||
|
||||
/** Matches AgentHarnessPhase so adapters do not need a second phase vocabulary. */
|
||||
export const SessionPhaseSchema = Type.Union([
|
||||
Type.Literal("idle"),
|
||||
Type.Literal("turn"),
|
||||
Type.Literal("compaction"),
|
||||
Type.Literal("branch_summary"),
|
||||
Type.Literal("retry"),
|
||||
]);
|
||||
export type SessionPhase = Static<typeof SessionPhaseSchema>;
|
||||
|
||||
export const ModelRefSchema = StrictObject({
|
||||
provider: IdSchema,
|
||||
id: IdSchema,
|
||||
});
|
||||
export type ModelRef = Static<typeof ModelRefSchema>;
|
||||
|
||||
export const ModelCostSchema = StrictObject({
|
||||
input: Type.Number({ minimum: 0 }),
|
||||
output: Type.Number({ minimum: 0 }),
|
||||
cacheRead: Type.Number({ minimum: 0 }),
|
||||
cacheWrite: Type.Number({ minimum: 0 }),
|
||||
});
|
||||
|
||||
export const ModelMetadataSchema = StrictObject({
|
||||
provider: IdSchema,
|
||||
id: IdSchema,
|
||||
name: Type.String({ minLength: 1 }),
|
||||
api: IdSchema,
|
||||
reasoning: Type.Boolean(),
|
||||
input: Type.Array(Type.Union([Type.Literal("text"), Type.Literal("image")])),
|
||||
contextWindow: Type.Integer({ minimum: 1 }),
|
||||
maxTokens: Type.Integer({ minimum: 1 }),
|
||||
cost: ModelCostSchema,
|
||||
supportedThinkingLevels: Type.Array(ThinkingLevelSchema, { minItems: 1 }),
|
||||
authenticated: Type.Boolean(),
|
||||
});
|
||||
export type ModelMetadata = Static<typeof ModelMetadataSchema>;
|
||||
|
||||
export const TextContentSchema = StrictObject({
|
||||
type: Type.Literal("text"),
|
||||
text: Type.String(),
|
||||
});
|
||||
export const ThinkingContentSchema = StrictObject({
|
||||
type: Type.Literal("thinking"),
|
||||
thinking: Type.String(),
|
||||
redacted: Type.Optional(Type.Boolean()),
|
||||
});
|
||||
export const ImageContentSchema = StrictObject({
|
||||
type: Type.Literal("image"),
|
||||
data: Type.String(),
|
||||
mimeType: Type.String({ minLength: 1 }),
|
||||
});
|
||||
export const ToolCallContentSchema = StrictObject({
|
||||
type: Type.Literal("tool_call"),
|
||||
toolCallId: IdSchema,
|
||||
toolName: IdSchema,
|
||||
input: JsonValueSchema,
|
||||
});
|
||||
export const UserContentSchema = Type.Union([TextContentSchema, ImageContentSchema]);
|
||||
export const AssistantContentSchema = Type.Union([TextContentSchema, ThinkingContentSchema, ToolCallContentSchema]);
|
||||
export const ToolContentSchema = Type.Union([TextContentSchema, ImageContentSchema]);
|
||||
export type TextContent = Static<typeof TextContentSchema>;
|
||||
export type ThinkingContent = Static<typeof ThinkingContentSchema>;
|
||||
export type ImageContent = Static<typeof ImageContentSchema>;
|
||||
export type ToolCallContent = Static<typeof ToolCallContentSchema>;
|
||||
|
||||
export const UsageSchema = StrictObject({
|
||||
input: Type.Integer({ minimum: 0 }),
|
||||
output: Type.Integer({ minimum: 0 }),
|
||||
cacheRead: Type.Integer({ minimum: 0 }),
|
||||
cacheWrite: Type.Integer({ minimum: 0 }),
|
||||
reasoning: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
totalTokens: Type.Integer({ minimum: 0 }),
|
||||
cost: StrictObject({
|
||||
input: Type.Number({ minimum: 0 }),
|
||||
output: Type.Number({ minimum: 0 }),
|
||||
cacheRead: Type.Number({ minimum: 0 }),
|
||||
cacheWrite: Type.Number({ minimum: 0 }),
|
||||
total: Type.Number({ minimum: 0 }),
|
||||
}),
|
||||
});
|
||||
export type Usage = Static<typeof UsageSchema>;
|
||||
|
||||
export const UserTranscriptItemSchema = StrictObject({
|
||||
id: IdSchema,
|
||||
role: Type.Literal("user"),
|
||||
content: Type.Array(UserContentSchema),
|
||||
timestamp: TimestampSchema,
|
||||
});
|
||||
export const AssistantTranscriptItemSchema = StrictObject({
|
||||
id: IdSchema,
|
||||
role: Type.Literal("assistant"),
|
||||
content: Type.Array(AssistantContentSchema),
|
||||
status: Type.Union([
|
||||
Type.Literal("streaming"),
|
||||
Type.Literal("complete"),
|
||||
Type.Literal("error"),
|
||||
Type.Literal("aborted"),
|
||||
]),
|
||||
model: ModelRefSchema,
|
||||
responseModel: Type.Optional(Type.String({ minLength: 1 })),
|
||||
usage: Type.Optional(UsageSchema),
|
||||
stopReason: Type.Optional(
|
||||
Type.Union([
|
||||
Type.Literal("stop"),
|
||||
Type.Literal("length"),
|
||||
Type.Literal("tool_use"),
|
||||
Type.Literal("error"),
|
||||
Type.Literal("aborted"),
|
||||
]),
|
||||
),
|
||||
errorMessage: Type.Optional(Type.String()),
|
||||
timestamp: TimestampSchema,
|
||||
});
|
||||
export const ToolTranscriptItemSchema = StrictObject({
|
||||
id: IdSchema,
|
||||
role: Type.Literal("tool"),
|
||||
toolCallId: IdSchema,
|
||||
toolName: IdSchema,
|
||||
input: JsonValueSchema,
|
||||
content: Type.Array(ToolContentSchema),
|
||||
details: Type.Optional(JsonValueSchema),
|
||||
status: Type.Union([Type.Literal("running"), Type.Literal("complete"), Type.Literal("error")]),
|
||||
isError: Type.Boolean(),
|
||||
usage: Type.Optional(UsageSchema),
|
||||
timestamp: TimestampSchema,
|
||||
});
|
||||
export const TranscriptItemSchema = Type.Union([
|
||||
UserTranscriptItemSchema,
|
||||
AssistantTranscriptItemSchema,
|
||||
ToolTranscriptItemSchema,
|
||||
]);
|
||||
export type UserTranscriptItem = Static<typeof UserTranscriptItemSchema>;
|
||||
export type AssistantTranscriptItem = Static<typeof AssistantTranscriptItemSchema>;
|
||||
export type ToolTranscriptItem = Static<typeof ToolTranscriptItemSchema>;
|
||||
export type TranscriptItem = Static<typeof TranscriptItemSchema>;
|
||||
|
||||
/** Normalized incremental activity. Snapshots remain authoritative. */
|
||||
export const TranscriptProgressSchema = Type.Union([
|
||||
StrictObject({
|
||||
type: Type.Literal("item_started"),
|
||||
item: TranscriptItemSchema,
|
||||
}),
|
||||
StrictObject({
|
||||
type: Type.Literal("assistant_delta"),
|
||||
messageId: IdSchema,
|
||||
contentIndex: Type.Integer({ minimum: 0 }),
|
||||
kind: Type.Union([Type.Literal("text"), Type.Literal("thinking"), Type.Literal("tool_call")]),
|
||||
delta: Type.String(),
|
||||
}),
|
||||
StrictObject({
|
||||
type: Type.Literal("item_updated"),
|
||||
item: Type.Union([AssistantTranscriptItemSchema, ToolTranscriptItemSchema]),
|
||||
}),
|
||||
StrictObject({
|
||||
type: Type.Literal("item_finished"),
|
||||
item: Type.Union([AssistantTranscriptItemSchema, ToolTranscriptItemSchema]),
|
||||
}),
|
||||
]);
|
||||
export type TranscriptProgress = Static<typeof TranscriptProgressSchema>;
|
||||
|
||||
const SessionSummaryProperties = {
|
||||
id: IdSchema,
|
||||
name: Type.Optional(Type.String()),
|
||||
cwd: Type.String({ minLength: 1 }),
|
||||
createdAt: TimestampSchema,
|
||||
updatedAt: TimestampSchema,
|
||||
phase: SessionPhaseSchema,
|
||||
model: ModelRefSchema,
|
||||
thinkingLevel: ThinkingLevelSchema,
|
||||
attached: Type.Boolean(),
|
||||
locked: Type.Boolean(),
|
||||
} as const;
|
||||
|
||||
export const SessionSummarySchema = StrictObject(SessionSummaryProperties);
|
||||
export const SessionSnapshotSchema = StrictObject({
|
||||
...SessionSummaryProperties,
|
||||
revision: Type.Integer({ minimum: 0 }),
|
||||
transcript: Type.Array(TranscriptItemSchema),
|
||||
queuedSteer: Type.Array(UserTranscriptItemSchema),
|
||||
queuedSteerCount: Type.Integer({ minimum: 0 }),
|
||||
});
|
||||
export type SessionSummary = Static<typeof SessionSummarySchema>;
|
||||
export type SessionSnapshot = Static<typeof SessionSnapshotSchema>;
|
||||
|
||||
export const ServerSnapshotSchema = StrictObject({
|
||||
serverId: IdSchema,
|
||||
protocolVersion: Type.Literal(PROTOCOL_VERSION),
|
||||
revision: Type.Integer({ minimum: 0 }),
|
||||
sessions: Type.Array(SessionSummarySchema),
|
||||
models: Type.Array(ModelMetadataSchema),
|
||||
});
|
||||
export type ServerSnapshot = Static<typeof ServerSnapshotSchema>;
|
||||
|
||||
export const ProtocolErrorCodeSchema = Type.Union([
|
||||
Type.Literal("auth"),
|
||||
Type.Literal("version"),
|
||||
Type.Literal("busy"),
|
||||
Type.Literal("session_locked"),
|
||||
Type.Literal("not_found"),
|
||||
Type.Literal("invalid_request"),
|
||||
]);
|
||||
export const ProtocolErrorSchema = StrictObject({
|
||||
code: ProtocolErrorCodeSchema,
|
||||
message: Type.String(),
|
||||
details: Type.Optional(JsonValueSchema),
|
||||
});
|
||||
export type ProtocolErrorCode = Static<typeof ProtocolErrorCodeSchema>;
|
||||
export type ProtocolError = Static<typeof ProtocolErrorSchema>;
|
||||
|
||||
const PromptPayloadProperties = {
|
||||
sessionId: IdSchema,
|
||||
text: Type.String(),
|
||||
} as const;
|
||||
|
||||
export const ListCommandSchema = StrictObject({ command: Type.Literal("list") });
|
||||
export const CreateCommandSchema = StrictObject({
|
||||
command: Type.Literal("create"),
|
||||
cwd: Type.Optional(Type.String({ minLength: 1 })),
|
||||
name: Type.Optional(Type.String()),
|
||||
model: Type.Optional(ModelRefSchema),
|
||||
thinkingLevel: Type.Optional(ThinkingLevelSchema),
|
||||
});
|
||||
export const AttachCommandSchema = StrictObject({ command: Type.Literal("attach"), sessionId: IdSchema });
|
||||
export const DetachCommandSchema = StrictObject({ command: Type.Literal("detach"), sessionId: IdSchema });
|
||||
export const PromptCommandSchema = StrictObject({ command: Type.Literal("prompt"), ...PromptPayloadProperties });
|
||||
export const SteerCommandSchema = StrictObject({ command: Type.Literal("steer"), ...PromptPayloadProperties });
|
||||
export const AbortCommandSchema = StrictObject({ command: Type.Literal("abort"), sessionId: IdSchema });
|
||||
export const SetModelCommandSchema = StrictObject({
|
||||
command: Type.Literal("set_model"),
|
||||
sessionId: IdSchema,
|
||||
model: ModelRefSchema,
|
||||
});
|
||||
export const SetThinkingCommandSchema = StrictObject({
|
||||
command: Type.Literal("set_thinking"),
|
||||
sessionId: IdSchema,
|
||||
thinkingLevel: ThinkingLevelSchema,
|
||||
});
|
||||
export const CommandSchema = Type.Union([
|
||||
ListCommandSchema,
|
||||
CreateCommandSchema,
|
||||
AttachCommandSchema,
|
||||
DetachCommandSchema,
|
||||
PromptCommandSchema,
|
||||
SteerCommandSchema,
|
||||
AbortCommandSchema,
|
||||
SetModelCommandSchema,
|
||||
SetThinkingCommandSchema,
|
||||
]);
|
||||
export type Command = Static<typeof CommandSchema>;
|
||||
export type CommandName = Command["command"];
|
||||
|
||||
export const CreateResultSchema = StrictObject({
|
||||
command: Type.Literal("create"),
|
||||
session: SessionSnapshotSchema,
|
||||
});
|
||||
export const AttachResultSchema = StrictObject({
|
||||
command: Type.Literal("attach"),
|
||||
session: SessionSnapshotSchema,
|
||||
});
|
||||
export const PromptResultSchema = StrictObject({
|
||||
command: Type.Literal("prompt"),
|
||||
session: SessionSnapshotSchema,
|
||||
});
|
||||
export const SteerResultSchema = StrictObject({
|
||||
command: Type.Literal("steer"),
|
||||
session: SessionSnapshotSchema,
|
||||
});
|
||||
export const AbortResultSchema = StrictObject({
|
||||
command: Type.Literal("abort"),
|
||||
session: SessionSnapshotSchema,
|
||||
});
|
||||
export const SetModelResultSchema = StrictObject({
|
||||
command: Type.Literal("set_model"),
|
||||
session: SessionSnapshotSchema,
|
||||
});
|
||||
export const SetThinkingResultSchema = StrictObject({
|
||||
command: Type.Literal("set_thinking"),
|
||||
session: SessionSnapshotSchema,
|
||||
});
|
||||
|
||||
export const ListResultSchema = StrictObject({
|
||||
command: Type.Literal("list"),
|
||||
sessions: Type.Array(SessionSummarySchema),
|
||||
});
|
||||
export const DetachResultSchema = StrictObject({
|
||||
command: Type.Literal("detach"),
|
||||
sessionId: IdSchema,
|
||||
});
|
||||
export const CommandResultSchema = Type.Union([
|
||||
ListResultSchema,
|
||||
CreateResultSchema,
|
||||
AttachResultSchema,
|
||||
DetachResultSchema,
|
||||
PromptResultSchema,
|
||||
SteerResultSchema,
|
||||
AbortResultSchema,
|
||||
SetModelResultSchema,
|
||||
SetThinkingResultSchema,
|
||||
]);
|
||||
export type CommandResult = Static<typeof CommandResultSchema>;
|
||||
|
||||
export type ResultForCommand<TCommand extends Command> = TCommand["command"] extends "list"
|
||||
? Static<typeof ListResultSchema>
|
||||
: TCommand["command"] extends "detach"
|
||||
? Static<typeof DetachResultSchema>
|
||||
: Extract<CommandResult, { command: TCommand["command"] }>;
|
||||
|
||||
/** Must be the first frame sent by a client. Version is intentionally an integer, not a coercible string. */
|
||||
export const ClientHelloSchema = StrictObject({
|
||||
type: Type.Literal("hello"),
|
||||
version: Type.Integer({ minimum: 0 }),
|
||||
token: Type.String({ minLength: 1 }),
|
||||
});
|
||||
export type ClientHello = Static<typeof ClientHelloSchema>;
|
||||
|
||||
export const RequestEnvelopeSchema = StrictObject({
|
||||
type: Type.Literal("request"),
|
||||
id: IdSchema,
|
||||
request: CommandSchema,
|
||||
});
|
||||
export type RequestEnvelope = Static<typeof RequestEnvelopeSchema>;
|
||||
export const ClientMessageSchema = Type.Union([ClientHelloSchema, RequestEnvelopeSchema]);
|
||||
export type ClientMessage = Static<typeof ClientMessageSchema>;
|
||||
|
||||
export const ServerEventSchema = Type.Union([
|
||||
StrictObject({ type: Type.Literal("server_snapshot"), snapshot: ServerSnapshotSchema }),
|
||||
StrictObject({ type: Type.Literal("session_snapshot"), snapshot: SessionSnapshotSchema }),
|
||||
StrictObject({
|
||||
type: Type.Literal("session_progress"),
|
||||
sessionId: IdSchema,
|
||||
progress: TranscriptProgressSchema,
|
||||
}),
|
||||
StrictObject({ type: Type.Literal("session_removed"), sessionId: IdSchema }),
|
||||
]);
|
||||
export type ServerEvent = Static<typeof ServerEventSchema>;
|
||||
|
||||
export const ServerHelloSchema = StrictObject({
|
||||
type: Type.Literal("hello"),
|
||||
version: Type.Literal(PROTOCOL_VERSION),
|
||||
connectionId: IdSchema,
|
||||
snapshot: ServerSnapshotSchema,
|
||||
});
|
||||
export const ServerHelloErrorSchema = StrictObject({
|
||||
type: Type.Literal("hello_error"),
|
||||
error: ProtocolErrorSchema,
|
||||
});
|
||||
export const ResponseEnvelopeSchema = Type.Union([
|
||||
StrictObject({
|
||||
type: Type.Literal("response"),
|
||||
id: IdSchema,
|
||||
ok: Type.Literal(true),
|
||||
result: CommandResultSchema,
|
||||
}),
|
||||
StrictObject({
|
||||
type: Type.Literal("response"),
|
||||
id: IdSchema,
|
||||
ok: Type.Literal(false),
|
||||
error: ProtocolErrorSchema,
|
||||
}),
|
||||
]);
|
||||
export const EventEnvelopeSchema = StrictObject({
|
||||
type: Type.Literal("event"),
|
||||
event: ServerEventSchema,
|
||||
});
|
||||
export const ServerMessageSchema = Type.Union([
|
||||
ServerHelloSchema,
|
||||
ServerHelloErrorSchema,
|
||||
ResponseEnvelopeSchema,
|
||||
EventEnvelopeSchema,
|
||||
]);
|
||||
export type ServerHello = Static<typeof ServerHelloSchema>;
|
||||
export type ServerHelloError = Static<typeof ServerHelloErrorSchema>;
|
||||
export type ResponseEnvelope = Static<typeof ResponseEnvelopeSchema>;
|
||||
export type EventEnvelope = Static<typeof EventEnvelopeSchema>;
|
||||
export type ServerMessage = Static<typeof ServerMessageSchema>;
|
||||
175
packages/protocol/test/cbor/cbor.test.ts
Normal file
175
packages/protocol/test/cbor/cbor.test.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
CborError,
|
||||
DEFAULT_MAX_CBOR_BYTE_LENGTH,
|
||||
DEFAULT_MAX_CBOR_CONTAINER_LENGTH,
|
||||
DEFAULT_MAX_CBOR_DEPTH,
|
||||
decodeCbor,
|
||||
encodeCbor,
|
||||
} from "../../src/index.ts";
|
||||
|
||||
function fromHex(hex: string): Uint8Array {
|
||||
if (hex.length % 2 !== 0) throw new Error("Hex fixture must contain whole bytes");
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let index = 0; index < bytes.length; index++)
|
||||
bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function toHex(bytes: Uint8Array): string {
|
||||
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
const knownVectors: ReadonlyArray<readonly [unknown, string]> = [
|
||||
[null, "f6"],
|
||||
[false, "f4"],
|
||||
[true, "f5"],
|
||||
[0, "00"],
|
||||
[1, "01"],
|
||||
[10, "0a"],
|
||||
[23, "17"],
|
||||
[24, "1818"],
|
||||
[25, "1819"],
|
||||
[100, "1864"],
|
||||
[1000, "1903e8"],
|
||||
[1_000_000, "1a000f4240"],
|
||||
[1_000_000_000_000, "1b000000e8d4a51000"],
|
||||
[Number.MAX_SAFE_INTEGER, "1b001fffffffffffff"],
|
||||
[-1, "20"],
|
||||
[-10, "29"],
|
||||
[-24, "37"],
|
||||
[-25, "3818"],
|
||||
[-100, "3863"],
|
||||
[-1000, "3903e7"],
|
||||
[-1_000_000, "3a000f423f"],
|
||||
[Number.MIN_SAFE_INTEGER, "3b001ffffffffffffe"],
|
||||
[1.1, "fb3ff199999999999a"],
|
||||
[-0, "fb8000000000000000"],
|
||||
[new Uint8Array([1, 2, 3, 4]), "4401020304"],
|
||||
["", "60"],
|
||||
["IETF", "6449455446"],
|
||||
["ü", "62c3bc"],
|
||||
["水", "63e6b0b4"],
|
||||
["𐅑", "64f0908591"],
|
||||
[[], "80"],
|
||||
[[1, 2, 3], "83010203"],
|
||||
[[1, [2, 3], [4, 5]], "8301820203820405"],
|
||||
[{ a: 1, b: [2, 3] }, "a26161016162820203"],
|
||||
];
|
||||
|
||||
describe("CBOR codec", () => {
|
||||
test.each(knownVectors)("encodes and decodes RFC 8949 vector %#", (value, wire) => {
|
||||
const encoded = encodeCbor(value);
|
||||
expect(toHex(encoded)).toBe(wire);
|
||||
const decoded = decodeCbor(fromHex(wire));
|
||||
if (typeof value === "number" && Object.is(value, -0)) expect(Object.is(decoded, -0)).toBe(true);
|
||||
else expect(decoded).toEqual(value);
|
||||
});
|
||||
|
||||
test("omits undefined object properties without omitting falsey values", () => {
|
||||
const value = { omitted: undefined, zero: 0, empty: "", no: false, nil: null };
|
||||
expect(decodeCbor(encodeCbor(value))).toEqual({ zero: 0, empty: "", no: false, nil: null });
|
||||
});
|
||||
|
||||
test("preserves a leading Unicode BOM and treats __proto__ as data", () => {
|
||||
expect(decodeCbor(fromHex("63efbbbf"))).toBe("\ufeff");
|
||||
const value: Record<string, unknown> = {};
|
||||
Object.defineProperty(value, "__proto__", { enumerable: true, value: "safe" });
|
||||
const decoded = decodeCbor(encodeCbor(value));
|
||||
expect(decoded).toEqual(value);
|
||||
expect(Object.getPrototypeOf(decoded)).toBe(Object.prototype);
|
||||
expect(Object.hasOwn(decoded as object, "__proto__")).toBe(true);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["top-level undefined", undefined],
|
||||
["undefined array element", [undefined]],
|
||||
["array hole", new Array(1)],
|
||||
["NaN", Number.NaN],
|
||||
["positive infinity", Number.POSITIVE_INFINITY],
|
||||
["negative infinity", Number.NEGATIVE_INFINITY],
|
||||
["unsafe positive integer", Number.MAX_SAFE_INTEGER + 1],
|
||||
["unsafe negative integer", Number.MIN_SAFE_INTEGER - 1],
|
||||
["bigint", 1n],
|
||||
["symbol", Symbol("unsupported")],
|
||||
["function", () => undefined],
|
||||
["Date", new Date(0)],
|
||||
["Map", new Map<string, unknown>()],
|
||||
] as const)("rejects unsupported encoder value: %s", (_label, value) => {
|
||||
expect(() => encodeCbor(value)).toThrow(CborError);
|
||||
});
|
||||
|
||||
test("rejects maps with enumerable symbol keys", () => {
|
||||
const value = { valid: true };
|
||||
Object.defineProperty(value, Symbol("key"), { enumerable: true, value: false });
|
||||
expect(() => encodeCbor(value)).toThrow(CborError);
|
||||
});
|
||||
|
||||
test("rejects lossy strings, cycles, and excessive encoder depth", () => {
|
||||
expect(() => encodeCbor("\ud800")).toThrow(/Unicode/i);
|
||||
|
||||
const cyclic: unknown[] = [];
|
||||
cyclic.push(cyclic);
|
||||
expect(() => encodeCbor(cyclic)).toThrow(/cycles/i);
|
||||
|
||||
let tooDeep: unknown = null;
|
||||
for (let depth = 0; depth <= DEFAULT_MAX_CBOR_DEPTH; depth++) tooDeep = [tooDeep];
|
||||
expect(() => encodeCbor(tooDeep)).toThrow(/depth/i);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["empty input", ""],
|
||||
["truncated integer", "18"],
|
||||
["reserved additional information", "1c"],
|
||||
["indefinite byte string", "5f"],
|
||||
["indefinite text string", "7f"],
|
||||
["indefinite array", "9f"],
|
||||
["indefinite map", "bf"],
|
||||
["tag", "c000"],
|
||||
["undefined", "f7"],
|
||||
["unsupported simple value", "e0"],
|
||||
["break outside an indefinite item", "ff"],
|
||||
["float16", "f93c00"],
|
||||
["float32", "fa3f800000"],
|
||||
["positive infinity", "fb7ff0000000000000"],
|
||||
["NaN", "fb7ff8000000000000"],
|
||||
["truncated float64", "fb3ff00000"],
|
||||
["truncated byte string", "44010203"],
|
||||
["truncated text string", "636162"],
|
||||
["truncated array", "8201"],
|
||||
["truncated map", "a16161"],
|
||||
["trailing data", "0000"],
|
||||
["non-string map key", "a10102"],
|
||||
["duplicate map key", "a2616101616102"],
|
||||
["invalid UTF-8 byte", "61ff"],
|
||||
["overlong UTF-8", "62c080"],
|
||||
["UTF-8 surrogate", "63eda080"],
|
||||
["unsafe positive integer", "1b0020000000000000"],
|
||||
["unsafe negative integer", "3b001fffffffffffff"],
|
||||
["unsafe integer encoded as float64", "fb4340000000000000"],
|
||||
] as const)("rejects invalid decoder input: %s", (_label, wire) => {
|
||||
expect(() => decodeCbor(fromHex(wire))).toThrow(CborError);
|
||||
});
|
||||
|
||||
test("enforces depth and declared length limits before traversing values", () => {
|
||||
const tooDeep = new Uint8Array(DEFAULT_MAX_CBOR_DEPTH + 2);
|
||||
tooDeep.fill(0x81, 0, -1);
|
||||
tooDeep[tooDeep.length - 1] = 0xf6;
|
||||
expect(() => decodeCbor(tooDeep)).toThrow(/depth/i);
|
||||
|
||||
const oversizedBytes = fromHex(`5a${(DEFAULT_MAX_CBOR_BYTE_LENGTH + 1).toString(16).padStart(8, "0")}`);
|
||||
const oversizedText = fromHex(`7a${(DEFAULT_MAX_CBOR_BYTE_LENGTH + 1).toString(16).padStart(8, "0")}`);
|
||||
const oversizedArray = fromHex(`9a${(DEFAULT_MAX_CBOR_CONTAINER_LENGTH + 1).toString(16).padStart(8, "0")}`);
|
||||
const oversizedMap = fromHex(`ba${(DEFAULT_MAX_CBOR_CONTAINER_LENGTH + 1).toString(16).padStart(8, "0")}`);
|
||||
for (const wire of [oversizedBytes, oversizedText, oversizedArray, oversizedMap]) {
|
||||
expect(() => decodeCbor(wire)).toThrow(/limit/i);
|
||||
}
|
||||
});
|
||||
|
||||
test("supports stricter caller-provided limits", () => {
|
||||
expect(() => decodeCbor(fromHex("83010203"), { maxContainerLength: 2 })).toThrow(/limit/i);
|
||||
expect(() => decodeCbor(fromHex("626162"), { maxByteLength: 2 })).toThrow(/limit/i);
|
||||
expect(() => encodeCbor([1, 2, 3], { maxContainerLength: 2 })).toThrow(/limit/i);
|
||||
expect(() => encodeCbor("ab", { maxByteLength: 2 })).toThrow(/limit/i);
|
||||
});
|
||||
});
|
||||
117
packages/protocol/test/framing.test.ts
Normal file
117
packages/protocol/test/framing.test.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { describe, expect, test } from "vitest";
|
||||
import { assertCompleteFrame, DEFAULT_MAX_FRAME_LENGTH, encodeFrame, FrameDecoder, FrameError } from "../src/index.ts";
|
||||
|
||||
function concatenate(...chunks: Uint8Array[]): Uint8Array {
|
||||
const result = new Uint8Array(chunks.reduce((length, chunk) => length + chunk.byteLength, 0));
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
describe("binary framing", () => {
|
||||
test("prefixes payloads with a four-byte big-endian length", () => {
|
||||
expect(encodeFrame(new Uint8Array([0xaa, 0xbb, 0xcc]))).toEqual(
|
||||
new Uint8Array([0x00, 0x00, 0x00, 0x03, 0xaa, 0xbb, 0xcc]),
|
||||
);
|
||||
expect(encodeFrame(new Uint8Array())).toEqual(new Uint8Array([0, 0, 0, 0]));
|
||||
});
|
||||
|
||||
test("validates one complete bounded frame without accepting trailing or partial bytes", () => {
|
||||
expect(() => assertCompleteFrame(new Uint8Array([0, 0, 0, 2, 1, 2]), { maxFrameLength: 2 })).not.toThrow();
|
||||
expect(() => assertCompleteFrame(new Uint8Array([0, 0, 0, 2, 1]))).toThrow(/complete/i);
|
||||
expect(() => assertCompleteFrame(new Uint8Array([0, 0, 0, 1, 1, 2]))).toThrow(/exactly/i);
|
||||
expect(() => assertCompleteFrame(new Uint8Array([0, 0, 0, 3, 1, 2, 3]), { maxFrameLength: 2 })).toThrow(/limit/i);
|
||||
});
|
||||
|
||||
test("decodes fragmented, coalesced, and empty frames in order", () => {
|
||||
const wire = concatenate(
|
||||
encodeFrame(new Uint8Array([1, 2, 3])),
|
||||
encodeFrame(new Uint8Array()),
|
||||
encodeFrame(new Uint8Array([4])),
|
||||
);
|
||||
const decoder = new FrameDecoder();
|
||||
const frames: Uint8Array[] = [];
|
||||
for (const byte of wire) frames.push(...decoder.push(new Uint8Array([byte])));
|
||||
decoder.end();
|
||||
expect(frames).toEqual([new Uint8Array([1, 2, 3]), new Uint8Array(), new Uint8Array([4])]);
|
||||
|
||||
const coalesced = new FrameDecoder();
|
||||
expect(coalesced.push(wire)).toEqual(frames);
|
||||
coalesced.end();
|
||||
});
|
||||
|
||||
test("assembles payloads spanning multiple internal blocks", () => {
|
||||
const payload = Uint8Array.from({ length: 70_000 }, (_, index) => index % 251);
|
||||
const wire = encodeFrame(payload);
|
||||
const decoder = new FrameDecoder();
|
||||
const frames = [
|
||||
...decoder.push(wire.subarray(0, 101)),
|
||||
...decoder.push(wire.subarray(101, 65_541)),
|
||||
...decoder.push(wire.subarray(65_541)),
|
||||
];
|
||||
decoder.end();
|
||||
expect(frames).toEqual([payload]);
|
||||
});
|
||||
|
||||
test("handles every split point across a frame", () => {
|
||||
const wire = encodeFrame(new Uint8Array([10, 20, 30, 40]));
|
||||
for (let split = 0; split <= wire.byteLength; split++) {
|
||||
const decoder = new FrameDecoder();
|
||||
const frames = [...decoder.push(wire.subarray(0, split)), ...decoder.push(wire.subarray(split))];
|
||||
decoder.end();
|
||||
expect(frames).toEqual([new Uint8Array([10, 20, 30, 40])]);
|
||||
}
|
||||
});
|
||||
|
||||
test("copies payload bytes instead of retaining or aliasing input chunks", () => {
|
||||
const chunk = encodeFrame(new Uint8Array([1, 2, 3]));
|
||||
const decoder = new FrameDecoder();
|
||||
const frames = decoder.push(chunk);
|
||||
chunk.fill(9);
|
||||
expect(frames).toEqual([new Uint8Array([1, 2, 3])]);
|
||||
});
|
||||
|
||||
test("accepts empty chunks and a clean empty stream", () => {
|
||||
const decoder = new FrameDecoder();
|
||||
expect(decoder.push(new Uint8Array())).toEqual([]);
|
||||
expect(() => decoder.end()).not.toThrow();
|
||||
});
|
||||
|
||||
test.each([
|
||||
["partial header", new Uint8Array([0, 0, 0])],
|
||||
["partial payload", new Uint8Array([0, 0, 0, 2, 1])],
|
||||
] as const)("rejects a truncated stream at end: %s", (_label, wire) => {
|
||||
const decoder = new FrameDecoder();
|
||||
expect(decoder.push(wire)).toEqual([]);
|
||||
expect(() => decoder.end()).toThrow(FrameError);
|
||||
});
|
||||
|
||||
test("rejects an oversized declared length as soon as its header is complete", () => {
|
||||
const decoder = new FrameDecoder({ maxFrameLength: 3 });
|
||||
expect(() => decoder.push(new Uint8Array([0, 0, 0, 4]))).toThrow(/limit/i);
|
||||
expect(() => decoder.push(new Uint8Array([1]))).toThrow(/failed/i);
|
||||
});
|
||||
|
||||
test("accepts a frame exactly at the configured maximum", () => {
|
||||
const decoder = new FrameDecoder({ maxFrameLength: 3 });
|
||||
expect(decoder.push(encodeFrame(new Uint8Array([1, 2, 3])))).toEqual([new Uint8Array([1, 2, 3])]);
|
||||
decoder.end();
|
||||
});
|
||||
|
||||
test("cannot be pushed after end", () => {
|
||||
const decoder = new FrameDecoder();
|
||||
decoder.end();
|
||||
expect(() => decoder.push(new Uint8Array())).toThrow(/ended/i);
|
||||
expect(() => decoder.end()).toThrow(/ended/i);
|
||||
});
|
||||
|
||||
test.each([-1, 1.5, Number.NaN, DEFAULT_MAX_FRAME_LENGTH * 1_000])(
|
||||
"rejects invalid maximum frame length: %s",
|
||||
(maxFrameLength) => {
|
||||
expect(() => new FrameDecoder({ maxFrameLength })).toThrow(RangeError);
|
||||
},
|
||||
);
|
||||
});
|
||||
248
packages/protocol/test/protocol.test.ts
Normal file
248
packages/protocol/test/protocol.test.ts
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
type ClientHello,
|
||||
type ClientMessage,
|
||||
ClientMessageDecoder,
|
||||
decodeCbor,
|
||||
encodeCbor,
|
||||
encodeClientMessage,
|
||||
encodeFrame,
|
||||
encodeServerMessage,
|
||||
FrameDecoder,
|
||||
isSupportedProtocolVersion,
|
||||
PROTOCOL_VERSION,
|
||||
ProtocolValidationError,
|
||||
parseClientMessage,
|
||||
parseServerMessage,
|
||||
type ServerHello,
|
||||
type ServerMessage,
|
||||
ServerMessageDecoder,
|
||||
type ServerSnapshot,
|
||||
} from "../src/index.ts";
|
||||
|
||||
const emptyServerSnapshot: ServerSnapshot = {
|
||||
serverId: "server-1",
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
revision: 0,
|
||||
sessions: [],
|
||||
models: [],
|
||||
};
|
||||
|
||||
const clientHello: ClientHello = {
|
||||
type: "hello",
|
||||
version: PROTOCOL_VERSION,
|
||||
token: "secret",
|
||||
};
|
||||
|
||||
const serverHello: ServerHello = {
|
||||
type: "hello",
|
||||
version: PROTOCOL_VERSION,
|
||||
connectionId: "connection-1",
|
||||
snapshot: emptyServerSnapshot,
|
||||
};
|
||||
|
||||
describe("protocol validation", () => {
|
||||
test("uses protocol version 2", () => {
|
||||
expect(PROTOCOL_VERSION).toBe(2);
|
||||
expect(isSupportedProtocolVersion(2)).toBe(true);
|
||||
expect(isSupportedProtocolVersion(1)).toBe(false);
|
||||
expect(isSupportedProtocolVersion(2.5)).toBe(false);
|
||||
});
|
||||
|
||||
test.each([0, 1, PROTOCOL_VERSION])("accepts integer client hello version %s for negotiation", (version) => {
|
||||
const message = { ...clientHello, version };
|
||||
expect(parseClientMessage(message)).toEqual(message);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ type: "hello", version: "2", token: "secret" },
|
||||
{ type: "hello", version: 2.5, token: "secret" },
|
||||
{ type: "hello", version: 2, token: "" },
|
||||
{ type: "hello", version: 2, token: "secret", extra: true },
|
||||
])("rejects an invalid strict handshake: $version", (message) => {
|
||||
expect(() => parseClientMessage(message)).toThrow(ProtocolValidationError);
|
||||
});
|
||||
|
||||
test("does not parse JSON strings as wire messages", () => {
|
||||
expect(() => parseClientMessage(JSON.stringify(clientHello))).toThrow(ProtocolValidationError);
|
||||
expect(() => parseServerMessage(JSON.stringify(serverHello))).toThrow(ProtocolValidationError);
|
||||
});
|
||||
|
||||
test("rejects image input while the MVP remains text-only", () => {
|
||||
expect(() =>
|
||||
parseClientMessage({
|
||||
type: "request",
|
||||
id: "request-1",
|
||||
request: {
|
||||
command: "prompt",
|
||||
sessionId: "session-1",
|
||||
text: "inspect",
|
||||
images: [{ type: "image", data: "abc", mimeType: "image/png" }],
|
||||
},
|
||||
}),
|
||||
).toThrow(ProtocolValidationError);
|
||||
});
|
||||
|
||||
test("parses a server handshake snapshot", () => {
|
||||
expect(parseServerMessage(serverHello)).toEqual(serverHello);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ type: "hello", version: 1, connectionId: "connection-1", snapshot: emptyServerSnapshot },
|
||||
{ type: "response", id: "request-1", ok: true, result: { command: "unknown" } },
|
||||
{ type: "event", event: { type: "session_removed", sessionId: 42 } },
|
||||
])("rejects invalid server messages", (wire) => {
|
||||
expect(() => parseServerMessage(wire)).toThrow(ProtocolValidationError);
|
||||
});
|
||||
|
||||
test("validates nested JSON tool details", () => {
|
||||
const message = {
|
||||
type: "event",
|
||||
event: {
|
||||
type: "session_progress",
|
||||
sessionId: "session-1",
|
||||
progress: {
|
||||
type: "item_finished",
|
||||
item: {
|
||||
id: "tool-1",
|
||||
role: "tool",
|
||||
toolCallId: "call-1",
|
||||
toolName: "read",
|
||||
input: { path: "/tmp/file" },
|
||||
content: [{ type: "text", text: "done" }],
|
||||
details: { lines: [1, 2, 3], cached: false },
|
||||
status: "complete",
|
||||
isError: false,
|
||||
timestamp: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(parseServerMessage(message)).toEqual(message);
|
||||
});
|
||||
|
||||
test("rejects cyclic protocol values with a protocol validation error", () => {
|
||||
const details: Record<string, unknown> = {};
|
||||
details.self = details;
|
||||
const message = {
|
||||
type: "response",
|
||||
id: "request-1",
|
||||
ok: false,
|
||||
error: { code: "invalid_request", message: "invalid", details },
|
||||
};
|
||||
|
||||
expect(() => parseServerMessage(message)).toThrow(ProtocolValidationError);
|
||||
});
|
||||
|
||||
test("validation errors do not retain rejected payloads", () => {
|
||||
let thrown: unknown;
|
||||
try {
|
||||
parseClientMessage({ type: "hello", version: "2", token: "x".repeat(2_000_000) });
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(ProtocolValidationError);
|
||||
expect(Object.hasOwn(thrown as object, "value")).toBe(false);
|
||||
expect((thrown as Error).message.length).toBeLessThan(1_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validated framed protocol APIs", () => {
|
||||
test("encodes complete client and server frames", () => {
|
||||
const clientFrames = new FrameDecoder().push(encodeClientMessage(clientHello));
|
||||
expect(clientFrames).toHaveLength(1);
|
||||
expect(parseClientMessage(decodeCbor(clientFrames[0]!))).toEqual(clientHello);
|
||||
|
||||
const serverFrames = new FrameDecoder().push(encodeServerMessage(serverHello));
|
||||
expect(serverFrames).toHaveLength(1);
|
||||
expect(parseServerMessage(decodeCbor(serverFrames[0]!))).toEqual(serverHello);
|
||||
});
|
||||
|
||||
test("enforces an outbound frame limit before returning encoded bytes", () => {
|
||||
expect(() => encodeClientMessage(clientHello, { maxFrameLength: 8 })).toThrow(ProtocolValidationError);
|
||||
expect(() => encodeServerMessage(serverHello, { maxFrameLength: 8 })).toThrow(ProtocolValidationError);
|
||||
});
|
||||
|
||||
test("validates messages before encoding", () => {
|
||||
expect(() => encodeClientMessage({ type: "hello", version: 2, token: "" })).toThrow(ProtocolValidationError);
|
||||
});
|
||||
|
||||
test("omits explicit undefined optional properties on the wire", () => {
|
||||
const message: ClientMessage = {
|
||||
type: "request",
|
||||
id: "request-1",
|
||||
request: { command: "create", cwd: undefined, name: undefined },
|
||||
};
|
||||
const [payload] = new FrameDecoder().push(encodeClientMessage(message));
|
||||
expect(decodeCbor(payload!)).toEqual({
|
||||
type: "request",
|
||||
id: "request-1",
|
||||
request: { command: "create" },
|
||||
});
|
||||
});
|
||||
|
||||
test("incrementally decodes fragmented and coalesced client messages", () => {
|
||||
const request: ClientMessage = {
|
||||
type: "request",
|
||||
id: "request-1",
|
||||
request: { command: "list" },
|
||||
};
|
||||
const first = encodeClientMessage(clientHello);
|
||||
const second = encodeClientMessage(request);
|
||||
const wire = new Uint8Array(first.byteLength + second.byteLength);
|
||||
wire.set(first);
|
||||
wire.set(second, first.byteLength);
|
||||
|
||||
for (let split = 0; split <= wire.byteLength; split++) {
|
||||
const decoder = new ClientMessageDecoder();
|
||||
const messages = [...decoder.push(wire.subarray(0, split)), ...decoder.push(wire.subarray(split))];
|
||||
decoder.end();
|
||||
expect(messages).toEqual([clientHello, request]);
|
||||
}
|
||||
});
|
||||
|
||||
test("incrementally decodes server messages", () => {
|
||||
const errorMessage: ServerMessage = {
|
||||
type: "hello_error",
|
||||
error: { code: "auth", message: "Invalid token" },
|
||||
};
|
||||
const decoder = new ServerMessageDecoder();
|
||||
expect(decoder.push(encodeServerMessage(errorMessage))).toEqual([errorMessage]);
|
||||
decoder.end();
|
||||
});
|
||||
|
||||
test.each([
|
||||
["empty CBOR payload", encodeFrame(new Uint8Array())],
|
||||
["malformed CBOR", encodeFrame(new Uint8Array([0xff]))],
|
||||
["schema-invalid CBOR", encodeFrame(encodeCbor({ type: "hello", version: 2, token: "", extra: true }))],
|
||||
] as const)("rejects invalid framed client input: %s", (_label, wire) => {
|
||||
const decoder = new ClientMessageDecoder();
|
||||
expect(() => decoder.push(wire)).toThrow(ProtocolValidationError);
|
||||
expect(() => decoder.push(encodeClientMessage(clientHello))).toThrow(/failed/i);
|
||||
});
|
||||
|
||||
test("rejects CBOR byte strings nested in JSON-valued fields", () => {
|
||||
const wire = encodeFrame(
|
||||
encodeCbor({
|
||||
type: "response",
|
||||
id: "request-1",
|
||||
ok: false,
|
||||
error: {
|
||||
code: "invalid_request",
|
||||
message: "invalid",
|
||||
details: { nested: new Uint8Array([1, 2, 3]) },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(() => new ServerMessageDecoder().push(wire)).toThrow(ProtocolValidationError);
|
||||
});
|
||||
|
||||
test("rejects truncated and oversized framing through the validated decoder", () => {
|
||||
const truncated = new ServerMessageDecoder();
|
||||
expect(truncated.push(new Uint8Array([0, 0, 0, 2, 1]))).toEqual([]);
|
||||
expect(() => truncated.end()).toThrow(ProtocolValidationError);
|
||||
|
||||
const oversized = new ClientMessageDecoder({ maxFrameLength: 3 });
|
||||
expect(() => oversized.push(new Uint8Array([0, 0, 0, 4]))).toThrow(ProtocolValidationError);
|
||||
});
|
||||
});
|
||||
9
packages/protocol/tsconfig.build.json
Normal file
9
packages/protocol/tsconfig.build.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "**/*.d.ts", "src/**/*.d.ts"]
|
||||
}
|
||||
10
packages/protocol/tsconfig.test.json
Normal file
10
packages/protocol/tsconfig.test.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"types": ["node", "vitest"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||
}
|
||||
9
packages/protocol/vitest.config.ts
Normal file
9
packages/protocol/vitest.config.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
reporters: process.env.GITHUB_ACTIONS ? ["dot", "github-actions"] : ["dot"],
|
||||
},
|
||||
});
|
||||
|
|
@ -17,6 +17,7 @@ import {
|
|||
toError,
|
||||
truncateHead,
|
||||
} from "@earendil-works/pi-agent-core";
|
||||
import { decodeCbor, encodeCbor, PROTOCOL_VERSION } from "@earendil-works/pi-protocol";
|
||||
|
||||
// Keep this entry browser-safe. It is bundled by scripts/check-browser-smoke.mjs
|
||||
// to catch accidental Node-only runtime imports in browser-facing package exports.
|
||||
|
|
@ -58,4 +59,6 @@ console.log(
|
|||
new FileError("not_found", "missing").code,
|
||||
toError("boom").message,
|
||||
typeof streamProxy,
|
||||
PROTOCOL_VERSION,
|
||||
decodeCbor(encodeCbor({ browser: true })),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ const packages = [
|
|||
{ directory: "packages/ai", name: "@earendil-works/pi-ai" },
|
||||
{ directory: "packages/tui", name: "@earendil-works/pi-tui" },
|
||||
{ directory: "packages/agent", name: "@earendil-works/pi-agent-core" },
|
||||
{ directory: "packages/protocol", name: "@earendil-works/pi-protocol" },
|
||||
{ directory: "packages/storage/sqlite-node", name: "@earendil-works/pi-storage-sqlite-node" },
|
||||
{ directory: "packages/coding-agent", name: "@earendil-works/pi-coding-agent" },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { join } from "node:path";
|
|||
const packages = [
|
||||
{ directory: "packages/ai", name: "@earendil-works/pi-ai" },
|
||||
{ directory: "packages/agent", name: "@earendil-works/pi-agent-core" },
|
||||
{ directory: "packages/protocol", name: "@earendil-works/pi-protocol" },
|
||||
{ directory: "packages/storage/sqlite-node", name: "@earendil-works/pi-storage-sqlite-node" },
|
||||
{ directory: "packages/tui", name: "@earendil-works/pi-tui" },
|
||||
{ directory: "packages/coding-agent", name: "@earendil-works/pi-coding-agent" },
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@
|
|||
"@earendil-works/pi-coding-agent": ["./packages/coding-agent/src/index.ts"],
|
||||
"@earendil-works/pi-coding-agent/hooks": ["./packages/coding-agent/src/core/hooks/index.ts"],
|
||||
"@earendil-works/pi-coding-agent/*": ["./packages/coding-agent/src/*"],
|
||||
"@earendil-works/pi-protocol": ["./packages/protocol/src/index.ts"],
|
||||
"@earendil-works/pi-protocol/*": ["./packages/protocol/src/*"],
|
||||
"@earendil-works/pi-server": ["./packages/server/src/index.ts"],
|
||||
"@earendil-works/pi-server/*": ["./packages/server/src/*"],
|
||||
"typebox": ["./node_modules/typebox"],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue