/** * The hook process boundary's wire protocol. * * A hook runs in its own `bun` process and reaches celilo — the database, the * master key, the live capability objects — only through these frames. See * `openspec/changes/hook-process-boundary/design.md`, D2 and D3. * * Newline-delimited JSON over a Unix socket, Zod-validated, in two * discriminated unions. Modelled directly on `packages/core/src/protocol.ts`, * which does the same job for the remote CLI. * * **Why a socket and not stdout.** Seventeen module script files spawn * subprocesses. A grandchild writing raw bytes to fd 1 would corrupt the frame * stream and no JS-level capture prevents it. The remote CLI gets away with * stdout because it *translates* every unrecognised line into a log message; * here the same line could be half a frame. The child's stdout and stderr stay * exactly what they are — human output the parent forwards to the logger. * * **Where this lives.** Both ends are framework code: the broker in * `broker.ts` and the runner shim in `hook-runner.ts`, both under * `apps/celilo/src/hooks/`. Neither side imports the other, so the protocol * needs no package of its own and `@celilo/capabilities` — which every MODULE * imports — stays out of it. A hook script never sees these types. */ import { z } from 'zod'; export const HOOK_PROTOCOL_VERSION = 2; /** Environment variable carrying the broker's socket path to the child. */ export const HOOK_SOCKET_ENV = 'CELILO_HOOK_SOCKET'; /** Environment variable carrying the parent's protocol version to the child. */ export const HOOK_PROTOCOL_VERSION_ENV = 'CELILO_HOOK_PROTOCOL_VERSION'; /** * Environment variable carrying the remote-ops broker's socket path (stage 3, * design D12). The ASKING half lives in `@celilo/capabilities`' remote * primitives, which a module bundles — so that side names this variable as a * string literal of its own (`packages/capabilities/src/remote.ts`), and the * two must agree the way the socket framing must. */ export const HOOK_REMOTE_SOCKET_ENV = 'CELILO_HOOK_REMOTE_SOCKET'; /** * Environment variable carrying the derived mount set to the child, as JSON * (task 4.7, the unjailed advisory lint). * * Set ONLY when the run is unjailed AND a mount set exists — its presence is * the shim's signal to install the lint, so a jailed run carries nothing. The * environment is the right channel rather than a protocol frame because this * is spawn-time configuration of the shim, exactly like the two socket paths * above, and because the value is finalised in the same planning step that * picks the spawn command. The child validates it with `MountSetWireSchema` * before use; it is written by celilo and read by celilo, but it crosses a * process boundary and gets the same treatment as any other payload. */ export const HOOK_MOUNT_SET_ENV = 'CELILO_HOOK_MOUNT_SET'; /** * An error crossing the boundary. * * `fields` is what carries `MissingProviderInputError`'s `providerModuleId` / * `ensureId` / `value` / `humanContext` (design D6). The framework READS that * error rather than displaying it — `invokeHook` inspects it to drive the * cross-module ensure interview — so those four fields have to survive the * round trip intact. They do, because `isMissingProviderInputError` is * duck-typed rather than `instanceof`: it was written that way for a module's * bundled copy of `@celilo/capabilities` (celilo#173), and a process is one * more of the same boundary. */ export const HookErrorSchema = z.object({ name: z.string(), message: z.string(), stack: z.string().optional(), fields: z.record(z.unknown()).optional(), }); export type HookError = z.infer; // ── child → parent ──────────────────────────────────────────────────────── /** Handshake. The parent checks the version and refuses a mismatch. */ export const ReadyFrameSchema = z.object({ type: z.literal('ready'), protocolVersion: z.number().int(), }); /** A capability method call, correlated with its `return`/`throw` by `id`. */ export const CallFrameSchema = z.object({ type: z.literal('call'), id: z.string(), capability: z.string(), method: z.string(), args: z.array(z.unknown()), }); /** * One hook-owned-state store operation, correlated like a `call` (and answered * by the same `return`/`throw` frames — the runner's pending map keys on `id` * alone). * * `store` names which of the module's two stores the call targets; `method` is * one of the four `HookStore` operations. A `transaction` call carries the * buffered operation list the CHILD accumulated — the hook's `fn` ran entirely * child-side, so a throwing `fn` never sends anything, which is what makes the * discard real rather than a promise the parent has to honor. * * Hook-owned-state design: the accessor is implemented once broker-side and * reaches the hook as its own RPC family over this envelope, NOT as a fake * capability — a fake one would surface in the `capabilities` shape frame and * appear to the hook as a provider module. */ export const StoreCallFrameSchema = z.object({ type: z.literal('store'), id: z.string(), store: z.enum(['secrets', 'config']), method: z.enum(['get', 'set', 'delete', 'transaction']), args: z.array(z.unknown()), }); /** One buffered operation inside a `transaction` store call. */ export const BufferedStoreOpSchema = z.object({ op: z.enum(['set', 'delete']), name: z.string(), value: z.string().optional(), }); export type BufferedStoreOp = z.infer; /** One `ctx.logger` call. Fire and forget — the hook does not wait on it. */ export const LogFrameSchema = z.object({ type: z.literal('log'), level: z.enum(['info', 'warn', 'error', 'success']), message: z.string(), }); /** The hook returned. Terminal. */ export const ResultFrameSchema = z.object({ type: z.literal('result'), outputs: z.record(z.unknown()), }); /** The hook threw. Terminal. Distinct from a `throw` answering one `call`. */ export const HookThrewFrameSchema = z.object({ type: z.literal('throw'), error: HookErrorSchema, }); export const ChildFrameSchema = z.discriminatedUnion('type', [ ReadyFrameSchema, CallFrameSchema, StoreCallFrameSchema, LogFrameSchema, ResultFrameSchema, HookThrewFrameSchema, ]); export type ChildFrame = z.infer; // ── parent → child ──────────────────────────────────────────────────────── /** * Everything in `HookContext` that is plain data: the hook's inputs, `config`, * `secrets`, `systems`, `debug`, `screenshotDir`. Not `logger` and not * `capabilities` — the shim rebuilds both from frames. */ export const ContextFrameSchema = z.object({ type: z.literal('context'), protocolVersion: z.number().int(), scriptPath: z.string(), context: z.record(z.unknown()), }); /** * One row of the mount set, on the wire. * * Structural twin of `mount-set.ts`'s `MountEntry`. Duplicated as a schema * rather than imported because this file is the wire contract and the wire * must not grow a compile-time dependency on the derivation's internals — * `hook-protocol.ts`'s docblock records who may import whom. */ export const MountEntrySchema = z.object({ path: z.string(), mode: z.enum(['ro', 'rw', 'tmpfs']), reason: z.string(), // Kept on the wire so this stays the structural twin its docblock below // claims. The child ignores it — absence is decided when the set is derived, // not by the hook — but a twin that has quietly stopped being one is how the // round-trip test starts asserting about a shape nothing sends. absence: z.enum(['required', 'declared-only', 'runtime', 'conditional']), }); /** The derived mount set, on the wire. Structural twin of `MountSet`. */ export const MountSetWireSchema = z.object({ entries: z.array(MountEntrySchema), chdir: z.string(), }); export type MountSetWire = z.infer; /** * The capability shape descriptor (design D2). * * The broker does not know what a capability is. It walks the object * `loadCapabilityFunctions` returns the way `wrapWithLogging` does: * function-valued keys become `methods`, everything else is copied into * `data` — which is where `stampProvider`'s `providerModuleId` lives, and a * hook reads it to name the provider in an error. * * An optional method the provider did not implement is simply absent from * `methods`, so it is absent on the proxy, so `if (cap.registerTrustedSource)` * keeps working with no special case. */ export const CapabilitiesFrameSchema = z.object({ type: z.literal('capabilities'), shape: z.record( z.object({ methods: z.array(z.string()), data: z.record(z.unknown()), }), ), }); /** A capability call returned. */ export const ReturnFrameSchema = z.object({ type: z.literal('return'), id: z.string(), value: z.unknown(), }); /** A capability call threw. */ export const CallThrewFrameSchema = z.object({ type: z.literal('throw'), id: z.string(), error: HookErrorSchema, }); export const ParentFrameSchema = z.discriminatedUnion('type', [ ContextFrameSchema, CapabilitiesFrameSchema, ReturnFrameSchema, CallThrewFrameSchema, ]); export type ParentFrame = z.infer; /** One capability's entry in the shape descriptor. */ export type CapabilityShape = z.infer['shape'][string]; // ── framing ─────────────────────────────────────────────────────────────── export type ParseResult = { ok: true; frame: T } | { ok: false; error: string }; /** `JSON.stringify` plus the delimiter. One frame, one line. */ export function encodeFrame(frame: ChildFrame | ParentFrame): string { return `${JSON.stringify(frame)}\n`; } /** * Parse one line into a frame. * * Returns the failure as a value rather than throwing it. A malformed line is * a hook failure with a readable message, never a parse crash inside celilo: * the whole point of the boundary is that the child cannot take the parent * down, and a reader that throws would hand that back. */ function parseFrame(schema: z.ZodType, line: string): ParseResult { let json: unknown; try { json = JSON.parse(line); } catch { return { ok: false, error: `not JSON: ${truncate(line)}` }; } const parsed = schema.safeParse(json); if (!parsed.success) { return { ok: false, error: `${parsed.error.issues[0]?.message ?? 'invalid'}: ${truncate(line)}`, }; } return { ok: true, frame: parsed.data }; } export function parseChildFrame(line: string): ParseResult { return parseFrame(ChildFrameSchema, line); } export function parseParentFrame(line: string): ParseResult { return parseFrame(ParentFrameSchema, line); } function truncate(line: string): string { return line.length > 120 ? `${line.slice(0, 120)}…` : line; } /** * Split a byte stream into complete lines, holding the partial tail. * * Both ends need this and neither can assume a frame arrives in one chunk — * a capability payload is easily larger than a socket read. */ export function createLineReader(onLine: (line: string) => void): (chunk: string) => void { let buffer = ''; return (chunk: string) => { buffer += chunk; let newline = buffer.indexOf('\n'); while (newline !== -1) { const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1); if (line.trim() !== '') onLine(line); newline = buffer.indexOf('\n'); } }; } /** * The handshake check, in one place so both ends produce the same sentence. * * Version 2 added the `store` frame family (hook-owned-state task 3.5). Both * ends of this protocol ship in the same celilo binary, so the check exists * for one situation: a runner answering a broker built from different source * than itself. It aborts the hook loudly instead of letting a store write * disappear into a frame the other side cannot parse. * * Names both numbers: a mismatch is an install skew (a `.deb` upgraded while a * module's bundled copy was not), and the operator needs to know which side is * which to fix it. */ export function versionMismatch(theirs: number, side: string): string | null { if (theirs === HOOK_PROTOCOL_VERSION) return null; return `Hook protocol version mismatch: ${side} speaks ${theirs}, this process speaks ${HOOK_PROTOCOL_VERSION}.`; } // ── error envelopes ─────────────────────────────────────────────────────── /** Fields `MissingProviderInputError` carries and the framework reads (D6). */ const CARRIED_ERROR_FIELDS = ['providerModuleId', 'ensureId', 'value', 'humanContext'] as const; /** Turn a thrown value into something that survives JSON. */ export function serializeError(error: unknown): HookError { if (!(error instanceof Error)) { return { name: 'Error', message: String(error) }; } const source = error as unknown as Record; const fields: Record = {}; for (const key of CARRIED_ERROR_FIELDS) { if (source[key] !== undefined) fields[key] = source[key]; } return { name: error.name, message: error.message, stack: error.stack, ...(Object.keys(fields).length > 0 ? { fields } : {}), }; } /** * Rebuild a real `Error` so `try`/`catch` inside a hook behaves as it does * in-process, and so the framework's duck-typed guards still recognise it. */ export function deserializeError(error: HookError): Error { const rebuilt = new Error(error.message); rebuilt.name = error.name; if (error.stack) rebuilt.stack = error.stack; for (const [key, value] of Object.entries(error.fields ?? {})) { (rebuilt as unknown as Record)[key] = value; } return rebuilt; }