diff --git a/docs/adr/009-protocol-provider-boundary.md b/docs/adr/009-protocol-provider-boundary.md index 8c2dac1f..d4e09f0a 100644 --- a/docs/adr/009-protocol-provider-boundary.md +++ b/docs/adr/009-protocol-provider-boundary.md @@ -3,5 +3,6 @@ - Status: accepted. - Context: T4 pins one canonical `@oh-my-pi/app-wire` artifact through `@t4-code/protocol`, but the client previously constructed `omp-app/1` frames throughout its runtime. That made a future wire-version change touch transport, reconnect, pairing, terminal, and heartbeat code even when those behaviors had not changed. - Decision: `OmpClient` consumes an `OmpProtocolProvider`. The provider contract receives T4 client messages and emits T4 server events, while each version-specific implementation owns wire frame construction, encoding, decoding, event normalization, command descriptions, and capability lookup. The current `omp-app/1` implementation lives separately from the provider contract. An immutable registry indexes providers by both implementation ID and declared protocol version, rejects duplicates, and supplies the pinned `omp-app/1` provider by default. A client may receive a provider directly or select one from a registry by ID, but it may not combine those two configuration paths. Application code may import the T4 protocol facade, but not the raw vendored app-wire package. -- Consequence: another wire implementation can be added beside `omp-app/1` and selected without duplicating transport or reconnect logic. Outbound wire shapes and version labels now have one owner. The client correlates requests, validates pairing, and tracks replay progress from normalized messages and payloads rather than version-specific frames. The live `ProjectionStore`, browser shell, desktop target manager, Electron IPC boundary, and renderer feature controllers consume the same version-free `{ kind, payload }` event union exported by `@t4-code/protocol`. Saved application state and renderer transport no longer require a wire-version field. Every concrete provider must run the shared conformance suite in addition to version-specific tests covering outbound wire shapes, inbound event normalization, injected-provider routing, and projection parity. The test-only `omp-app/2` provider uses a deliberately different envelope to prove that the shared client path does not depend on `omp-app/1` fields. +- Consequence: another wire implementation can be added beside `omp-app/1` and selected without duplicating transport or reconnect logic. Outbound wire shapes and version labels now have one owner. The client correlates requests, validates pairing, and tracks replay progress from normalized messages and payloads rather than version-specific frames. The live `ProjectionStore`, browser shell, desktop target manager, Electron IPC boundary, and renderer feature controllers consume the same version-free `{ kind, payload }` event union exported by `@t4-code/protocol`. Saved application state and renderer transport no longer require a wire-version field. Every concrete provider must run the shared conformance suite and a versioned golden corpus that checks exact outbound frames, exact normalized inbound events from both objects and JSON text, and fail-closed malformed inputs. Version-specific tests still cover injected-provider routing and projection parity. The test-only `omp-app/2` provider uses a deliberately different envelope and runs the same corpus contract to prove that the shared client path does not depend on `omp-app/1` fields. +- Runtime vocabulary: each provider declares an immutable list of the normalized server event kinds it supports. Direct injection and registry selection reject empty, mutable, duplicate, or unknown declarations before opening a transport, and the client fails closed if a provider later returns an undeclared event. T4's `OmpServerFrame` deliberately excludes the client-only `pair.start` variant that the upstream `ServerFrame` type includes but its server decoder cannot emit. The exhaustive event-kind table is checked by TypeScript against the remaining server-frame union, and the pinned provider decodes every canonical server fixture shipped by app-wire. - Non-goals: this does not redefine OMP's canonical wire schema, add a production second protocol version, automatically guess or negotiate a wire format, or change the upstream OMP repository. Event payload fields mirror the current canonical contract until later decisions define narrower domain-specific payloads. diff --git a/packages/client/src/omp-app-v1-protocol-provider.ts b/packages/client/src/omp-app-v1-protocol-provider.ts index 961414e2..dbc4196f 100644 --- a/packages/client/src/omp-app-v1-protocol-provider.ts +++ b/packages/client/src/omp-app-v1-protocol-provider.ts @@ -1,5 +1,6 @@ import { COMMAND_DESCRIPTORS, + OMP_SERVER_EVENT_KINDS, PROTOCOL_VERSION, decodeClientFrame, decodeServerFrame, @@ -115,6 +116,7 @@ function encodeAppV1ClientMessage(message: OmpClientMessage): string { export const ompAppV1ProtocolProvider: OmpProtocolProvider = Object.freeze({ id: "omp-app-v1", protocolVersion: PROTOCOL_VERSION, + serverEventKinds: OMP_SERVER_EVENT_KINDS, encodeClientMessage: encodeAppV1ClientMessage, decodeServerEvent: (input: unknown) => ompServerEventFromFrame(decodeServerFrame(input)), commandDescriptor: (command: string) => COMMAND_DESCRIPTORS[command], diff --git a/packages/client/src/omp-client-contracts.ts b/packages/client/src/omp-client-contracts.ts index 7d8a779b..4f5fd707 100644 --- a/packages/client/src/omp-client-contracts.ts +++ b/packages/client/src/omp-client-contracts.ts @@ -1,4 +1,4 @@ -import type { PairOkFrame, RequestId, Cursor, ServerFrame } from "@t4-code/protocol"; +import type { PairOkFrame, RequestId, Cursor, OmpServerFrame } from "@t4-code/protocol"; import type { ProjectionStore } from "./projection.ts"; import type { OmpClientMessage, OmpPairOk, OmpProtocolProvider, OmpResponse } from "./omp-protocol-provider.ts"; import type { OmpProtocolProviderRegistry } from "./omp-protocol-provider-registry.ts"; @@ -95,7 +95,7 @@ export interface TerminalInputIntent { hostId: string; sessionId: string; termin export interface TerminalResizeIntent { hostId: string; sessionId: string; terminalId: string; cols: number; rows: number; } export interface TerminalCloseIntent { hostId: string; sessionId: string; terminalId: string; reason?: string; } /** Pair credentials never cross this public subscription boundary. */ -export type PublicServerFrame = Exclude; +export type PublicServerFrame = Exclude; export const MAX_SAVED = 128; export const MAX_PENDING = 256; diff --git a/packages/client/src/omp-client-frames.ts b/packages/client/src/omp-client-frames.ts index 827d3ca4..c70fe5d8 100644 --- a/packages/client/src/omp-client-frames.ts +++ b/packages/client/src/omp-client-frames.ts @@ -1,13 +1,30 @@ import { AppWireError } from "@t4-code/protocol"; -import type { OmpServerEvent } from "./omp-protocol-provider.ts"; +import type { OmpProtocolProvider, OmpServerEvent } from "./omp-protocol-provider.ts"; type ServerEvent = Extract; type DurableEvent = ServerEvent<"entry" | "event" | "session.delta">; +class ProtocolProviderContractError extends Error { + constructor() { + super("protocol provider returned an undeclared server event"); + this.name = "ProtocolProviderContractError"; + } +} export function safeFrameDecodeFailure(error: unknown): string { + if (error instanceof ProtocolProviderContractError) return error.message; if (!(error instanceof AppWireError)) return "invalid server frame"; const safePath = error.path !== undefined && /^[A-Za-z0-9.[\]_-]{1,128}$/u.test(error.path) ? ` at ${error.path}` : ""; return `invalid server frame (${error.code}${safePath})`; } +export function decodeProviderServerEvent( + provider: OmpProtocolProvider, + input: unknown, +): OmpServerEvent { + const event = provider.decodeServerEvent(input); + if (!provider.serverEventKinds.includes(event.kind)) { + throw new ProtocolProviderContractError(); + } + return event; +} export interface FrameDispatchHandlers { welcome(message: ServerEvent<"welcome">): void; pong(nonce: string): void; @@ -46,7 +63,6 @@ export class OmpClientEventDispatcher { } import type { CursorRecord, OmpClientOptions } from "./omp-client-contracts.ts"; import { encodeOutgoingMessage } from "./omp-client-outbound.ts"; -import type { OmpProtocolProvider } from "./omp-protocol-provider.ts"; export function sendClientHello( provider: OmpProtocolProvider, diff --git a/packages/client/src/omp-client-runtime.ts b/packages/client/src/omp-client-runtime.ts index 37076c51..5d1dd28e 100644 --- a/packages/client/src/omp-client-runtime.ts +++ b/packages/client/src/omp-client-runtime.ts @@ -42,7 +42,7 @@ import { PendingRequests } from "./omp-client-pending.ts"; import { ClientTimerRegistry } from "./omp-client-timers.ts"; import { OmpClientEvents } from "./omp-client-events.ts"; import { OmpClientConnection } from "./omp-client-connection.ts"; -import { OmpClientEventDispatcher, safeFrameDecodeFailure, sendClientHello } from "./omp-client-frames.ts"; +import { decodeProviderServerEvent, OmpClientEventDispatcher, safeFrameDecodeFailure, sendClientHello } from "./omp-client-frames.ts"; import { OmpClientReconnectHealth } from "./omp-client-reconnect-health.ts"; import { encodeOutgoingMessage } from "./omp-client-outbound.ts"; import { resolveOmpProtocolProvider } from "./omp-protocol-provider-registry.ts"; @@ -440,7 +440,10 @@ export class OmpClient { private handleRaw(raw: string | Uint8Array, generation: number): void | Promise { if (generation !== this.generation || this.closedByUser) return; try { - return this.inboundDispatcher.dispatch(this.protocol.decodeServerEvent(raw), generation); + return this.inboundDispatcher.dispatch( + decodeProviderServerEvent(this.protocol, raw), + generation, + ); } catch (error) { if (generation === this.generation) this.protocolFailure(safeFrameDecodeFailure(error)); } diff --git a/packages/client/src/omp-protocol-provider-registry.ts b/packages/client/src/omp-protocol-provider-registry.ts index e934840b..45d798a1 100644 --- a/packages/client/src/omp-protocol-provider-registry.ts +++ b/packages/client/src/omp-protocol-provider-registry.ts @@ -1,6 +1,9 @@ +import { OMP_SERVER_EVENT_KINDS } from "@t4-code/protocol"; import { ompAppV1ProtocolProvider } from "./omp-app-v1-protocol-provider.ts"; import type { OmpProtocolProvider } from "./omp-protocol-provider.ts"; +const knownServerEventKinds: ReadonlySet = new Set(OMP_SERVER_EVENT_KINDS); + function registryKey(value: string, label: string): string { const hasControlCharacter = Array.from(value).some((character) => { const codePoint = character.codePointAt(0); @@ -12,6 +15,29 @@ function registryKey(value: string, label: string): string { return value; } +function validateProvider(provider: OmpProtocolProvider): OmpProtocolProvider { + registryKey(provider.id, "id"); + registryKey(provider.protocolVersion, "version"); + if (!Array.isArray(provider.serverEventKinds) || provider.serverEventKinds.length === 0) { + throw new Error(`protocol provider ${provider.id} must declare server event kinds`); + } + if (!Object.isFrozen(provider.serverEventKinds)) { + throw new Error(`protocol provider ${provider.id} server event kinds must be immutable`); + } + const eventKinds = new Set(); + for (const kind of provider.serverEventKinds) { + registryKey(kind, "server event kind"); + if (!knownServerEventKinds.has(kind)) { + throw new Error(`unknown protocol provider server event kind: ${kind}`); + } + if (eventKinds.has(kind)) { + throw new Error(`duplicate protocol provider server event kind: ${kind}`); + } + eventKinds.add(kind); + } + return provider; +} + /** Immutable lookup table for concrete protocol adapters. */ export class OmpProtocolProviderRegistry { readonly providers: readonly OmpProtocolProvider[]; @@ -25,7 +51,8 @@ export class OmpProtocolProviderRegistry { } const byId = new Map(); const byVersion = new Map(); - for (const provider of providers) { + for (const candidate of providers) { + const provider = validateProvider(candidate); const id = registryKey(provider.id, "id"); const version = registryKey(provider.protocolVersion, "version"); if (byId.has(id)) throw new Error(`duplicate protocol provider id: ${id}`); @@ -70,7 +97,7 @@ export function resolveOmpProtocolProvider(options: { if (options.protocolProviderId !== undefined || options.protocolProviderRegistry !== undefined) { throw new Error("direct protocol provider cannot be combined with registry selection"); } - return options.protocolProvider; + return validateProvider(options.protocolProvider); } const registry = options.protocolProviderRegistry ?? defaultOmpProtocolProviderRegistry; return registry.requireById(options.protocolProviderId); diff --git a/packages/client/src/omp-protocol-provider.ts b/packages/client/src/omp-protocol-provider.ts index 259f8340..03f94752 100644 --- a/packages/client/src/omp-protocol-provider.ts +++ b/packages/client/src/omp-protocol-provider.ts @@ -89,6 +89,8 @@ export type OmpClientMessage = export interface OmpProtocolProvider { readonly id: string; readonly protocolVersion: string; + /** Normalized server events this adapter can validate and decode. */ + readonly serverEventKinds: readonly OmpServerEvent["kind"][]; /** Validate and encode one logical T4 message using this provider's wire format. */ encodeClientMessage(message: OmpClientMessage): string; decodeServerEvent(input: unknown): OmpServerEvent; diff --git a/packages/client/src/projection.ts b/packages/client/src/projection.ts index 8557682d..31d40aad 100644 --- a/packages/client/src/projection.ts +++ b/packages/client/src/projection.ts @@ -2,9 +2,9 @@ import { isCursor } from "@t4-code/protocol"; import type { Cursor, DurableEntry, + OmpServerFrame, SessionEvent, SessionRef, - ServerFrame, } from "@t4-code/protocol"; import { ImmutableSet } from "./immutable-set.ts"; import { ImmutableMap } from "./immutable-map.ts"; @@ -36,7 +36,7 @@ import { } from "./transcript-retention.ts"; import type { PublicOmpServerEvent } from "./omp-protocol-provider.ts"; -export type ProjectionFrame = Exclude>; +export type ProjectionFrame = Exclude>; type ProjectionEventFrameFromEvent = Event extends PublicOmpServerEvent ? Readonly<{ type: Event["kind"] } & Event["payload"]> : never; diff --git a/packages/client/test/fixtures/protocol/README.md b/packages/client/test/fixtures/protocol/README.md new file mode 100644 index 00000000..32b5e596 --- /dev/null +++ b/packages/client/test/fixtures/protocol/README.md @@ -0,0 +1,15 @@ +# Protocol provider corpora + +These files are synthetic contract examples. They do not contain captured user traffic, real +credentials, or private host data. + +Each production `OmpProtocolProvider` should have a versioned corpus and run it through +`protocolProviderCorpus`. A corpus records: + +- every logical client message kind and its exact encoded wire frame; +- representative inbound wire frames and their exact normalized T4 events; and +- malformed inputs that the provider must reject. + +`schemaVersion` versions the corpus file shape, not the OMP wire protocol. Change a golden wire or +event only when the provider contract intentionally changes. Do not refresh expected values merely +to make a failing test pass; inspect the decoder or encoder change first. diff --git a/packages/client/test/fixtures/protocol/omp-app-v1-corpus.json b/packages/client/test/fixtures/protocol/omp-app-v1-corpus.json new file mode 100644 index 00000000..4ce62df9 --- /dev/null +++ b/packages/client/test/fixtures/protocol/omp-app-v1-corpus.json @@ -0,0 +1,561 @@ +{ + "schemaVersion": 1, + "provider": { + "id": "omp-app-v1", + "protocolVersion": "omp-app/1" + }, + "outbound": [ + { + "name": "hello-with-resume-and-authentication", + "message": { + "kind": "hello", + "client": { + "name": "t4-code", + "version": "0.1.22", + "build": "corpus", + "platform": "linux" + }, + "requestedFeatures": ["resume"], + "savedCursors": [ + { + "hostId": "host-a", + "sessionId": "session-a", + "cursor": { "epoch": "epoch-1", "seq": 42 } + } + ], + "capabilities": ["sessions.read"], + "authentication": { + "deviceId": "device-1", + "deviceToken": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + } + }, + "wire": { + "v": "omp-app/1", + "type": "hello", + "protocol": { "min": "omp-app/1", "max": "omp-app/1" }, + "client": { + "name": "t4-code", + "version": "0.1.22", + "build": "corpus", + "platform": "linux" + }, + "requestedFeatures": ["resume"], + "savedCursors": [ + { + "hostId": "host-a", + "sessionId": "session-a", + "cursor": { "epoch": "epoch-1", "seq": 42 } + } + ], + "capabilities": { "client": ["sessions.read"] }, + "authentication": { + "deviceId": "device-1", + "deviceToken": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + } + } + }, + { + "name": "session-prompt-command", + "message": { + "kind": "command", + "requestId": "request-command", + "commandId": "command-command", + "hostId": "host-a", + "sessionId": "session-a", + "command": "session.prompt", + "expectedRevision": "revision-1", + "args": { "text": "hello" } + }, + "wire": { + "v": "omp-app/1", + "type": "command", + "requestId": "request-command", + "commandId": "command-command", + "hostId": "host-a", + "sessionId": "session-a", + "command": "session.prompt", + "expectedRevision": "revision-1", + "args": { "message": "hello" } + } + }, + { + "name": "approved-confirmation", + "message": { + "kind": "confirm", + "requestId": "request-confirm", + "confirmationId": "confirmation-1", + "commandId": "command-confirm", + "hostId": "host-a", + "sessionId": "session-a", + "decision": "approve" + }, + "wire": { + "v": "omp-app/1", + "type": "confirm", + "requestId": "request-confirm", + "confirmationId": "confirmation-1", + "commandId": "command-confirm", + "hostId": "host-a", + "sessionId": "session-a", + "decision": "approve" + } + }, + { + "name": "pair-start", + "message": { + "kind": "pair-start", + "requestId": "request-pair", + "code": "483921", + "deviceId": "device-1", + "deviceName": "Corpus device", + "platform": "linux", + "requestedCapabilities": ["sessions.read"] + }, + "wire": { + "v": "omp-app/1", + "type": "pair.start", + "requestId": "request-pair", + "code": "483921", + "deviceId": "device-1", + "deviceName": "Corpus device", + "platform": "linux", + "requestedCapabilities": ["sessions.read"] + } + }, + { + "name": "terminal-input-base64", + "message": { + "kind": "terminal-input", + "hostId": "host-a", + "sessionId": "session-a", + "terminalId": "terminal-1", + "data": "aGVsbG8=", + "encoding": "base64" + }, + "wire": { + "v": "omp-app/1", + "type": "terminal.input", + "hostId": "host-a", + "sessionId": "session-a", + "terminalId": "terminal-1", + "data": "aGVsbG8=", + "encoding": "base64" + } + }, + { + "name": "terminal-resize", + "message": { + "kind": "terminal-resize", + "hostId": "host-a", + "sessionId": "session-a", + "terminalId": "terminal-1", + "cols": 120, + "rows": 40 + }, + "wire": { + "v": "omp-app/1", + "type": "terminal.resize", + "hostId": "host-a", + "sessionId": "session-a", + "terminalId": "terminal-1", + "cols": 120, + "rows": 40 + } + }, + { + "name": "terminal-close", + "message": { + "kind": "terminal-close", + "hostId": "host-a", + "sessionId": "session-a", + "terminalId": "terminal-1", + "reason": "corpus complete" + }, + "wire": { + "v": "omp-app/1", + "type": "terminal.close", + "hostId": "host-a", + "sessionId": "session-a", + "terminalId": "terminal-1", + "reason": "corpus complete" + } + }, + { + "name": "heartbeat-ping", + "message": { + "kind": "ping", + "nonce": "nonce-1", + "timestamp": "2030-01-01T00:00:00.000Z" + }, + "wire": { + "v": "omp-app/1", + "type": "ping", + "nonce": "nonce-1", + "timestamp": "2030-01-01T00:00:00.000Z" + } + } + ], + "inbound": [ + { + "name": "welcome", + "wire": { + "v": "omp-app/1", + "type": "welcome", + "selectedProtocol": "omp-app/1", + "hostId": "host-a", + "ompVersion": "1.0", + "ompBuild": "corpus", + "appserverVersion": "1.0", + "appserverBuild": "corpus", + "epoch": "epoch-2", + "authentication": "local", + "grantedCapabilities": ["sessions.read", "sessions.prompt"], + "grantedFeatures": ["resume"], + "negotiatedLimits": { "maxInputBytes": 1048576 }, + "resumed": false + }, + "event": { + "kind": "welcome", + "payload": { + "selectedProtocol": "omp-app/1", + "hostId": "host-a", + "ompVersion": "1.0", + "ompBuild": "corpus", + "appserverVersion": "1.0", + "appserverBuild": "corpus", + "epoch": "epoch-2", + "authentication": "local", + "grantedCapabilities": ["sessions.read", "sessions.prompt"], + "grantedFeatures": ["resume"], + "negotiatedLimits": { "maxInputBytes": 1048576 }, + "resumed": false + } + } + }, + { + "name": "successful-response", + "wire": { + "v": "omp-app/1", + "type": "response", + "requestId": "request-1", + "commandId": "command-1", + "hostId": "host-a", + "sessionId": "session-a", + "command": "session.prompt", + "ok": true, + "result": { "accepted": true } + }, + "event": { + "kind": "response", + "payload": { + "requestId": "request-1", + "commandId": "command-1", + "hostId": "host-a", + "sessionId": "session-a", + "command": "session.prompt", + "ok": true, + "result": { "accepted": true } + } + } + }, + { + "name": "authorization-error", + "wire": { + "v": "omp-app/1", + "type": "error", + "code": "NOT_AUTHORIZED", + "message": "pairing required", + "requestId": "request-1", + "details": { "retryable": false } + }, + "event": { + "kind": "error", + "payload": { + "code": "NOT_AUTHORIZED", + "message": "pairing required", + "requestId": "request-1", + "details": { "retryable": false } + } + } + }, + { + "name": "session-list", + "wire": { + "v": "omp-app/1", + "type": "sessions", + "hostId": "host-a", + "cursor": { "epoch": "epoch-2", "seq": 3 }, + "sessions": [ + { + "hostId": "host-a", + "sessionId": "session-a", + "project": { "projectId": "project-a", "name": "Corpus" }, + "revision": "revision-10", + "title": "Corpus session", + "status": "active", + "updatedAt": "2030-01-01T00:00:00.000Z", + "liveState": { + "phase": "work", + "sessionControl": { + "mode": "observer", + "lockStatus": "live", + "transcript": "live" + } + }, + "pendingApproval": false, + "pendingUserInput": false + } + ] + }, + "event": { + "kind": "sessions", + "payload": { + "hostId": "host-a", + "cursor": { "epoch": "epoch-2", "seq": 3 }, + "sessions": [ + { + "hostId": "host-a", + "sessionId": "session-a", + "project": { "projectId": "project-a", "name": "Corpus" }, + "revision": "revision-10", + "title": "Corpus session", + "status": "active", + "updatedAt": "2030-01-01T00:00:00.000Z", + "liveState": { + "phase": "work", + "sessionControl": { + "mode": "observer", + "lockStatus": "live", + "transcript": "live" + } + }, + "pendingApproval": false, + "pendingUserInput": false + } + ], + "totalCount": 1, + "truncated": false + } + } + }, + { + "name": "transcript-snapshot", + "wire": { + "v": "omp-app/1", + "type": "snapshot", + "cursor": { "epoch": "epoch-2", "seq": 9 }, + "revision": "revision-10", + "hostId": "host-a", + "sessionId": "session-a", + "entries": [ + { + "id": "entry-1", + "parentId": null, + "hostId": "host-a", + "sessionId": "session-a", + "kind": "message", + "timestamp": "2030-01-01T00:00:00.000Z", + "data": { "role": "user", "text": "hello" } + } + ], + "continuity": { "epoch": "epoch-2" } + }, + "event": { + "kind": "snapshot", + "payload": { + "cursor": { "epoch": "epoch-2", "seq": 9 }, + "revision": "revision-10", + "hostId": "host-a", + "sessionId": "session-a", + "entries": [ + { + "id": "entry-1", + "parentId": null, + "hostId": "host-a", + "sessionId": "session-a", + "kind": "message", + "timestamp": "2030-01-01T00:00:00.000Z", + "data": { "role": "user", "text": "hello" } + } + ], + "continuity": { "epoch": "epoch-2" } + } + } + }, + { + "name": "streaming-event", + "wire": { + "v": "omp-app/1", + "type": "event", + "cursor": { "epoch": "epoch-2", "seq": 11 }, + "hostId": "host-a", + "sessionId": "session-a", + "event": { + "type": "message.delta", + "entryId": "entry-1", + "text": " world" + } + }, + "event": { + "kind": "event", + "payload": { + "cursor": { "epoch": "epoch-2", "seq": 11 }, + "hostId": "host-a", + "sessionId": "session-a", + "event": { + "type": "message.delta", + "entryId": "entry-1", + "text": " world" + } + } + } + }, + { + "name": "confirmation-challenge", + "wire": { + "v": "omp-app/1", + "type": "confirmation", + "confirmationId": "confirmation-1", + "commandId": "command-1", + "hostId": "host-a", + "sessionId": "session-a", + "commandHash": "sha256:abc", + "revision": "revision-1", + "expiresAt": "2030-01-01T00:10:00.000Z", + "summary": "Write src/index.ts", + "preview": "export {}\n" + }, + "event": { + "kind": "confirmation", + "payload": { + "confirmationId": "confirmation-1", + "commandId": "command-1", + "hostId": "host-a", + "sessionId": "session-a", + "commandHash": "sha256:abc", + "revision": "revision-1", + "expiresAt": "2030-01-01T00:10:00.000Z", + "summary": "Write src/index.ts", + "preview": "export {}\n" + } + } + }, + { + "name": "terminal-output", + "wire": { + "v": "omp-app/1", + "type": "terminal.output", + "hostId": "host-a", + "sessionId": "session-a", + "terminalId": "terminal-1", + "cursor": { "epoch": "epoch-2", "seq": 12 }, + "stream": "stdout", + "data": "ready\n" + }, + "event": { + "kind": "terminal.output", + "payload": { + "hostId": "host-a", + "sessionId": "session-a", + "terminalId": "terminal-1", + "cursor": { "epoch": "epoch-2", "seq": 12 }, + "stream": "stdout", + "data": "ready\n" + } + } + }, + { + "name": "continuity-gap", + "wire": { + "v": "omp-app/1", + "type": "gap", + "hostId": "host-a", + "sessionId": "session-a", + "from": { "epoch": "epoch-2", "seq": 13 }, + "to": { "epoch": "epoch-2", "seq": 18 }, + "reason": "reconnect" + }, + "event": { + "kind": "gap", + "payload": { + "hostId": "host-a", + "sessionId": "session-a", + "from": { "epoch": "epoch-2", "seq": 13 }, + "to": { "epoch": "epoch-2", "seq": 18 }, + "reason": "reconnect" + } + } + }, + { + "name": "heartbeat-pong", + "wire": { + "v": "omp-app/1", + "type": "pong", + "nonce": "nonce-1", + "timestamp": "2030-01-01T00:00:01.000Z" + }, + "event": { + "kind": "pong", + "payload": { + "nonce": "nonce-1", + "timestamp": "2030-01-01T00:00:01.000Z" + } + } + }, + { + "name": "pair-ok", + "wire": { + "v": "omp-app/1", + "type": "pair.ok", + "requestId": "request-pair", + "pairingId": "pairing-1", + "deviceId": "device-1", + "deviceName": "Corpus device", + "platform": "linux", + "requestedCapabilities": ["sessions.read"], + "grantedCapabilities": ["sessions.read"], + "deviceToken": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "expiresAt": "2030-01-01T00:10:00.000Z" + }, + "event": { + "kind": "pair.ok", + "payload": { + "requestId": "request-pair", + "pairingId": "pairing-1", + "deviceId": "device-1", + "deviceName": "Corpus device", + "platform": "linux", + "requestedCapabilities": ["sessions.read"], + "grantedCapabilities": ["sessions.read"], + "deviceToken": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "expiresAt": "2030-01-01T00:10:00.000Z" + } + } + } + ], + "invalidInbound": [ + { "name": "null", "wire": null }, + { "name": "empty-object", "wire": {} }, + { + "name": "wrong-protocol-version", + "wire": { + "v": "omp-app/2", + "type": "welcome", + "selectedProtocol": "omp-app/2", + "hostId": "host-a" + } + }, + { + "name": "unknown-frame-kind", + "wire": { "v": "omp-app/1", "type": "future.event" } + }, + { + "name": "incomplete-welcome", + "wire": { + "v": "omp-app/1", + "type": "welcome", + "selectedProtocol": "omp-app/1" + } + } + ] +} diff --git a/packages/client/test/protocol-provider-canonical-fixtures.test.ts b/packages/client/test/protocol-provider-canonical-fixtures.test.ts new file mode 100644 index 00000000..80c341d9 --- /dev/null +++ b/packages/client/test/protocol-provider-canonical-fixtures.test.ts @@ -0,0 +1,84 @@ +import { readFileSync, readdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + OMP_SERVER_EVENT_KINDS, + decodeClientFrame, +} from "@t4-code/protocol"; +import { describe, expect, it } from "vite-plus/test"; + +import { ompAppV1ProtocolProvider } from "../src/index.ts"; + +const protocolEntry = fileURLToPath(import.meta.resolve("@t4-code/protocol")); +const protocolRoot = dirname(dirname(protocolEntry)); +const fixtureRoot = join( + protocolRoot, + "node_modules", + "@oh-my-pi", + "app-wire", + "fixtures", + "v1", +); +const fixtureNames = readdirSync(fixtureRoot).filter((name) => name.endsWith(".json")).sort(); +const STRUCTURAL_FIXTURES = new Set(["entry.json"]); + +function fixture(name: string): unknown { + return JSON.parse(readFileSync(join(fixtureRoot, name), "utf8")) as unknown; +} + +function isCanonicalClientFrame(input: unknown): boolean { + try { + decodeClientFrame(input); + return true; + } catch { + return false; + } +} + +describe("omp-app/1 canonical fixture coverage", () => { + it("declares every normalized event kind in the pinned contract", () => { + expect(ompAppV1ProtocolProvider.serverEventKinds).toEqual(OMP_SERVER_EVENT_KINDS); + expect(ompAppV1ProtocolProvider.serverEventKinds).not.toContain("pair.start"); + }); + + it("classifies and validates every fixture shipped by app-wire", () => { + const serverKinds = new Set(); + let clientFixtures = 0; + let invalidFixtures = 0; + let structuralFixtures = 0; + + for (const name of fixtureNames) { + const input = fixture(name); + if (name.endsWith(".invalid.json")) { + invalidFixtures += 1; + expect(() => ompAppV1ProtocolProvider.decodeServerEvent(input), name).toThrow(); + continue; + } + if (STRUCTURAL_FIXTURES.has(name)) { + structuralFixtures += 1; + expect(() => ompAppV1ProtocolProvider.decodeServerEvent(input), name).toThrow(); + continue; + } + if (isCanonicalClientFrame(input)) { + clientFixtures += 1; + expect(() => ompAppV1ProtocolProvider.decodeServerEvent(input), name).toThrow(); + continue; + } + + const event = ompAppV1ProtocolProvider.decodeServerEvent(input); + const rawType = (input as { readonly type?: unknown }).type; + expect(event.kind, name).toBe(rawType); + expect(ompAppV1ProtocolProvider.serverEventKinds, name).toContain(event.kind); + expect(event.payload, name).not.toHaveProperty("v"); + expect(event.payload, name).not.toHaveProperty("type"); + expect(Object.isFrozen(event), name).toBe(true); + expect(Object.isFrozen(event.payload), name).toBe(true); + serverKinds.add(event.kind); + } + + expect(serverKinds.size).toBeGreaterThanOrEqual(20); + expect(clientFixtures).toBeGreaterThanOrEqual(5); + expect(invalidFixtures).toBeGreaterThanOrEqual(3); + expect(structuralFixtures).toBe(STRUCTURAL_FIXTURES.size); + }); +}); diff --git a/packages/client/test/protocol-provider-conformance.ts b/packages/client/test/protocol-provider-conformance.ts index 2d6d4820..31ef2cda 100644 --- a/packages/client/test/protocol-provider-conformance.ts +++ b/packages/client/test/protocol-provider-conformance.ts @@ -54,9 +54,18 @@ export function protocolProviderConformance( expect(decoded.payload).not.toHaveProperty("type"); expect(Object.isFrozen(decoded)).toBe(true); expect(Object.isFrozen(decoded.payload)).toBe(true); + expect(options.provider.serverEventKinds).toContain(decoded.kind); } }); + it("declares one immutable normalized server event vocabulary", () => { + expect(options.provider.serverEventKinds.length).toBeGreaterThan(0); + expect(new Set(options.provider.serverEventKinds).size).toBe( + options.provider.serverEventKinds.length, + ); + expect(Object.isFrozen(options.provider.serverEventKinds)).toBe(true); + }); + it("declares the protocol selected by its welcome event", () => { const welcome = options.inboundFrames .map((input) => options.provider.decodeServerEvent(input)) diff --git a/packages/client/test/protocol-provider-corpus.test.ts b/packages/client/test/protocol-provider-corpus.test.ts new file mode 100644 index 00000000..5b4461ee --- /dev/null +++ b/packages/client/test/protocol-provider-corpus.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { ompAppV1ProtocolProvider } from "../src/index.ts"; +import { + decodeProtocolProviderCorpus, + loadProtocolProviderCorpus, + protocolProviderCorpus, +} from "./protocol-provider-corpus.ts"; + +const corpus = loadProtocolProviderCorpus( + new URL("./fixtures/protocol/omp-app-v1-corpus.json", import.meta.url), +); + +protocolProviderCorpus({ provider: ompAppV1ProtocolProvider, corpus }); + +describe("protocol provider corpus schema", () => { + it("rejects an unknown corpus schema before running provider tests", () => { + expect(() => decodeProtocolProviderCorpus({ schemaVersion: 2 })).toThrow( + "unsupported protocol corpus schema", + ); + }); + + it("rejects an incomplete corpus before it can hide missing coverage", () => { + expect(() => + decodeProtocolProviderCorpus({ + schemaVersion: 1, + provider: { id: "omp-app-v1", protocolVersion: "omp-app/1" }, + outbound: [], + inbound: [], + invalidInbound: [], + }), + ).toThrow("protocol corpus outbound must be a non-empty array"); + }); + + it("rejects unknown logical messages and malformed normalized events", () => { + const base = { + schemaVersion: 1, + provider: { id: "omp-app-v1", protocolVersion: "omp-app/1" }, + invalidInbound: [{ name: "invalid", wire: null }], + }; + expect(() => + decodeProtocolProviderCorpus({ + ...base, + outbound: [{ name: "future", message: { kind: "future" }, wire: {} }], + inbound: [{ name: "welcome", wire: {}, event: { kind: "welcome", payload: {} } }], + }), + ).toThrow("unknown protocol corpus outbound kind"); + expect(() => + decodeProtocolProviderCorpus({ + ...base, + outbound: [{ name: "ping", message: { kind: "ping" }, wire: {} }], + inbound: [{ name: "missing-kind", wire: {}, event: { kind: "", payload: {} } }], + }), + ).toThrow("event.kind must be a non-empty string"); + }); +}); diff --git a/packages/client/test/protocol-provider-corpus.ts b/packages/client/test/protocol-provider-corpus.ts new file mode 100644 index 00000000..b6c80b6b --- /dev/null +++ b/packages/client/test/protocol-provider-corpus.ts @@ -0,0 +1,188 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vite-plus/test"; + +import type { + OmpClientMessage, + OmpProtocolProvider, + OmpServerEvent, +} from "../src/index.ts"; + +const OUTBOUND_KINDS = [ + "hello", + "command", + "confirm", + "pair-start", + "terminal-input", + "terminal-resize", + "terminal-close", + "ping", +] as const satisfies readonly OmpClientMessage["kind"][]; + +const REPRESENTATIVE_INBOUND_KINDS = [ + "welcome", + "response", + "error", + "sessions", + "snapshot", + "event", + "confirmation", + "terminal.output", + "gap", + "pong", + "pair.ok", +] as const satisfies readonly OmpServerEvent["kind"][]; + +interface ProtocolCorpusCase { + readonly name: string; +} + +interface ProtocolCorpusOutboundCase extends ProtocolCorpusCase { + readonly message: OmpClientMessage; + readonly wire: Readonly>; +} + +interface ProtocolCorpusInboundCase extends ProtocolCorpusCase { + readonly wire: Readonly>; + readonly event: OmpServerEvent; +} + +interface ProtocolCorpusInvalidCase extends ProtocolCorpusCase { + readonly wire: unknown; +} + +export interface ProtocolProviderCorpus { + readonly schemaVersion: 1; + readonly provider: { + readonly id: string; + readonly protocolVersion: string; + }; + readonly outbound: readonly ProtocolCorpusOutboundCase[]; + readonly inbound: readonly ProtocolCorpusInboundCase[]; + readonly invalidInbound: readonly ProtocolCorpusInvalidCase[]; +} + +function record(value: unknown, label: string): Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } + return value as Record; +} + +function nonEmptyString(value: unknown, label: string): string { + if (typeof value !== "string" || value.length === 0) { + throw new Error(`${label} must be a non-empty string`); + } + return value; +} + +function cases(value: unknown, label: string): Record[] { + if (!Array.isArray(value) || value.length === 0) { + throw new Error(`${label} must be a non-empty array`); + } + return value.map((item, index) => record(item, `${label}[${index}]`)); +} + +export function decodeProtocolProviderCorpus(input: unknown): ProtocolProviderCorpus { + const corpus = record(input, "protocol corpus"); + if (corpus.schemaVersion !== 1) throw new Error("unsupported protocol corpus schema"); + const provider = record(corpus.provider, "protocol corpus provider"); + const outbound = cases(corpus.outbound, "protocol corpus outbound").map((entry, index) => { + const message = record(entry.message, `protocol corpus outbound[${index}].message`); + if (!OUTBOUND_KINDS.includes(message.kind as OmpClientMessage["kind"])) { + throw new Error(`unknown protocol corpus outbound kind: ${String(message.kind)}`); + } + return Object.freeze({ + name: nonEmptyString(entry.name, `protocol corpus outbound[${index}].name`), + message: message as unknown as OmpClientMessage, + wire: record(entry.wire, `protocol corpus outbound[${index}].wire`), + }); + }); + const inbound = cases(corpus.inbound, "protocol corpus inbound").map((entry, index) => { + const event = record(entry.event, `protocol corpus inbound[${index}].event`); + nonEmptyString(event.kind, `protocol corpus inbound[${index}].event.kind`); + record(event.payload, `protocol corpus inbound[${index}].event.payload`); + return Object.freeze({ + name: nonEmptyString(entry.name, `protocol corpus inbound[${index}].name`), + wire: record(entry.wire, `protocol corpus inbound[${index}].wire`), + event: event as unknown as OmpServerEvent, + }); + }); + const invalidInbound = cases(corpus.invalidInbound, "protocol corpus invalidInbound").map( + (entry, index) => + Object.freeze({ + name: nonEmptyString(entry.name, `protocol corpus invalidInbound[${index}].name`), + wire: entry.wire, + }), + ); + return Object.freeze({ + schemaVersion: 1, + provider: Object.freeze({ + id: nonEmptyString(provider.id, "protocol corpus provider.id"), + protocolVersion: nonEmptyString( + provider.protocolVersion, + "protocol corpus provider.protocolVersion", + ), + }), + outbound: Object.freeze(outbound), + inbound: Object.freeze(inbound), + invalidInbound: Object.freeze(invalidInbound), + }); +} + +export function loadProtocolProviderCorpus(url: URL): ProtocolProviderCorpus { + return decodeProtocolProviderCorpus(JSON.parse(readFileSync(url, "utf8")) as unknown); +} + +function uniqueCaseNames(corpus: ProtocolProviderCorpus): string[] { + return [...corpus.outbound, ...corpus.inbound, ...corpus.invalidInbound].map( + (entry) => entry.name, + ); +} + +/** Exact checked-in wire examples every concrete protocol provider must satisfy. */ +export function protocolProviderCorpus(options: { + readonly provider: OmpProtocolProvider; + readonly corpus: ProtocolProviderCorpus; +}): void { + const { provider, corpus } = options; + describe(`${provider.protocolVersion} golden protocol corpus`, () => { + it("belongs to the selected provider and has unique case names", () => { + expect(corpus.provider).toEqual({ + id: provider.id, + protocolVersion: provider.protocolVersion, + }); + const names = uniqueCaseNames(corpus); + expect(new Set(names).size).toBe(names.length); + }); + + it("encodes every logical client message into its exact wire frame", () => { + expect(new Set(corpus.outbound.map((entry) => entry.message.kind))).toEqual( + new Set(OUTBOUND_KINDS), + ); + for (const entry of corpus.outbound) { + expect(JSON.parse(provider.encodeClientMessage(entry.message))).toEqual(entry.wire); + } + }); + + it("decodes representative wire frames into exact normalized events", () => { + expect(new Set(corpus.inbound.map((entry) => entry.event.kind))).toEqual( + new Set(REPRESENTATIVE_INBOUND_KINDS), + ); + for (const entry of corpus.inbound) { + expect(provider.serverEventKinds).toContain(entry.event.kind); + const fromObject = provider.decodeServerEvent(entry.wire); + const fromText = provider.decodeServerEvent(JSON.stringify(entry.wire)); + expect.soft(fromObject, entry.name).toEqual(entry.event); + expect.soft(fromText, `${entry.name} from JSON text`).toEqual(entry.event); + expect(Object.isFrozen(fromObject)).toBe(true); + expect(Object.isFrozen(fromObject.payload)).toBe(true); + } + }); + + it("fails closed for every invalid wire example", () => { + for (const entry of corpus.invalidInbound) { + expect(() => provider.decodeServerEvent(entry.wire)).toThrow(); + } + }); + }); +} diff --git a/packages/client/test/protocol-provider-registry.test.ts b/packages/client/test/protocol-provider-registry.test.ts index 112979a4..6a3afa31 100644 --- a/packages/client/test/protocol-provider-registry.test.ts +++ b/packages/client/test/protocol-provider-registry.test.ts @@ -1,4 +1,4 @@ -import { hostId } from "@t4-code/protocol"; +import { hostId, OMP_SERVER_EVENT_KINDS } from "@t4-code/protocol"; import { describe, expect, it } from "vite-plus/test"; import { @@ -12,6 +12,11 @@ import { type OmpTransport, } from "../src/index.ts"; import { protocolProviderConformance } from "./protocol-provider-conformance.ts"; +import { + loadProtocolProviderCorpus, + protocolProviderCorpus, + type ProtocolProviderCorpus, +} from "./protocol-provider-corpus.ts"; const FUTURE_ID = "fixture-v2"; const FUTURE_PROTOCOL = "omp-app/2"; @@ -37,7 +42,11 @@ function futureWelcome(): OmpServerEventOf<"welcome"> { } function futureWireWelcome(): Readonly> { - return Object.freeze({ protocol: FUTURE_PROTOCOL, event: futureWelcome() }); + return futureWireEvent(futureWelcome()); +} + +function futureWireEvent(event: OmpServerEvent): Readonly> { + return Object.freeze({ protocol: FUTURE_PROTOCOL, event }); } function record(value: unknown): Record { @@ -50,6 +59,7 @@ function record(value: unknown): Record { const futureProvider: OmpProtocolProvider = Object.freeze({ id: FUTURE_ID, protocolVersion: FUTURE_PROTOCOL, + serverEventKinds: OMP_SERVER_EVENT_KINDS, encodeClientMessage(message: OmpClientMessage): string { return JSON.stringify({ protocol: FUTURE_PROTOCOL, message }); }, @@ -57,12 +67,20 @@ const futureProvider: OmpProtocolProvider = Object.freeze({ const envelope = record(typeof input === "string" ? JSON.parse(input) : input); if (envelope.protocol !== FUTURE_PROTOCOL) throw new Error("future protocol mismatch"); const event = record(envelope.event); - if (event.kind !== "welcome") throw new Error("future welcome required"); const payload = record(event.payload); - if (payload.selectedProtocol !== FUTURE_PROTOCOL || payload.hostId !== "future-host") { + if (typeof event.kind !== "string" || event.kind.length === 0) { + throw new Error("future event kind required"); + } + if ( + event.kind === "welcome" && + payload.selectedProtocol !== FUTURE_PROTOCOL + ) { throw new Error("invalid future welcome"); } - return futureWelcome(); + return Object.freeze({ + kind: event.kind, + payload: Object.freeze({ ...payload }), + }) as OmpServerEvent; }, commandDescriptor: (command: string) => ompAppV1ProtocolProvider.commandDescriptor(command), requiredCapability: (command: string) => ompAppV1ProtocolProvider.requiredCapability(command), @@ -107,6 +125,53 @@ protocolProviderConformance({ knownCommand: { name: "session.list", capability: "sessions.read" }, }); +const appV1Corpus = loadProtocolProviderCorpus( + new URL("./fixtures/protocol/omp-app-v1-corpus.json", import.meta.url), +); +function futureCorpusEvent(event: OmpServerEvent): OmpServerEvent { + if (event.kind !== "welcome") return event; + return Object.freeze({ + ...event, + payload: Object.freeze({ ...event.payload, selectedProtocol: FUTURE_PROTOCOL }), + }); +} +const futureCorpus: ProtocolProviderCorpus = Object.freeze({ + schemaVersion: 1, + provider: Object.freeze({ id: FUTURE_ID, protocolVersion: FUTURE_PROTOCOL }), + outbound: Object.freeze( + appV1Corpus.outbound.map((entry) => + Object.freeze({ + name: entry.name, + message: entry.message, + wire: Object.freeze({ protocol: FUTURE_PROTOCOL, message: entry.message }), + }), + ), + ), + inbound: Object.freeze( + appV1Corpus.inbound.map((entry) => { + const event = futureCorpusEvent(entry.event); + return Object.freeze({ + name: entry.name, + wire: futureWireEvent(event), + event, + }); + }), + ), + invalidInbound: Object.freeze([ + Object.freeze({ name: "missing-envelope", wire: {} }), + Object.freeze({ + name: "wrong-protocol", + wire: { protocol: "omp-app/1", event: futureWelcome() }, + }), + Object.freeze({ + name: "missing-event", + wire: { protocol: FUTURE_PROTOCOL }, + }), + ]), +}); + +protocolProviderCorpus({ provider: futureProvider, corpus: futureCorpus }); + describe("OmpProtocolProviderRegistry", () => { it("indexes immutable providers by id and protocol version", () => { const registry = new OmpProtocolProviderRegistry( @@ -137,6 +202,44 @@ describe("OmpProtocolProviderRegistry", () => { expect(() => new OmpProtocolProviderRegistry([futureProvider], "missing")).toThrow("unknown default"); }); + it("rejects incomplete, duplicate, and unknown event declarations", () => { + expect( + () => + new OmpProtocolProviderRegistry([ + { ...futureProvider, serverEventKinds: [] }, + ]), + ).toThrow("must declare server event kinds"); + expect( + () => + new OmpProtocolProviderRegistry([ + { + ...futureProvider, + serverEventKinds: [...OMP_SERVER_EVENT_KINDS], + }, + ]), + ).toThrow("server event kinds must be immutable"); + expect( + () => + new OmpProtocolProviderRegistry([ + { + ...futureProvider, + serverEventKinds: Object.freeze([...OMP_SERVER_EVENT_KINDS, "welcome"]), + }, + ]), + ).toThrow("duplicate protocol provider server event kind"); + expect( + () => + new OmpProtocolProviderRegistry([ + { + ...futureProvider, + serverEventKinds: Object.freeze([ + "future.event", + ]) as unknown as OmpProtocolProvider["serverEventKinds"], + }, + ]), + ).toThrow("unknown protocol provider server event kind"); + }); + it("connects through a selected provider with a different wire shape", async () => { const registry = new OmpProtocolProviderRegistry([ompAppV1ProtocolProvider, futureProvider]); const transport = new FutureTransport(); @@ -187,5 +290,12 @@ describe("OmpProtocolProviderRegistry", () => { protocolProviderRegistry: registry, }), ).toThrow("unknown protocol provider"); + expect( + () => + new OmpClient({ + transport, + protocolProvider: { ...futureProvider, serverEventKinds: [] }, + }), + ).toThrow("must declare server event kinds"); }); }); diff --git a/packages/client/test/protocol-provider.test.ts b/packages/client/test/protocol-provider.test.ts index 981f4986..993d3257 100644 --- a/packages/client/test/protocol-provider.test.ts +++ b/packages/client/test/protocol-provider.test.ts @@ -120,10 +120,13 @@ class HandshakeTransport implements OmpTransport { return () => undefined; } close(): void {} + emit(input: unknown): void { + for (const listener of this.messages) listener(JSON.stringify(input)); + } send(data: string): void { const frame = decodeClientFrame(data); if (frame.type !== "hello") return; - for (const listener of this.messages) listener(JSON.stringify(welcomeFrame())); + this.emit(welcomeFrame()); } } @@ -280,4 +283,31 @@ describe("OmpProtocolProvider", () => { expect(client.state).toBe("ready"); await client.close(); }); + + it("fails closed when a provider returns an undeclared server event", async () => { + const transport = new HandshakeTransport(); + const provider: OmpProtocolProvider = { + ...ompAppV1ProtocolProvider, + serverEventKinds: Object.freeze(["welcome"]), + }; + const client = new OmpClient({ + hostId: "provider-host", + protocolProvider: provider, + transport: () => transport, + }); + const errors: string[] = []; + client.onError((error) => errors.push(error.message)); + + await client.connect(); + transport.emit({ + v: "omp-app/1", + type: "pong", + nonce: "unexpected", + timestamp: "2030-01-01T00:00:00.000Z", + }); + + expect(client.state).toBe("fatal"); + expect(errors).toEqual(["protocol provider returned an undeclared server event"]); + await client.close(); + }); }); diff --git a/packages/protocol/src/desktop-ipc.ts b/packages/protocol/src/desktop-ipc.ts index 1be50efe..c4d7c0e8 100644 --- a/packages/protocol/src/desktop-ipc.ts +++ b/packages/protocol/src/desktop-ipc.ts @@ -13,12 +13,12 @@ import { type ConfirmationId, type HostId, type ResultError, - type ServerFrame, type SessionId, type TerminalId, } from "@oh-my-pi/app-wire"; import { ompServerEventFromFrame, + type OmpServerFrame, type PublicOmpServerEvent, } from "./server-event.ts"; import { @@ -32,7 +32,7 @@ export type { DesktopUpdatePhase, DesktopUpdateState } from "./app-update.ts"; export type { PairLinkEvent } from "./pair-link.ts"; export const DESKTOP_IPC_VERSION = PROTOCOL_VERSION; -export type RendererServerFrame = Exclude; +export type RendererServerFrame = Exclude; export type RendererServerEvent = PublicOmpServerEvent; export function rendererServerEventFromFrame(frame: RendererServerFrame): RendererServerEvent { diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index 7147abf2..200ef7a9 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -8,6 +8,7 @@ import { type SessionRef, type SessionsFrame, } from "@oh-my-pi/app-wire"; +import type { OmpServerFrame } from "./server-event.ts"; export * from "@oh-my-pi/app-wire"; export * from "./app-update.ts"; @@ -210,11 +211,11 @@ function decodeResponse(input: Record): ServerFrame { * present control shape that this client does not understand. The immutable * app-wire decoder stays strict; valid known frames retain their exact shape. */ -export function decodeServerFrame(input: unknown): ServerFrame { +export function decodeServerFrame(input: unknown): OmpServerFrame { const value = materialize(input); - if (!isRecord(value)) return decodeAppWireServerFrame(value); + if (!isRecord(value)) return decodeAppWireServerFrame(value) as OmpServerFrame; if (value.type === "sessions") return decodeSessions(value); - if (value.type === "session.delta") return decodeSessionDelta(value); - if (value.type === "response") return decodeResponse(value); - return decodeAppWireServerFrame(value); + if (value.type === "session.delta") return decodeSessionDelta(value) as OmpServerFrame; + if (value.type === "response") return decodeResponse(value) as OmpServerFrame; + return decodeAppWireServerFrame(value) as OmpServerFrame; } diff --git a/packages/protocol/src/server-event.ts b/packages/protocol/src/server-event.ts index 16763b7f..2742fa68 100644 --- a/packages/protocol/src/server-event.ts +++ b/packages/protocol/src/server-event.ts @@ -1,5 +1,61 @@ import type { ServerFrame } from "@oh-my-pi/app-wire"; +/** Server frames the upstream decoder can actually emit. */ +export type OmpServerFrame = Exclude; + +const OMP_SERVER_EVENT_KIND_MEMBERS = { + welcome: true, + sessions: true, + snapshot: true, + entry: true, + event: true, + agent: true, + terminal: true, + files: true, + review: true, + audit: true, + "pair.ok": true, + "pair.error": true, + confirmation: true, + response: true, + gap: true, + error: true, + pong: true, + bye: true, + "host.watch": true, + "session.watch": true, + "session.state": true, + "session.delta": true, + lease: true, + "prompt.lease": true, + "agent.state": true, + "agent.lifecycle": true, + "agent.progress": true, + "agent.event": true, + "agent.transcript": true, + "terminal.output": true, + "terminal.exit": true, + "files.list": true, + "files.read": true, + "files.write": true, + "files.patch": true, + "files.diff": true, + "audit.tail": true, + "audit.event": true, + catalog: true, + settings: true, + "preview.launch": true, + "preview.state": true, + "preview.navigation": true, + "preview.capture": true, + "preview.error": true, +} as const satisfies Record; + +/** Exhaustive normalized event vocabulary for the pinned OMP server contract. */ +export const OMP_SERVER_EVENT_KINDS: readonly OmpServerFrame["type"][] = Object.freeze( + Object.keys(OMP_SERVER_EVENT_KIND_MEMBERS) as OmpServerFrame["type"][], +); + type KnownFields = { [Key in keyof Value as string extends Key ? never @@ -14,20 +70,20 @@ type PreservedIndex = string extends keyof Value ? Readonly> : object; -type NormalizeProtocolFields = +type NormalizeProtocolFields = Frame extends { type: "welcome" } ? Omit & { readonly selectedProtocol: string } : Payload; -export type OmpServerEventPayload = Readonly< +export type OmpServerEventPayload = Readonly< NormalizeProtocolFields< Frame, Omit, "v" | "type"> & PreservedIndex > >; -export type OmpServerEventFromFrame = - Frame extends ServerFrame +export type OmpServerEventFromFrame = + Frame extends OmpServerFrame ? Readonly<{ kind: Frame["type"]; payload: OmpServerEventPayload; @@ -35,12 +91,12 @@ export type OmpServerEventFromFrame = : never; /** Stable, version-free event union shared by protocol providers and applications. */ -export type OmpServerEvent = OmpServerEventFromFrame; +export type OmpServerEvent = OmpServerEventFromFrame; /** Pairing credentials never cross application-facing event boundaries. */ export type PublicOmpServerEvent = Exclude; -export function ompServerEventFromFrame( +export function ompServerEventFromFrame( frame: Frame, ): OmpServerEventFromFrame { const { v: _version, type, ...payload } = frame; diff --git a/packages/protocol/test/server-event.test.ts b/packages/protocol/test/server-event.test.ts index 9275be30..be278b81 100644 --- a/packages/protocol/test/server-event.test.ts +++ b/packages/protocol/test/server-event.test.ts @@ -1,5 +1,6 @@ import { hostId, + OMP_SERVER_EVENT_KINDS, ompServerEventFromFrame, pairingId, requestId, @@ -47,6 +48,15 @@ function pairOk(): PairOkFrame { } describe("shared server events", () => { + it("publishes the complete immutable server event vocabulary", () => { + expect(OMP_SERVER_EVENT_KINDS).toHaveLength(45); + expect(new Set(OMP_SERVER_EVENT_KINDS).size).toBe(OMP_SERVER_EVENT_KINDS.length); + expect(Object.isFrozen(OMP_SERVER_EVENT_KINDS)).toBe(true); + expect(OMP_SERVER_EVENT_KINDS).toContain("welcome"); + expect(OMP_SERVER_EVENT_KINDS).toContain("preview.error"); + expect(OMP_SERVER_EVENT_KINDS).not.toContain("pair.start"); + }); + it("removes wire envelope fields and freezes the normalized boundary", () => { const event = ompServerEventFromFrame(welcome()); const rendererEvent: RendererServerEvent = event;