import { createHash } from "node:crypto"; import path from "node:path"; import { canonicalJson, parseJson, type JsonValue } from "../state/json.js"; export const CLIENT_PROTOCOL_VERSION = 1; export const CLIENT_PROTOCOL_SCHEMA = "pi-workflows.client.v1" as const; export const MAX_PROTOCOL_MESSAGE_BYTES = 1024 * 1024; export const CLIENT_OPERATIONS = [ "run.start", "run.pause", "run.resume", "run.cancel", "run.restart", "run.changeSettings", "run.status", "run.list", "checkpoint.answer", "decision.answer", "interaction.submit", "interaction.assistant", "interaction.update", "workflowMessage.reportBranch", "workflowTurn.report", "sessionView.clearTerminal", "followUp.queue", "followUp.remove", "session.record", "channel.status", "channel.reload", "channel.recover", "resourceManager.list", "resourceManager.get", "resourceManager.apply", "resourceManager.reconcile", "resourceManager.delete", "server.status", "server.stop", "view.runs.watch", "view.runs.page", "view.run.get", "view.run.watch", "view.run.unwatch", "view.page", "view.content", "view.session.watch", "state.status", "state.verify", "state.backup", "state.prune", ] as const; export const CLIENT_OUTCOMES = [ "accepted", "adopted", "rejected", "conflict", "notFound", "claimLost", "unavailable", ] as const; export const CLIENT_EVENTS = [ "runs", "run_snapshot", "run_patch", "run_page", "session_snapshot", "unavailable", ] as const; export type ClientOperation = (typeof CLIENT_OPERATIONS)[number]; export type ClientOutcome = (typeof CLIENT_OUTCOMES)[number]; export type ClientEventName = (typeof CLIENT_EVENTS)[number]; export type ClientHello = { schema: typeof CLIENT_PROTOCOL_SCHEMA; type: "hello"; connectionId: string; packageVersion: string; }; export type ClientRequest = { schema: typeof CLIENT_PROTOCOL_SCHEMA; type: "request"; requestId: string; clientId: string; operation: ClientOperation; idempotencyKey: string; runId?: string; expectedRevision?: number; payload: JsonValue; }; export type ClientResponse = { schema: typeof CLIENT_PROTOCOL_SCHEMA; type: "response"; requestId: string; outcome: ClientOutcome; revision?: number; receipt?: JsonValue; error?: string; }; export type ClientEvent = { schema: typeof CLIENT_PROTOCOL_SCHEMA; type: "event"; subscriptionId: string; event: ClientEventName; revision?: number; runId?: string; payload: JsonValue; }; export type ClientMessage = ClientHello | ClientRequest | ClientResponse | ClientEvent; export class ClientProtocolError extends Error { constructor(message: string) { super(message); this.name = "ClientProtocolError"; } } export function encodeProtocolMessage(message: ClientMessage): Buffer { const encoded = Buffer.from(canonicalJson(message), "utf8"); if (encoded.byteLength > MAX_PROTOCOL_MESSAGE_BYTES) { throw new ClientProtocolError("Client protocol message exceeds 1 MiB"); } return encoded; } export function encodeProtocolLine(message: ClientMessage): Buffer { const encoded = Buffer.concat([encodeProtocolMessage(message), Buffer.from("\n")]); if (encoded.byteLength > MAX_PROTOCOL_MESSAGE_BYTES) { throw new ClientProtocolError("Client protocol message exceeds 1 MiB"); } return encoded; } export function parseClientMessage(line: string | Buffer): ClientMessage { const value = parseProtocolValue(line); if (value.schema !== CLIENT_PROTOCOL_SCHEMA) { throw new ClientProtocolError("Invalid client protocol schema"); } switch (value.type) { case "hello": return parseHello(value); case "request": return parseRequestValue(value); case "response": return parseResponseValue(value); case "event": return parseEventValue(value); default: throw new ClientProtocolError("Invalid client protocol message type"); } } export function parseClientRequest(line: string | Buffer): ClientRequest { const message = parseClientMessage(line); if (message.type !== "request") throw new ClientProtocolError("Expected client request"); return message; } export function parseClientResponse(line: string | Buffer): ClientResponse { const message = parseClientMessage(line); if (message.type !== "response") throw new ClientProtocolError("Expected client response"); return message; } export function clientRequestFingerprint(request: ClientRequest): Buffer { // The host verifies current session authority before reading these receipts. // Reconnecting changes authority evidence, not the logical response. const sessionResponse = [ "interaction.submit", "interaction.assistant", "interaction.update", "checkpoint.answer", "decision.answer", "run.restart", ].includes(request.operation); const payload = sessionResponse && typeof request.payload === "object" && request.payload !== null && !Array.isArray(request.payload) ? Object.fromEntries( Object.entries(request.payload).filter(([key]) => key !== "coordinatorEpoch"), ) : request.payload; return createHash("sha256") .update(canonicalJson({ ...request, requestId: undefined, payload })) .digest(); } export function clientSocketPath(databasePath: string): string { const stateDirectory = path.dirname(path.resolve(databasePath)); if (process.platform === "win32") { const suffix = createHash("sha256").update(stateDirectory).digest("hex").slice(0, 24); return `\\\\.\\pipe\\pi-workflows-${suffix}`; } return path.join(stateDirectory, "host", "host.sock"); } export class NdjsonFrameDecoder { private buffered = Buffer.alloc(0); constructor( private readonly maxMessageBytes = MAX_PROTOCOL_MESSAGE_BYTES, private readonly oversizedMessage = "Client protocol message exceeds 1 MiB", ) {} push(chunk: Buffer): Buffer[] { this.buffered = Buffer.concat([this.buffered, chunk]); if (this.buffered.byteLength > this.maxMessageBytes && !this.buffered.includes(0x0a)) { throw new ClientProtocolError(this.oversizedMessage); } const frames: Buffer[] = []; for (;;) { const newline = this.buffered.indexOf(0x0a); if (newline < 0) break; const frame = this.buffered.subarray(0, newline); this.buffered = this.buffered.subarray(newline + 1); if (frame.byteLength === 0) continue; if (frame.byteLength + 1 > this.maxMessageBytes) { throw new ClientProtocolError(this.oversizedMessage); } frames.push(frame); } return frames; } } function parseHello(value: Record): ClientHello { requireExactKeys(value, ["schema", "type", "connectionId", "packageVersion"]); requireOpaqueId(value.connectionId, "connectionId"); requireText(value.packageVersion, "packageVersion"); return value as ClientHello; } function parseRequestValue(value: Record): ClientRequest { requireExactKeys(value, [ "schema", "type", "requestId", "clientId", "operation", "idempotencyKey", "runId", "expectedRevision", "payload", ]); requireOpaqueId(value.requestId, "requestId"); requireOpaqueId(value.clientId, "clientId"); requireOpaqueId(value.idempotencyKey, "idempotencyKey"); if (!CLIENT_OPERATIONS.includes(value.operation as ClientOperation)) { throw new ClientProtocolError("Invalid client request operation"); } if (value.runId !== undefined) requireOpaqueId(value.runId, "runId"); requireOptionalRevision(value.expectedRevision, "request expectedRevision"); if (!Object.hasOwn(value, "payload")) { throw new ClientProtocolError("Client request payload is required"); } canonicalJson(value.payload); return value as ClientRequest; } function parseResponseValue(value: Record): ClientResponse { requireExactKeys(value, [ "schema", "type", "requestId", "outcome", "revision", "receipt", "error", ]); requireOpaqueId(value.requestId, "requestId"); if (!CLIENT_OUTCOMES.includes(value.outcome as ClientOutcome)) { throw new ClientProtocolError("Invalid client response outcome"); } requireOptionalRevision(value.revision, "response revision"); if (value.error !== undefined) requireText(value.error, "response error"); if (value.receipt !== undefined) canonicalJson(value.receipt); return value as ClientResponse; } function parseEventValue(value: Record): ClientEvent { requireExactKeys(value, [ "schema", "type", "subscriptionId", "event", "revision", "runId", "payload", ]); requireOpaqueId(value.subscriptionId, "subscriptionId"); if (!CLIENT_EVENTS.includes(value.event as ClientEventName)) { throw new ClientProtocolError("Invalid client event name"); } requireOptionalRevision(value.revision, "event revision"); if (value.runId !== undefined) requireOpaqueId(value.runId, "runId"); if (!Object.hasOwn(value, "payload")) { throw new ClientProtocolError("Client event payload is required"); } canonicalJson(value.payload); return value as ClientEvent; } function parseProtocolValue(line: string | Buffer): Record { const buffer = Buffer.isBuffer(line) ? line : Buffer.from(line, "utf8"); if (buffer.byteLength > MAX_PROTOCOL_MESSAGE_BYTES) { throw new ClientProtocolError("Client protocol message exceeds 1 MiB"); } let value: unknown; try { value = parseJson(buffer.toString("utf8")); } catch { throw new ClientProtocolError("Client protocol message is not valid canonical JSON"); } if (!isRecord(value)) throw new ClientProtocolError("Client protocol message must be an object"); if (canonicalJson(value) !== buffer.toString("utf8")) { throw new ClientProtocolError("Client protocol message must use canonical JSON"); } return value; } function requireExactKeys(value: Record, allowed: readonly string[]): void { const allowedKeys = new Set(allowed); for (const key of Object.keys(value)) { if (!allowedKeys.has(key)) { throw new ClientProtocolError(`Unknown client protocol field: ${key}`); } } } function requireOpaqueId(value: unknown, field: string): asserts value is string { if (typeof value !== "string" || value.length === 0 || Buffer.byteLength(value) > 256) { throw new ClientProtocolError(`${field} must be nonempty text of at most 256 bytes`); } } function requireText(value: unknown, field: string): asserts value is string { if (typeof value !== "string" || value.length === 0) { throw new ClientProtocolError(`${field} must be nonempty text`); } } function requireOptionalRevision(value: unknown, field: string): void { if (value !== undefined && (!Number.isSafeInteger(value) || (value as number) < 0)) { throw new ClientProtocolError(`${field} must be a non-negative integer`); } } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); }