/** * The hook runner shim. * * This is the ONLY thing in celilo that `import()`s a module's hook script, * and it runs in its own `bun` process with an allow-listed environment. It * connects to the broker's socket, receives the context and the capability * shape, rebuilds `HookContext` on this side, invokes the hook, and reports. * * It is spawned, never imported — `executeHookScript` runs * `bun ` and talks to it over the socket named in * `CELILO_HOOK_SOCKET`. Nothing here is exported for that reason. * * Execution function (Rule 10.1). */ import { connect } from 'node:net'; import { isCompiledHook } from '@celilo/capabilities'; import { type CapabilityShape, type ChildFrame, HOOK_PROTOCOL_VERSION, HOOK_SOCKET_ENV, createLineReader, encodeFrame, parseParentFrame, serializeError, versionMismatch, } from './hook-protocol'; import { buildStoreView } from './hook-store-proxy'; import type { HookContext, HookLogger } from './types'; import { forwardLintWarnings } from './unjailed-lint'; // The advisory lint was installed before this module was even loaded — the // executor spawns `hook-runner-entry.ts`, whose first statement forces // `unjailed-lint`'s evaluation (see that module's docblock for why the order // is load-bearing there and irrelevant here). const socketPath = process.env[HOOK_SOCKET_ENV]; if (!socketPath) { process.stderr.write( `${HOOK_SOCKET_ENV} is not set; the hook runner is not spawnable directly.\n`, ); process.exit(2); } const socket = connect(socketPath); socket.setEncoding('utf-8'); /** Capability calls in flight, correlated with their answer by id. */ const pending = new Map void; reject: (e: Error) => void }>(); let nextCallId = 0; let contextData: Record | undefined; let scriptPath: string | undefined; let shape: Record | undefined; let started = false; function send(frame: ChildFrame): void { socket.write(encodeFrame(frame)); } /** * Write the terminal frame and leave. * * The explicit exit is deliberate. A hook that leaves a timer or an open * handle behind would otherwise hold this process open long past its answer, * and the parent — which treats process exit as the one terminal event — * would sit there until the timeout killed a hook that had already finished. */ function finish(frame: ChildFrame): void { socket.end(encodeFrame(frame), () => process.exit(0)); // The flush callback does not fire if the peer is already gone. setTimeout(() => process.exit(0), 2000).unref(); } const logger: HookLogger = { info: (message) => send({ type: 'log', level: 'info', message }), warn: (message) => send({ type: 'log', level: 'warn', message }), error: (message) => send({ type: 'log', level: 'error', message }), success: (message) => send({ type: 'log', level: 'success', message }), }; /** * Rebuild `context.capabilities` from the shape descriptor. * * Each method forwards; each non-function property is copied verbatim, which * is what keeps `providerModuleId` readable. A method the provider does not * implement is simply absent from the descriptor and therefore absent here, so * `if (capabilities.firewall.registerTrustedSource)` still answers correctly * with no special case for optional methods. */ function buildCapabilities(descriptor: Record): Record { const capabilities: Record = {}; for (const [name, entry] of Object.entries(descriptor)) { const proxy: Record = { ...entry.data }; for (const method of entry.methods) { proxy[method] = (...args: unknown[]) => callBroker(name, method, args); } capabilities[name] = proxy; } return capabilities; } function callBroker(capability: string, method: string, args: unknown[]): Promise { const id = `c${nextCallId++}`; return new Promise((resolve, reject) => { pending.set(id, { resolve, reject }); send({ type: 'call', id, capability, method, args }); }); } /** * Store calls share the capability call's correlation machinery: the broker * answers both families with the same `return`/`throw` frames, keyed by `id`. */ function callStore( store: 'secrets' | 'config', method: 'get' | 'set' | 'delete' | 'transaction', args: unknown[], ): Promise { const id = `c${nextCallId++}`; return new Promise((resolve, reject) => { pending.set(id, { resolve, reject }); send({ type: 'store', id, store, method, args }); }); } /** * The `defineHook` brand check, moved here from the executor unchanged * (HOOK_API_V2 Phase 8 / D8). The brand is a `Symbol.for` key, so it survives * the identity boundary between this copy of `@celilo/capabilities` and the * one the module bundles (celilo#173) — which is the same reason the check * could move at all. */ async function runHook(): Promise { if (started || !contextData || !shape || !scriptPath) return; started = true; try { // celilo built this object and removed exactly two fields, so the cast // says something true. The check is here because a shape error would // otherwise surface deep inside somebody's hook as a missing property. for (const field of ['config', 'secrets', 'systems', 'debug', 'screenshotDir'] as const) { if (!(field in contextData)) throw new Error(`Hook context is missing '${field}'.`); } // Before the import, not after — the lint's warnings flow through the // hook's logger from here on (anything earlier was buffered). forwardLintWarnings(logger.warn); // Attach the hook-owned-state accessor onto both maps. The maps crossed // the context frame as plain data; the accessor methods exist only on // this side, where the socket is. Old map reads keep working; new code // calls `secrets.set(...)` / `config.get(...)` (hook-owned-state task 3.5). // The casts say something true: celilo built both fields and controls // their shape, the same rationale as the field check above. const stores = buildStoreView( 'secrets', (contextData.secrets ?? {}) as Record, callStore, ); const configStores = buildStoreView( 'config', (contextData.config ?? {}) as Record, callStore, ); const context = { ...contextData, config: configStores, secrets: stores, logger, capabilities: buildCapabilities(shape), } as unknown as HookContext; const module = await import(scriptPath); if (typeof module.default !== 'function') { throw new Error(`Hook script must export a default function: ${scriptPath}`); } if (!isCompiledHook(module.default)) { throw new Error( `Hook script ${scriptPath} does not use defineHook(). As of HOOK_API_V2 Phase 8, all hook scripts must wrap their handler with defineHook from @celilo/capabilities so the executor can verify the brand and apply pre-flight checks. See reference/MODULE_DEVELOPMENT_GUIDE.md "Hooks" section for the migration pattern.`, ); } const result = await module.default(context); if (result === null || result === undefined) { finish({ type: 'result', outputs: {} }); return; } if (typeof result !== 'object' || Array.isArray(result)) { throw new Error('Hook script must return an object (or nothing)'); } finish({ type: 'result', outputs: result as Record }); } catch (error) { finish({ type: 'throw', error: serializeError(error) }); } } const feed = createLineReader((line) => { const parsed = parseParentFrame(line); if (!parsed.ok) { // Deliberately WITHOUT the offending line, unlike the broker's mirror of // this check. celilo builds these frames with `JSON.stringify`, so their // content is no help in diagnosing a parse failure — and the `context` // frame carries the module's secrets, which would then ride an error // message out to the operator's terminal and any alert it raises. The // other direction echoes the line because there the bytes ARE the // diagnostic: a grandchild writing to the socket is the failure design D3 // exists to catch. finish({ type: 'throw', error: { name: 'Error', message: 'Malformed frame from celilo; the hook context could not be read.', }, }); return; } const frame = parsed.frame; switch (frame.type) { case 'context': { const mismatch = versionMismatch(frame.protocolVersion, 'celilo'); if (mismatch) { finish({ type: 'throw', error: { name: 'Error', message: mismatch } }); return; } contextData = frame.context; scriptPath = frame.scriptPath; void runHook(); return; } case 'capabilities': shape = frame.shape; void runHook(); return; case 'return': { pending.get(frame.id)?.resolve(frame.value); pending.delete(frame.id); return; } case 'throw': { // Rebuilt inline rather than via deserializeError so the hook's own // `catch` sees a real Error carrying MissingProviderInputError's fields // — `isMissingProviderInputError` is duck-typed and reads them. const rebuilt = new Error(frame.error.message); rebuilt.name = frame.error.name; if (frame.error.stack) rebuilt.stack = frame.error.stack; for (const [key, value] of Object.entries(frame.error.fields ?? {})) { (rebuilt as unknown as Record)[key] = value; } pending.get(frame.id)?.reject(rebuilt); pending.delete(frame.id); return; } } }); socket.on('data', feed); socket.on('connect', () => send({ type: 'ready', protocolVersion: HOOK_PROTOCOL_VERSION })); socket.on('error', (error) => { // The channel is the only way to report anything, so there is nowhere to // send this. Exit non-zero and let the parent say what it saw. process.stderr.write(`hook runner: socket error: ${error.message}\n`); process.exit(3); });