From cde2516b27e78409501a91b2f6e4e366b65f7e28 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 17 Feb 2026 07:03:43 -0600 Subject: [PATCH 01/33] refactor(kernel-utils): move ifDefined from kernel-agents to kernel-utils Co-Authored-By: Claude Opus 4.6 --- packages/kernel-agents/src/utils.ts | 12 +---------- packages/kernel-utils/src/index.test.ts | 1 + packages/kernel-utils/src/index.ts | 2 +- packages/kernel-utils/src/misc.test.ts | 27 ++++++++++++++++++++++++- packages/kernel-utils/src/misc.ts | 13 ++++++++++++ 5 files changed, 42 insertions(+), 13 deletions(-) diff --git a/packages/kernel-agents/src/utils.ts b/packages/kernel-agents/src/utils.ts index 6112bfe2b..986afdd99 100644 --- a/packages/kernel-agents/src/utils.ts +++ b/packages/kernel-agents/src/utils.ts @@ -2,17 +2,7 @@ import type { Logger } from '@metamask/logger'; import type { SampleCollector } from './types.ts'; -/** - * Return a new object with the undefined values removed. - * - * @param record - The record to filter. - * @returns The new object with the undefined values removed. - */ -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export const ifDefined = (record: Record) => - Object.fromEntries( - Object.entries(record).filter(([_, value]) => value !== undefined), - ); +export { ifDefined } from '@metamask/kernel-utils'; /** * Await a promise, and call the abort callback when done or on error. diff --git a/packages/kernel-utils/src/index.test.ts b/packages/kernel-utils/src/index.test.ts index 67cef419d..7533cd072 100644 --- a/packages/kernel-utils/src/index.test.ts +++ b/packages/kernel-utils/src/index.test.ts @@ -14,6 +14,7 @@ describe('index', () => { 'delay', 'fetchValidatedJson', 'fromHex', + 'ifDefined', 'installWakeDetector', 'isJsonRpcCall', 'isJsonRpcMessage', diff --git a/packages/kernel-utils/src/index.ts b/packages/kernel-utils/src/index.ts index 934437874..09d85af54 100644 --- a/packages/kernel-utils/src/index.ts +++ b/packages/kernel-utils/src/index.ts @@ -3,7 +3,7 @@ export { makeDiscoverableExo } from './discoverable.ts'; export type { DiscoverableExo } from './discoverable.ts'; export type { JsonSchema, MethodSchema } from './schema.ts'; export { fetchValidatedJson } from './fetchValidatedJson.ts'; -export { abortableDelay, delay, makeCounter } from './misc.ts'; +export { abortableDelay, delay, ifDefined, makeCounter } from './misc.ts'; export { stringify } from './stringify.ts'; export { installWakeDetector } from './wake-detector.ts'; export type { WakeDetectorOptions } from './wake-detector.ts'; diff --git a/packages/kernel-utils/src/misc.test.ts b/packages/kernel-utils/src/misc.test.ts index 64ef72d57..83bb3a929 100644 --- a/packages/kernel-utils/src/misc.test.ts +++ b/packages/kernel-utils/src/misc.test.ts @@ -1,7 +1,32 @@ import { AbortError } from '@metamask/kernel-errors'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { abortableDelay, delay, makeCounter } from './misc.ts'; +import { abortableDelay, delay, ifDefined, makeCounter } from './misc.ts'; + +describe('ifDefined', () => { + it('removes undefined values', () => { + expect(ifDefined({ a: 1, b: undefined, c: 3 })).toStrictEqual({ + a: 1, + c: 3, + }); + }); + + it('returns empty object when all values are undefined', () => { + expect(ifDefined({ a: undefined, b: undefined })).toStrictEqual({}); + }); + + it('preserves all values when none are undefined', () => { + expect(ifDefined({ a: 1, b: 'two', c: null })).toStrictEqual({ + a: 1, + b: 'two', + c: null, + }); + }); + + it('returns empty object for empty input', () => { + expect(ifDefined({})).toStrictEqual({}); + }); +}); describe('misc utilities', () => { beforeEach(() => { diff --git a/packages/kernel-utils/src/misc.ts b/packages/kernel-utils/src/misc.ts index 5f5893adb..8c2b30dcf 100644 --- a/packages/kernel-utils/src/misc.ts +++ b/packages/kernel-utils/src/misc.ts @@ -1,5 +1,18 @@ import { AbortError } from '@metamask/kernel-errors'; +/** + * Return a new object with the undefined values removed. + * Useful for building options bags with exact optional property types. + * + * @param record - The record to filter. + * @returns The new object with the undefined values removed. + */ +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export const ifDefined = (record: Record) => + Object.fromEntries( + Object.entries(record).filter(([_, value]) => value !== undefined), + ); + /** * A simple counter which increments and returns when called. * From 9b29f26e74fa5062d0c82ed1fab97ecdbd50bf08 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 17 Feb 2026 07:03:51 -0600 Subject: [PATCH 02/33] feat(ocap-kernel): add console vat and system console vat with IO dispatch The system console vat manages a REPL loop over an IO channel, dispatching CLI commands (help, status, launch, terminate, subclusters, listRefs, revoke) and managing refs in persistent baggage. Refs use a monotonic counter (d-1, d-2, ...) since crypto.randomUUID() is unavailable under SES lockdown. Cross-vat errors are serialized via JSON.stringify fallback for reliable error reporting. Co-Authored-By: Claude Opus 4.6 --- .../src/vats/system-console-vat.test.ts | 383 ++++++++++++++++++ .../src/vats/system-console-vat.ts | 369 +++++++++++++++++ 2 files changed, 752 insertions(+) create mode 100644 packages/ocap-kernel/src/vats/system-console-vat.test.ts create mode 100644 packages/ocap-kernel/src/vats/system-console-vat.ts diff --git a/packages/ocap-kernel/src/vats/system-console-vat.test.ts b/packages/ocap-kernel/src/vats/system-console-vat.test.ts new file mode 100644 index 000000000..7cc1da57d --- /dev/null +++ b/packages/ocap-kernel/src/vats/system-console-vat.test.ts @@ -0,0 +1,383 @@ +import { makeDefaultExo } from '@metamask/kernel-utils/exo'; +import { describe, it, expect, beforeEach } from 'vitest'; + +import { buildRootObject } from './system-console-vat.ts'; + +/** + * Create a mock baggage store. + * + * @returns A mock baggage with has/get/set/init methods. + */ +function makeMockBaggage() { + const store = new Map(); + return { + has: (key: string) => store.has(key), + get: (key: string) => store.get(key), + set: (key: string, value: unknown) => store.set(key, value), + init: (key: string, value: unknown) => { + if (store.has(key)) { + throw new Error(`Key already exists: ${key}`); + } + store.set(key, value); + }, + }; +} + +/** + * Create a mock IO service with controllable read queue. + * + * @returns Mock IO service and control functions. + */ +function makeMockIOService() { + const readQueue: (string | null)[] = []; + const pendingReads: ((value: string | null) => void)[] = []; + const written: string[] = []; + + return { + ioService: makeDefaultExo('mockIOService', { + async read() { + const queued = readQueue.shift(); + if (queued !== undefined) { + return queued; + } + return new Promise((resolve) => { + pendingReads.push(resolve); + }); + }, + async write(data: string) { + written.push(data); + }, + }), + deliverLine(line: string): void { + const reader = pendingReads.shift(); + if (reader) { + reader(line); + } else { + readQueue.push(line); + } + }, + deliverEOF(): void { + const reader = pendingReads.shift(); + if (reader) { + reader(null); + } else { + readQueue.push(null); + } + }, + get written() { + return written; + }, + }; +} + +/** + * Create a mock kernel facet using plain functions (not vi.fn) to avoid + * SES lockdown issues with frozen mock internals. + * + * @returns A mock kernel facet and call trackers. + */ +function makeMockKernelFacet() { + const calls: Record = { + getStatus: [], + getSubclusters: [], + launchSubcluster: [], + terminateSubcluster: [], + }; + + const facet = makeDefaultExo('mockKernelFacet', { + async getStatus(...args: unknown[]) { + calls.getStatus.push(args); + return { + incarnation: 1, + subclusters: 0, + vats: 1, + pendingMessages: 0, + }; + }, + async getSubclusters(...args: unknown[]) { + calls.getSubclusters.push(args); + return []; + }, + async launchSubcluster(...args: unknown[]) { + calls.launchSubcluster.push(args); + return { + subclusterId: 'sub-1', + rootKref: 'ko1', + bootstrapResult: undefined, + }; + }, + async terminateSubcluster(...args: unknown[]) { + calls.terminateSubcluster.push(args); + }, + }); + + return { facet, calls }; +} + +describe('system-console-vat', () => { + let baggage: ReturnType; + let kernelFacet: ReturnType; + let io: ReturnType; + + beforeEach(() => { + baggage = makeMockBaggage(); + kernelFacet = makeMockKernelFacet(); + io = makeMockIOService(); + }); + + describe('bootstrap', () => { + it('stores kernel facet in baggage', async () => { + const root = buildRootObject( + {}, + { name: 'test-console' }, + baggage as never, + ); + await root.bootstrap({}, { kernelFacet: kernelFacet.facet }); + expect(baggage.has('kernelFacet')).toBe(true); + }); + + it('starts REPL loop when console IO service is provided', async () => { + const root = buildRootObject( + {}, + { name: 'test-console' }, + baggage as never, + ); + await root.bootstrap( + {}, + { + kernelFacet: kernelFacet.facet, + console: io.ioService, + }, + ); + + // Give it a tick to start + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Send a help command — if the REPL loop is running, it will respond + io.deliverLine(JSON.stringify({ method: 'help' })); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(io.written.length).toBeGreaterThan(0); + }); + }); + + describe('REPL dispatch', () => { + async function setupRepl() { + const root = buildRootObject( + {}, + { name: 'test-console' }, + baggage as never, + ); + await root.bootstrap( + {}, + { + kernelFacet: kernelFacet.facet, + console: io.ioService, + }, + ); + await new Promise((resolve) => setTimeout(resolve, 10)); + return root; + } + + async function sendRequest(request: Record) { + io.deliverLine(JSON.stringify(request)); + await new Promise((resolve) => setTimeout(resolve, 50)); + const lastWrite = io.written[io.written.length - 1]; + return JSON.parse(lastWrite!) as { + ok: boolean; + result?: unknown; + error?: string; + }; + } + + it('dispatches help command', async () => { + await setupRepl(); + const response = await sendRequest({ method: 'help' }); + + expect(response.ok).toBe(true); + expect(response.result).toStrictEqual({ + commands: expect.arrayContaining([ + expect.stringContaining('help'), + expect.stringContaining('status'), + ]), + }); + }); + + it('dispatches status command', async () => { + await setupRepl(); + const response = await sendRequest({ method: 'status' }); + + expect(response.ok).toBe(true); + expect(kernelFacet.calls.getStatus).toHaveLength(1); + }); + + it('dispatches subclusters command', async () => { + await setupRepl(); + const response = await sendRequest({ method: 'subclusters' }); + + expect(response.ok).toBe(true); + expect(kernelFacet.calls.getSubclusters).toHaveLength(1); + }); + + it('dispatches launch command and issues ref', async () => { + await setupRepl(); + const config = { + bootstrap: 'test', + vats: { test: { bundleSpec: 'test-bundle' } }, + }; + const response = await sendRequest({ method: 'launch', args: [config] }); + + expect(response.ok).toBe(true); + const result = response.result as { ref: string; subclusterId: string }; + expect(result.ref).toMatch(/^d-\d+$/u); + expect(result.subclusterId).toBe('sub-1'); + expect(kernelFacet.calls.launchSubcluster).toHaveLength(1); + }); + + it('dispatches terminate command', async () => { + await setupRepl(); + const response = await sendRequest({ + method: 'terminate', + args: ['sub-1'], + }); + + expect(response.ok).toBe(true); + expect(kernelFacet.calls.terminateSubcluster).toHaveLength(1); + }); + + it('dispatches revoke command', async () => { + await setupRepl(); + + // First launch to get a ref + const launchResponse = await sendRequest({ + method: 'launch', + args: [{ bootstrap: 'x', vats: { x: { bundleSpec: 'x' } } }], + }); + const { ref } = launchResponse.result as { ref: string }; + + // Revoke the ref + const response = await sendRequest({ method: 'revoke', args: [ref] }); + expect(response).toStrictEqual({ ok: true, result: { ok: true } }); + }); + + it('dispatches listRefs command', async () => { + await setupRepl(); + + // Launch to create a ref + await sendRequest({ + method: 'launch', + args: [{ bootstrap: 'x', vats: { x: { bundleSpec: 'x' } } }], + }); + + const response = await sendRequest({ method: 'listRefs' }); + expect(response.ok).toBe(true); + const result = response.result as { + refs: { ref: string; kref: string }[]; + }; + expect(result.refs).toHaveLength(1); + expect(result.refs[0]!.kref).toBe('ko1'); + }); + + it('returns error for unknown command', async () => { + await setupRepl(); + const response = await sendRequest({ method: 'bogus' }); + + expect(response.ok).toBe(false); + expect(response.error).toContain('Unknown command'); + }); + + it('returns error for invalid JSON', async () => { + await setupRepl(); + io.deliverLine('not json'); + await new Promise((resolve) => setTimeout(resolve, 50)); + + const lastWrite = io.written[io.written.length - 1]; + const response = JSON.parse(lastWrite!) as { ok: boolean; error: string }; + expect(response.ok).toBe(false); + expect(response.error).toBeDefined(); + }); + + it('continues after EOF (client disconnect)', async () => { + await setupRepl(); + + // Send a command + const response1 = await sendRequest({ method: 'help' }); + expect(response1.ok).toBe(true); + + // Simulate disconnect + io.deliverEOF(); + await new Promise((resolve) => setTimeout(resolve, 20)); + + // Send another command (new connection) + const response2 = await sendRequest({ method: 'status' }); + expect(response2.ok).toBe(true); + }); + }); + + describe('ref manager', () => { + it('issues idempotent refs for the same kref', async () => { + const root = buildRootObject( + {}, + { name: 'test-console' }, + baggage as never, + ); + await root.bootstrap({}, { kernelFacet: kernelFacet.facet }); + + const ref1 = root.issueRef('ko1'); + const ref2 = root.issueRef('ko1'); + expect(ref1).toBe(ref2); + expect(ref1).toMatch(/^d-\d+$/u); + }); + + it('issues different refs for different krefs', async () => { + const root = buildRootObject( + {}, + { name: 'test-console' }, + baggage as never, + ); + await root.bootstrap({}, { kernelFacet: kernelFacet.facet }); + + const ref1 = root.issueRef('ko1'); + const ref2 = root.issueRef('ko2'); + expect(ref1).not.toBe(ref2); + }); + + it('persists refs in baggage', async () => { + const root = buildRootObject( + {}, + { name: 'test-console' }, + baggage as never, + ); + await root.bootstrap({}, { kernelFacet: kernelFacet.facet }); + + root.issueRef('ko1'); + expect(baggage.has('refs')).toBe(true); + expect(baggage.has('krefToRef')).toBe(true); + }); + + it('lists issued refs', async () => { + const root = buildRootObject( + {}, + { name: 'test-console' }, + baggage as never, + ); + await root.bootstrap({}, { kernelFacet: kernelFacet.facet }); + + const ref = root.issueRef('ko1'); + const refList = root.listRefs(); + expect(refList).toStrictEqual([{ ref, kref: 'ko1' }]); + }); + }); + + describe('help', () => { + it('returns command list', () => { + const root = buildRootObject( + {}, + { name: 'test-console' }, + baggage as never, + ); + const result = root.help(); + expect(result).toHaveProperty('commands'); + expect(result.commands.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/packages/ocap-kernel/src/vats/system-console-vat.ts b/packages/ocap-kernel/src/vats/system-console-vat.ts new file mode 100644 index 000000000..020a797ef --- /dev/null +++ b/packages/ocap-kernel/src/vats/system-console-vat.ts @@ -0,0 +1,369 @@ +// eslint-disable-next-line import-x/no-extraneous-dependencies, n/no-extraneous-import -- vat dependency provided by kernel runtime +import { E } from '@endo/eventual-send'; +import { makeDefaultExo } from '@metamask/kernel-utils/exo'; + +import type { + Baggage, + ClusterConfig, + KernelStatus, + Subcluster, + SubclusterLaunchResult, +} from '../types.ts'; + +/** + * Kernel facet interface for system vat operations. + */ +type KernelFacet = { + getStatus: () => Promise; + getSubclusters: () => Promise; + launchSubcluster: (config: ClusterConfig) => Promise; + terminateSubcluster: (subclusterId: string) => Promise; + queueMessage: ( + target: string, + method: string, + args: unknown[], + ) => Promise; +}; + +/** + * Services provided to the system console vat during bootstrap. + */ +type BootstrapServices = { + kernelFacet?: KernelFacet; + console?: IOService; +}; + +/** + * IO service interface for reading and writing lines. + */ +type IOService = { + read: () => Promise; + write: (data: string) => Promise; +}; + +/** + * A JSON request from the CLI. + */ +type Request = { + ref?: string; + method: string; + args?: unknown[]; +}; + +/** + * Build function for the system console vat. + * + * This vat manages the REPL loop over an IO channel, dispatching CLI + * commands and managing refs (capability references) in persistent baggage. + * + * @param _vatPowers - The vat powers (unused). + * @param _parameters - The vat parameters (unused). + * @param _parameters.name - Optional name for the console vat. + * @param baggage - The vat's persistent baggage storage. + * @returns The root object for the new vat. + */ +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function buildRootObject( + _vatPowers: unknown, + _parameters: { name?: string }, + baggage: Baggage, +) { + // Monotonic counter for generating unique ref identifiers (persisted in baggage) + let refCounter: number = baggage.has('refCounter') + ? (baggage.get('refCounter') as number) + : 0; + // Restore kernel facet from baggage if available (for resuscitation) + let kernelFacet: KernelFacet | undefined = baggage.has('kernelFacet') + ? (baggage.get('kernelFacet') as KernelFacet) + : undefined; + + // Ref manager state in baggage: ref → kref and kref → ref maps + // Stored as plain objects since baggage serializes them + const refs: Record = baggage.has('refs') + ? (baggage.get('refs') as Record) + : {}; + const krefToRef: Record = baggage.has('krefToRef') + ? (baggage.get('krefToRef') as Record) + : {}; + + /** + * Persist the current ref state to baggage. + */ + function persistRefs(): void { + if (baggage.has('refs')) { + baggage.set('refs', harden({ ...refs })); + } else { + baggage.init('refs', harden({ ...refs })); + } + if (baggage.has('krefToRef')) { + baggage.set('krefToRef', harden({ ...krefToRef })); + } else { + baggage.init('krefToRef', harden({ ...krefToRef })); + } + } + + /** + * Issue a ref for a kref. If the kref already has a ref, return it. + * + * @param kref - The kernel reference. + * @returns The issued ref. + */ + function issueRef(kref: string): string { + const existing = krefToRef[kref]; + if (existing) { + return existing; + } + refCounter += 1; + if (baggage.has('refCounter')) { + baggage.set('refCounter', refCounter); + } else { + baggage.init('refCounter', refCounter); + } + const ref = `d-${refCounter}`; + refs[ref] = kref; + krefToRef[kref] = ref; + persistRefs(); + return ref; + } + + /** + * Look up the kref for a ref. + * + * @param ref - The ref to look up. + * @returns The kref, or undefined if not found. + */ + function lookupKref(ref: string): string | undefined { + return refs[ref]; + } + + /** + * Revoke a ref, removing it from both maps. + * + * @param ref - The ref to revoke. + * @returns True if the ref was found and revoked. + */ + function revokeRef(ref: string): boolean { + const kref = refs[ref]; + if (!kref) { + return false; + } + delete refs[ref]; + delete krefToRef[kref]; + persistRefs(); + return true; + } + + /** + * List all issued refs. + * + * @returns Array of ref/kref pairs. + */ + function listRefs(): { ref: string; kref: string }[] { + return Object.entries(refs).map(([ref, kref]) => ({ ref, kref })); + } + + /** + * Get the kernel facet, throwing if not yet bootstrapped. + * + * @returns The kernel facet. + */ + function requireKernelFacet(): KernelFacet { + if (!kernelFacet) { + throw new Error('Kernel facet not available (bootstrap not called?)'); + } + return kernelFacet; + } + + /** + * Dispatch a request that has no ref (operates on the system console itself). + * + * @param method - The method name. + * @param args - The method arguments. + * @returns The response payload. + */ + async function dispatchConsoleMethod( + method: string, + args: unknown[], + ): Promise { + switch (method) { + case 'help': + return { + commands: [ + 'help - show available commands', + 'status - kernel status', + 'launch - launch a subcluster', + 'terminate - terminate a subcluster', + 'subclusters - list subclusters', + 'revoke - revoke a ref', + 'listRefs - list all issued refs', + ], + }; + + case 'status': + return E(requireKernelFacet()).getStatus(); + + case 'subclusters': + return E(requireKernelFacet()).getSubclusters(); + + case 'launch': { + const config = args[0] as ClusterConfig; + if (!config) { + throw new Error('launch requires a config argument'); + } + const result = await E(requireKernelFacet()).launchSubcluster(config); + const ref = issueRef(result.rootKref); + return { ref, subclusterId: result.subclusterId }; + } + + case 'terminate': { + const subclusterId = args[0] as string; + if (!subclusterId) { + throw new Error('terminate requires a subclusterId argument'); + } + await E(requireKernelFacet()).terminateSubcluster(subclusterId); + return { ok: true }; + } + + case 'revoke': { + const ref = args[0] as string; + if (!ref) { + throw new Error('revoke requires a ref argument'); + } + return { ok: revokeRef(ref) }; + } + + case 'listRefs': + return { refs: listRefs() }; + + default: + throw new Error(`Unknown command: ${method}`); + } + } + + /** + * Handle a single parsed request and return the response. + * + * @param request - The parsed request. + * @returns The response payload. + */ + async function handleRequest(request: Request): Promise { + const { ref, method, args = [] } = request; + + if (!ref) { + return dispatchConsoleMethod(method, args); + } + + // Ref-based dispatch: resolve ref → kref, then queue message + const kref = lookupKref(ref); + if (!kref) { + throw new Error(`Unknown ref: ${ref}`); + } + return E(requireKernelFacet()).queueMessage(kref, method, args); + } + + /** + * Run the REPL loop: read a JSON line, dispatch, write response, repeat. + * + * @param ioService - The IO service to read/write from. + */ + async function runReplLoop(ioService: IOService): Promise { + for (;;) { + const line = await E(ioService).read(); + if (line === null) { + // Client disconnected — wait for next connection + continue; + } + + let response: unknown; + try { + const request = JSON.parse(line) as Request; + const result = await handleRequest(request); + response = { ok: true, result }; + } catch (error) { + // Errors crossing vat boundaries may arrive as plain objects. + // Try multiple strategies to extract a human-readable message. + let errorMessage: string; + if (error instanceof Error) { + errorMessage = error.message ?? error.stack ?? String(error); + } else if (typeof error === 'string') { + errorMessage = error; + } else { + try { + errorMessage = JSON.stringify(error); + } catch { + errorMessage = String(error); + } + } + response = { ok: false, error: errorMessage }; + } + + try { + await E(ioService).write(JSON.stringify(response)); + } catch { + // Write failed (client disconnected mid-response) — continue loop + } + } + } + + return makeDefaultExo('root', { + /** + * Bootstrap the vat. + * + * @param _vats - The vats object (unused). + * @param services - The services object containing kernelFacet and console IO. + */ + async bootstrap( + _vats: unknown, + services: BootstrapServices, + ): Promise { + if (!kernelFacet && services.kernelFacet) { + kernelFacet = services.kernelFacet; + baggage.init('kernelFacet', kernelFacet); + } + + if (services.console) { + // Fire-and-forget the REPL loop — it runs indefinitely + // eslint-disable-next-line no-console -- vat diagnostic output + runReplLoop(services.console).catch(console.error); + } + }, + + /** + * Get help information. + * + * @returns The help object. + */ + help() { + return harden({ + commands: [ + 'help - show available commands', + 'status - kernel status', + 'launch - launch a subcluster', + 'terminate - terminate a subcluster', + 'subclusters - list subclusters', + 'revoke - revoke a ref', + 'listRefs - list all issued refs', + ], + }); + }, + + /** + * Issue a ref for a kref. Exposed for the daemon to get the initial console ref. + * + * @param kref - The kernel reference. + * @returns The issued ref. + */ + issueRef(kref: string): string { + return issueRef(kref); + }, + + /** + * List all issued refs. + * + * @returns Array of ref/kref pairs. + */ + listRefs(): { ref: string; kref: string }[] { + return listRefs(); + }, + }); +} From 8e9367f63e2e15842bec4e181d5636d7d6359097 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 17 Feb 2026 07:04:00 -0600 Subject: [PATCH 03/33] feat(nodejs): add daemon orchestration with IO channel support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add startDaemon() which boots a kernel with a system console vat listening on a UNIX domain socket IO channel. The kernel process IS the daemon — no separate HTTP server. Includes socket channel fix to block reads when no client is connected, flush-daemon utility, and e2e tests for the full daemon stack protocol. Co-Authored-By: Claude Opus 4.6 --- packages/nodejs/src/daemon/flush-daemon.ts | 31 ++ .../nodejs/src/daemon/start-daemon.test.ts | 104 +++++ packages/nodejs/src/daemon/start-daemon.ts | 107 ++++++ packages/nodejs/src/index.ts | 7 + packages/nodejs/src/io/socket-channel.test.ts | 14 +- packages/nodejs/src/io/socket-channel.ts | 4 +- packages/nodejs/src/kernel/make-kernel.ts | 9 +- packages/nodejs/test/e2e/daemon-stack.test.ts | 356 ++++++++++++++++++ packages/nodejs/test/helpers/kernel.ts | 11 +- .../nodejs/test/vats/system-console-vat.ts | 288 ++++++++++++++ 10 files changed, 923 insertions(+), 8 deletions(-) create mode 100644 packages/nodejs/src/daemon/flush-daemon.ts create mode 100644 packages/nodejs/src/daemon/start-daemon.test.ts create mode 100644 packages/nodejs/src/daemon/start-daemon.ts create mode 100644 packages/nodejs/test/e2e/daemon-stack.test.ts create mode 100644 packages/nodejs/test/vats/system-console-vat.ts diff --git a/packages/nodejs/src/daemon/flush-daemon.ts b/packages/nodejs/src/daemon/flush-daemon.ts new file mode 100644 index 000000000..cf4b2adbe --- /dev/null +++ b/packages/nodejs/src/daemon/flush-daemon.ts @@ -0,0 +1,31 @@ +import { rm } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +/** + * Options for flushing daemon state. + */ +export type FlushDaemonOptions = { + /** UNIX socket path. Defaults to ~/.ocap/console.sock. */ + socketPath?: string; + /** SQLite database filename. Defaults to ~/.ocap/kernel.sqlite. */ + dbFilename?: string; +}; + +/** + * Delete all daemon state: kernel DB, bundles cache, and socket. + * + * @param options - Optional overrides for file paths. + */ +export async function flushDaemon(options?: FlushDaemonOptions): Promise { + const ocapDir = join(homedir(), '.ocap'); + const socketPath = options?.socketPath ?? join(ocapDir, 'console.sock'); + const dbFilename = options?.dbFilename ?? join(ocapDir, 'kernel.sqlite'); + const bundlesDir = join(ocapDir, 'bundles'); + + await Promise.all([ + rm(dbFilename, { force: true }), + rm(socketPath, { force: true }), + rm(bundlesDir, { recursive: true, force: true }), + ]); +} diff --git a/packages/nodejs/src/daemon/start-daemon.test.ts b/packages/nodejs/src/daemon/start-daemon.test.ts new file mode 100644 index 000000000..21e027f56 --- /dev/null +++ b/packages/nodejs/src/daemon/start-daemon.test.ts @@ -0,0 +1,104 @@ +import { vi, describe, it, expect, afterEach } from 'vitest'; + +import { startDaemon } from './start-daemon.ts'; +import type { DaemonHandle } from './start-daemon.ts'; + +// Mock makeKernel to avoid real kernel creation +vi.mock('../kernel/make-kernel.ts', () => ({ + makeKernel: vi.fn().mockResolvedValue({ + initIdentity: vi.fn().mockResolvedValue(undefined), + stop: vi.fn().mockResolvedValue(undefined), + }), +})); + +// Mock filesystem operations +vi.mock('node:fs/promises', async () => { + const actual = + await vi.importActual( + 'node:fs/promises', + ); + return { + ...actual, + mkdir: vi.fn().mockResolvedValue(undefined), + }; +}); + +describe('startDaemon', () => { + let handle: DaemonHandle | undefined; + + afterEach(async () => { + if (handle) { + const toClose = handle; + handle = undefined; + await toClose.close(); + } + }); + + it('creates kernel with IO-based system subcluster config', async () => { + const { makeKernel } = await import('../kernel/make-kernel.ts'); + const mockedMakeKernel = vi.mocked(makeKernel); + + const tmpSocket = `/tmp/daemon-test-${Date.now()}-${Math.random().toString(36).slice(2)}.sock`; + + handle = await startDaemon({ + systemConsoleBundleSpec: 'http://localhost/bundle', + systemConsoleName: 'my-console', + socketPath: tmpSocket, + }); + + expect(mockedMakeKernel).toHaveBeenCalledWith( + expect.objectContaining({ + systemSubclusters: [ + { + name: 'my-console', + config: { + bootstrap: 'my-console', + io: { + console: { + type: 'socket', + path: tmpSocket, + }, + }, + services: ['kernelFacet', 'console'], + vats: { + 'my-console': { + bundleSpec: 'http://localhost/bundle', + parameters: { name: 'my-console' }, + }, + }, + }, + }, + ], + }), + ); + }); + + it('returns socket path and close function', async () => { + const tmpSocket = `/tmp/daemon-test-${Date.now()}-${Math.random().toString(36).slice(2)}.sock`; + + handle = await startDaemon({ + systemConsoleBundleSpec: 'http://localhost/bundle', + socketPath: tmpSocket, + }); + + expect(handle.socketPath).toBe(tmpSocket); + expect(typeof handle.close).toBe('function'); + expect(handle.kernel).toBeDefined(); + }); + + it('calls kernel.stop on close', async () => { + const tmpSocket = `/tmp/daemon-test-${Date.now()}-${Math.random().toString(36).slice(2)}.sock`; + + handle = await startDaemon({ + systemConsoleBundleSpec: 'http://localhost/bundle', + socketPath: tmpSocket, + }); + + const { stop } = handle.kernel; + const toClose = handle; + handle = undefined; + await toClose.close(); + + expect(stop).toHaveBeenCalled(); + }); +}); diff --git a/packages/nodejs/src/daemon/start-daemon.ts b/packages/nodejs/src/daemon/start-daemon.ts new file mode 100644 index 000000000..a778ec15f --- /dev/null +++ b/packages/nodejs/src/daemon/start-daemon.ts @@ -0,0 +1,107 @@ +import { ifDefined } from '@metamask/kernel-utils'; +import type { Logger } from '@metamask/logger'; +import type { Kernel, SystemSubclusterConfig } from '@metamask/ocap-kernel'; +import { mkdir } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +import { makeKernel } from '../kernel/make-kernel.ts'; + +/** + * Options for starting the daemon. + */ +export type StartDaemonOptions = { + /** UNIX socket path for the system console IO channel. Defaults to ~/.ocap/console.sock. */ + socketPath?: string; + /** URL to the bundled system-console-vat. */ + systemConsoleBundleSpec: string; + /** Name for the system console subcluster. Defaults to 'system-console'. */ + systemConsoleName?: string; + /** Path to vat worker file. */ + workerFilePath?: string; + /** SQLite database filename. Defaults to ~/.ocap/kernel.sqlite. */ + dbFilename?: string; + /** If true, clear kernel storage. */ + resetStorage?: boolean; + /** Logger instance. */ + logger?: Logger; + /** Seed for libp2p key generation. */ + keySeed?: string; +}; + +/** + * Handle returned by {@link startDaemon}. + */ +export type DaemonHandle = { + kernel: Kernel; + socketPath: string; + close: () => Promise; +}; + +/** + * Start the OCAP daemon. + * + * Creates a kernel with a system console vat that listens for commands + * on a UNIX domain socket IO channel. The kernel process IS the daemon. + * + * @param options - Configuration options. + * @returns A daemon handle. + */ +export async function startDaemon( + options: StartDaemonOptions, +): Promise { + const { + systemConsoleBundleSpec, + systemConsoleName = 'system-console', + workerFilePath, + resetStorage, + logger, + keySeed, + } = options; + + const ocapDir = join(homedir(), '.ocap'); + await mkdir(ocapDir, { recursive: true }); + + const socketPath = options.socketPath ?? join(ocapDir, 'console.sock'); + const dbFilename = options.dbFilename ?? join(ocapDir, 'kernel.sqlite'); + + // Build system subcluster config with IO channel for the console socket + const systemSubcluster: SystemSubclusterConfig = { + name: systemConsoleName, + config: { + bootstrap: systemConsoleName, + io: { + console: { + type: 'socket' as const, + path: socketPath, + }, + }, + services: ['kernelFacet', 'console'], + vats: { + [systemConsoleName]: { + bundleSpec: systemConsoleBundleSpec, + parameters: { name: systemConsoleName }, + }, + }, + }, + }; + + const kernel = await makeKernel({ + ...ifDefined({ workerFilePath, resetStorage, logger }), + dbFilename, + keySeed, + systemSubclusters: [systemSubcluster], + }); + + await kernel.initIdentity(); + + const close = async (): Promise => { + await kernel.stop(); + }; + + return { + kernel, + socketPath, + close, + }; +} diff --git a/packages/nodejs/src/index.ts b/packages/nodejs/src/index.ts index 49c133fdf..41f78b592 100644 --- a/packages/nodejs/src/index.ts +++ b/packages/nodejs/src/index.ts @@ -2,3 +2,10 @@ export { NodejsPlatformServices } from './kernel/PlatformServices.ts'; export { makeKernel } from './kernel/make-kernel.ts'; export { makeNodeJsVatSupervisor } from './vat/make-supervisor.ts'; export { makeIOChannelFactory, makeSocketIOChannel } from './io/index.ts'; +export { startDaemon } from './daemon/start-daemon.ts'; +export type { + StartDaemonOptions, + DaemonHandle, +} from './daemon/start-daemon.ts'; +export { flushDaemon } from './daemon/flush-daemon.ts'; +export type { FlushDaemonOptions } from './daemon/flush-daemon.ts'; diff --git a/packages/nodejs/src/io/socket-channel.test.ts b/packages/nodejs/src/io/socket-channel.test.ts index d6a773658..fe8bf982c 100644 --- a/packages/nodejs/src/io/socket-channel.test.ts +++ b/packages/nodejs/src/io/socket-channel.test.ts @@ -134,13 +134,21 @@ describe('makeSocketIOChannel', () => { expect(result).toBeNull(); }); - it('returns null when no client is connected', async () => { + it('blocks read until a client connects and sends data', async () => { const socketPath = tempSocketPath(); const channel = await makeSocketIOChannel('test', socketPath); channels.push(channel); - const result = await channel.read(); - expect(result).toBeNull(); + // Start read before any client connects — should block + const readPromise = channel.read(); + + // Connect and send data + const client = await connectToSocket(socketPath); + clients.push(client); + await writeLine(client, 'hello'); + + const result = await readPromise; + expect(result).toBe('hello'); }); it('throws on write when no client is connected', async () => { diff --git a/packages/nodejs/src/io/socket-channel.ts b/packages/nodejs/src/io/socket-channel.ts index c6cce477f..97ebfc46e 100644 --- a/packages/nodejs/src/io/socket-channel.ts +++ b/packages/nodejs/src/io/socket-channel.ts @@ -132,9 +132,7 @@ export async function makeSocketIOChannel( if (queued !== undefined) { return queued; } - if (!currentSocket) { - return null; - } + // Block until data arrives (from a current or future client connection) return new Promise((resolve) => { readerQueue.push({ resolve }); }); diff --git a/packages/nodejs/src/kernel/make-kernel.ts b/packages/nodejs/src/kernel/make-kernel.ts index 00f6353b4..68bd9b4ab 100644 --- a/packages/nodejs/src/kernel/make-kernel.ts +++ b/packages/nodejs/src/kernel/make-kernel.ts @@ -1,7 +1,10 @@ import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/nodejs'; import { Logger } from '@metamask/logger'; import { Kernel } from '@metamask/ocap-kernel'; -import type { IOChannelFactory } from '@metamask/ocap-kernel'; +import type { + IOChannelFactory, + SystemSubclusterConfig, +} from '@metamask/ocap-kernel'; import { NodejsPlatformServices } from './PlatformServices.ts'; import { makeIOChannelFactory } from '../io/index.ts'; @@ -16,6 +19,7 @@ import { makeIOChannelFactory } from '../io/index.ts'; * @param options.logger - The logger to use for the kernel. * @param options.keySeed - Optional seed for libp2p key generation. * @param options.ioChannelFactory - Optional factory for creating IO channels. + * @param options.systemSubclusters - Optional system subcluster configurations. * @returns The kernel, initialized. */ export async function makeKernel({ @@ -25,6 +29,7 @@ export async function makeKernel({ logger, keySeed, ioChannelFactory, + systemSubclusters, }: { workerFilePath?: string; resetStorage?: boolean; @@ -32,6 +37,7 @@ export async function makeKernel({ logger?: Logger; keySeed?: string | undefined; ioChannelFactory?: IOChannelFactory; + systemSubclusters?: SystemSubclusterConfig[]; }): Promise { const rootLogger = logger ?? new Logger('kernel-worker'); const platformServicesClient = new NodejsPlatformServices({ @@ -48,6 +54,7 @@ export async function makeKernel({ logger: rootLogger.subLogger({ tags: ['kernel'] }), keySeed, ioChannelFactory: ioChannelFactory ?? makeIOChannelFactory(), + ...(systemSubclusters ? { systemSubclusters } : {}), }); return kernel; diff --git a/packages/nodejs/test/e2e/daemon-stack.test.ts b/packages/nodejs/test/e2e/daemon-stack.test.ts new file mode 100644 index 000000000..d29b962b7 --- /dev/null +++ b/packages/nodejs/test/e2e/daemon-stack.test.ts @@ -0,0 +1,356 @@ +import type { KernelDatabase } from '@metamask/kernel-store'; +import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/nodejs'; +import { waitUntilQuiescent } from '@metamask/kernel-utils'; +import type { Kernel, IOChannel, IOConfig } from '@metamask/ocap-kernel'; +import * as net from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { describe, it, expect, afterEach } from 'vitest'; + +import { makeTestKernel } from '../helpers/kernel.ts'; + +const SYSTEM_CONSOLE_NAME = 'system-console'; + +/** + * Generate a unique temp socket path. + * + * @returns A unique socket path. + */ +function tempSocketPath(): string { + return join( + tmpdir(), + `daemon-e2e-${Date.now()}-${Math.random().toString(36).slice(2)}.sock`, + ); +} + +/** + * Connect to a UNIX socket. + * + * @param socketPath - The socket path. + * @returns The connected socket. + */ +async function connectToSocket(socketPath: string): Promise { + return new Promise((resolve, reject) => { + const client = net.createConnection(socketPath, () => { + client.removeListener('error', reject); + resolve(client); + }); + client.on('error', reject); + }); +} + +/** + * Write a newline-delimited line to a socket. + * + * @param socket - The socket. + * @param line - The line to write. + */ +async function writeLine(socket: net.Socket, line: string): Promise { + return new Promise((resolve, reject) => { + socket.write(`${line}\n`, (error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); +} + +/** + * Read a newline-delimited line from a socket. + * + * @param socket - The socket. + * @returns The line read. + */ +async function readLine(socket: net.Socket): Promise { + return new Promise((resolve) => { + let buffer = ''; + const onData = (data: Buffer): void => { + buffer += data.toString(); + const idx = buffer.indexOf('\n'); + if (idx !== -1) { + socket.removeListener('data', onData); + resolve(buffer.slice(0, idx)); + } + }; + socket.on('data', onData); + }); +} + +/** + * Send a JSON request over a socket and read the JSON response. + * + * @param socketPath - The socket path. + * @param request - The request object. + * @returns The parsed response. + */ +async function sendCommand( + socketPath: string, + request: Record, +): Promise<{ ok: boolean; result?: unknown; error?: string }> { + const socket = await connectToSocket(socketPath); + try { + await writeLine(socket, JSON.stringify(request)); + const responseLine = await readLine(socket); + return JSON.parse(responseLine) as { + ok: boolean; + result?: unknown; + error?: string; + }; + } finally { + socket.destroy(); + } +} + +/** + * Create a test socket IO channel factory. + * + * @returns The factory function. + */ +function makeTestIOChannelFactory() { + const fsPromises = import('node:fs/promises'); + + return async (_name: string, config: IOConfig): Promise => { + if (config.type !== 'socket') { + throw new Error(`unsupported IO type: ${config.type}`); + } + const fs = await fsPromises; + const lineQueue: string[] = []; + const readerQueue: { resolve: (value: string | null) => void }[] = []; + let currentSocket: net.Socket | null = null; + let lineBuffer = ''; + let closed = false; + + function deliverLine(line: string): void { + const reader = readerQueue.shift(); + if (reader) { + reader.resolve(line); + } else { + lineQueue.push(line); + } + } + + function deliverEOF(): void { + while (readerQueue.length > 0) { + readerQueue.shift()?.resolve(null); + } + } + + const server = net.createServer((socket) => { + if (currentSocket) { + socket.destroy(); + return; + } + currentSocket = socket; + lineBuffer = ''; + socket.on('data', (data: Buffer) => { + lineBuffer += data.toString(); + let idx = lineBuffer.indexOf('\n'); + while (idx !== -1) { + deliverLine(lineBuffer.slice(0, idx)); + lineBuffer = lineBuffer.slice(idx + 1); + idx = lineBuffer.indexOf('\n'); + } + }); + socket.on('end', () => { + if (lineBuffer.length > 0) { + deliverLine(lineBuffer); + lineBuffer = ''; + } + currentSocket = null; + deliverEOF(); + }); + socket.on('error', () => { + currentSocket = null; + deliverEOF(); + }); + }); + + try { + await fs.unlink(config.path); + } catch { + // ignore + } + + await new Promise((resolve, reject) => { + server.on('error', reject); + server.listen(config.path, () => { + server.removeListener('error', reject); + resolve(); + }); + }); + + return { + async read() { + if (closed) { + return null; + } + const queued = lineQueue.shift(); + if (queued !== undefined) { + return queued; + } + if (!currentSocket) { + return null; + } + return new Promise((resolve) => { + readerQueue.push({ resolve }); + }); + }, + async write(data: string) { + if (!currentSocket) { + throw new Error('no connected client'); + } + const socket = currentSocket; + return new Promise((resolve, reject) => { + socket.write(`${data}\n`, (error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + }, + async close() { + if (closed) { + return; + } + closed = true; + deliverEOF(); + currentSocket?.destroy(); + currentSocket = null; + await new Promise((resolve) => { + server.close(() => resolve()); + }); + try { + await fs.unlink(config.path); + } catch { + // ignore + } + }, + }; + }; +} + +/** + * Get the bundle spec for the system console vat test bundle. + * + * @returns The bundle spec URL. + */ +function getSystemConsoleBundleSpec(): string { + const bundlePath = join( + import.meta.dirname, + '../vats/system-console-vat.bundle', + ); + return pathToFileURL(bundlePath).href; +} + +describe('Daemon Stack (IO socket protocol)', { timeout: 30_000 }, () => { + let kernel: Kernel | undefined; + let kernelDatabase: KernelDatabase | undefined; + + /** + * Boot a kernel with a system console subcluster using IO socket. + * + * @returns The socket path. + */ + async function bootDaemonStack(): Promise { + const socketPath = tempSocketPath(); + + kernelDatabase = await makeSQLKernelDatabase({ dbFilename: ':memory:' }); + kernel = await makeTestKernel(kernelDatabase, { + ioChannelFactory: makeTestIOChannelFactory(), + systemSubclusters: [ + { + name: SYSTEM_CONSOLE_NAME, + config: { + bootstrap: SYSTEM_CONSOLE_NAME, + io: { + console: { + type: 'socket' as const, + path: socketPath, + }, + }, + services: ['kernelFacet', 'console'], + vats: { + [SYSTEM_CONSOLE_NAME]: { + bundleSpec: getSystemConsoleBundleSpec(), + parameters: { name: SYSTEM_CONSOLE_NAME }, + }, + }, + }, + }, + ], + }); + + await kernel.initIdentity(); + await waitUntilQuiescent(100); + + return socketPath; + } + + afterEach(async () => { + if (kernel) { + const stopResult = kernel.stop(); + kernel = undefined; + await stopResult; + } + if (kernelDatabase) { + kernelDatabase.close(); + kernelDatabase = undefined; + } + }); + + it('dispatches help command via socket', async () => { + const socketPath = await bootDaemonStack(); + + const response = await sendCommand(socketPath, { method: 'help' }); + + expect(response.ok).toBe(true); + const result = response.result as { commands: string[] }; + expect(result.commands).toBeDefined(); + expect(result.commands.length).toBeGreaterThan(0); + expect(result.commands.some((cmd) => cmd.includes('help'))).toBe(true); + expect(result.commands.some((cmd) => cmd.includes('status'))).toBe(true); + }); + + it('dispatches status command via socket', async () => { + const socketPath = await bootDaemonStack(); + + const response = await sendCommand(socketPath, { method: 'status' }); + + expect(response.ok).toBe(true); + }); + + it('dispatches listRefs command', async () => { + const socketPath = await bootDaemonStack(); + + const response = await sendCommand(socketPath, { method: 'listRefs' }); + + expect(response.ok).toBe(true); + const result = response.result as { refs: { ref: string; kref: string }[] }; + expect(result.refs).toBeDefined(); + expect(Array.isArray(result.refs)).toBe(true); + }); + + it('returns error for unknown command', async () => { + const socketPath = await bootDaemonStack(); + + const response = await sendCommand(socketPath, { method: 'nonexistent' }); + + expect(response.ok).toBe(false); + expect(response.error).toContain('Unknown command'); + }); + + it('handles sequential requests on separate connections', async () => { + const socketPath = await bootDaemonStack(); + + const response1 = await sendCommand(socketPath, { method: 'help' }); + expect(response1.ok).toBe(true); + + const response2 = await sendCommand(socketPath, { method: 'status' }); + expect(response2.ok).toBe(true); + }); +}); diff --git a/packages/nodejs/test/helpers/kernel.ts b/packages/nodejs/test/helpers/kernel.ts index 524b17bb7..1595897c1 100644 --- a/packages/nodejs/test/helpers/kernel.ts +++ b/packages/nodejs/test/helpers/kernel.ts @@ -4,6 +4,7 @@ import { Logger } from '@metamask/logger'; import { Kernel, kunser } from '@metamask/ocap-kernel'; import type { ClusterConfig, + IOChannelFactory, SystemSubclusterConfig, } from '@metamask/ocap-kernel'; @@ -13,6 +14,7 @@ type MakeTestKernelOptions = { resetStorage?: boolean; mnemonic?: string; systemSubclusters?: SystemSubclusterConfig[]; + ioChannelFactory?: IOChannelFactory; }; /** @@ -24,13 +26,19 @@ type MakeTestKernelOptions = { * @param options.resetStorage - Whether to reset the storage (default: true). * @param options.mnemonic - Optional BIP39 mnemonic string. * @param options.systemSubclusters - Optional system subcluster configurations. + * @param options.ioChannelFactory - Optional IO channel factory. * @returns The kernel. */ export async function makeTestKernel( kernelDatabase: KernelDatabase, options: MakeTestKernelOptions = {}, ): Promise { - const { resetStorage = true, mnemonic, systemSubclusters } = options; + const { + resetStorage = true, + mnemonic, + systemSubclusters, + ioChannelFactory, + } = options; const logger = new Logger('test-kernel'); const platformServices = new NodejsPlatformServices({ @@ -40,6 +48,7 @@ export async function makeTestKernel( resetStorage, mnemonic, systemSubclusters, + ioChannelFactory, logger: logger.subLogger({ tags: ['kernel'] }), }); diff --git a/packages/nodejs/test/vats/system-console-vat.ts b/packages/nodejs/test/vats/system-console-vat.ts new file mode 100644 index 000000000..905538bad --- /dev/null +++ b/packages/nodejs/test/vats/system-console-vat.ts @@ -0,0 +1,288 @@ +import { E } from '@endo/eventual-send'; +import { makeDefaultExo } from '@metamask/kernel-utils/exo'; +import type { + Baggage, + ClusterConfig, + KernelStatus, + Subcluster, + SubclusterLaunchResult, +} from '@metamask/ocap-kernel'; + +/** + * Kernel facet interface for system vat operations. + */ +type KernelFacet = { + getStatus: () => Promise; + getSubclusters: () => Promise; + launchSubcluster: (config: ClusterConfig) => Promise; + terminateSubcluster: (subclusterId: string) => Promise; + queueMessage: ( + target: string, + method: string, + args: unknown[], + ) => Promise; +}; + +/** + * Services provided to the system console vat during bootstrap. + */ +type BootstrapServices = { + kernelFacet?: KernelFacet; + console?: IOService; +}; + +/** + * IO service interface for reading and writing lines. + */ +type IOService = { + read: () => Promise; + write: (data: string) => Promise; +}; + +/** + * A JSON request from the CLI. + */ +type Request = { + ref?: string; + method: string; + args?: unknown[]; +}; + +/** + * Build function for the system console vat. + * + * This vat manages the REPL loop over an IO channel, dispatching CLI + * commands and managing refs (capability references) in persistent baggage. + * + * @param _vatPowers - The vat powers (unused). + * @param parameters - The vat parameters. + * @param parameters.name - Optional name for the console vat. + * @param baggage - The vat's persistent baggage storage. + * @returns The root object for the new vat. + */ +export function buildRootObject( + _vatPowers: unknown, + parameters: { name?: string }, + baggage: Baggage, +) { + const name = parameters.name ?? 'system-console'; + + // Monotonic counter for generating unique ref identifiers (persisted in baggage) + let refCounter: number = baggage.has('refCounter') + ? (baggage.get('refCounter') as number) + : 0; + + // Restore kernel facet from baggage if available (for resuscitation) + let kernelFacet: KernelFacet | undefined = baggage.has('kernelFacet') + ? (baggage.get('kernelFacet') as KernelFacet) + : undefined; + + // Ref manager state in baggage + const refs: Record = baggage.has('refs') + ? (baggage.get('refs') as Record) + : {}; + const krefToRef: Record = baggage.has('krefToRef') + ? (baggage.get('krefToRef') as Record) + : {}; + + function persistRefs(): void { + if (baggage.has('refs')) { + baggage.set('refs', harden({ ...refs })); + } else { + baggage.init('refs', harden({ ...refs })); + } + if (baggage.has('krefToRef')) { + baggage.set('krefToRef', harden({ ...krefToRef })); + } else { + baggage.init('krefToRef', harden({ ...krefToRef })); + } + } + + function issueRef(kref: string): string { + const existing = krefToRef[kref]; + if (existing) { + return existing; + } + refCounter += 1; + if (baggage.has('refCounter')) { + baggage.set('refCounter', refCounter); + } else { + baggage.init('refCounter', refCounter); + } + const ref = `d-${refCounter}`; + refs[ref] = kref; + krefToRef[kref] = ref; + persistRefs(); + return ref; + } + + function lookupKref(ref: string): string | undefined { + return refs[ref]; + } + + function revokeRef(ref: string): boolean { + const kref = refs[ref]; + if (!kref) { + return false; + } + delete refs[ref]; + delete krefToRef[kref]; + persistRefs(); + return true; + } + + function listRefs(): { ref: string; kref: string }[] { + return Object.entries(refs).map(([ref, kref]) => ({ ref, kref })); + } + + async function dispatchConsoleMethod( + method: string, + args: unknown[], + ): Promise { + switch (method) { + case 'help': + return { + commands: [ + 'help - show available commands', + 'status - kernel status', + 'launch - launch a subcluster', + 'terminate - terminate a subcluster', + 'subclusters - list subclusters', + 'revoke - revoke a ref', + 'listRefs - list all issued refs', + ], + }; + + case 'status': + return E(kernelFacet!).getStatus(); + + case 'subclusters': + return E(kernelFacet!).getSubclusters(); + + case 'launch': { + const config = args[0] as ClusterConfig; + if (!config) { + throw new Error('launch requires a config argument'); + } + const result = await E(kernelFacet!).launchSubcluster(config); + const ref = issueRef(result.rootKref); + return { ref, subclusterId: result.subclusterId }; + } + + case 'terminate': { + const subclusterId = args[0] as string; + if (!subclusterId) { + throw new Error('terminate requires a subclusterId argument'); + } + await E(kernelFacet!).terminateSubcluster(subclusterId); + return { ok: true }; + } + + case 'revoke': { + const ref = args[0] as string; + if (!ref) { + throw new Error('revoke requires a ref argument'); + } + return { ok: revokeRef(ref) }; + } + + case 'listRefs': + return { refs: listRefs() }; + + default: + throw new Error(`Unknown command: ${method}`); + } + } + + async function handleRequest(request: Request): Promise { + const { ref, method, args = [] } = request; + + if (!ref) { + return dispatchConsoleMethod(method, args); + } + + const kref = lookupKref(ref); + if (!kref) { + throw new Error(`Unknown ref: ${ref}`); + } + return E(kernelFacet!).queueMessage(kref, method, args); + } + + async function runReplLoop(ioService: IOService): Promise { + for (;;) { + const line = await E(ioService).read(); + if (line === null) { + continue; + } + + let response: unknown; + try { + const request = JSON.parse(line) as Request; + const result = await handleRequest(request); + response = { ok: true, result }; + } catch (error) { + // Errors crossing vat boundaries may arrive as plain objects. + // Try multiple strategies to extract a human-readable message. + let errorMessage: string; + if (error instanceof Error) { + errorMessage = error.message ?? error.stack ?? String(error); + } else if (typeof error === 'string') { + errorMessage = error; + } else { + try { + errorMessage = JSON.stringify(error); + } catch { + errorMessage = String(error); + } + } + response = { ok: false, error: errorMessage }; + } + + try { + await E(ioService).write(JSON.stringify(response)); + } catch { + // Write failed — continue loop + } + } + } + + return makeDefaultExo('root', { + async bootstrap( + _vats: unknown, + services: BootstrapServices, + ): Promise { + if (!kernelFacet && services.kernelFacet) { + kernelFacet = services.kernelFacet; + baggage.init('kernelFacet', kernelFacet); + } + + if (services.console) { + runReplLoop(services.console).catch((error) => { + console.error(`[${name}] REPL loop error:`, error); + }); + } + }, + + help() { + return harden({ + commands: [ + 'help - show available commands', + 'status - kernel status', + 'launch - launch a subcluster', + 'terminate - terminate a subcluster', + 'subclusters - list subclusters', + 'revoke - revoke a ref', + 'listRefs - list all issued refs', + ], + }); + }, + + issueRef(kref: string): string { + return issueRef(kref); + }, + + listRefs(): { ref: string; kref: string }[] { + return listRefs(); + }, + }); +} From 2806502a6a29024a6251b8148333f9b6f9c9c4e9 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 17 Feb 2026 07:24:16 -0600 Subject: [PATCH 04/33] feat(cli): add ok binary with yargs CLI for kernel interaction Add the 'ok' CLI that communicates with the kernel daemon over a UNIX domain socket using newline-delimited JSON. Uses yargs for command definitions with --help support on all commands. Supports three input modes: file arg (ok file.ocap method), stdin redirect (ok launch < config.json), and pipe (cat config.json | ok launch). Relative bundleSpec paths in launch configs are resolved to file:// URLs against CWD. Ref results are output as .ocap files when stdout is not a TTY. Co-Authored-By: Claude Opus 4.6 --- packages/cli/package.json | 3 +- packages/cli/src/commands/daemon-client.ts | 173 ++++++++ packages/cli/src/commands/daemon-entry.ts | 99 +++++ packages/cli/src/commands/daemon-spawn.ts | 48 +++ packages/cli/src/ok.ts | 450 +++++++++++++++++++++ packages/cli/tsconfig.build.json | 3 +- yarn.lock | 1 + 7 files changed, 775 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/commands/daemon-client.ts create mode 100644 packages/cli/src/commands/daemon-entry.ts create mode 100644 packages/cli/src/commands/daemon-spawn.ts create mode 100644 packages/cli/src/ok.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index 4a6c8317b..2652c1710 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -9,7 +9,8 @@ }, "type": "module", "bin": { - "ocap": "./dist/app.mjs" + "ocap": "./dist/app.mjs", + "ok": "./dist/ok.mjs" }, "files": [ "dist/" diff --git a/packages/cli/src/commands/daemon-client.ts b/packages/cli/src/commands/daemon-client.ts new file mode 100644 index 000000000..8cb77502d --- /dev/null +++ b/packages/cli/src/commands/daemon-client.ts @@ -0,0 +1,173 @@ +import { createConnection } from 'node:net'; +import type { Socket } from 'node:net'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +/** + * Get the default daemon socket path. + * + * @returns The socket path. + */ +export function getSocketPath(): string { + return join(homedir(), '.ocap', 'console.sock'); +} + +/** + * Connect to a UNIX domain socket. + * + * @param socketPath - The socket path to connect to. + * @returns A connected socket. + */ +async function connectSocket(socketPath: string): Promise { + return new Promise((resolve, reject) => { + const socket = createConnection(socketPath, () => { + socket.removeListener('error', reject); + resolve(socket); + }); + socket.on('error', reject); + }); +} + +/** + * Read a single newline-delimited line from a socket. + * + * @param socket - The socket to read from. + * @returns The line read. + */ +async function readLine(socket: Socket): Promise { + return new Promise((resolve, reject) => { + let buffer = ''; + const onData = (data: Buffer): void => { + buffer += data.toString(); + const idx = buffer.indexOf('\n'); + if (idx !== -1) { + socket.removeAllListeners('data'); + socket.removeAllListeners('error'); + resolve(buffer.slice(0, idx)); + } + }; + socket.on('data', onData); + socket.once('error', (error) => { + socket.removeAllListeners('data'); + reject(error); + }); + }); +} + +/** + * Write a newline-delimited line to a socket. + * + * @param socket - The socket to write to. + * @param line - The line to write. + */ +async function writeLine(socket: Socket, line: string): Promise { + return new Promise((resolve, reject) => { + socket.write(`${line}\n`, (error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); +} + +/** + * The response shape from the system console vat. + */ +export type ConsoleResponse = { + ok: boolean; + result?: unknown; + error?: string; +}; + +/** + * Send a JSON request to the daemon over a UNIX socket and return the response. + * + * Opens a connection, writes one JSON line, reads one JSON response line, + * then closes the connection. + * + * @param socketPath - The UNIX socket path. + * @param request - The request to send. + * @param request.ref - Optional ref targeting a capability. + * @param request.method - The method name to invoke. + * @param request.args - Optional arguments array. + * @returns The parsed response. + */ +export async function sendCommand( + socketPath: string, + request: { ref?: string; method: string; args?: unknown[] }, +): Promise { + const socket = await connectSocket(socketPath); + try { + await writeLine(socket, JSON.stringify(request)); + const responseLine = await readLine(socket); + return JSON.parse(responseLine) as ConsoleResponse; + } finally { + socket.destroy(); + } +} + +/** + * Check whether the daemon is running by probing the socket. + * + * @param socketPath - The UNIX socket path. + * @returns True if the daemon socket accepts a connection. + */ +export async function isDaemonRunning(socketPath: string): Promise { + try { + const socket = await connectSocket(socketPath); + socket.destroy(); + return true; + } catch { + return false; + } +} + +/** + * Read all content from stdin, stripping shebang lines. + * + * @returns The stdin content with shebang lines removed. + */ +export async function readStdin(): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + process.stdin.on('data', (chunk: Buffer) => chunks.push(chunk)); + process.stdin.on('end', () => { + const raw = Buffer.concat(chunks).toString().trim(); + const lines = raw.split('\n').filter((line) => !line.startsWith('#!')); + resolve(lines.join('\n').trim()); + }); + process.stdin.on('error', reject); + }); +} + +/** + * Read a ref from stdin. Strips shebang lines. + * + * @returns The ref string. + */ +export async function readRefFromStdin(): Promise { + const content = await readStdin(); + if (!content) { + throw new Error('No ref found in stdin'); + } + return content; +} + +/** + * Read a ref from a .ocap file. Strips shebang lines. + * + * @param filePath - The path to the .ocap file. + * @returns The ref string. + */ +export async function readRefFromFile(filePath: string): Promise { + const { readFile } = await import('node:fs/promises'); + const raw = (await readFile(filePath, 'utf-8')).trim(); + const lines = raw.split('\n').filter((line) => !line.startsWith('#!')); + const ref = lines.join('\n').trim(); + if (!ref) { + throw new Error(`No ref found in ${filePath}`); + } + return ref; +} diff --git a/packages/cli/src/commands/daemon-entry.ts b/packages/cli/src/commands/daemon-entry.ts new file mode 100644 index 000000000..4e0f710f3 --- /dev/null +++ b/packages/cli/src/commands/daemon-entry.ts @@ -0,0 +1,99 @@ +/* eslint-disable n/no-process-exit, n/no-process-env, n/no-sync */ +import '@metamask/kernel-shims/endoify-node'; +import { Logger } from '@metamask/logger'; +import type { LogEntry } from '@metamask/logger'; +import { mkdir } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { homedir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { bundleFile } from './bundle.ts'; + +/** + * Create a file transport that writes logs to a file. + * + * @param logPath - The log file path. + * @returns A log transport function. + */ +function makeFileTransport(logPath: string) { + // eslint-disable-next-line @typescript-eslint/no-require-imports, n/global-require -- need sync fs for log transport + const { appendFileSync } = require('node:fs') as typeof import('node:fs'); + return (entry: LogEntry): void => { + const line = `[${new Date().toISOString()}] [${entry.level}] ${entry.message ?? ''} ${(entry.data ?? []).map(String).join(' ')}\n`; + appendFileSync(logPath, line); + }; +} + +/** + * Main daemon entry point. Starts the daemon process and keeps it running. + */ +async function main(): Promise { + const ocapDir = join(homedir(), '.ocap'); + await mkdir(ocapDir, { recursive: true }); + + const logPath = join(ocapDir, 'daemon.log'); + const logger = new Logger({ + tags: ['daemon'], + transports: [makeFileTransport(logPath)], + }); + + try { + const socketPath = + process.env.OCAP_SOCKET_PATH ?? join(ocapDir, 'console.sock'); + const consoleName = process.env.OCAP_CONSOLE_NAME ?? 'system-console'; + + // Bundle system console vat if needed + const bundlesDir = join(ocapDir, 'bundles'); + await mkdir(bundlesDir, { recursive: true }); + + const bundlePath = join(bundlesDir, 'system-console-vat.bundle'); + const cjsRequire = createRequire(import.meta.url); + const kernelPkgPath = cjsRequire.resolve( + '@metamask/ocap-kernel/package.json', + ); + const vatSource = resolve( + dirname(kernelPkgPath), + 'src/vats/system-console-vat.ts', + ); + logger.info(`Bundling system console vat from ${vatSource}...`); + await bundleFile(vatSource, { logger, targetPath: bundlePath }); + const bundleSpec = pathToFileURL(bundlePath).href; + + // Dynamically import to avoid pulling @ocap/nodejs into the CLI bundle graph + // eslint-disable-next-line import-x/no-extraneous-dependencies -- workspace package + const { startDaemon } = await import('@ocap/nodejs'); + + const handle = await startDaemon({ + systemConsoleBundleSpec: bundleSpec, + systemConsoleName: consoleName, + socketPath, + resetStorage: true, + logger, + }); + + logger.info(`Daemon started. Socket: ${handle.socketPath}`); + + // Keep the process alive + const shutdown = async (signal: string): Promise => { + logger.info(`Received ${signal}, shutting down...`); + await handle.close(); + process.exit(0); + }; + + process.on('SIGTERM', () => { + shutdown('SIGTERM').catch(() => process.exit(1)); + }); + process.on('SIGINT', () => { + shutdown('SIGINT').catch(() => process.exit(1)); + }); + } catch (error) { + logger.error('Daemon startup failed:', error); + process.exit(1); + } +} + +main().catch((error) => { + process.stderr.write(`Daemon fatal: ${String(error)}\n`); + process.exit(1); +}); diff --git a/packages/cli/src/commands/daemon-spawn.ts b/packages/cli/src/commands/daemon-spawn.ts new file mode 100644 index 000000000..0b9e128f9 --- /dev/null +++ b/packages/cli/src/commands/daemon-spawn.ts @@ -0,0 +1,48 @@ +import { spawn } from 'node:child_process'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { isDaemonRunning } from './daemon-client.ts'; + +const POLL_INTERVAL_MS = 100; +const MAX_POLLS = 300; // 30 seconds + +/** + * Ensure the daemon is running. If it is not, spawn it as a detached process + * and wait until the socket becomes responsive. + * + * @param socketPath - The UNIX socket path. + */ +export async function ensureDaemon(socketPath: string): Promise { + if (await isDaemonRunning(socketPath)) { + return; + } + + process.stderr.write('Starting daemon...\n'); + + const currentDir = dirname(fileURLToPath(import.meta.url)); + const entryPath = join(currentDir, 'daemon-entry.mjs'); + + const child = spawn(process.execPath, [entryPath], { + detached: true, + stdio: 'ignore', + env: { + ...process.env, // eslint-disable-line n/no-process-env -- pass env to child + OCAP_SOCKET_PATH: socketPath, + }, + }); + child.unref(); + + // Poll until daemon responds + for (let i = 0; i < MAX_POLLS; i++) { + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + if (await isDaemonRunning(socketPath)) { + process.stderr.write('Daemon ready.\n'); + return; + } + } + + throw new Error( + `Daemon did not start within ${(MAX_POLLS * POLL_INTERVAL_MS) / 1000}s`, + ); +} diff --git a/packages/cli/src/ok.ts b/packages/cli/src/ok.ts new file mode 100644 index 000000000..adc021768 --- /dev/null +++ b/packages/cli/src/ok.ts @@ -0,0 +1,450 @@ +/* eslint-disable n/no-process-exit, n/no-sync, no-negated-condition */ +import '@metamask/kernel-shims/endoify-node'; +import { existsSync, fstatSync } from 'node:fs'; +import { writeFile, chmod } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import yargs from 'yargs'; +import { hideBin } from 'yargs/helpers'; + +import { + getSocketPath, + sendCommand, + readStdin, + readRefFromStdin, + readRefFromFile, +} from './commands/daemon-client.ts'; +import { ensureDaemon } from './commands/daemon-spawn.ts'; + +/** + * Handle the core invocation: resolve ref, call method, output result. + * + * @param args - CLI arguments after `ok`. + * @param socketPath - The daemon socket path. + */ +async function handleInvoke(args: string[], socketPath: string): Promise { + let ref: string | undefined; + let method: string; + let methodArgs: string[]; + + const firstArg = args[0]; + if ( + firstArg !== undefined && + (firstArg.endsWith('.ocap') || existsSync(firstArg)) + ) { + // File arg mode: ok [...args] + ref = await readRefFromFile(firstArg); + method = args[1] ?? 'help'; + methodArgs = args.slice(2); + } else if ( + !process.stdin.isTTY && + (fstatSync(0).isFIFO() || fstatSync(0).isFile()) + ) { + // Redirected stdin (pipe or file): could be a ref (d-) or JSON data + const stdinContent = await readStdin(); + if (!stdinContent) { + throw new Error('No input on stdin'); + } + if (stdinContent.startsWith('d-')) { + // Ref mode: ok [...args] < file.ocap + ref = stdinContent; + method = args[0] ?? 'help'; + methodArgs = args.slice(1); + } else { + // Data mode: cat config.json | ok launch + method = args[0] ?? 'help'; + methodArgs = [stdinContent, ...args.slice(1)]; + } + } else { + // No ref — dispatch on the system console itself + method = args[0] ?? 'help'; + methodArgs = args.slice(1); + } + + // For launch: resolve relative bundleSpec paths to file:// URLs + if (method === 'launch') { + methodArgs = methodArgs.map((arg) => { + try { + const parsed = JSON.parse(arg) as unknown; + if (isClusterConfigLike(parsed)) { + return JSON.stringify(resolveBundleSpecs(parsed)); + } + } catch { + // not JSON — leave as-is + } + return arg; + }); + } + + // Parse args: try JSON for each, fall back to string + const parsedArgs = methodArgs.map((arg) => { + try { + return JSON.parse(arg) as unknown; + } catch { + return arg; + } + }); + + const request: { ref?: string; method: string; args?: unknown[] } = { + method, + ...(ref !== undefined ? { ref } : {}), + ...(parsedArgs.length > 0 ? { args: parsedArgs } : {}), + }; + + const response = await sendCommand(socketPath, request); + + if (!response.ok) { + process.stderr.write(`Error: ${response.error}\n`); + process.exit(1); + } + + const isTTY = process.stdout.isTTY ?? false; + const { result } = response; + + // Check if result contains a ref (capability) + const resultRef = isRefResult(result) ? result.ref : undefined; + + if (resultRef && !isTTY) { + // Piped: output .ocap content for the ref + process.stdout.write(`#!/usr/bin/env ok\n${resultRef}\n`); + } else if (isTTY) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } else { + process.stdout.write(`${JSON.stringify(result)}\n`); + } +} + +/** + * Check if a result object contains a ref field. + * + * @param result - The result to check. + * @returns True if the result has a ref string. + */ +function isRefResult(result: unknown): result is { ref: string } { + return ( + typeof result === 'object' && + result !== null && + 'ref' in result && + typeof (result as { ref: unknown }).ref === 'string' + ); +} + +/** + * Check if a value looks like a cluster config (has bootstrap + vats). + * + * @param value - The value to check. + * @returns True if the value has bootstrap and vats fields. + */ +function isClusterConfigLike( + value: unknown, +): value is { vats: Record } { + return ( + typeof value === 'object' && + value !== null && + 'bootstrap' in value && + 'vats' in value && + typeof (value as { vats: unknown }).vats === 'object' + ); +} + +/** + * Resolve relative bundleSpec paths in a cluster config to file:// URLs. + * + * @param config - The cluster config object. + * @returns The config with resolved bundleSpec URLs. + */ +function resolveBundleSpecs(config: { + vats: Record; +}): unknown { + const resolvedVats: Record = {}; + for (const [vatName, vatConfig] of Object.entries(config.vats)) { + const spec = vatConfig.bundleSpec; + if (spec && !spec.includes('://')) { + resolvedVats[vatName] = { + ...vatConfig, + bundleSpec: pathToFileURL(resolve(spec)).href, + }; + } else { + resolvedVats[vatName] = vatConfig; + } + } + return { ...config, vats: resolvedVats }; +} + +/** + * Handle daemon management commands. + * + * @param args - CLI arguments after `ok daemon`. + * @param socketPath - The daemon socket path. + */ +async function handleDaemon(args: string[], socketPath: string): Promise { + const subcommand = args[0]; + + if (subcommand === 'stop') { + const { isDaemonRunning } = await import('./commands/daemon-client.ts'); + if (await isDaemonRunning(socketPath)) { + process.stderr.write( + 'Stopping daemon... (send SIGTERM to daemon process)\n', + ); + process.stderr.write( + `Run: kill $(lsof -t ${socketPath}) or use pkill -f daemon-entry\n`, + ); + } else { + process.stderr.write('Daemon is not running.\n'); + } + return; + } + + if (subcommand === 'begone') { + const forGood = args.includes('--forgood'); + if (!forGood) { + process.stderr.write( + 'Usage: ok daemon begone --forgood\n' + + 'This will delete all OCAP daemon state.\n', + ); + process.exit(1); + } + // eslint-disable-next-line import-x/no-extraneous-dependencies -- workspace package + const { flushDaemon } = await import('@ocap/nodejs'); + await flushDaemon({ socketPath }); + process.stderr.write('All daemon state flushed.\n'); + return; + } + + // Default: start daemon (or confirm running) + let consoleName = 'system-console'; + const consoleIdx = args.indexOf('--console'); + if (consoleIdx !== -1 && args[consoleIdx + 1]) { + consoleName = args[consoleIdx + 1] ?? consoleName; + } + + // eslint-disable-next-line n/no-process-env -- CLI sets env for daemon child process + process.env.OCAP_CONSOLE_NAME = consoleName; + + await ensureDaemon(socketPath); + + // Check if the .ocap file exists + const ocapPath = `${consoleName}.ocap`; + if (!existsSync(ocapPath)) { + // Request listRefs from the daemon to find the system console ref + const response = await sendCommand(socketPath, { method: 'listRefs' }); + + if (response.ok) { + const result = response.result as { + refs: { ref: string; kref: string }[]; + }; + const firstRef = result.refs[0]; + if (firstRef) { + const content = `#!/usr/bin/env ok\n${firstRef.ref}\n`; + await writeFile(ocapPath, content); + await chmod(ocapPath, 0o755); + process.stderr.write(`Created ${ocapPath}\n`); + } + } + } + + process.stderr.write(`Daemon running. Socket: ${socketPath}\n`); +} + +/** + * Handle revoke command. + * + * @param args - CLI arguments after `ok revoke`. + * @param socketPath - The daemon socket path. + */ +async function handleRevoke(args: string[], socketPath: string): Promise { + let ref: string; + + const firstArg = args[0]; + if ( + firstArg !== undefined && + (firstArg.endsWith('.ocap') || existsSync(firstArg)) + ) { + ref = await readRefFromFile(firstArg); + } else if ( + !process.stdin.isTTY && + (fstatSync(0).isFIFO() || fstatSync(0).isFile()) + ) { + ref = await readRefFromStdin(); + } else { + process.stderr.write( + 'Usage: ok revoke \n ok revoke < file.ocap\n', + ); + process.exit(1); + } + + const response = await sendCommand(socketPath, { + method: 'revoke', + args: [ref], + }); + + if (response.ok && (response.result as { ok: boolean }).ok) { + process.stderr.write(`Revoked ref: ${ref}\n`); + } else { + process.stderr.write(`Ref not found: ${ref}\n`); + process.exit(1); + } +} + +const socketPath = getSocketPath(); + +const cli = yargs(hideBin(process.argv)) + .scriptName('ok') + .usage('$0 [file.ocap] [...args]') + + .command( + 'launch [config]', + 'Launch a subcluster', + (_yargs) => + _yargs + .positional('config', { + describe: 'Cluster config as inline JSON string', + type: 'string', + }) + .example( + '$0 launch \'{"bootstrap":"v","vats":{"v":{"bundleSpec":"file:///path/to.bundle"}}}\'', + 'Inline JSON', + ) + .example('$0 launch < config.json > root.ocap', 'File redirect') + .example('cat config.json | $0 launch', 'Piped'), + async (args) => { + await ensureDaemon(socketPath); + await handleInvoke( + ['launch', ...(args.config ? [args.config] : [])], + socketPath, + ); + }, + ) + + .command( + 'terminate ', + 'Terminate a subcluster', + (_yargs) => + _yargs.positional('subclusterId', { + describe: 'ID of the subcluster to terminate', + type: 'string', + demandOption: true, + }), + async (args) => { + await ensureDaemon(socketPath); + await handleInvoke(['terminate', String(args.subclusterId)], socketPath); + }, + ) + + .command( + 'status', + 'Show kernel status', + () => ({}), + async () => { + await ensureDaemon(socketPath); + await handleInvoke(['status'], socketPath); + }, + ) + + .command( + 'subclusters', + 'List subclusters', + () => ({}), + async () => { + await ensureDaemon(socketPath); + await handleInvoke(['subclusters'], socketPath); + }, + ) + + .command( + 'listRefs', + 'List all issued refs', + () => ({}), + async () => { + await ensureDaemon(socketPath); + await handleInvoke(['listRefs'], socketPath); + }, + ) + + .command( + 'help', + 'Show available kernel commands', + () => ({}), + async () => { + await ensureDaemon(socketPath); + await handleInvoke(['help'], socketPath); + }, + ) + + .command( + 'revoke [target]', + 'Revoke a capability ref', + (_yargs) => + _yargs + .positional('target', { + describe: 'Path to .ocap file', + type: 'string', + }) + .example('$0 revoke file.ocap', 'By file path') + .example('$0 revoke < file.ocap', 'From stdin'), + async (args) => { + await handleRevoke(args.target ? [String(args.target)] : [], socketPath); + }, + ) + + .command( + 'daemon [subcommand]', + 'Manage the daemon process', + (_yargs) => + _yargs + .positional('subcommand', { + describe: 'Subcommand: stop, begone', + type: 'string', + }) + .option('console', { + describe: 'System console name', + type: 'string', + default: 'system-console', + }) + .option('forgood', { + describe: 'Confirm state deletion (for begone)', + type: 'boolean', + }), + async (args) => { + const daemonArgs: string[] = []; + if (args.subcommand) { + daemonArgs.push(String(args.subcommand)); + } + if (args.forgood) { + daemonArgs.push('--forgood'); + } + if (args.console && args.console !== 'system-console') { + daemonArgs.push('--console', String(args.console)); + } + await handleDaemon(daemonArgs, socketPath); + }, + ) + + // Default: file.ocap dispatch or bare invocation + .command( + '$0 [args..]', + false, + (_yargs) => _yargs.strict(false), + async (args) => { + const invokeArgs = ((args.args ?? []) as string[]).map(String); + await ensureDaemon(socketPath); + await handleInvoke( + invokeArgs.length > 0 ? invokeArgs : ['help'], + socketPath, + ); + }, + ) + + .version(false) + .fail((message, error) => { + if (error) { + process.stderr.write( + `Error: ${error instanceof Error ? error.message : String(error)}\n`, + ); + } else if (message) { + process.stderr.write(`${message}\n`); + } + process.exit(1); + }); + +await cli.parse(); diff --git a/packages/cli/tsconfig.build.json b/packages/cli/tsconfig.build.json index 5982e62d4..0aac5d7a6 100644 --- a/packages/cli/tsconfig.build.json +++ b/packages/cli/tsconfig.build.json @@ -6,7 +6,8 @@ "outDir": "./dist", "emitDeclarationOnly": false, "rootDir": "./src", - "types": ["ses", "node"] + "types": ["ses", "node"], + "paths": {} }, "references": [ { "path": "../logger/tsconfig.build.json" }, diff --git a/yarn.lock b/yarn.lock index cc2c6e1c8..d3b4fa063 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3498,6 +3498,7 @@ __metadata: yargs: "npm:^17.7.2" bin: ocap: ./dist/app.mjs + ok: ./dist/ok.mjs languageName: unknown linkType: soft From 5f8febae18a6eaf50326bd84ceeea48f0d51b980 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 17 Feb 2026 08:20:12 -0600 Subject: [PATCH 05/33] feat(ocap-kernel,cli,nodejs): two-tier access for system console vat Implement a two-tier access model: unauthenticated daemon-tier commands (help, status) and privileged ref-based dispatch via .ocap capability files. Self-ref dispatch bypasses kernel round-trip for the console root object. Fix kref leaks, improve socket channel reliability with stale connection detection and client-side retry. --- packages/cli/src/commands/daemon-client.ts | 59 +++- packages/cli/src/commands/daemon-entry.ts | 15 +- packages/cli/src/ok.ts | 284 +++++++--------- packages/nodejs/src/daemon/flush-daemon.ts | 3 + .../nodejs/src/daemon/start-daemon.test.ts | 36 +- packages/nodejs/src/daemon/start-daemon.ts | 11 + packages/nodejs/src/io/socket-channel.ts | 18 +- packages/nodejs/test/e2e/daemon-stack.test.ts | 312 ++++++++--------- .../nodejs/test/vats/system-console-vat.ts | 289 +--------------- packages/ocap-kernel/src/Kernel.ts | 23 +- packages/ocap-kernel/src/kernel-facet.test.ts | 2 + packages/ocap-kernel/src/kernel-facet.ts | 1 + .../src/vats/system-console-vat.test.ts | 292 +++++++++++----- .../src/vats/system-console-vat.ts | 317 +++++++++++++----- 14 files changed, 840 insertions(+), 822 deletions(-) diff --git a/packages/cli/src/commands/daemon-client.ts b/packages/cli/src/commands/daemon-client.ts index 8cb77502d..7897962b0 100644 --- a/packages/cli/src/commands/daemon-client.ts +++ b/packages/cli/src/commands/daemon-client.ts @@ -3,6 +3,8 @@ import type { Socket } from 'node:net'; import { homedir } from 'node:os'; import { join } from 'node:path'; +const READ_TIMEOUT_MS = 30_000; + /** * Get the default daemon socket path. * @@ -37,20 +39,45 @@ async function connectSocket(socketPath: string): Promise { async function readLine(socket: Socket): Promise { return new Promise((resolve, reject) => { let buffer = ''; + + const timer = setTimeout(() => { + cleanup(); + reject(new Error('Daemon response timed out')); + }, READ_TIMEOUT_MS); + + /** + * Remove all listeners and clear the timeout. + */ + function cleanup(): void { + clearTimeout(timer); + socket.removeAllListeners('data'); + socket.removeAllListeners('error'); + socket.removeAllListeners('end'); + socket.removeAllListeners('close'); + } + const onData = (data: Buffer): void => { buffer += data.toString(); const idx = buffer.indexOf('\n'); if (idx !== -1) { - socket.removeAllListeners('data'); - socket.removeAllListeners('error'); + cleanup(); resolve(buffer.slice(0, idx)); } }; + socket.on('data', onData); socket.once('error', (error) => { - socket.removeAllListeners('data'); + cleanup(); reject(error); }); + socket.once('end', () => { + cleanup(); + reject(new Error('Socket closed before response received')); + }); + socket.once('close', () => { + cleanup(); + reject(new Error('Socket closed before response received')); + }); }); } @@ -85,7 +112,8 @@ export type ConsoleResponse = { * Send a JSON request to the daemon over a UNIX socket and return the response. * * Opens a connection, writes one JSON line, reads one JSON response line, - * then closes the connection. + * then closes the connection. Retries once after a short delay if the + * connection is rejected (e.g. due to a probe connection race). * * @param socketPath - The UNIX socket path. * @param request - The request to send. @@ -98,13 +126,24 @@ export async function sendCommand( socketPath: string, request: { ref?: string; method: string; args?: unknown[] }, ): Promise { - const socket = await connectSocket(socketPath); + const attempt = async (): Promise => { + const socket = await connectSocket(socketPath); + try { + await writeLine(socket, JSON.stringify(request)); + const responseLine = await readLine(socket); + return JSON.parse(responseLine) as ConsoleResponse; + } finally { + socket.destroy(); + } + }; + try { - await writeLine(socket, JSON.stringify(request)); - const responseLine = await readLine(socket); - return JSON.parse(responseLine) as ConsoleResponse; - } finally { - socket.destroy(); + return await attempt(); + } catch { + // Retry once after a short delay — the daemon's socket channel may + // still be cleaning up a previous probe connection. + await new Promise((resolve) => setTimeout(resolve, 100)); + return attempt(); } } diff --git a/packages/cli/src/commands/daemon-entry.ts b/packages/cli/src/commands/daemon-entry.ts index 4e0f710f3..85fc704be 100644 --- a/packages/cli/src/commands/daemon-entry.ts +++ b/packages/cli/src/commands/daemon-entry.ts @@ -2,7 +2,7 @@ import '@metamask/kernel-shims/endoify-node'; import { Logger } from '@metamask/logger'; import type { LogEntry } from '@metamask/logger'; -import { mkdir } from 'node:fs/promises'; +import { chmod, mkdir, rm, writeFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { homedir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; @@ -72,12 +72,25 @@ async function main(): Promise { logger, }); + // Write the admin .ocap file so `ok ` works + const ocapPath = + process.env.OCAP_CONSOLE_PATH ?? join(ocapDir, `${consoleName}.ocap`); + await mkdir(dirname(ocapPath), { recursive: true }); + await writeFile(ocapPath, `#!/usr/bin/env ok\n${handle.selfRef}\n`); + await chmod(ocapPath, 0o700); + logger.info(`Wrote ${ocapPath}`); + + // Write PID file so `ok daemon stop` can signal this process + const pidPath = join(ocapDir, 'daemon.pid'); + await writeFile(pidPath, String(process.pid)); + logger.info(`Daemon started. Socket: ${handle.socketPath}`); // Keep the process alive const shutdown = async (signal: string): Promise => { logger.info(`Received ${signal}, shutting down...`); await handle.close(); + await rm(pidPath, { force: true }); process.exit(0); }; diff --git a/packages/cli/src/ok.ts b/packages/cli/src/ok.ts index adc021768..15401c6f7 100644 --- a/packages/cli/src/ok.ts +++ b/packages/cli/src/ok.ts @@ -1,21 +1,34 @@ /* eslint-disable n/no-process-exit, n/no-sync, no-negated-condition */ import '@metamask/kernel-shims/endoify-node'; import { existsSync, fstatSync } from 'node:fs'; -import { writeFile, chmod } from 'node:fs/promises'; -import { resolve } from 'node:path'; +import { readFile, rm } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; import { getSocketPath, + isDaemonRunning, sendCommand, readStdin, - readRefFromStdin, readRefFromFile, } from './commands/daemon-client.ts'; import { ensureDaemon } from './commands/daemon-spawn.ts'; +const home = homedir(); + +/** + * Replace the home directory prefix with `~` for display. + * + * @param path - An absolute path. + * @returns The path with the home prefix replaced. + */ +function tildify(path: string): string { + return path.startsWith(home) ? `~${path.slice(home.length)}` : path; +} + /** * Handle the core invocation: resolve ref, call method, output result. * @@ -61,8 +74,11 @@ async function handleInvoke(args: string[], socketPath: string): Promise { methodArgs = args.slice(1); } - // For launch: resolve relative bundleSpec paths to file:// URLs + // For launch: resolve relative bundleSpec paths to file:// URLs. + // Handle shell word-splitting: `$(cat file.json)` without quotes splits + // JSON into many args. Try joining all args as one JSON string first. if (method === 'launch') { + methodArgs = rejoinSplitJson(methodArgs); methodArgs = methodArgs.map((arg) => { try { const parsed = JSON.parse(arg) as unknown; @@ -129,6 +145,39 @@ function isRefResult(result: unknown): result is { ref: string } { ); } +/** + * Rejoin args that were word-split by the shell from a single JSON value. + * + * When a user writes `ok launch $(cat config.json)` without quotes, bash + * splits the JSON on whitespace into many argv entries. This function + * detects that pattern and reassembles the original JSON. + * + * @param args - The method arguments (possibly word-split). + * @returns The args, with split JSON rejoined into a single element. + */ +function rejoinSplitJson(args: string[]): string[] { + if (args.length <= 1) { + return args; + } + // If the first arg already parses as a complete JSON object, no fix needed + const first = args[0]; + if (first !== undefined) { + try { + JSON.parse(first); + return args; + } catch { + // First arg alone isn't valid JSON — try joining + } + } + const joined = args.join(' '); + try { + JSON.parse(joined); + return [joined]; + } catch { + return args; + } +} + /** * Check if a value looks like a cluster config (has bootstrap + vats). * @@ -151,6 +200,7 @@ function isClusterConfigLike( * Resolve relative bundleSpec paths in a cluster config to file:// URLs. * * @param config - The cluster config object. + * @param config.vats - The vat configurations with optional bundleSpec paths. * @returns The config with resolved bundleSpec URLs. */ function resolveBundleSpecs(config: { @@ -181,16 +231,54 @@ async function handleDaemon(args: string[], socketPath: string): Promise { const subcommand = args[0]; if (subcommand === 'stop') { - const { isDaemonRunning } = await import('./commands/daemon-client.ts'); - if (await isDaemonRunning(socketPath)) { + if (!(await isDaemonRunning(socketPath))) { + process.stderr.write('Daemon is not running.\n'); + return; + } + + const pidPath = join(homedir(), '.ocap', 'daemon.pid'); + + let pid: number | undefined; + try { + pid = Number(await readFile(pidPath, 'utf-8')); + } catch { + // PID file missing — fall back to manual instructions + } + + if (!pid || Number.isNaN(pid)) { process.stderr.write( - 'Stopping daemon... (send SIGTERM to daemon process)\n', + 'PID file not found. Stop the daemon manually:\n' + + ` kill $(lsof -t ${tildify(socketPath)})\n`, ); + return; + } + + process.stderr.write('Stopping daemon...\n'); + try { + process.kill(pid, 'SIGTERM'); + } catch { process.stderr.write( - `Run: kill $(lsof -t ${socketPath}) or use pkill -f daemon-entry\n`, + 'Failed to send SIGTERM (process may already be gone).\n', ); + await rm(pidPath, { force: true }); + return; + } + + // Poll until socket stops responding (max 5s) + const pollEnd = Date.now() + 5_000; + while (Date.now() < pollEnd) { + await new Promise((_resolve) => setTimeout(_resolve, 250)); + if (!(await isDaemonRunning(socketPath))) { + break; + } + } + + await rm(pidPath, { force: true }); + + if (await isDaemonRunning(socketPath)) { + process.stderr.write('Daemon did not stop within 5 seconds.\n'); } else { - process.stderr.write('Daemon is not running.\n'); + process.stderr.write('Daemon stopped.\n'); } return; } @@ -212,77 +300,29 @@ async function handleDaemon(args: string[], socketPath: string): Promise { } // Default: start daemon (or confirm running) - let consoleName = 'system-console'; + let consolePath = 'system-console.ocap'; const consoleIdx = args.indexOf('--console'); if (consoleIdx !== -1 && args[consoleIdx + 1]) { - consoleName = args[consoleIdx + 1] ?? consoleName; + consolePath = args[consoleIdx + 1] ?? consolePath; } + // Resolve relative to PWD + const ocapPath = resolve(consolePath); + // Derive the console name from the filename (strip .ocap if present) + const consoleName = ocapPath.endsWith('.ocap') + ? ocapPath.slice(ocapPath.lastIndexOf('/') + 1, -5) + : ocapPath.slice(ocapPath.lastIndexOf('/') + 1); + // eslint-disable-next-line n/no-process-env -- CLI sets env for daemon child process process.env.OCAP_CONSOLE_NAME = consoleName; + // eslint-disable-next-line n/no-process-env -- CLI sets env for daemon child process + process.env.OCAP_CONSOLE_PATH = ocapPath; await ensureDaemon(socketPath); - // Check if the .ocap file exists - const ocapPath = `${consoleName}.ocap`; - if (!existsSync(ocapPath)) { - // Request listRefs from the daemon to find the system console ref - const response = await sendCommand(socketPath, { method: 'listRefs' }); - - if (response.ok) { - const result = response.result as { - refs: { ref: string; kref: string }[]; - }; - const firstRef = result.refs[0]; - if (firstRef) { - const content = `#!/usr/bin/env ok\n${firstRef.ref}\n`; - await writeFile(ocapPath, content); - await chmod(ocapPath, 0o755); - process.stderr.write(`Created ${ocapPath}\n`); - } - } - } - - process.stderr.write(`Daemon running. Socket: ${socketPath}\n`); -} - -/** - * Handle revoke command. - * - * @param args - CLI arguments after `ok revoke`. - * @param socketPath - The daemon socket path. - */ -async function handleRevoke(args: string[], socketPath: string): Promise { - let ref: string; - - const firstArg = args[0]; - if ( - firstArg !== undefined && - (firstArg.endsWith('.ocap') || existsSync(firstArg)) - ) { - ref = await readRefFromFile(firstArg); - } else if ( - !process.stdin.isTTY && - (fstatSync(0).isFIFO() || fstatSync(0).isFile()) - ) { - ref = await readRefFromStdin(); - } else { - process.stderr.write( - 'Usage: ok revoke \n ok revoke < file.ocap\n', - ); - process.exit(1); - } - - const response = await sendCommand(socketPath, { - method: 'revoke', - args: [ref], - }); - - if (response.ok && (response.result as { ok: boolean }).ok) { - process.stderr.write(`Revoked ref: ${ref}\n`); - } else { - process.stderr.write(`Ref not found: ${ref}\n`); - process.exit(1); + process.stderr.write(`Daemon running. Socket: ${tildify(socketPath)}\n`); + if (existsSync(ocapPath)) { + process.stderr.write(`Admin console: ${tildify(ocapPath)}\n`); } } @@ -291,101 +331,7 @@ const socketPath = getSocketPath(); const cli = yargs(hideBin(process.argv)) .scriptName('ok') .usage('$0 [file.ocap] [...args]') - - .command( - 'launch [config]', - 'Launch a subcluster', - (_yargs) => - _yargs - .positional('config', { - describe: 'Cluster config as inline JSON string', - type: 'string', - }) - .example( - '$0 launch \'{"bootstrap":"v","vats":{"v":{"bundleSpec":"file:///path/to.bundle"}}}\'', - 'Inline JSON', - ) - .example('$0 launch < config.json > root.ocap', 'File redirect') - .example('cat config.json | $0 launch', 'Piped'), - async (args) => { - await ensureDaemon(socketPath); - await handleInvoke( - ['launch', ...(args.config ? [args.config] : [])], - socketPath, - ); - }, - ) - - .command( - 'terminate ', - 'Terminate a subcluster', - (_yargs) => - _yargs.positional('subclusterId', { - describe: 'ID of the subcluster to terminate', - type: 'string', - demandOption: true, - }), - async (args) => { - await ensureDaemon(socketPath); - await handleInvoke(['terminate', String(args.subclusterId)], socketPath); - }, - ) - - .command( - 'status', - 'Show kernel status', - () => ({}), - async () => { - await ensureDaemon(socketPath); - await handleInvoke(['status'], socketPath); - }, - ) - - .command( - 'subclusters', - 'List subclusters', - () => ({}), - async () => { - await ensureDaemon(socketPath); - await handleInvoke(['subclusters'], socketPath); - }, - ) - - .command( - 'listRefs', - 'List all issued refs', - () => ({}), - async () => { - await ensureDaemon(socketPath); - await handleInvoke(['listRefs'], socketPath); - }, - ) - - .command( - 'help', - 'Show available kernel commands', - () => ({}), - async () => { - await ensureDaemon(socketPath); - await handleInvoke(['help'], socketPath); - }, - ) - - .command( - 'revoke [target]', - 'Revoke a capability ref', - (_yargs) => - _yargs - .positional('target', { - describe: 'Path to .ocap file', - type: 'string', - }) - .example('$0 revoke file.ocap', 'By file path') - .example('$0 revoke < file.ocap', 'From stdin'), - async (args) => { - await handleRevoke(args.target ? [String(args.target)] : [], socketPath); - }, - ) + .help(false) .command( 'daemon [subcommand]', @@ -397,9 +343,9 @@ const cli = yargs(hideBin(process.argv)) type: 'string', }) .option('console', { - describe: 'System console name', + describe: 'Path for the .ocap admin file (relative to PWD)', type: 'string', - default: 'system-console', + default: 'system-console.ocap', }) .option('forgood', { describe: 'Confirm state deletion (for begone)', @@ -413,7 +359,7 @@ const cli = yargs(hideBin(process.argv)) if (args.forgood) { daemonArgs.push('--forgood'); } - if (args.console && args.console !== 'system-console') { + if (args.console && args.console !== 'system-console.ocap') { daemonArgs.push('--console', String(args.console)); } await handleDaemon(daemonArgs, socketPath); diff --git a/packages/nodejs/src/daemon/flush-daemon.ts b/packages/nodejs/src/daemon/flush-daemon.ts index cf4b2adbe..426cb422e 100644 --- a/packages/nodejs/src/daemon/flush-daemon.ts +++ b/packages/nodejs/src/daemon/flush-daemon.ts @@ -23,9 +23,12 @@ export async function flushDaemon(options?: FlushDaemonOptions): Promise { const dbFilename = options?.dbFilename ?? join(ocapDir, 'kernel.sqlite'); const bundlesDir = join(ocapDir, 'bundles'); + const pidPath = join(ocapDir, 'daemon.pid'); + await Promise.all([ rm(dbFilename, { force: true }), rm(socketPath, { force: true }), rm(bundlesDir, { recursive: true, force: true }), + rm(pidPath, { force: true }), ]); } diff --git a/packages/nodejs/src/daemon/start-daemon.test.ts b/packages/nodejs/src/daemon/start-daemon.test.ts index 21e027f56..9f18c24bd 100644 --- a/packages/nodejs/src/daemon/start-daemon.test.ts +++ b/packages/nodejs/src/daemon/start-daemon.test.ts @@ -8,9 +8,19 @@ vi.mock('../kernel/make-kernel.ts', () => ({ makeKernel: vi.fn().mockResolvedValue({ initIdentity: vi.fn().mockResolvedValue(undefined), stop: vi.fn().mockResolvedValue(undefined), + getSystemSubclusterRoot: vi.fn().mockReturnValue('ko-root'), + queueMessage: vi.fn().mockResolvedValue({ body: '"d-1"', slots: [] }), }), })); +// Mock kunser to deserialise the capdata returned by queueMessage +vi.mock('@metamask/ocap-kernel', async () => { + const actual = await vi.importActual( + '@metamask/ocap-kernel', + ); + return { ...actual, kunser: vi.fn().mockReturnValue('d-1') }; +}); + // Mock filesystem operations vi.mock('node:fs/promises', async () => { const actual = @@ -73,7 +83,7 @@ describe('startDaemon', () => { ); }); - it('returns socket path and close function', async () => { + it('returns socket path, selfRef, and close function', async () => { const tmpSocket = `/tmp/daemon-test-${Date.now()}-${Math.random().toString(36).slice(2)}.sock`; handle = await startDaemon({ @@ -82,10 +92,34 @@ describe('startDaemon', () => { }); expect(handle.socketPath).toBe(tmpSocket); + expect(handle.selfRef).toBe('d-1'); expect(typeof handle.close).toBe('function'); expect(handle.kernel).toBeDefined(); }); + it('issues a self-ref via getSystemSubclusterRoot and queueMessage', async () => { + const { makeKernel } = await import('../kernel/make-kernel.ts'); + const mockedMakeKernel = vi.mocked(makeKernel); + + const tmpSocket = `/tmp/daemon-test-${Date.now()}-${Math.random().toString(36).slice(2)}.sock`; + + handle = await startDaemon({ + systemConsoleBundleSpec: 'http://localhost/bundle', + systemConsoleName: 'my-console', + socketPath: tmpSocket, + }); + + const mockKernel = await mockedMakeKernel.mock.results[0]!.value; + expect(mockKernel.getSystemSubclusterRoot).toHaveBeenCalledWith( + 'my-console', + ); + expect(mockKernel.queueMessage).toHaveBeenCalledWith( + 'ko-root', + 'issueRef', + ['ko-root', true], + ); + }); + it('calls kernel.stop on close', async () => { const tmpSocket = `/tmp/daemon-test-${Date.now()}-${Math.random().toString(36).slice(2)}.sock`; diff --git a/packages/nodejs/src/daemon/start-daemon.ts b/packages/nodejs/src/daemon/start-daemon.ts index a778ec15f..5533f1e51 100644 --- a/packages/nodejs/src/daemon/start-daemon.ts +++ b/packages/nodejs/src/daemon/start-daemon.ts @@ -1,6 +1,7 @@ import { ifDefined } from '@metamask/kernel-utils'; import type { Logger } from '@metamask/logger'; import type { Kernel, SystemSubclusterConfig } from '@metamask/ocap-kernel'; +import { kunser } from '@metamask/ocap-kernel'; import { mkdir } from 'node:fs/promises'; import { homedir } from 'node:os'; import { join } from 'node:path'; @@ -35,6 +36,7 @@ export type StartDaemonOptions = { export type DaemonHandle = { kernel: Kernel; socketPath: string; + selfRef: string; close: () => Promise; }; @@ -95,6 +97,14 @@ export async function startDaemon( await kernel.initIdentity(); + // Issue a self-ref so the admin .ocap file can address the console root object + const rootKref = kernel.getSystemSubclusterRoot(systemConsoleName); + const capData = await kernel.queueMessage(rootKref, 'issueRef', [ + rootKref, + true, + ]); + const selfRef = kunser(capData) as string; + const close = async (): Promise => { await kernel.stop(); }; @@ -102,6 +112,7 @@ export async function startDaemon( return { kernel, socketPath, + selfRef, close, }; } diff --git a/packages/nodejs/src/io/socket-channel.ts b/packages/nodejs/src/io/socket-channel.ts index 97ebfc46e..3acda62f5 100644 --- a/packages/nodejs/src/io/socket-channel.ts +++ b/packages/nodejs/src/io/socket-channel.ts @@ -90,13 +90,21 @@ export async function makeSocketIOChannel( const server = net.createServer((socket) => { if (currentSocket) { - // Only one connection at a time - socket.destroy(); - return; + if (currentSocket.readableEnded || currentSocket.destroyed) { + // Old connection is dead but events haven't been fully processed; + // clean it up and accept the new connection. + currentSocket.removeAllListeners(); + currentSocket.destroy(); + currentSocket = null; + } else { + // Existing active client — reject the new connection + socket.destroy(); + return; + } } - // Drain stale state from any previous connection + // Drain stale data from any previous connection, but keep pending + // readers alive so they can receive data from the new connection. lineQueue.length = 0; - deliverEOF(); currentSocket = socket; decoder = new StringDecoder('utf8'); diff --git a/packages/nodejs/test/e2e/daemon-stack.test.ts b/packages/nodejs/test/e2e/daemon-stack.test.ts index d29b962b7..9e815db44 100644 --- a/packages/nodejs/test/e2e/daemon-stack.test.ts +++ b/packages/nodejs/test/e2e/daemon-stack.test.ts @@ -1,13 +1,15 @@ import type { KernelDatabase } from '@metamask/kernel-store'; import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/nodejs'; import { waitUntilQuiescent } from '@metamask/kernel-utils'; -import type { Kernel, IOChannel, IOConfig } from '@metamask/ocap-kernel'; +import type { Kernel } from '@metamask/ocap-kernel'; +import { kunser } from '@metamask/ocap-kernel'; import * as net from 'node:net'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { describe, it, expect, afterEach } from 'vitest'; +import { makeIOChannelFactory } from '../../src/io/index.ts'; import { makeTestKernel } from '../helpers/kernel.ts'; const SYSTEM_CONSOLE_NAME = 'system-console'; @@ -104,136 +106,6 @@ async function sendCommand( } } -/** - * Create a test socket IO channel factory. - * - * @returns The factory function. - */ -function makeTestIOChannelFactory() { - const fsPromises = import('node:fs/promises'); - - return async (_name: string, config: IOConfig): Promise => { - if (config.type !== 'socket') { - throw new Error(`unsupported IO type: ${config.type}`); - } - const fs = await fsPromises; - const lineQueue: string[] = []; - const readerQueue: { resolve: (value: string | null) => void }[] = []; - let currentSocket: net.Socket | null = null; - let lineBuffer = ''; - let closed = false; - - function deliverLine(line: string): void { - const reader = readerQueue.shift(); - if (reader) { - reader.resolve(line); - } else { - lineQueue.push(line); - } - } - - function deliverEOF(): void { - while (readerQueue.length > 0) { - readerQueue.shift()?.resolve(null); - } - } - - const server = net.createServer((socket) => { - if (currentSocket) { - socket.destroy(); - return; - } - currentSocket = socket; - lineBuffer = ''; - socket.on('data', (data: Buffer) => { - lineBuffer += data.toString(); - let idx = lineBuffer.indexOf('\n'); - while (idx !== -1) { - deliverLine(lineBuffer.slice(0, idx)); - lineBuffer = lineBuffer.slice(idx + 1); - idx = lineBuffer.indexOf('\n'); - } - }); - socket.on('end', () => { - if (lineBuffer.length > 0) { - deliverLine(lineBuffer); - lineBuffer = ''; - } - currentSocket = null; - deliverEOF(); - }); - socket.on('error', () => { - currentSocket = null; - deliverEOF(); - }); - }); - - try { - await fs.unlink(config.path); - } catch { - // ignore - } - - await new Promise((resolve, reject) => { - server.on('error', reject); - server.listen(config.path, () => { - server.removeListener('error', reject); - resolve(); - }); - }); - - return { - async read() { - if (closed) { - return null; - } - const queued = lineQueue.shift(); - if (queued !== undefined) { - return queued; - } - if (!currentSocket) { - return null; - } - return new Promise((resolve) => { - readerQueue.push({ resolve }); - }); - }, - async write(data: string) { - if (!currentSocket) { - throw new Error('no connected client'); - } - const socket = currentSocket; - return new Promise((resolve, reject) => { - socket.write(`${data}\n`, (error) => { - if (error) { - reject(error); - } else { - resolve(); - } - }); - }); - }, - async close() { - if (closed) { - return; - } - closed = true; - deliverEOF(); - currentSocket?.destroy(); - currentSocket = null; - await new Promise((resolve) => { - server.close(() => resolve()); - }); - try { - await fs.unlink(config.path); - } catch { - // ignore - } - }, - }; - }; -} - /** * Get the bundle spec for the system console vat test bundle. * @@ -261,7 +133,7 @@ describe('Daemon Stack (IO socket protocol)', { timeout: 30_000 }, () => { kernelDatabase = await makeSQLKernelDatabase({ dbFilename: ':memory:' }); kernel = await makeTestKernel(kernelDatabase, { - ioChannelFactory: makeTestIOChannelFactory(), + ioChannelFactory: makeIOChannelFactory(), systemSubclusters: [ { name: SYSTEM_CONSOLE_NAME, @@ -303,54 +175,162 @@ describe('Daemon Stack (IO socket protocol)', { timeout: 30_000 }, () => { } }); - it('dispatches help command via socket', async () => { - const socketPath = await bootDaemonStack(); + describe('daemon tier (no ref)', () => { + it('dispatches help command with daemon-tier commands only', async () => { + const socketPath = await bootDaemonStack(); - const response = await sendCommand(socketPath, { method: 'help' }); + const response = await sendCommand(socketPath, { method: 'help' }); - expect(response.ok).toBe(true); - const result = response.result as { commands: string[] }; - expect(result.commands).toBeDefined(); - expect(result.commands.length).toBeGreaterThan(0); - expect(result.commands.some((cmd) => cmd.includes('help'))).toBe(true); - expect(result.commands.some((cmd) => cmd.includes('status'))).toBe(true); - }); + expect(response.ok).toBe(true); + expect(response.result).toStrictEqual({ + commands: ['help - show available commands', 'status - daemon status'], + }); + }); - it('dispatches status command via socket', async () => { - const socketPath = await bootDaemonStack(); + it('dispatches status command returning liveness indicator', async () => { + const socketPath = await bootDaemonStack(); - const response = await sendCommand(socketPath, { method: 'status' }); + const response = await sendCommand(socketPath, { method: 'status' }); - expect(response.ok).toBe(true); - }); + expect(response).toStrictEqual({ + ok: true, + result: { running: true }, + }); + }); - it('dispatches listRefs command', async () => { - const socketPath = await bootDaemonStack(); + it('returns error for unknown command', async () => { + const socketPath = await bootDaemonStack(); - const response = await sendCommand(socketPath, { method: 'listRefs' }); + const response = await sendCommand(socketPath, { + method: 'nonexistent', + }); - expect(response.ok).toBe(true); - const result = response.result as { refs: { ref: string; kref: string }[] }; - expect(result.refs).toBeDefined(); - expect(Array.isArray(result.refs)).toBe(true); - }); + expect(response.ok).toBe(false); + expect(response.error).toContain('Unknown command'); + }); + + it('rejects privileged commands at daemon tier', async () => { + const socketPath = await bootDaemonStack(); + + const response = await sendCommand(socketPath, { method: 'ls' }); + + expect(response.ok).toBe(false); + expect(response.error).toContain('Unknown command'); + }); - it('returns error for unknown command', async () => { - const socketPath = await bootDaemonStack(); + it('handles sequential requests on separate connections', async () => { + const socketPath = await bootDaemonStack(); - const response = await sendCommand(socketPath, { method: 'nonexistent' }); + const response1 = await sendCommand(socketPath, { method: 'help' }); + expect(response1.ok).toBe(true); - expect(response.ok).toBe(false); - expect(response.error).toContain('Unknown command'); + const response2 = await sendCommand(socketPath, { method: 'status' }); + expect(response2.ok).toBe(true); + }); }); - it('handles sequential requests on separate connections', async () => { - const socketPath = await bootDaemonStack(); + describe('privileged tier (ref-based dispatch)', () => { + /** + * Boot daemon and issue a self-ref for the console root object. + * + * @returns The socket path and the issued ref. + */ + async function bootWithSelfRef(): Promise<{ + socketPath: string; + selfRef: string; + }> { + const socketPath = await bootDaemonStack(); + + // Issue a self-ref via kernel API (same as start-daemon.ts does) + const rootKref = kernel!.getSystemSubclusterRoot(SYSTEM_CONSOLE_NAME); + const capData = await kernel!.queueMessage(rootKref, 'issueRef', [ + rootKref, + true, + ]); + const selfRef = kunser(capData) as string; + + return { socketPath, selfRef }; + } + + it('dispatches help via ref', async () => { + const { socketPath, selfRef } = await bootWithSelfRef(); + + const response = await sendCommand(socketPath, { + ref: selfRef, + method: 'help', + }); + + expect(response.ok).toBe(true); + const result = response.result as { commands: string[] }; + expect(result.commands).toContain('help - show available commands'); + expect(result.commands).toContain('ls - list all issued refs'); + }); - const response1 = await sendCommand(socketPath, { method: 'help' }); - expect(response1.ok).toBe(true); + it('dispatches status via ref (returns kernel status)', async () => { + const { socketPath, selfRef } = await bootWithSelfRef(); + + const response = await sendCommand(socketPath, { + ref: selfRef, + method: 'status', + }); - const response2 = await sendCommand(socketPath, { method: 'status' }); - expect(response2.ok).toBe(true); + expect(response.ok).toBe(true); + const result = response.result as Record; + expect(result).toHaveProperty('vats'); + expect(result).toHaveProperty('subclusters'); + }); + + it('dispatches ls via ref', async () => { + const { socketPath, selfRef } = await bootWithSelfRef(); + + const response = await sendCommand(socketPath, { + ref: selfRef, + method: 'ls', + }); + + expect(response.ok).toBe(true); + const result = response.result as { refs: string[] }; + expect(Array.isArray(result.refs)).toBe(true); + }); + + it('dispatches subclusters via ref', async () => { + const { socketPath, selfRef } = await bootWithSelfRef(); + + const response = await sendCommand(socketPath, { + ref: selfRef, + method: 'subclusters', + }); + + expect(response.ok).toBe(true); + expect(Array.isArray(response.result)).toBe(true); + }); + + it('dispatches invoke to call method on a ref through the kernel', async () => { + const { socketPath, selfRef } = await bootWithSelfRef(); + + // Use invoke to call 'ls' on the self-ref (goes through getPresence + E()) + const response = await sendCommand(socketPath, { + ref: selfRef, + method: 'invoke', + args: [selfRef, 'ls'], + }); + + expect(response.ok).toBe(true); + const result = response.result as { refs: string[] }; + expect(Array.isArray(result.refs)).toBe(true); + }); + + it('returns error when invoke targets unknown ref', async () => { + const { socketPath, selfRef } = await bootWithSelfRef(); + + const response = await sendCommand(socketPath, { + ref: selfRef, + method: 'invoke', + args: ['d-999', 'someMethod'], + }); + + expect(response.ok).toBe(false); + expect(response.error).toContain('Unknown ref: d-999'); + }); }); }); diff --git a/packages/nodejs/test/vats/system-console-vat.ts b/packages/nodejs/test/vats/system-console-vat.ts index 905538bad..75da555db 100644 --- a/packages/nodejs/test/vats/system-console-vat.ts +++ b/packages/nodejs/test/vats/system-console-vat.ts @@ -1,288 +1 @@ -import { E } from '@endo/eventual-send'; -import { makeDefaultExo } from '@metamask/kernel-utils/exo'; -import type { - Baggage, - ClusterConfig, - KernelStatus, - Subcluster, - SubclusterLaunchResult, -} from '@metamask/ocap-kernel'; - -/** - * Kernel facet interface for system vat operations. - */ -type KernelFacet = { - getStatus: () => Promise; - getSubclusters: () => Promise; - launchSubcluster: (config: ClusterConfig) => Promise; - terminateSubcluster: (subclusterId: string) => Promise; - queueMessage: ( - target: string, - method: string, - args: unknown[], - ) => Promise; -}; - -/** - * Services provided to the system console vat during bootstrap. - */ -type BootstrapServices = { - kernelFacet?: KernelFacet; - console?: IOService; -}; - -/** - * IO service interface for reading and writing lines. - */ -type IOService = { - read: () => Promise; - write: (data: string) => Promise; -}; - -/** - * A JSON request from the CLI. - */ -type Request = { - ref?: string; - method: string; - args?: unknown[]; -}; - -/** - * Build function for the system console vat. - * - * This vat manages the REPL loop over an IO channel, dispatching CLI - * commands and managing refs (capability references) in persistent baggage. - * - * @param _vatPowers - The vat powers (unused). - * @param parameters - The vat parameters. - * @param parameters.name - Optional name for the console vat. - * @param baggage - The vat's persistent baggage storage. - * @returns The root object for the new vat. - */ -export function buildRootObject( - _vatPowers: unknown, - parameters: { name?: string }, - baggage: Baggage, -) { - const name = parameters.name ?? 'system-console'; - - // Monotonic counter for generating unique ref identifiers (persisted in baggage) - let refCounter: number = baggage.has('refCounter') - ? (baggage.get('refCounter') as number) - : 0; - - // Restore kernel facet from baggage if available (for resuscitation) - let kernelFacet: KernelFacet | undefined = baggage.has('kernelFacet') - ? (baggage.get('kernelFacet') as KernelFacet) - : undefined; - - // Ref manager state in baggage - const refs: Record = baggage.has('refs') - ? (baggage.get('refs') as Record) - : {}; - const krefToRef: Record = baggage.has('krefToRef') - ? (baggage.get('krefToRef') as Record) - : {}; - - function persistRefs(): void { - if (baggage.has('refs')) { - baggage.set('refs', harden({ ...refs })); - } else { - baggage.init('refs', harden({ ...refs })); - } - if (baggage.has('krefToRef')) { - baggage.set('krefToRef', harden({ ...krefToRef })); - } else { - baggage.init('krefToRef', harden({ ...krefToRef })); - } - } - - function issueRef(kref: string): string { - const existing = krefToRef[kref]; - if (existing) { - return existing; - } - refCounter += 1; - if (baggage.has('refCounter')) { - baggage.set('refCounter', refCounter); - } else { - baggage.init('refCounter', refCounter); - } - const ref = `d-${refCounter}`; - refs[ref] = kref; - krefToRef[kref] = ref; - persistRefs(); - return ref; - } - - function lookupKref(ref: string): string | undefined { - return refs[ref]; - } - - function revokeRef(ref: string): boolean { - const kref = refs[ref]; - if (!kref) { - return false; - } - delete refs[ref]; - delete krefToRef[kref]; - persistRefs(); - return true; - } - - function listRefs(): { ref: string; kref: string }[] { - return Object.entries(refs).map(([ref, kref]) => ({ ref, kref })); - } - - async function dispatchConsoleMethod( - method: string, - args: unknown[], - ): Promise { - switch (method) { - case 'help': - return { - commands: [ - 'help - show available commands', - 'status - kernel status', - 'launch - launch a subcluster', - 'terminate - terminate a subcluster', - 'subclusters - list subclusters', - 'revoke - revoke a ref', - 'listRefs - list all issued refs', - ], - }; - - case 'status': - return E(kernelFacet!).getStatus(); - - case 'subclusters': - return E(kernelFacet!).getSubclusters(); - - case 'launch': { - const config = args[0] as ClusterConfig; - if (!config) { - throw new Error('launch requires a config argument'); - } - const result = await E(kernelFacet!).launchSubcluster(config); - const ref = issueRef(result.rootKref); - return { ref, subclusterId: result.subclusterId }; - } - - case 'terminate': { - const subclusterId = args[0] as string; - if (!subclusterId) { - throw new Error('terminate requires a subclusterId argument'); - } - await E(kernelFacet!).terminateSubcluster(subclusterId); - return { ok: true }; - } - - case 'revoke': { - const ref = args[0] as string; - if (!ref) { - throw new Error('revoke requires a ref argument'); - } - return { ok: revokeRef(ref) }; - } - - case 'listRefs': - return { refs: listRefs() }; - - default: - throw new Error(`Unknown command: ${method}`); - } - } - - async function handleRequest(request: Request): Promise { - const { ref, method, args = [] } = request; - - if (!ref) { - return dispatchConsoleMethod(method, args); - } - - const kref = lookupKref(ref); - if (!kref) { - throw new Error(`Unknown ref: ${ref}`); - } - return E(kernelFacet!).queueMessage(kref, method, args); - } - - async function runReplLoop(ioService: IOService): Promise { - for (;;) { - const line = await E(ioService).read(); - if (line === null) { - continue; - } - - let response: unknown; - try { - const request = JSON.parse(line) as Request; - const result = await handleRequest(request); - response = { ok: true, result }; - } catch (error) { - // Errors crossing vat boundaries may arrive as plain objects. - // Try multiple strategies to extract a human-readable message. - let errorMessage: string; - if (error instanceof Error) { - errorMessage = error.message ?? error.stack ?? String(error); - } else if (typeof error === 'string') { - errorMessage = error; - } else { - try { - errorMessage = JSON.stringify(error); - } catch { - errorMessage = String(error); - } - } - response = { ok: false, error: errorMessage }; - } - - try { - await E(ioService).write(JSON.stringify(response)); - } catch { - // Write failed — continue loop - } - } - } - - return makeDefaultExo('root', { - async bootstrap( - _vats: unknown, - services: BootstrapServices, - ): Promise { - if (!kernelFacet && services.kernelFacet) { - kernelFacet = services.kernelFacet; - baggage.init('kernelFacet', kernelFacet); - } - - if (services.console) { - runReplLoop(services.console).catch((error) => { - console.error(`[${name}] REPL loop error:`, error); - }); - } - }, - - help() { - return harden({ - commands: [ - 'help - show available commands', - 'status - kernel status', - 'launch - launch a subcluster', - 'terminate - terminate a subcluster', - 'subclusters - list subclusters', - 'revoke - revoke a ref', - 'listRefs - list all issued refs', - ], - }); - }, - - issueRef(kref: string): string { - return issueRef(kref); - }, - - listRefs(): { ref: string; kref: string }[] { - return listRefs(); - }, - }); -} +export { buildRootObject } from '@metamask/ocap-kernel/src/vats/system-console-vat.ts'; diff --git a/packages/ocap-kernel/src/Kernel.ts b/packages/ocap-kernel/src/Kernel.ts index 4a5296aa4..01465bbb0 100644 --- a/packages/ocap-kernel/src/Kernel.ts +++ b/packages/ocap-kernel/src/Kernel.ts @@ -11,7 +11,7 @@ import { KernelRouter } from './KernelRouter.ts'; import { KernelServiceManager } from './KernelServiceManager.ts'; import type { KernelService } from './KernelServiceManager.ts'; import type { SlotValue } from './liveslots/kernel-marshal.ts'; -import { kslot } from './liveslots/kernel-marshal.ts'; +import { kslot, kunser } from './liveslots/kernel-marshal.ts'; import { OcapURLManager } from './remotes/kernel/OcapURLManager.ts'; import { RemoteManager } from './remotes/kernel/RemoteManager.ts'; import type { RemoteCommsOptions } from './remotes/types.ts'; @@ -379,6 +379,27 @@ export class Kernel { return this.#kernelQueue.enqueueMessage(target, method, args); } + /** + * Send a message to an object in a vat and return the deserialized result. + * + * Unlike {@link queueMessage}, which returns raw CapData, this method + * deserializes the result so that it can be returned through a kernel + * service and re-serialized by liveslots for the calling vat. + * + * @param target - The kref of the object to invoke. + * @param method - The method name. + * @param args - The method arguments. + * @returns The deserialized result of the method invocation. + */ + async invokeMethod( + target: KRef, + method: string, + args: unknown[], + ): Promise { + const capData = await this.queueMessage(target, method, args); + return kunser(capData); + } + /** * Issue an OCAP URL for a kernel reference. * diff --git a/packages/ocap-kernel/src/kernel-facet.test.ts b/packages/ocap-kernel/src/kernel-facet.test.ts index 4e53f8a58..ddb4ddddf 100644 --- a/packages/ocap-kernel/src/kernel-facet.test.ts +++ b/packages/ocap-kernel/src/kernel-facet.test.ts @@ -11,6 +11,7 @@ const makeMockKernel = (): KernelFacetSource => ({ getSubcluster: () => undefined, getSubclusters: () => [], getSystemSubclusterRoot: () => 'ko99', + invokeMethod: async () => Promise.resolve(null), launchSubcluster: async () => Promise.resolve({ subclusterId: 's1', @@ -32,6 +33,7 @@ describe('makeKernelFacet', () => { expect(typeof facet.getSubcluster).toBe('function'); expect(typeof facet.getSubclusters).toBe('function'); expect(typeof facet.getSystemSubclusterRoot).toBe('function'); + expect(typeof facet.invokeMethod).toBe('function'); expect(typeof facet.launchSubcluster).toBe('function'); expect(typeof facet.ping).toBe('function'); expect(typeof facet.pingVat).toBe('function'); diff --git a/packages/ocap-kernel/src/kernel-facet.ts b/packages/ocap-kernel/src/kernel-facet.ts index 79ecbe443..13ff05321 100644 --- a/packages/ocap-kernel/src/kernel-facet.ts +++ b/packages/ocap-kernel/src/kernel-facet.ts @@ -8,6 +8,7 @@ const kernelFacetMethodNames = [ 'getSubcluster', 'getSubclusters', 'getSystemSubclusterRoot', + 'invokeMethod', 'launchSubcluster', 'pingVat', 'queueMessage', diff --git a/packages/ocap-kernel/src/vats/system-console-vat.test.ts b/packages/ocap-kernel/src/vats/system-console-vat.test.ts index 7cc1da57d..d22086d28 100644 --- a/packages/ocap-kernel/src/vats/system-console-vat.test.ts +++ b/packages/ocap-kernel/src/vats/system-console-vat.test.ts @@ -80,11 +80,16 @@ function makeMockKernelFacet() { const calls: Record = { getStatus: [], getSubclusters: [], + invokeMethod: [], launchSubcluster: [], terminateSubcluster: [], }; const facet = makeDefaultExo('mockKernelFacet', { + async invokeMethod(...args: unknown[]) { + calls.invokeMethod.push(args); + return { mocked: true }; + }, async getStatus(...args: unknown[]) { calls.getStatus.push(args); return { @@ -160,7 +165,7 @@ describe('system-console-vat', () => { }); }); - describe('REPL dispatch', () => { + describe('REPL dispatch (daemon tier)', () => { async function setupRepl() { const root = buildRootObject( {}, @@ -189,92 +194,66 @@ describe('system-console-vat', () => { }; } - it('dispatches help command', async () => { + it('dispatches help command with daemon-tier commands only', async () => { await setupRepl(); const response = await sendRequest({ method: 'help' }); expect(response.ok).toBe(true); expect(response.result).toStrictEqual({ - commands: expect.arrayContaining([ - expect.stringContaining('help'), - expect.stringContaining('status'), - ]), + commands: ['help - show available commands', 'status - daemon status'], }); }); - it('dispatches status command', async () => { + it('dispatches status command returning liveness indicator', async () => { await setupRepl(); const response = await sendRequest({ method: 'status' }); - expect(response.ok).toBe(true); - expect(kernelFacet.calls.getStatus).toHaveLength(1); - }); - - it('dispatches subclusters command', async () => { - await setupRepl(); - const response = await sendRequest({ method: 'subclusters' }); - - expect(response.ok).toBe(true); - expect(kernelFacet.calls.getSubclusters).toHaveLength(1); + expect(response).toStrictEqual({ + ok: true, + result: { running: true }, + }); }); - it('dispatches launch command and issues ref', async () => { - await setupRepl(); - const config = { - bootstrap: 'test', - vats: { test: { bundleSpec: 'test-bundle' } }, - }; - const response = await sendRequest({ method: 'launch', args: [config] }); + it.each(['launch', 'terminate', 'subclusters', 'ls', 'revoke', 'invoke'])( + 'returns "Unknown command" for privileged command "%s"', + async (method) => { + await setupRepl(); + const response = await sendRequest({ method }); - expect(response.ok).toBe(true); - const result = response.result as { ref: string; subclusterId: string }; - expect(result.ref).toMatch(/^d-\d+$/u); - expect(result.subclusterId).toBe('sub-1'); - expect(kernelFacet.calls.launchSubcluster).toHaveLength(1); - }); + expect(response.ok).toBe(false); + expect(response.error).toContain('Unknown command'); + }, + ); - it('dispatches terminate command', async () => { - await setupRepl(); - const response = await sendRequest({ - method: 'terminate', - args: ['sub-1'], - }); + it('dispatches self-ref REPL command directly on root', async () => { + const root = await setupRepl(); + const ref = root.issueRef('ko-self', true); + const response = await sendRequest({ ref, method: 'help' }); expect(response.ok).toBe(true); - expect(kernelFacet.calls.terminateSubcluster).toHaveLength(1); - }); - - it('dispatches revoke command', async () => { - await setupRepl(); - - // First launch to get a ref - const launchResponse = await sendRequest({ - method: 'launch', - args: [{ bootstrap: 'x', vats: { x: { bundleSpec: 'x' } } }], + expect(response.result).toStrictEqual({ + commands: [ + 'help - show available commands', + 'status - kernel status', + 'subclusters - list subclusters', + 'launch - launch a subcluster', + 'terminate - terminate a subcluster', + 'ls - list all issued refs', + 'revoke - revoke a ref', + 'invoke [...args] - call a method on a ref', + ], }); - const { ref } = launchResponse.result as { ref: string }; - - // Revoke the ref - const response = await sendRequest({ method: 'revoke', args: [ref] }); - expect(response).toStrictEqual({ ok: true, result: { ok: true } }); + // Should NOT have called invokeMethod — dispatch was direct + expect(kernelFacet.calls.invokeMethod).toHaveLength(0); }); - it('dispatches listRefs command', async () => { - await setupRepl(); - - // Launch to create a ref - await sendRequest({ - method: 'launch', - args: [{ bootstrap: 'x', vats: { x: { bundleSpec: 'x' } } }], - }); + it('returns error for unknown method on self-ref', async () => { + const root = await setupRepl(); + const ref = root.issueRef('ko-self', true); + const response = await sendRequest({ ref, method: 'nonexistent' }); - const response = await sendRequest({ method: 'listRefs' }); - expect(response.ok).toBe(true); - const result = response.result as { - refs: { ref: string; kref: string }[]; - }; - expect(result.refs).toHaveLength(1); - expect(result.refs[0]!.kref).toBe('ko1'); + expect(response.ok).toBe(false); + expect(response.error).toContain('Unknown method on root'); }); it('returns error for unknown command', async () => { @@ -296,6 +275,39 @@ describe('system-console-vat', () => { expect(response.error).toBeDefined(); }); + it('returns error for non-object request', async () => { + await setupRepl(); + const response = await sendRequest(42 as never); + expect(response.ok).toBe(false); + expect(response.error).toContain('Request must be a JSON object'); + }); + + it('returns error for request missing method', async () => { + await setupRepl(); + const response = await sendRequest({ ref: 'd-1' }); + expect(response.ok).toBe(false); + expect(response.error).toContain( + 'Request must have a string "method" field', + ); + }); + + it('returns error for non-string ref', async () => { + await setupRepl(); + const response = await sendRequest({ method: 'help', ref: 123 } as never); + expect(response.ok).toBe(false); + expect(response.error).toContain('"ref" must be a string'); + }); + + it('returns error for non-array args', async () => { + await setupRepl(); + const response = await sendRequest({ + method: 'help', + args: 'not-array', + } as never); + expect(response.ok).toBe(false); + expect(response.error).toContain('"args" must be an array'); + }); + it('continues after EOF (client disconnect)', async () => { await setupRepl(); @@ -313,6 +325,128 @@ describe('system-console-vat', () => { }); }); + describe('privileged root object methods', () => { + async function setupRoot() { + const root = buildRootObject( + {}, + { name: 'test-console' }, + baggage as never, + ); + await root.bootstrap({}, { kernelFacet: kernelFacet.facet }); + return root; + } + + it('returns help with all privileged commands', async () => { + const root = await setupRoot(); + const result = root.help(); + expect(result.commands).toStrictEqual([ + 'help - show available commands', + 'status - kernel status', + 'subclusters - list subclusters', + 'launch - launch a subcluster', + 'terminate - terminate a subcluster', + 'ls - list all issued refs', + 'revoke - revoke a ref', + 'invoke [...args] - call a method on a ref', + ]); + }); + + it('returns kernel status', async () => { + const root = await setupRoot(); + const result = await root.status(); + + expect(result).toStrictEqual({ + incarnation: 1, + subclusters: 0, + vats: 1, + pendingMessages: 0, + }); + expect(kernelFacet.calls.getStatus).toHaveLength(1); + }); + + it('returns subclusters list', async () => { + const root = await setupRoot(); + const result = await root.subclusters(); + + expect(result).toStrictEqual([]); + expect(kernelFacet.calls.getSubclusters).toHaveLength(1); + }); + + it('launches subcluster and issues ref', async () => { + const root = await setupRoot(); + const config = { + bootstrap: 'test', + vats: { test: { bundleSpec: 'test-bundle' } }, + }; + const result = await root.launch(config); + + expect(result.ref).toMatch(/^d-\d+$/u); + expect(result.subclusterId).toBe('sub-1'); + expect(kernelFacet.calls.launchSubcluster).toHaveLength(1); + }); + + it('terminates subcluster', async () => { + const root = await setupRoot(); + const result = await root.terminate('sub-1'); + + expect(result).toStrictEqual({ ok: true }); + expect(kernelFacet.calls.terminateSubcluster).toHaveLength(1); + }); + + it('revokes a ref', async () => { + const root = await setupRoot(); + root.issueRef('ko1'); + + const result = root.revoke('d-1'); + expect(result).toStrictEqual({ ok: true }); + }); + + it('returns false when revoking unknown ref', async () => { + const root = await setupRoot(); + const result = root.revoke('d-999'); + expect(result).toStrictEqual({ ok: false }); + }); + + it('lists issued refs', async () => { + const root = await setupRoot(); + root.issueRef('ko1'); + root.issueRef('ko2'); + + const result = root.ls(); + expect(result.refs).toHaveLength(2); + expect(result.refs[0]).toMatch(/^d-\d+$/u); + expect(result.refs[1]).toMatch(/^d-\d+$/u); + }); + + it('returns empty list when no refs issued', async () => { + const root = await setupRoot(); + const result = root.ls(); + expect(result).toStrictEqual({ refs: [] }); + }); + + it('throws when invoke target ref is unknown', async () => { + const root = await setupRoot(); + await expect(root.invoke('d-999', 'transfer')).rejects.toThrow( + 'Unknown ref: d-999', + ); + }); + + it('throws when invoke is called without a target ref', async () => { + const root = await setupRoot(); + await expect(root.invoke('', 'transfer')).rejects.toThrow( + 'invoke requires a target ref', + ); + }); + + it('throws when invoke is called without a method', async () => { + const root = await setupRoot(); + const ref = root.issueRef('ko-wallet'); + await expect(root.invoke(ref, '')).rejects.toThrow( + 'invoke requires a method name', + ); + }); + }); + describe('ref manager', () => { it('issues idempotent refs for the same kref', async () => { const root = buildRootObject( @@ -353,31 +487,5 @@ describe('system-console-vat', () => { expect(baggage.has('refs')).toBe(true); expect(baggage.has('krefToRef')).toBe(true); }); - - it('lists issued refs', async () => { - const root = buildRootObject( - {}, - { name: 'test-console' }, - baggage as never, - ); - await root.bootstrap({}, { kernelFacet: kernelFacet.facet }); - - const ref = root.issueRef('ko1'); - const refList = root.listRefs(); - expect(refList).toStrictEqual([{ ref, kref: 'ko1' }]); - }); - }); - - describe('help', () => { - it('returns command list', () => { - const root = buildRootObject( - {}, - { name: 'test-console' }, - baggage as never, - ); - const result = root.help(); - expect(result).toHaveProperty('commands'); - expect(result.commands.length).toBeGreaterThan(0); - }); }); }); diff --git a/packages/ocap-kernel/src/vats/system-console-vat.ts b/packages/ocap-kernel/src/vats/system-console-vat.ts index 020a797ef..beb50c373 100644 --- a/packages/ocap-kernel/src/vats/system-console-vat.ts +++ b/packages/ocap-kernel/src/vats/system-console-vat.ts @@ -16,13 +16,13 @@ import type { type KernelFacet = { getStatus: () => Promise; getSubclusters: () => Promise; - launchSubcluster: (config: ClusterConfig) => Promise; - terminateSubcluster: (subclusterId: string) => Promise; - queueMessage: ( + invokeMethod: ( target: string, method: string, args: unknown[], ) => Promise; + launchSubcluster: (config: ClusterConfig) => Promise; + terminateSubcluster: (subclusterId: string) => Promise; }; /** @@ -68,57 +68,81 @@ export function buildRootObject( _parameters: { name?: string }, baggage: Baggage, ) { + /** + * Get a value from baggage, or return a fallback if the key is absent. + * + * @param key - The baggage key. + * @param fallback - The value to return if the key is absent. + * @returns The stored value or the fallback. + */ + function baggageGet(key: string, fallback: T): T { + return baggage.has(key) ? (baggage.get(key) as T) : fallback; + } + + /** + * Set a value in baggage, initialising the key if it doesn't exist. + * + * @param key - The baggage key. + * @param value - The value to store. + */ + function baggagePut(key: string, value: unknown): void { + if (baggage.has(key)) { + baggage.set(key, value); + } else { + baggage.init(key, value); + } + } + // Monotonic counter for generating unique ref identifiers (persisted in baggage) - let refCounter: number = baggage.has('refCounter') - ? (baggage.get('refCounter') as number) - : 0; + let refCounter: number = baggageGet('refCounter', 0); // Restore kernel facet from baggage if available (for resuscitation) - let kernelFacet: KernelFacet | undefined = baggage.has('kernelFacet') - ? (baggage.get('kernelFacet') as KernelFacet) - : undefined; + let kernelFacet: KernelFacet | undefined = baggageGet< + KernelFacet | undefined + >('kernelFacet', undefined); + + // Track which kref is the root's own, so isSelf-ref dispatch avoids kernel round-trip + let selfKref: string | undefined = baggageGet( + 'selfKref', + undefined, + ); // Ref manager state in baggage: ref → kref and kref → ref maps // Stored as plain objects since baggage serializes them - const refs: Record = baggage.has('refs') - ? (baggage.get('refs') as Record) - : {}; - const krefToRef: Record = baggage.has('krefToRef') - ? (baggage.get('krefToRef') as Record) - : {}; + const refs: Record = baggageGet( + 'refs', + {} as Record, + ); + const krefToRef: Record = baggageGet( + 'krefToRef', + {} as Record, + ); /** * Persist the current ref state to baggage. */ function persistRefs(): void { - if (baggage.has('refs')) { - baggage.set('refs', harden({ ...refs })); - } else { - baggage.init('refs', harden({ ...refs })); - } - if (baggage.has('krefToRef')) { - baggage.set('krefToRef', harden({ ...krefToRef })); - } else { - baggage.init('krefToRef', harden({ ...krefToRef })); - } + baggagePut('refs', harden({ ...refs })); + baggagePut('krefToRef', harden({ ...krefToRef })); } /** * Issue a ref for a kref. If the kref already has a ref, return it. * * @param kref - The kernel reference. + * @param isSelf - If true, marks this kref as the root's own for direct dispatch. * @returns The issued ref. */ - function issueRef(kref: string): string { + function issueRef(kref: string, isSelf?: boolean): string { + if (isSelf) { + selfKref = kref; + baggagePut('selfKref', selfKref); + } const existing = krefToRef[kref]; if (existing) { return existing; } refCounter += 1; - if (baggage.has('refCounter')) { - baggage.set('refCounter', refCounter); - } else { - baggage.init('refCounter', refCounter); - } + baggagePut('refCounter', refCounter); const ref = `d-${refCounter}`; refs[ref] = kref; krefToRef[kref] = ref; @@ -175,65 +199,49 @@ export function buildRootObject( } /** - * Dispatch a request that has no ref (operates on the system console itself). + * Dispatch a method call on the root exo directly, bypassing the kernel. * * @param method - The method name. * @param args - The method arguments. + * @returns The result of the method call. + */ + function dispatchOnSelf(method: string, args: unknown[]): unknown { + // eslint-disable-next-line @typescript-eslint/no-use-before-define + const fn = root[method as keyof typeof root] as + | ((...a: unknown[]) => unknown) + | undefined; + if (typeof fn !== 'function') { + throw new Error(`Unknown method on root: ${method}`); + } + // eslint-disable-next-line @typescript-eslint/no-use-before-define + return fn.call(root, ...args); + } + + /** + * Dispatch a request that has no ref (daemon-tier commands only). + * + * Only basic liveness commands are available without a capability ref. + * Privileged operations require a ref obtained from the `.ocap` file. + * + * @param method - The method name. + * @param _args - The method arguments (unused for daemon-tier commands). * @returns The response payload. */ async function dispatchConsoleMethod( method: string, - args: unknown[], + _args: unknown[], ): Promise { switch (method) { case 'help': return { commands: [ 'help - show available commands', - 'status - kernel status', - 'launch - launch a subcluster', - 'terminate - terminate a subcluster', - 'subclusters - list subclusters', - 'revoke - revoke a ref', - 'listRefs - list all issued refs', + 'status - daemon status', ], }; case 'status': - return E(requireKernelFacet()).getStatus(); - - case 'subclusters': - return E(requireKernelFacet()).getSubclusters(); - - case 'launch': { - const config = args[0] as ClusterConfig; - if (!config) { - throw new Error('launch requires a config argument'); - } - const result = await E(requireKernelFacet()).launchSubcluster(config); - const ref = issueRef(result.rootKref); - return { ref, subclusterId: result.subclusterId }; - } - - case 'terminate': { - const subclusterId = args[0] as string; - if (!subclusterId) { - throw new Error('terminate requires a subclusterId argument'); - } - await E(requireKernelFacet()).terminateSubcluster(subclusterId); - return { ok: true }; - } - - case 'revoke': { - const ref = args[0] as string; - if (!ref) { - throw new Error('revoke requires a ref argument'); - } - return { ok: revokeRef(ref) }; - } - - case 'listRefs': - return { refs: listRefs() }; + return { running: true }; default: throw new Error(`Unknown command: ${method}`); @@ -253,12 +261,46 @@ export function buildRootObject( return dispatchConsoleMethod(method, args); } - // Ref-based dispatch: resolve ref → kref, then queue message + // Ref-based dispatch: resolve ref → kref const kref = lookupKref(ref); if (!kref) { throw new Error(`Unknown ref: ${ref}`); } - return E(requireKernelFacet()).queueMessage(kref, method, args); + + // Self-ref: dispatch directly to avoid kernel round-trip + if (kref === selfKref) { + return dispatchOnSelf(method, args); + } + + // External ref: dispatch through the kernel's message queue + return E(requireKernelFacet()).invokeMethod(kref, method, args); + } + + /** + * Validate and coerce a parsed JSON value into a {@link Request}. + * + * @param parsed - The raw parsed JSON value. + * @returns The validated request. + */ + function validateRequest(parsed: unknown): Request { + if (typeof parsed !== 'object' || parsed === null) { + throw new Error('Request must be a JSON object'); + } + const obj = parsed as Record; + if (typeof obj.method !== 'string') { + throw new Error('Request must have a string "method" field'); + } + if (obj.ref !== undefined && typeof obj.ref !== 'string') { + throw new Error('"ref" must be a string'); + } + if (obj.args !== undefined && !Array.isArray(obj.args)) { + throw new Error('"args" must be an array'); + } + return { + method: obj.method, + ...(typeof obj.ref === 'string' ? { ref: obj.ref } : {}), + ...(Array.isArray(obj.args) ? { args: obj.args as unknown[] } : {}), + }; } /** @@ -276,7 +318,7 @@ export function buildRootObject( let response: unknown; try { - const request = JSON.parse(line) as Request; + const request = validateRequest(JSON.parse(line)); const result = await handleRequest(request); response = { ok: true, result }; } catch (error) { @@ -305,7 +347,7 @@ export function buildRootObject( } } - return makeDefaultExo('root', { + const root = makeDefaultExo('root', { /** * Bootstrap the vat. * @@ -318,7 +360,7 @@ export function buildRootObject( ): Promise { if (!kernelFacet && services.kernelFacet) { kernelFacet = services.kernelFacet; - baggage.init('kernelFacet', kernelFacet); + baggagePut('kernelFacet', kernelFacet); } if (services.console) { @@ -329,7 +371,18 @@ export function buildRootObject( }, /** - * Get help information. + * Issue a ref for a kref. Exposed for the daemon to get the initial console ref. + * + * @param kref - The kernel reference. + * @param isSelf - If true, marks this kref as the root's own for direct dispatch. + * @returns The issued ref. + */ + issueRef(kref: string, isSelf?: boolean): string { + return issueRef(kref, isSelf); + }, + + /** + * Get help information (privileged — lists all available commands). * * @returns The help object. */ @@ -338,32 +391,118 @@ export function buildRootObject( commands: [ 'help - show available commands', 'status - kernel status', + 'subclusters - list subclusters', 'launch - launch a subcluster', 'terminate - terminate a subcluster', - 'subclusters - list subclusters', + 'ls - list all issued refs', 'revoke - revoke a ref', - 'listRefs - list all issued refs', + 'invoke [...args] - call a method on a ref', ], }); }, /** - * Issue a ref for a kref. Exposed for the daemon to get the initial console ref. + * Get kernel status. * - * @param kref - The kernel reference. - * @returns The issued ref. + * @returns The kernel status. */ - issueRef(kref: string): string { - return issueRef(kref); + async status(): Promise { + return E(requireKernelFacet()).getStatus(); + }, + + /** + * List subclusters. + * + * @returns The subclusters. + */ + async subclusters(): Promise { + return E(requireKernelFacet()).getSubclusters(); + }, + + /** + * Launch a subcluster and issue a ref for its root object. + * + * @param config - The cluster config. + * @returns The issued ref and subcluster ID. + */ + async launch( + config: ClusterConfig, + ): Promise<{ ref: string; subclusterId: string }> { + if (!config) { + throw new Error('launch requires a config argument'); + } + const result = await E(requireKernelFacet()).launchSubcluster(config); + const ref = issueRef(result.rootKref); + return harden({ ref, subclusterId: result.subclusterId }); + }, + + /** + * Terminate a subcluster. + * + * @param subclusterId - The subcluster ID. + * @returns Confirmation. + */ + async terminate(subclusterId: string): Promise<{ ok: true }> { + if (!subclusterId) { + throw new Error('terminate requires a subclusterId argument'); + } + await E(requireKernelFacet()).terminateSubcluster(subclusterId); + return harden({ ok: true as const }); + }, + + /** + * Revoke a ref. + * + * @param ref - The ref to revoke. + * @returns Whether the ref was found and revoked. + */ + revoke(ref: string): { ok: boolean } { + if (!ref) { + throw new Error('revoke requires a ref argument'); + } + return harden({ ok: revokeRef(ref) }); }, /** * List all issued refs. * - * @returns Array of ref/kref pairs. + * @returns Array of ref strings. + */ + ls(): { refs: string[] } { + return harden({ refs: listRefs().map((entry) => entry.ref) }); + }, + + /** + * Invoke a method on a target ref, forwarding pure-data arguments + * through the kernel. + * + * @param targetRef - The ref to invoke the method on. + * @param method - The method name. + * @param args - The method arguments. + * @returns The result of the method call. */ - listRefs(): { ref: string; kref: string }[] { - return listRefs(); + async invoke( + targetRef: string, + method: string, + ...args: unknown[] + ): Promise { + if (!targetRef) { + throw new Error('invoke requires a target ref'); + } + if (!method) { + throw new Error('invoke requires a method name'); + } + const kref = lookupKref(targetRef); + if (!kref) { + throw new Error(`Unknown ref: ${targetRef}`); + } + // Self-ref: dispatch directly to avoid kernel round-trip + if (kref === selfKref) { + return dispatchOnSelf(method, args); + } + return E(requireKernelFacet()).invokeMethod(kref, method, args); }, }); + + return root; } From 21d6581cb3f06b4b0a623f250b073f70c6b5a451 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Wed, 18 Feb 2026 17:05:43 -0800 Subject: [PATCH 06/33] refactor(cli,nodejs,kernel-utils): replace system-console-vat with direct JSON-RPC daemon Replace the system-console-vat architecture with direct JSON-RPC over Unix socket. The old flow routed CLI commands through IOChannels and a REPL vat; the new flow sends JSON-RPC requests directly to kernel RPC handlers. - Add RPC socket server and daemon lifecycle to @ocap/nodejs under ./daemon export path, reusing RpcService and rpcHandlers from the kernel - Simplify CLI: ok.ts sends JSON-RPC commands, daemon-entry.ts boots kernel and starts the daemon socket server - Move libp2p relay from @ocap/cli to @metamask/kernel-utils under ./libp2p export path, breaking the cli<->nodejs dependency cycle - Remove @ocap/cli devDep from packages that only used the binary; use yarn run -T ocap for workspace-wide binary access - Delete system-console-vat and related IOChannel/ref plumbing - makeKernel now returns { kernel, kernelDatabase } Co-Authored-By: Claude Opus 4.6 --- package.json | 5 +- packages/cli/package.json | 15 +- packages/cli/src/app.ts | 2 +- packages/cli/src/commands/daemon-client.ts | 93 +--- packages/cli/src/commands/daemon-entry.ts | 57 +- packages/cli/src/ok.ts | 199 +------ packages/extension/package.json | 1 - packages/extension/scripts/start.sh | 2 +- packages/extension/scripts/test-e2e-ci.sh | 4 +- .../extension/test/e2e/remote-comms.test.ts | 2 +- packages/kernel-test/package.json | 1 - packages/kernel-utils/package.json | 21 + .../src/libp2p-relay.ts} | 10 +- packages/nodejs/README.md | 2 +- packages/nodejs/package.json | 12 +- packages/nodejs/scripts/test-e2e-ci.sh | 4 +- packages/nodejs/src/daemon/index.ts | 6 + .../nodejs/src/daemon/rpc-socket-server.ts | 163 ++++++ .../nodejs/src/daemon/start-daemon.test.ts | 120 ++--- packages/nodejs/src/daemon/start-daemon.ts | 95 +--- packages/nodejs/src/index.ts | 8 +- .../nodejs/src/kernel/make-kernel.test.ts | 2 +- packages/nodejs/src/kernel/make-kernel.ts | 15 +- packages/nodejs/test/e2e/daemon-stack.test.ts | 283 +++------- .../nodejs/test/e2e/kernel-worker.test.ts | 2 +- packages/nodejs/test/e2e/remote-comms.test.ts | 2 +- .../nodejs/test/vats/system-console-vat.ts | 1 - packages/ocap-kernel/package.json | 1 - .../src/vats/system-console-vat.test.ts | 491 ----------------- .../src/vats/system-console-vat.ts | 508 ------------------ packages/omnium-gatherum/package.json | 1 - packages/omnium-gatherum/scripts/start.sh | 2 +- yarn.lock | 29 +- 33 files changed, 461 insertions(+), 1698 deletions(-) rename packages/{cli/src/relay.ts => kernel-utils/src/libp2p-relay.ts} (95%) create mode 100644 packages/nodejs/src/daemon/index.ts create mode 100644 packages/nodejs/src/daemon/rpc-socket-server.ts delete mode 100644 packages/nodejs/test/vats/system-console-vat.ts delete mode 100644 packages/ocap-kernel/src/vats/system-console-vat.test.ts delete mode 100644 packages/ocap-kernel/src/vats/system-console-vat.ts diff --git a/package.json b/package.json index 84fdc1684..6bd429980 100644 --- a/package.json +++ b/package.json @@ -112,7 +112,6 @@ "allowScripts": { "$root$": true, "@ocap/cli>@metamask/logger>@metamask/streams": true, - "@ocap/cli>@libp2p/webrtc>@ipshipyard/node-datachannel": false, "@lavamoat/preinstall-always-fail": false, "eslint-import-resolver-typescript>unrs-resolver": false, "eslint-plugin-import-x>unrs-resolver": false, @@ -123,7 +122,9 @@ "vitest>@vitest/browser>webdriverio>@wdio/utils>edgedriver": false, "vitest>@vitest/browser>webdriverio>@wdio/utils>geckodriver": false, "vitest>@vitest/mocker>msw": false, - "@ocap/cli>@metamask/kernel-shims>@libp2p/webrtc>@ipshipyard/node-datachannel": false + "@ocap/cli>@ocap/nodejs>@libp2p/webrtc>@ipshipyard/node-datachannel": false, + "@ocap/cli>@ocap/nodejs>@metamask/kernel-store>better-sqlite3": false, + "@ocap/cli>@ocap/nodejs>@metamask/streams": false } }, "resolutions": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 2652c1710..238182d98 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -35,26 +35,16 @@ "test:dev:quiet": "yarn test:dev --reporter @ocap/repo-tools/vitest-reporters/silent" }, "dependencies": { - "@chainsafe/libp2p-noise": "^16.1.3", - "@chainsafe/libp2p-yamux": "patch:@chainsafe/libp2p-yamux@npm%3A7.0.4#~/.yarn/patches/@chainsafe-libp2p-yamux-npm-7.0.4-284c2f6812.patch", "@endo/promise-kit": "^1.1.13", - "@libp2p/autonat": "2.0.38", - "@libp2p/circuit-relay-v2": "3.2.24", - "@libp2p/crypto": "5.1.8", - "@libp2p/identify": "3.0.39", - "@libp2p/interface": "2.11.0", - "@libp2p/ping": "2.0.37", - "@libp2p/tcp": "10.1.19", - "@libp2p/websockets": "9.2.19", "@metamask/kernel-shims": "workspace:^", "@metamask/kernel-utils": "workspace:^", "@metamask/logger": "workspace:^", "@metamask/utils": "^11.9.0", + "@ocap/nodejs": "workspace:^", "@ocap/repo-tools": "workspace:^", "@types/node": "^22.13.1", "chokidar": "^4.0.1", "glob": "^11.0.0", - "libp2p": "2.10.0", "serve-handler": "^6.1.6", "vite": "^7.3.0", "yargs": "^17.7.2" @@ -96,7 +86,6 @@ "node": ">=22" }, "exports": { - "./package.json": "./package.json", - "./relay": "./dist/relay.mjs" + "./package.json": "./package.json" } } diff --git a/packages/cli/src/app.ts b/packages/cli/src/app.ts index 0e8226292..15256bc80 100755 --- a/packages/cli/src/app.ts +++ b/packages/cli/src/app.ts @@ -1,4 +1,5 @@ import '@metamask/kernel-shims/endoify-node'; +import { startRelay } from '@metamask/kernel-utils/libp2p'; import { Logger } from '@metamask/logger'; import type { LogEntry } from '@metamask/logger'; import path from 'node:path'; @@ -10,7 +11,6 @@ import { getServer } from './commands/serve.ts'; import { watchDir } from './commands/watch.ts'; import { defaultConfig } from './config.ts'; import type { Config } from './config.ts'; -import { startRelay } from './relay.ts'; import { withTimeout } from './utils.ts'; /** diff --git a/packages/cli/src/commands/daemon-client.ts b/packages/cli/src/commands/daemon-client.ts index 7897962b0..f9b2ce653 100644 --- a/packages/cli/src/commands/daemon-client.ts +++ b/packages/cli/src/commands/daemon-client.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto'; import { createConnection } from 'node:net'; import type { Socket } from 'node:net'; import { homedir } from 'node:os'; @@ -100,38 +101,46 @@ async function writeLine(socket: Socket, line: string): Promise { } /** - * The response shape from the system console vat. + * A JSON-RPC 2.0 response. */ -export type ConsoleResponse = { - ok: boolean; +export type JsonRpcResponse = { + jsonrpc: '2.0'; + id: string | null; result?: unknown; - error?: string; + error?: { code: number; message: string }; }; /** - * Send a JSON request to the daemon over a UNIX socket and return the response. + * Send a JSON-RPC request to the daemon over a UNIX socket and return the response. * - * Opens a connection, writes one JSON line, reads one JSON response line, - * then closes the connection. Retries once after a short delay if the - * connection is rejected (e.g. due to a probe connection race). + * Opens a connection, writes one JSON-RPC request line, reads one JSON-RPC + * response line, then closes the connection. Retries once after a short delay + * if the connection is rejected (e.g. due to a probe connection race). * * @param socketPath - The UNIX socket path. - * @param request - The request to send. - * @param request.ref - Optional ref targeting a capability. - * @param request.method - The method name to invoke. - * @param request.args - Optional arguments array. - * @returns The parsed response. + * @param method - The RPC method name. + * @param params - Optional method parameters. + * @returns The parsed JSON-RPC response. */ export async function sendCommand( socketPath: string, - request: { ref?: string; method: string; args?: unknown[] }, -): Promise { - const attempt = async (): Promise => { + method: string, + params?: Record, +): Promise { + const id = randomUUID(); + const request = { + jsonrpc: '2.0', + id, + method, + ...(params === undefined ? {} : { params }), + }; + + const attempt = async (): Promise => { const socket = await connectSocket(socketPath); try { await writeLine(socket, JSON.stringify(request)); const responseLine = await readLine(socket); - return JSON.parse(responseLine) as ConsoleResponse; + return JSON.parse(responseLine) as JsonRpcResponse; } finally { socket.destroy(); } @@ -140,7 +149,7 @@ export async function sendCommand( try { return await attempt(); } catch { - // Retry once after a short delay — the daemon's socket channel may + // Retry once after a short delay — the daemon's socket may // still be cleaning up a previous probe connection. await new Promise((resolve) => setTimeout(resolve, 100)); return attempt(); @@ -162,51 +171,3 @@ export async function isDaemonRunning(socketPath: string): Promise { return false; } } - -/** - * Read all content from stdin, stripping shebang lines. - * - * @returns The stdin content with shebang lines removed. - */ -export async function readStdin(): Promise { - return new Promise((resolve, reject) => { - const chunks: Buffer[] = []; - process.stdin.on('data', (chunk: Buffer) => chunks.push(chunk)); - process.stdin.on('end', () => { - const raw = Buffer.concat(chunks).toString().trim(); - const lines = raw.split('\n').filter((line) => !line.startsWith('#!')); - resolve(lines.join('\n').trim()); - }); - process.stdin.on('error', reject); - }); -} - -/** - * Read a ref from stdin. Strips shebang lines. - * - * @returns The ref string. - */ -export async function readRefFromStdin(): Promise { - const content = await readStdin(); - if (!content) { - throw new Error('No ref found in stdin'); - } - return content; -} - -/** - * Read a ref from a .ocap file. Strips shebang lines. - * - * @param filePath - The path to the .ocap file. - * @returns The ref string. - */ -export async function readRefFromFile(filePath: string): Promise { - const { readFile } = await import('node:fs/promises'); - const raw = (await readFile(filePath, 'utf-8')).trim(); - const lines = raw.split('\n').filter((line) => !line.startsWith('#!')); - const ref = lines.join('\n').trim(); - if (!ref) { - throw new Error(`No ref found in ${filePath}`); - } - return ref; -} diff --git a/packages/cli/src/commands/daemon-entry.ts b/packages/cli/src/commands/daemon-entry.ts index 85fc704be..b277a1653 100644 --- a/packages/cli/src/commands/daemon-entry.ts +++ b/packages/cli/src/commands/daemon-entry.ts @@ -1,14 +1,12 @@ -/* eslint-disable n/no-process-exit, n/no-process-env, n/no-sync */ +/* eslint-disable n/no-process-exit, n/no-process-env */ import '@metamask/kernel-shims/endoify-node'; import { Logger } from '@metamask/logger'; import type { LogEntry } from '@metamask/logger'; -import { chmod, mkdir, rm, writeFile } from 'node:fs/promises'; -import { createRequire } from 'node:module'; +import { makeKernel } from '@ocap/nodejs'; +import { startDaemon } from '@ocap/nodejs/daemon'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; import { homedir } from 'node:os'; -import { dirname, join, resolve } from 'node:path'; -import { pathToFileURL } from 'node:url'; - -import { bundleFile } from './bundle.ts'; +import { join } from 'node:path'; /** * Create a file transport that writes logs to a file. @@ -18,10 +16,11 @@ import { bundleFile } from './bundle.ts'; */ function makeFileTransport(logPath: string) { // eslint-disable-next-line @typescript-eslint/no-require-imports, n/global-require -- need sync fs for log transport - const { appendFileSync } = require('node:fs') as typeof import('node:fs'); + const fs = require('node:fs') as typeof import('node:fs'); return (entry: LogEntry): void => { const line = `[${new Date().toISOString()}] [${entry.level}] ${entry.message ?? ''} ${(entry.data ?? []).map(String).join(' ')}\n`; - appendFileSync(logPath, line); + // eslint-disable-next-line n/no-sync -- synchronous write needed for log transport reliability + fs.appendFileSync(logPath, line); }; } @@ -41,44 +40,18 @@ async function main(): Promise { try { const socketPath = process.env.OCAP_SOCKET_PATH ?? join(ocapDir, 'console.sock'); - const consoleName = process.env.OCAP_CONSOLE_NAME ?? 'system-console'; - - // Bundle system console vat if needed - const bundlesDir = join(ocapDir, 'bundles'); - await mkdir(bundlesDir, { recursive: true }); - - const bundlePath = join(bundlesDir, 'system-console-vat.bundle'); - const cjsRequire = createRequire(import.meta.url); - const kernelPkgPath = cjsRequire.resolve( - '@metamask/ocap-kernel/package.json', - ); - const vatSource = resolve( - dirname(kernelPkgPath), - 'src/vats/system-console-vat.ts', - ); - logger.info(`Bundling system console vat from ${vatSource}...`); - await bundleFile(vatSource, { logger, targetPath: bundlePath }); - const bundleSpec = pathToFileURL(bundlePath).href; - // Dynamically import to avoid pulling @ocap/nodejs into the CLI bundle graph - // eslint-disable-next-line import-x/no-extraneous-dependencies -- workspace package - const { startDaemon } = await import('@ocap/nodejs'); - - const handle = await startDaemon({ - systemConsoleBundleSpec: bundleSpec, - systemConsoleName: consoleName, - socketPath, + const { kernel, kernelDatabase } = await makeKernel({ resetStorage: true, logger, }); + await kernel.initIdentity(); - // Write the admin .ocap file so `ok ` works - const ocapPath = - process.env.OCAP_CONSOLE_PATH ?? join(ocapDir, `${consoleName}.ocap`); - await mkdir(dirname(ocapPath), { recursive: true }); - await writeFile(ocapPath, `#!/usr/bin/env ok\n${handle.selfRef}\n`); - await chmod(ocapPath, 0o700); - logger.info(`Wrote ${ocapPath}`); + const handle = await startDaemon({ + socketPath, + kernel, + kernelDatabase, + }); // Write PID file so `ok daemon stop` can signal this process const pidPath = join(ocapDir, 'daemon.pid'); diff --git a/packages/cli/src/ok.ts b/packages/cli/src/ok.ts index 15401c6f7..14b09def0 100644 --- a/packages/cli/src/ok.ts +++ b/packages/cli/src/ok.ts @@ -1,6 +1,6 @@ -/* eslint-disable n/no-process-exit, n/no-sync, no-negated-condition */ +/* eslint-disable n/no-process-exit */ import '@metamask/kernel-shims/endoify-node'; -import { existsSync, fstatSync } from 'node:fs'; +import { flushDaemon } from '@ocap/nodejs/daemon'; import { readFile, rm } from 'node:fs/promises'; import { homedir } from 'node:os'; import { join, resolve } from 'node:path'; @@ -12,8 +12,6 @@ import { getSocketPath, isDaemonRunning, sendCommand, - readStdin, - readRefFromFile, } from './commands/daemon-client.ts'; import { ensureDaemon } from './commands/daemon-spawn.ts'; @@ -30,151 +28,45 @@ function tildify(path: string): string { } /** - * Handle the core invocation: resolve ref, call method, output result. + * Handle the core invocation: call an RPC method on the daemon. * * @param args - CLI arguments after `ok`. * @param socketPath - The daemon socket path. */ async function handleInvoke(args: string[], socketPath: string): Promise { - let ref: string | undefined; - let method: string; - let methodArgs: string[]; + const method = args[0] ?? 'getStatus'; + const rawParams = args[1]; - const firstArg = args[0]; - if ( - firstArg !== undefined && - (firstArg.endsWith('.ocap') || existsSync(firstArg)) - ) { - // File arg mode: ok [...args] - ref = await readRefFromFile(firstArg); - method = args[1] ?? 'help'; - methodArgs = args.slice(2); - } else if ( - !process.stdin.isTTY && - (fstatSync(0).isFIFO() || fstatSync(0).isFile()) - ) { - // Redirected stdin (pipe or file): could be a ref (d-) or JSON data - const stdinContent = await readStdin(); - if (!stdinContent) { - throw new Error('No input on stdin'); - } - if (stdinContent.startsWith('d-')) { - // Ref mode: ok [...args] < file.ocap - ref = stdinContent; - method = args[0] ?? 'help'; - methodArgs = args.slice(1); - } else { - // Data mode: cat config.json | ok launch - method = args[0] ?? 'help'; - methodArgs = [stdinContent, ...args.slice(1)]; - } - } else { - // No ref — dispatch on the system console itself - method = args[0] ?? 'help'; - methodArgs = args.slice(1); - } - - // For launch: resolve relative bundleSpec paths to file:// URLs. - // Handle shell word-splitting: `$(cat file.json)` without quotes splits - // JSON into many args. Try joining all args as one JSON string first. - if (method === 'launch') { - methodArgs = rejoinSplitJson(methodArgs); - methodArgs = methodArgs.map((arg) => { - try { - const parsed = JSON.parse(arg) as unknown; - if (isClusterConfigLike(parsed)) { - return JSON.stringify(resolveBundleSpecs(parsed)); - } - } catch { - // not JSON — leave as-is - } - return arg; - }); - } - - // Parse args: try JSON for each, fall back to string - const parsedArgs = methodArgs.map((arg) => { + // For launchSubcluster: resolve relative bundleSpec paths to file:// URLs. + let params: Record | undefined; + if (rawParams !== undefined) { try { - return JSON.parse(arg) as unknown; + const parsed = JSON.parse(rawParams) as Record; + if (method === 'launchSubcluster' && isClusterConfigLike(parsed)) { + params = resolveBundleSpecs(parsed) as Record; + } else { + params = parsed; + } } catch { - return arg; + // Not valid JSON — wrap as a simple value + params = { value: rawParams }; } - }); - - const request: { ref?: string; method: string; args?: unknown[] } = { - method, - ...(ref !== undefined ? { ref } : {}), - ...(parsedArgs.length > 0 ? { args: parsedArgs } : {}), - }; + } - const response = await sendCommand(socketPath, request); + const response = await sendCommand(socketPath, method, params); - if (!response.ok) { - process.stderr.write(`Error: ${response.error}\n`); + if (response.error) { + process.stderr.write( + `Error: ${response.error.message} (code ${String(response.error.code)})\n`, + ); process.exit(1); } const isTTY = process.stdout.isTTY ?? false; - const { result } = response; - - // Check if result contains a ref (capability) - const resultRef = isRefResult(result) ? result.ref : undefined; - - if (resultRef && !isTTY) { - // Piped: output .ocap content for the ref - process.stdout.write(`#!/usr/bin/env ok\n${resultRef}\n`); - } else if (isTTY) { - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + if (isTTY) { + process.stdout.write(`${JSON.stringify(response.result, null, 2)}\n`); } else { - process.stdout.write(`${JSON.stringify(result)}\n`); - } -} - -/** - * Check if a result object contains a ref field. - * - * @param result - The result to check. - * @returns True if the result has a ref string. - */ -function isRefResult(result: unknown): result is { ref: string } { - return ( - typeof result === 'object' && - result !== null && - 'ref' in result && - typeof (result as { ref: unknown }).ref === 'string' - ); -} - -/** - * Rejoin args that were word-split by the shell from a single JSON value. - * - * When a user writes `ok launch $(cat config.json)` without quotes, bash - * splits the JSON on whitespace into many argv entries. This function - * detects that pattern and reassembles the original JSON. - * - * @param args - The method arguments (possibly word-split). - * @returns The args, with split JSON rejoined into a single element. - */ -function rejoinSplitJson(args: string[]): string[] { - if (args.length <= 1) { - return args; - } - // If the first arg already parses as a complete JSON object, no fix needed - const first = args[0]; - if (first !== undefined) { - try { - JSON.parse(first); - return args; - } catch { - // First arg alone isn't valid JSON — try joining - } - } - const joined = args.join(' '); - try { - JSON.parse(joined); - return [joined]; - } catch { - return args; + process.stdout.write(`${JSON.stringify(response.result)}\n`); } } @@ -292,45 +184,21 @@ async function handleDaemon(args: string[], socketPath: string): Promise { ); process.exit(1); } - // eslint-disable-next-line import-x/no-extraneous-dependencies -- workspace package - const { flushDaemon } = await import('@ocap/nodejs'); await flushDaemon({ socketPath }); process.stderr.write('All daemon state flushed.\n'); return; } // Default: start daemon (or confirm running) - let consolePath = 'system-console.ocap'; - const consoleIdx = args.indexOf('--console'); - if (consoleIdx !== -1 && args[consoleIdx + 1]) { - consolePath = args[consoleIdx + 1] ?? consolePath; - } - - // Resolve relative to PWD - const ocapPath = resolve(consolePath); - // Derive the console name from the filename (strip .ocap if present) - const consoleName = ocapPath.endsWith('.ocap') - ? ocapPath.slice(ocapPath.lastIndexOf('/') + 1, -5) - : ocapPath.slice(ocapPath.lastIndexOf('/') + 1); - - // eslint-disable-next-line n/no-process-env -- CLI sets env for daemon child process - process.env.OCAP_CONSOLE_NAME = consoleName; - // eslint-disable-next-line n/no-process-env -- CLI sets env for daemon child process - process.env.OCAP_CONSOLE_PATH = ocapPath; - await ensureDaemon(socketPath); - process.stderr.write(`Daemon running. Socket: ${tildify(socketPath)}\n`); - if (existsSync(ocapPath)) { - process.stderr.write(`Admin console: ${tildify(ocapPath)}\n`); - } } const socketPath = getSocketPath(); const cli = yargs(hideBin(process.argv)) .scriptName('ok') - .usage('$0 [file.ocap] [...args]') + .usage('$0 [params-json]') .help(false) .command( @@ -342,11 +210,6 @@ const cli = yargs(hideBin(process.argv)) describe: 'Subcommand: stop, begone', type: 'string', }) - .option('console', { - describe: 'Path for the .ocap admin file (relative to PWD)', - type: 'string', - default: 'system-console.ocap', - }) .option('forgood', { describe: 'Confirm state deletion (for begone)', type: 'boolean', @@ -359,14 +222,11 @@ const cli = yargs(hideBin(process.argv)) if (args.forgood) { daemonArgs.push('--forgood'); } - if (args.console && args.console !== 'system-console.ocap') { - daemonArgs.push('--console', String(args.console)); - } await handleDaemon(daemonArgs, socketPath); }, ) - // Default: file.ocap dispatch or bare invocation + // Default: RPC method dispatch .command( '$0 [args..]', false, @@ -374,10 +234,7 @@ const cli = yargs(hideBin(process.argv)) async (args) => { const invokeArgs = ((args.args ?? []) as string[]).map(String); await ensureDaemon(socketPath); - await handleInvoke( - invokeArgs.length > 0 ? invokeArgs : ['help'], - socketPath, - ); + await handleInvoke(invokeArgs, socketPath); }, ) diff --git a/packages/extension/package.json b/packages/extension/package.json index 04877660a..433b66f37 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -61,7 +61,6 @@ "@metamask/eslint-config": "^15.0.0", "@metamask/eslint-config-nodejs": "^15.0.0", "@metamask/eslint-config-typescript": "^15.0.0", - "@ocap/cli": "workspace:^", "@ocap/kernel-test": "workspace:^", "@ocap/repo-tools": "workspace:^", "@playwright/test": "^1.57.0", diff --git a/packages/extension/scripts/start.sh b/packages/extension/scripts/start.sh index 7b7d41d9b..c17674d10 100755 --- a/packages/extension/scripts/start.sh +++ b/packages/extension/scripts/start.sh @@ -4,7 +4,7 @@ set -x set -e set -o pipefail -yarn ocap relay & +yarn run -T ocap relay & RELAY_PID=$! function cleanup() { diff --git a/packages/extension/scripts/test-e2e-ci.sh b/packages/extension/scripts/test-e2e-ci.sh index c59a55b87..3e2043f7b 100755 --- a/packages/extension/scripts/test-e2e-ci.sh +++ b/packages/extension/scripts/test-e2e-ci.sh @@ -6,8 +6,8 @@ set -o pipefail yarn build # Bundle and serve test vats (e.g., empty-vat used by minimal-cluster.json) -yarn ocap bundle "../kernel-test/src/vats/default" -yarn ocap serve "../kernel-test/src/vats/default" & +yarn run -T ocap bundle "../kernel-test/src/vats/default" +yarn run -T ocap serve "../kernel-test/src/vats/default" & SERVER_PID=$! function cleanup() { diff --git a/packages/extension/test/e2e/remote-comms.test.ts b/packages/extension/test/e2e/remote-comms.test.ts index a56c30b86..23bb442f7 100644 --- a/packages/extension/test/e2e/remote-comms.test.ts +++ b/packages/extension/test/e2e/remote-comms.test.ts @@ -1,4 +1,4 @@ -import { startRelay } from '@ocap/cli/relay'; +import { startRelay } from '@metamask/kernel-utils/libp2p'; import { test, expect } from '@playwright/test'; import type { Page, BrowserContext } from '@playwright/test'; import { rm } from 'node:fs/promises'; diff --git a/packages/kernel-test/package.json b/packages/kernel-test/package.json index 2cb1cdea7..c5e4b56be 100644 --- a/packages/kernel-test/package.json +++ b/packages/kernel-test/package.json @@ -69,7 +69,6 @@ "@metamask/eslint-config-nodejs": "^15.0.0", "@metamask/eslint-config-typescript": "^15.0.0", "@metamask/kernel-shims": "workspace:^", - "@ocap/cli": "workspace:^", "@ocap/repo-tools": "workspace:^", "@typescript-eslint/eslint-plugin": "^8.29.0", "@typescript-eslint/parser": "^8.29.0", diff --git a/packages/kernel-utils/package.json b/packages/kernel-utils/package.json index c76f0dc0d..aa637b250 100644 --- a/packages/kernel-utils/package.json +++ b/packages/kernel-utils/package.json @@ -49,6 +49,16 @@ "default": "./dist/discoverable.cjs" } }, + "./libp2p": { + "import": { + "types": "./dist/libp2p-relay.d.mts", + "default": "./dist/libp2p-relay.mjs" + }, + "require": { + "types": "./dist/libp2p-relay.d.cts", + "default": "./dist/libp2p-relay.cjs" + } + }, "./package.json": "./package.json" }, "main": "./dist/index.cjs", @@ -77,13 +87,24 @@ "test:dev:quiet": "yarn test:dev --reporter @ocap/repo-tools/vitest-reporters/silent" }, "dependencies": { + "@chainsafe/libp2p-noise": "^16.1.3", + "@chainsafe/libp2p-yamux": "patch:@chainsafe/libp2p-yamux@npm%3A7.0.4#~/.yarn/patches/@chainsafe-libp2p-yamux-npm-7.0.4-284c2f6812.patch", "@endo/captp": "^4.4.8", "@endo/errors": "^1.2.13", "@endo/exo": "^1.5.12", "@endo/patterns": "^1.7.0", "@endo/promise-kit": "^1.1.13", + "@libp2p/autonat": "2.0.38", + "@libp2p/circuit-relay-v2": "3.2.24", + "@libp2p/crypto": "5.1.8", + "@libp2p/identify": "3.0.39", + "@libp2p/interface": "2.11.0", + "@libp2p/ping": "2.0.37", + "@libp2p/tcp": "10.1.19", + "@libp2p/websockets": "9.2.19", "@metamask/superstruct": "^3.2.1", "@metamask/utils": "^11.9.0", + "libp2p": "2.10.0", "setimmediate": "^1.0.5" }, "devDependencies": { diff --git a/packages/cli/src/relay.ts b/packages/kernel-utils/src/libp2p-relay.ts similarity index 95% rename from packages/cli/src/relay.ts rename to packages/kernel-utils/src/libp2p-relay.ts index 9acbe4fd4..9edadd65a 100644 --- a/packages/cli/src/relay.ts +++ b/packages/kernel-utils/src/libp2p-relay.ts @@ -8,7 +8,6 @@ import type { Libp2p, PrivateKey } from '@libp2p/interface'; import { ping } from '@libp2p/ping'; import { tcp } from '@libp2p/tcp'; import { webSockets } from '@libp2p/websockets'; -import type { Logger } from '@metamask/logger'; import { createLibp2p } from 'libp2p'; /** @@ -19,13 +18,20 @@ import { createLibp2p } from 'libp2p'; */ const RELAY_LOCAL_ID = 200; +/** + * A minimal logger interface for relay events. + */ +type RelayLogger = { + log: (message: string, ...args: unknown[]) => void; +}; + /** * Start the relay server. * * @param logger - The logger to use. * @returns The libp2p instance. */ -export async function startRelay(logger: Logger | Console): Promise { +export async function startRelay(logger: RelayLogger): Promise { const privateKey = await generateKeyPair(RELAY_LOCAL_ID); const libp2p = await createLibp2p({ privateKey, diff --git a/packages/nodejs/README.md b/packages/nodejs/README.md index 957fc4830..abcef732d 100644 --- a/packages/nodejs/README.md +++ b/packages/nodejs/README.md @@ -25,7 +25,7 @@ cd ~/path/to/ocap-kernel/packages/nodejs If it's not already running, start the `@ocap/cli` in `kernel-test/src/vats/default`. ```sh -yarn ocap start ../kernel-test/src/vats/default +yarn run -T ocap start ../kernel-test/src/vats/default ``` Then, run the end to end tests. diff --git a/packages/nodejs/package.json b/packages/nodejs/package.json index b84481062..5789d66e5 100644 --- a/packages/nodejs/package.json +++ b/packages/nodejs/package.json @@ -23,6 +23,16 @@ "default": "./dist/index.cjs" } }, + "./daemon": { + "import": { + "types": "./dist/daemon/index.d.mts", + "default": "./dist/daemon/index.mjs" + }, + "require": { + "types": "./dist/daemon/index.d.cts", + "default": "./dist/daemon/index.cjs" + } + }, "./package.json": "./package.json" }, "files": [ @@ -55,6 +65,7 @@ "@libp2p/interface": "2.11.0", "@libp2p/tcp": "10.1.19", "@libp2p/webrtc": "5.2.24", + "@metamask/kernel-rpc-methods": "workspace:^", "@metamask/kernel-shims": "workspace:^", "@metamask/kernel-store": "workspace:^", "@metamask/kernel-utils": "workspace:^", @@ -70,7 +81,6 @@ "@metamask/eslint-config": "^15.0.0", "@metamask/eslint-config-nodejs": "^15.0.0", "@metamask/eslint-config-typescript": "^15.0.0", - "@ocap/cli": "workspace:^", "@ocap/repo-tools": "workspace:^", "@ts-bridge/cli": "^0.6.3", "@ts-bridge/shims": "^0.1.1", diff --git a/packages/nodejs/scripts/test-e2e-ci.sh b/packages/nodejs/scripts/test-e2e-ci.sh index 17c15d3d5..226289ae2 100755 --- a/packages/nodejs/scripts/test-e2e-ci.sh +++ b/packages/nodejs/scripts/test-e2e-ci.sh @@ -4,10 +4,10 @@ set -x set -e set -o pipefail -yarn ocap bundle "./test/vats" +yarn run -T ocap bundle "./test/vats" # Start the server in background and capture its PID -yarn ocap serve "./test/vats" & +yarn run -T ocap serve "./test/vats" & SERVER_PID=$! function cleanup() { diff --git a/packages/nodejs/src/daemon/index.ts b/packages/nodejs/src/daemon/index.ts new file mode 100644 index 000000000..e62d2511a --- /dev/null +++ b/packages/nodejs/src/daemon/index.ts @@ -0,0 +1,6 @@ +export { startDaemon } from './start-daemon.ts'; +export type { StartDaemonOptions, DaemonHandle } from './start-daemon.ts'; +export { startRpcSocketServer } from './rpc-socket-server.ts'; +export type { RpcSocketServerHandle } from './rpc-socket-server.ts'; +export { flushDaemon } from './flush-daemon.ts'; +export type { FlushDaemonOptions } from './flush-daemon.ts'; diff --git a/packages/nodejs/src/daemon/rpc-socket-server.ts b/packages/nodejs/src/daemon/rpc-socket-server.ts new file mode 100644 index 000000000..0ceb21a00 --- /dev/null +++ b/packages/nodejs/src/daemon/rpc-socket-server.ts @@ -0,0 +1,163 @@ +import { RpcService } from '@metamask/kernel-rpc-methods'; +import type { KernelDatabase } from '@metamask/kernel-store'; +import type { Kernel } from '@metamask/ocap-kernel'; +import { rpcHandlers } from '@metamask/ocap-kernel/rpc'; +import { createServer } from 'node:net'; +import type { Server } from 'node:net'; + +/** + * Handle returned by {@link startRpcSocketServer}. + */ +export type RpcSocketServerHandle = { + close: () => Promise; +}; + +/** + * Start a Unix socket server that processes JSON-RPC requests through RpcService. + * + * Each connection reads one newline-delimited JSON-RPC request, processes it + * via the kernel's RPC handlers, writes a JSON-RPC response, and closes. + * + * @param options - Server options. + * @param options.socketPath - The Unix socket path to listen on. + * @param options.kernel - The kernel instance. + * @param options.kernelDatabase - The kernel database instance. + * @returns A handle with a `close()` function for cleanup. + */ +export async function startRpcSocketServer({ + socketPath, + kernel, + kernelDatabase, +}: { + socketPath: string; + kernel: Kernel; + kernelDatabase: KernelDatabase; +}): Promise { + const rpcService = new RpcService(rpcHandlers, { + kernel, + executeDBQuery: (sql: string) => kernelDatabase.executeQuery(sql), + }); + + const server = createServer((socket) => { + let buffer = ''; + + socket.on('data', (data) => { + buffer += data.toString(); + const idx = buffer.indexOf('\n'); + if (idx === -1) { + return; + } + + const line = buffer.slice(0, idx); + buffer = ''; + + processRequest(rpcService, line) + .then((response) => { + socket.end(`${JSON.stringify(response)}\n`); + return undefined; + }) + .catch(() => { + socket.end( + `${JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32603, message: 'Internal error' } })}\n`, + ); + }); + }); + + socket.on('error', () => { + // Ignore client socket errors (e.g. broken pipe from probe connections) + }); + }); + + await listen(server, socketPath); + + return { + close: async () => { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + }, + }; +} + +/** + * Process a single JSON-RPC request line and return a JSON-RPC response. + * + * @param rpcService - The RPC service to execute methods against. + * @param line - The raw JSON line from the socket. + * @returns A JSON-RPC response object. + */ +async function processRequest( + rpcService: RpcService, + line: string, +): Promise> { + let id: unknown = null; + + try { + const request = JSON.parse(line) as { + jsonrpc?: string; + id?: unknown; + method?: string; + params?: unknown; + }; + id = request.id ?? null; + + const { method } = request; + // Default to empty array when no params provided (handlers expect validated params) + const params = request.params ?? []; + + if (typeof method !== 'string') { + return { + jsonrpc: '2.0', + id, + error: { code: -32600, message: 'Invalid request: missing method' }, + }; + } + + rpcService.assertHasMethod(method); + const result = await rpcService.execute(method, params); + + return { jsonrpc: '2.0', id, result: result ?? null }; + } catch (error) { + const code = isRpcError(error) ? error.code : -32603; + const message = error instanceof Error ? error.message : 'Internal error'; + + return { jsonrpc: '2.0', id, error: { code, message } }; + } +} + +/** + * Check if an error is an RPC error with a numeric code. + * + * @param error - The error to check. + * @returns True if the error has a numeric code property. + */ +function isRpcError(error: unknown): error is { code: number } { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + typeof (error as { code: unknown }).code === 'number' + ); +} + +/** + * Start listening on a Unix socket path. + * + * @param server - The net.Server instance. + * @param socketPath - The Unix socket path. + */ +async function listen(server: Server, socketPath: string): Promise { + return new Promise((resolve, reject) => { + server.on('error', reject); + server.listen(socketPath, () => { + server.removeListener('error', reject); + resolve(); + }); + }); +} diff --git a/packages/nodejs/src/daemon/start-daemon.test.ts b/packages/nodejs/src/daemon/start-daemon.test.ts index 9f18c24bd..d4ac55af5 100644 --- a/packages/nodejs/src/daemon/start-daemon.test.ts +++ b/packages/nodejs/src/daemon/start-daemon.test.ts @@ -3,35 +3,24 @@ import { vi, describe, it, expect, afterEach } from 'vitest'; import { startDaemon } from './start-daemon.ts'; import type { DaemonHandle } from './start-daemon.ts'; -// Mock makeKernel to avoid real kernel creation -vi.mock('../kernel/make-kernel.ts', () => ({ - makeKernel: vi.fn().mockResolvedValue({ - initIdentity: vi.fn().mockResolvedValue(undefined), - stop: vi.fn().mockResolvedValue(undefined), - getSystemSubclusterRoot: vi.fn().mockReturnValue('ko-root'), - queueMessage: vi.fn().mockResolvedValue({ body: '"d-1"', slots: [] }), +const { mockRpcServerClose } = vi.hoisted(() => ({ + mockRpcServerClose: vi.fn().mockResolvedValue(undefined), +})); + +// Mock RPC socket server to avoid real socket creation +vi.mock('./rpc-socket-server.ts', () => ({ + startRpcSocketServer: vi.fn().mockResolvedValue({ + close: mockRpcServerClose, }), })); -// Mock kunser to deserialise the capdata returned by queueMessage -vi.mock('@metamask/ocap-kernel', async () => { - const actual = await vi.importActual( - '@metamask/ocap-kernel', - ); - return { ...actual, kunser: vi.fn().mockReturnValue('d-1') }; -}); +const mockKernel = { + stop: vi.fn().mockResolvedValue(undefined), +}; -// Mock filesystem operations -vi.mock('node:fs/promises', async () => { - const actual = - await vi.importActual( - 'node:fs/promises', - ); - return { - ...actual, - mkdir: vi.fn().mockResolvedValue(undefined), - }; -}); +const mockKernelDatabase = { + executeQuery: vi.fn().mockReturnValue([]), +}; describe('startDaemon', () => { let handle: DaemonHandle | undefined; @@ -42,97 +31,56 @@ describe('startDaemon', () => { handle = undefined; await toClose.close(); } + vi.clearAllMocks(); }); - it('creates kernel with IO-based system subcluster config', async () => { - const { makeKernel } = await import('../kernel/make-kernel.ts'); - const mockedMakeKernel = vi.mocked(makeKernel); + it('starts RPC socket server with kernel and database', async () => { + const { startRpcSocketServer } = await import('./rpc-socket-server.ts'); + const mockedStartRpc = vi.mocked(startRpcSocketServer); const tmpSocket = `/tmp/daemon-test-${Date.now()}-${Math.random().toString(36).slice(2)}.sock`; handle = await startDaemon({ - systemConsoleBundleSpec: 'http://localhost/bundle', - systemConsoleName: 'my-console', socketPath: tmpSocket, + kernel: mockKernel as never, + kernelDatabase: mockKernelDatabase as never, }); - expect(mockedMakeKernel).toHaveBeenCalledWith( - expect.objectContaining({ - systemSubclusters: [ - { - name: 'my-console', - config: { - bootstrap: 'my-console', - io: { - console: { - type: 'socket', - path: tmpSocket, - }, - }, - services: ['kernelFacet', 'console'], - vats: { - 'my-console': { - bundleSpec: 'http://localhost/bundle', - parameters: { name: 'my-console' }, - }, - }, - }, - }, - ], - }), - ); - }); - - it('returns socket path, selfRef, and close function', async () => { - const tmpSocket = `/tmp/daemon-test-${Date.now()}-${Math.random().toString(36).slice(2)}.sock`; - - handle = await startDaemon({ - systemConsoleBundleSpec: 'http://localhost/bundle', + expect(mockedStartRpc).toHaveBeenCalledWith({ socketPath: tmpSocket, + kernel: mockKernel, + kernelDatabase: mockKernelDatabase, }); - - expect(handle.socketPath).toBe(tmpSocket); - expect(handle.selfRef).toBe('d-1'); - expect(typeof handle.close).toBe('function'); - expect(handle.kernel).toBeDefined(); }); - it('issues a self-ref via getSystemSubclusterRoot and queueMessage', async () => { - const { makeKernel } = await import('../kernel/make-kernel.ts'); - const mockedMakeKernel = vi.mocked(makeKernel); - + it('returns socket path, kernel, and close function', async () => { const tmpSocket = `/tmp/daemon-test-${Date.now()}-${Math.random().toString(36).slice(2)}.sock`; handle = await startDaemon({ - systemConsoleBundleSpec: 'http://localhost/bundle', - systemConsoleName: 'my-console', socketPath: tmpSocket, + kernel: mockKernel as never, + kernelDatabase: mockKernelDatabase as never, }); - const mockKernel = await mockedMakeKernel.mock.results[0]!.value; - expect(mockKernel.getSystemSubclusterRoot).toHaveBeenCalledWith( - 'my-console', - ); - expect(mockKernel.queueMessage).toHaveBeenCalledWith( - 'ko-root', - 'issueRef', - ['ko-root', true], - ); + expect(handle.socketPath).toBe(tmpSocket); + expect(handle.kernel).toBe(mockKernel); + expect(typeof handle.close).toBe('function'); }); - it('calls kernel.stop on close', async () => { + it('closes RPC server and stops kernel on close', async () => { const tmpSocket = `/tmp/daemon-test-${Date.now()}-${Math.random().toString(36).slice(2)}.sock`; handle = await startDaemon({ - systemConsoleBundleSpec: 'http://localhost/bundle', socketPath: tmpSocket, + kernel: mockKernel as never, + kernelDatabase: mockKernelDatabase as never, }); - const { stop } = handle.kernel; const toClose = handle; handle = undefined; await toClose.close(); - expect(stop).toHaveBeenCalled(); + expect(mockRpcServerClose).toHaveBeenCalled(); + expect(mockKernel.stop).toHaveBeenCalled(); }); }); diff --git a/packages/nodejs/src/daemon/start-daemon.ts b/packages/nodejs/src/daemon/start-daemon.ts index 5533f1e51..76315f45b 100644 --- a/packages/nodejs/src/daemon/start-daemon.ts +++ b/packages/nodejs/src/daemon/start-daemon.ts @@ -1,33 +1,18 @@ -import { ifDefined } from '@metamask/kernel-utils'; -import type { Logger } from '@metamask/logger'; -import type { Kernel, SystemSubclusterConfig } from '@metamask/ocap-kernel'; -import { kunser } from '@metamask/ocap-kernel'; -import { mkdir } from 'node:fs/promises'; -import { homedir } from 'node:os'; -import { join } from 'node:path'; +import type { KernelDatabase } from '@metamask/kernel-store'; +import type { Kernel } from '@metamask/ocap-kernel'; -import { makeKernel } from '../kernel/make-kernel.ts'; +import { startRpcSocketServer } from './rpc-socket-server.ts'; /** * Options for starting the daemon. */ export type StartDaemonOptions = { - /** UNIX socket path for the system console IO channel. Defaults to ~/.ocap/console.sock. */ - socketPath?: string; - /** URL to the bundled system-console-vat. */ - systemConsoleBundleSpec: string; - /** Name for the system console subcluster. Defaults to 'system-console'. */ - systemConsoleName?: string; - /** Path to vat worker file. */ - workerFilePath?: string; - /** SQLite database filename. Defaults to ~/.ocap/kernel.sqlite. */ - dbFilename?: string; - /** If true, clear kernel storage. */ - resetStorage?: boolean; - /** Logger instance. */ - logger?: Logger; - /** Seed for libp2p key generation. */ - keySeed?: string; + /** UNIX socket path for the RPC server. */ + socketPath: string; + /** A running kernel instance. */ + kernel: Kernel; + /** The kernel database instance. */ + kernelDatabase: KernelDatabase; }; /** @@ -36,15 +21,14 @@ export type StartDaemonOptions = { export type DaemonHandle = { kernel: Kernel; socketPath: string; - selfRef: string; close: () => Promise; }; /** * Start the OCAP daemon. * - * Creates a kernel with a system console vat that listens for commands - * on a UNIX domain socket IO channel. The kernel process IS the daemon. + * Starts a JSON-RPC socket server that exposes kernel control methods + * on a UNIX domain socket. * * @param options - Configuration options. * @returns A daemon handle. @@ -52,67 +36,22 @@ export type DaemonHandle = { export async function startDaemon( options: StartDaemonOptions, ): Promise { - const { - systemConsoleBundleSpec, - systemConsoleName = 'system-console', - workerFilePath, - resetStorage, - logger, - keySeed, - } = options; - - const ocapDir = join(homedir(), '.ocap'); - await mkdir(ocapDir, { recursive: true }); - - const socketPath = options.socketPath ?? join(ocapDir, 'console.sock'); - const dbFilename = options.dbFilename ?? join(ocapDir, 'kernel.sqlite'); - - // Build system subcluster config with IO channel for the console socket - const systemSubcluster: SystemSubclusterConfig = { - name: systemConsoleName, - config: { - bootstrap: systemConsoleName, - io: { - console: { - type: 'socket' as const, - path: socketPath, - }, - }, - services: ['kernelFacet', 'console'], - vats: { - [systemConsoleName]: { - bundleSpec: systemConsoleBundleSpec, - parameters: { name: systemConsoleName }, - }, - }, - }, - }; + const { socketPath, kernel, kernelDatabase } = options; - const kernel = await makeKernel({ - ...ifDefined({ workerFilePath, resetStorage, logger }), - dbFilename, - keySeed, - systemSubclusters: [systemSubcluster], + const rpcServer = await startRpcSocketServer({ + socketPath, + kernel, + kernelDatabase, }); - await kernel.initIdentity(); - - // Issue a self-ref so the admin .ocap file can address the console root object - const rootKref = kernel.getSystemSubclusterRoot(systemConsoleName); - const capData = await kernel.queueMessage(rootKref, 'issueRef', [ - rootKref, - true, - ]); - const selfRef = kunser(capData) as string; - const close = async (): Promise => { + await rpcServer.close(); await kernel.stop(); }; return { kernel, socketPath, - selfRef, close, }; } diff --git a/packages/nodejs/src/index.ts b/packages/nodejs/src/index.ts index 41f78b592..1a1eeb323 100644 --- a/packages/nodejs/src/index.ts +++ b/packages/nodejs/src/index.ts @@ -1,11 +1,5 @@ export { NodejsPlatformServices } from './kernel/PlatformServices.ts'; export { makeKernel } from './kernel/make-kernel.ts'; +export type { MakeKernelResult } from './kernel/make-kernel.ts'; export { makeNodeJsVatSupervisor } from './vat/make-supervisor.ts'; export { makeIOChannelFactory, makeSocketIOChannel } from './io/index.ts'; -export { startDaemon } from './daemon/start-daemon.ts'; -export type { - StartDaemonOptions, - DaemonHandle, -} from './daemon/start-daemon.ts'; -export { flushDaemon } from './daemon/flush-daemon.ts'; -export type { FlushDaemonOptions } from './daemon/flush-daemon.ts'; diff --git a/packages/nodejs/src/kernel/make-kernel.test.ts b/packages/nodejs/src/kernel/make-kernel.test.ts index 2fdfdb43d..57b0293d6 100644 --- a/packages/nodejs/src/kernel/make-kernel.test.ts +++ b/packages/nodejs/src/kernel/make-kernel.test.ts @@ -14,7 +14,7 @@ vi.mock('@metamask/kernel-store/sqlite/nodejs', async () => { describe('makeKernel', () => { it('should return a Kernel', async () => { - const kernel = await makeKernel({}); + const { kernel } = await makeKernel({}); expect(kernel).toBeInstanceOf(Kernel); }); diff --git a/packages/nodejs/src/kernel/make-kernel.ts b/packages/nodejs/src/kernel/make-kernel.ts index 68bd9b4ab..81e4a37e4 100644 --- a/packages/nodejs/src/kernel/make-kernel.ts +++ b/packages/nodejs/src/kernel/make-kernel.ts @@ -1,3 +1,4 @@ +import type { KernelDatabase } from '@metamask/kernel-store'; import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/nodejs'; import { Logger } from '@metamask/logger'; import { Kernel } from '@metamask/ocap-kernel'; @@ -9,6 +10,14 @@ import type { import { NodejsPlatformServices } from './PlatformServices.ts'; import { makeIOChannelFactory } from '../io/index.ts'; +/** + * Result of {@link makeKernel}. + */ +export type MakeKernelResult = { + kernel: Kernel; + kernelDatabase: KernelDatabase; +}; + /** * The main function for the kernel worker. * @@ -20,7 +29,7 @@ import { makeIOChannelFactory } from '../io/index.ts'; * @param options.keySeed - Optional seed for libp2p key generation. * @param options.ioChannelFactory - Optional factory for creating IO channels. * @param options.systemSubclusters - Optional system subcluster configurations. - * @returns The kernel, initialized. + * @returns The kernel and its database. */ export async function makeKernel({ workerFilePath, @@ -38,7 +47,7 @@ export async function makeKernel({ keySeed?: string | undefined; ioChannelFactory?: IOChannelFactory; systemSubclusters?: SystemSubclusterConfig[]; -}): Promise { +}): Promise { const rootLogger = logger ?? new Logger('kernel-worker'); const platformServicesClient = new NodejsPlatformServices({ workerFilePath, @@ -57,5 +66,5 @@ export async function makeKernel({ ...(systemSubclusters ? { systemSubclusters } : {}), }); - return kernel; + return { kernel, kernelDatabase }; } diff --git a/packages/nodejs/test/e2e/daemon-stack.test.ts b/packages/nodejs/test/e2e/daemon-stack.test.ts index 9e815db44..090a868a3 100644 --- a/packages/nodejs/test/e2e/daemon-stack.test.ts +++ b/packages/nodejs/test/e2e/daemon-stack.test.ts @@ -1,19 +1,15 @@ import type { KernelDatabase } from '@metamask/kernel-store'; import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/nodejs'; -import { waitUntilQuiescent } from '@metamask/kernel-utils'; import type { Kernel } from '@metamask/ocap-kernel'; -import { kunser } from '@metamask/ocap-kernel'; import * as net from 'node:net'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { pathToFileURL } from 'node:url'; import { describe, it, expect, afterEach } from 'vitest'; -import { makeIOChannelFactory } from '../../src/io/index.ts'; +import type { RpcSocketServerHandle } from '../../src/daemon/index.ts'; +import { startRpcSocketServer } from '../../src/daemon/index.ts'; import { makeTestKernel } from '../helpers/kernel.ts'; -const SYSTEM_CONSOLE_NAME = 'system-console'; - /** * Generate a unique temp socket path. * @@ -82,49 +78,51 @@ async function readLine(socket: net.Socket): Promise { } /** - * Send a JSON request over a socket and read the JSON response. + * A JSON-RPC 2.0 response. + */ +type JsonRpcResponse = { + jsonrpc: '2.0'; + id: string | null; + result?: unknown; + error?: { code: number; message: string }; +}; + +/** + * Send a JSON-RPC request over a socket and read the JSON-RPC response. * * @param socketPath - The socket path. - * @param request - The request object. - * @returns The parsed response. + * @param method - The RPC method name. + * @param params - Optional method parameters. + * @returns The parsed JSON-RPC response. */ -async function sendCommand( +async function sendJsonRpc( socketPath: string, - request: Record, -): Promise<{ ok: boolean; result?: unknown; error?: string }> { + method: string, + params?: Record, +): Promise { const socket = await connectToSocket(socketPath); try { + const request = { + jsonrpc: '2.0', + id: '1', + method, + ...(params === undefined ? {} : { params }), + }; await writeLine(socket, JSON.stringify(request)); const responseLine = await readLine(socket); - return JSON.parse(responseLine) as { - ok: boolean; - result?: unknown; - error?: string; - }; + return JSON.parse(responseLine) as JsonRpcResponse; } finally { socket.destroy(); } } -/** - * Get the bundle spec for the system console vat test bundle. - * - * @returns The bundle spec URL. - */ -function getSystemConsoleBundleSpec(): string { - const bundlePath = join( - import.meta.dirname, - '../vats/system-console-vat.bundle', - ); - return pathToFileURL(bundlePath).href; -} - -describe('Daemon Stack (IO socket protocol)', { timeout: 30_000 }, () => { +describe('Daemon Stack (JSON-RPC socket protocol)', { timeout: 30_000 }, () => { let kernel: Kernel | undefined; let kernelDatabase: KernelDatabase | undefined; + let rpcServer: RpcSocketServerHandle | undefined; /** - * Boot a kernel with a system console subcluster using IO socket. + * Boot a kernel with an RPC socket server. * * @returns The socket path. */ @@ -132,38 +130,24 @@ describe('Daemon Stack (IO socket protocol)', { timeout: 30_000 }, () => { const socketPath = tempSocketPath(); kernelDatabase = await makeSQLKernelDatabase({ dbFilename: ':memory:' }); - kernel = await makeTestKernel(kernelDatabase, { - ioChannelFactory: makeIOChannelFactory(), - systemSubclusters: [ - { - name: SYSTEM_CONSOLE_NAME, - config: { - bootstrap: SYSTEM_CONSOLE_NAME, - io: { - console: { - type: 'socket' as const, - path: socketPath, - }, - }, - services: ['kernelFacet', 'console'], - vats: { - [SYSTEM_CONSOLE_NAME]: { - bundleSpec: getSystemConsoleBundleSpec(), - parameters: { name: SYSTEM_CONSOLE_NAME }, - }, - }, - }, - }, - ], - }); - + kernel = await makeTestKernel(kernelDatabase); await kernel.initIdentity(); - await waitUntilQuiescent(100); + + rpcServer = await startRpcSocketServer({ + socketPath, + kernel, + kernelDatabase, + }); return socketPath; } afterEach(async () => { + if (rpcServer) { + const toClose = rpcServer; + rpcServer = undefined; + await toClose.close(); + } if (kernel) { const stopResult = kernel.stop(); kernel = undefined; @@ -175,162 +159,71 @@ describe('Daemon Stack (IO socket protocol)', { timeout: 30_000 }, () => { } }); - describe('daemon tier (no ref)', () => { - it('dispatches help command with daemon-tier commands only', async () => { - const socketPath = await bootDaemonStack(); - - const response = await sendCommand(socketPath, { method: 'help' }); - - expect(response.ok).toBe(true); - expect(response.result).toStrictEqual({ - commands: ['help - show available commands', 'status - daemon status'], - }); - }); - - it('dispatches status command returning liveness indicator', async () => { - const socketPath = await bootDaemonStack(); - - const response = await sendCommand(socketPath, { method: 'status' }); - - expect(response).toStrictEqual({ - ok: true, - result: { running: true }, - }); - }); - - it('returns error for unknown command', async () => { - const socketPath = await bootDaemonStack(); + it('returns kernel status via getStatus', async () => { + const socketPath = await bootDaemonStack(); - const response = await sendCommand(socketPath, { - method: 'nonexistent', - }); + const response = await sendJsonRpc(socketPath, 'getStatus'); - expect(response.ok).toBe(false); - expect(response.error).toContain('Unknown command'); - }); - - it('rejects privileged commands at daemon tier', async () => { - const socketPath = await bootDaemonStack(); - - const response = await sendCommand(socketPath, { method: 'ls' }); - - expect(response.ok).toBe(false); - expect(response.error).toContain('Unknown command'); - }); - - it('handles sequential requests on separate connections', async () => { - const socketPath = await bootDaemonStack(); - - const response1 = await sendCommand(socketPath, { method: 'help' }); - expect(response1.ok).toBe(true); - - const response2 = await sendCommand(socketPath, { method: 'status' }); - expect(response2.ok).toBe(true); - }); + expect(response.jsonrpc).toBe('2.0'); + expect(response.error).toBeUndefined(); + expect(response.result).toBeDefined(); + const result = response.result as Record; + expect(result).toHaveProperty('vats'); + expect(result).toHaveProperty('subclusters'); }); - describe('privileged tier (ref-based dispatch)', () => { - /** - * Boot daemon and issue a self-ref for the console root object. - * - * @returns The socket path and the issued ref. - */ - async function bootWithSelfRef(): Promise<{ - socketPath: string; - selfRef: string; - }> { - const socketPath = await bootDaemonStack(); - - // Issue a self-ref via kernel API (same as start-daemon.ts does) - const rootKref = kernel!.getSystemSubclusterRoot(SYSTEM_CONSOLE_NAME); - const capData = await kernel!.queueMessage(rootKref, 'issueRef', [ - rootKref, - true, - ]); - const selfRef = kunser(capData) as string; - - return { socketPath, selfRef }; - } - - it('dispatches help via ref', async () => { - const { socketPath, selfRef } = await bootWithSelfRef(); - - const response = await sendCommand(socketPath, { - ref: selfRef, - method: 'help', - }); + it('returns error for unknown method', async () => { + const socketPath = await bootDaemonStack(); - expect(response.ok).toBe(true); - const result = response.result as { commands: string[] }; - expect(result.commands).toContain('help - show available commands'); - expect(result.commands).toContain('ls - list all issued refs'); - }); + const response = await sendJsonRpc(socketPath, 'nonexistentMethod'); - it('dispatches status via ref (returns kernel status)', async () => { - const { socketPath, selfRef } = await bootWithSelfRef(); + expect(response.error).toBeDefined(); + expect(response.error!.code).toBe(-32601); + }); - const response = await sendCommand(socketPath, { - ref: selfRef, - method: 'status', - }); + it('executes DB query', async () => { + const socketPath = await bootDaemonStack(); - expect(response.ok).toBe(true); - const result = response.result as Record; - expect(result).toHaveProperty('vats'); - expect(result).toHaveProperty('subclusters'); + const response = await sendJsonRpc(socketPath, 'executeDBQuery', { + sql: 'SELECT key, value FROM kv LIMIT 5', }); - it('dispatches ls via ref', async () => { - const { socketPath, selfRef } = await bootWithSelfRef(); - - const response = await sendCommand(socketPath, { - ref: selfRef, - method: 'ls', - }); - - expect(response.ok).toBe(true); - const result = response.result as { refs: string[] }; - expect(Array.isArray(result.refs)).toBe(true); - }); + expect(response.error).toBeUndefined(); + expect(Array.isArray(response.result)).toBe(true); + }); - it('dispatches subclusters via ref', async () => { - const { socketPath, selfRef } = await bootWithSelfRef(); + it('handles sequential requests on separate connections', async () => { + const socketPath = await bootDaemonStack(); - const response = await sendCommand(socketPath, { - ref: selfRef, - method: 'subclusters', - }); + const response1 = await sendJsonRpc(socketPath, 'getStatus'); + expect(response1.error).toBeUndefined(); + expect(response1.result).toBeDefined(); - expect(response.ok).toBe(true); - expect(Array.isArray(response.result)).toBe(true); - }); + const response2 = await sendJsonRpc(socketPath, 'getStatus'); + expect(response2.error).toBeUndefined(); + expect(response2.result).toBeDefined(); + }); - it('dispatches invoke to call method on a ref through the kernel', async () => { - const { socketPath, selfRef } = await bootWithSelfRef(); + it('terminates all vats', async () => { + const socketPath = await bootDaemonStack(); - // Use invoke to call 'ls' on the self-ref (goes through getPresence + E()) - const response = await sendCommand(socketPath, { - ref: selfRef, - method: 'invoke', - args: [selfRef, 'ls'], - }); + const response = await sendJsonRpc(socketPath, 'terminateAllVats'); - expect(response.ok).toBe(true); - const result = response.result as { refs: string[] }; - expect(Array.isArray(result.refs)).toBe(true); - }); + expect(response.error).toBeUndefined(); + }); - it('returns error when invoke targets unknown ref', async () => { - const { socketPath, selfRef } = await bootWithSelfRef(); + it('returns proper JSON-RPC error structure', async () => { + const socketPath = await bootDaemonStack(); - const response = await sendCommand(socketPath, { - ref: selfRef, - method: 'invoke', - args: ['d-999', 'someMethod'], - }); + const response = await sendJsonRpc(socketPath, 'nonexistent'); - expect(response.ok).toBe(false); - expect(response.error).toContain('Unknown ref: d-999'); + expect(response).toStrictEqual({ + jsonrpc: '2.0', + id: '1', + error: expect.objectContaining({ + code: expect.any(Number), + message: expect.any(String), + }), }); }); }); diff --git a/packages/nodejs/test/e2e/kernel-worker.test.ts b/packages/nodejs/test/e2e/kernel-worker.test.ts index 7573bf33e..31e33cf1b 100644 --- a/packages/nodejs/test/e2e/kernel-worker.test.ts +++ b/packages/nodejs/test/e2e/kernel-worker.test.ts @@ -17,7 +17,7 @@ describe('Kernel Worker', () => { const testVatIds = ['v1', 'v2', 'v3'].sort(); beforeEach(async () => { - kernel = await makeKernel({}); + ({ kernel } = await makeKernel({})); }); afterEach(async () => { diff --git a/packages/nodejs/test/e2e/remote-comms.test.ts b/packages/nodejs/test/e2e/remote-comms.test.ts index 15c082618..2b9ae154d 100644 --- a/packages/nodejs/test/e2e/remote-comms.test.ts +++ b/packages/nodejs/test/e2e/remote-comms.test.ts @@ -1,9 +1,9 @@ import type { Libp2p } from '@libp2p/interface'; import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/nodejs'; import { waitUntilQuiescent } from '@metamask/kernel-utils'; +import { startRelay } from '@metamask/kernel-utils/libp2p'; import { Kernel, kunser, makeKernelStore } from '@metamask/ocap-kernel'; import type { KRef } from '@metamask/ocap-kernel'; -import { startRelay } from '@ocap/cli/relay'; import { delay } from '@ocap/repo-tools/test-utils'; import { describe, it, expect, beforeEach, afterEach } from 'vitest'; diff --git a/packages/nodejs/test/vats/system-console-vat.ts b/packages/nodejs/test/vats/system-console-vat.ts deleted file mode 100644 index 75da555db..000000000 --- a/packages/nodejs/test/vats/system-console-vat.ts +++ /dev/null @@ -1 +0,0 @@ -export { buildRootObject } from '@metamask/ocap-kernel/src/vats/system-console-vat.ts'; diff --git a/packages/ocap-kernel/package.json b/packages/ocap-kernel/package.json index 251fc37c6..1ac119bbe 100644 --- a/packages/ocap-kernel/package.json +++ b/packages/ocap-kernel/package.json @@ -109,7 +109,6 @@ "@metamask/eslint-config": "^15.0.0", "@metamask/eslint-config-nodejs": "^15.0.0", "@metamask/eslint-config-typescript": "^15.0.0", - "@ocap/cli": "workspace:^", "@ocap/repo-tools": "workspace:^", "@ts-bridge/cli": "^0.6.3", "@ts-bridge/shims": "^0.1.1", diff --git a/packages/ocap-kernel/src/vats/system-console-vat.test.ts b/packages/ocap-kernel/src/vats/system-console-vat.test.ts deleted file mode 100644 index d22086d28..000000000 --- a/packages/ocap-kernel/src/vats/system-console-vat.test.ts +++ /dev/null @@ -1,491 +0,0 @@ -import { makeDefaultExo } from '@metamask/kernel-utils/exo'; -import { describe, it, expect, beforeEach } from 'vitest'; - -import { buildRootObject } from './system-console-vat.ts'; - -/** - * Create a mock baggage store. - * - * @returns A mock baggage with has/get/set/init methods. - */ -function makeMockBaggage() { - const store = new Map(); - return { - has: (key: string) => store.has(key), - get: (key: string) => store.get(key), - set: (key: string, value: unknown) => store.set(key, value), - init: (key: string, value: unknown) => { - if (store.has(key)) { - throw new Error(`Key already exists: ${key}`); - } - store.set(key, value); - }, - }; -} - -/** - * Create a mock IO service with controllable read queue. - * - * @returns Mock IO service and control functions. - */ -function makeMockIOService() { - const readQueue: (string | null)[] = []; - const pendingReads: ((value: string | null) => void)[] = []; - const written: string[] = []; - - return { - ioService: makeDefaultExo('mockIOService', { - async read() { - const queued = readQueue.shift(); - if (queued !== undefined) { - return queued; - } - return new Promise((resolve) => { - pendingReads.push(resolve); - }); - }, - async write(data: string) { - written.push(data); - }, - }), - deliverLine(line: string): void { - const reader = pendingReads.shift(); - if (reader) { - reader(line); - } else { - readQueue.push(line); - } - }, - deliverEOF(): void { - const reader = pendingReads.shift(); - if (reader) { - reader(null); - } else { - readQueue.push(null); - } - }, - get written() { - return written; - }, - }; -} - -/** - * Create a mock kernel facet using plain functions (not vi.fn) to avoid - * SES lockdown issues with frozen mock internals. - * - * @returns A mock kernel facet and call trackers. - */ -function makeMockKernelFacet() { - const calls: Record = { - getStatus: [], - getSubclusters: [], - invokeMethod: [], - launchSubcluster: [], - terminateSubcluster: [], - }; - - const facet = makeDefaultExo('mockKernelFacet', { - async invokeMethod(...args: unknown[]) { - calls.invokeMethod.push(args); - return { mocked: true }; - }, - async getStatus(...args: unknown[]) { - calls.getStatus.push(args); - return { - incarnation: 1, - subclusters: 0, - vats: 1, - pendingMessages: 0, - }; - }, - async getSubclusters(...args: unknown[]) { - calls.getSubclusters.push(args); - return []; - }, - async launchSubcluster(...args: unknown[]) { - calls.launchSubcluster.push(args); - return { - subclusterId: 'sub-1', - rootKref: 'ko1', - bootstrapResult: undefined, - }; - }, - async terminateSubcluster(...args: unknown[]) { - calls.terminateSubcluster.push(args); - }, - }); - - return { facet, calls }; -} - -describe('system-console-vat', () => { - let baggage: ReturnType; - let kernelFacet: ReturnType; - let io: ReturnType; - - beforeEach(() => { - baggage = makeMockBaggage(); - kernelFacet = makeMockKernelFacet(); - io = makeMockIOService(); - }); - - describe('bootstrap', () => { - it('stores kernel facet in baggage', async () => { - const root = buildRootObject( - {}, - { name: 'test-console' }, - baggage as never, - ); - await root.bootstrap({}, { kernelFacet: kernelFacet.facet }); - expect(baggage.has('kernelFacet')).toBe(true); - }); - - it('starts REPL loop when console IO service is provided', async () => { - const root = buildRootObject( - {}, - { name: 'test-console' }, - baggage as never, - ); - await root.bootstrap( - {}, - { - kernelFacet: kernelFacet.facet, - console: io.ioService, - }, - ); - - // Give it a tick to start - await new Promise((resolve) => setTimeout(resolve, 10)); - - // Send a help command — if the REPL loop is running, it will respond - io.deliverLine(JSON.stringify({ method: 'help' })); - await new Promise((resolve) => setTimeout(resolve, 50)); - expect(io.written.length).toBeGreaterThan(0); - }); - }); - - describe('REPL dispatch (daemon tier)', () => { - async function setupRepl() { - const root = buildRootObject( - {}, - { name: 'test-console' }, - baggage as never, - ); - await root.bootstrap( - {}, - { - kernelFacet: kernelFacet.facet, - console: io.ioService, - }, - ); - await new Promise((resolve) => setTimeout(resolve, 10)); - return root; - } - - async function sendRequest(request: Record) { - io.deliverLine(JSON.stringify(request)); - await new Promise((resolve) => setTimeout(resolve, 50)); - const lastWrite = io.written[io.written.length - 1]; - return JSON.parse(lastWrite!) as { - ok: boolean; - result?: unknown; - error?: string; - }; - } - - it('dispatches help command with daemon-tier commands only', async () => { - await setupRepl(); - const response = await sendRequest({ method: 'help' }); - - expect(response.ok).toBe(true); - expect(response.result).toStrictEqual({ - commands: ['help - show available commands', 'status - daemon status'], - }); - }); - - it('dispatches status command returning liveness indicator', async () => { - await setupRepl(); - const response = await sendRequest({ method: 'status' }); - - expect(response).toStrictEqual({ - ok: true, - result: { running: true }, - }); - }); - - it.each(['launch', 'terminate', 'subclusters', 'ls', 'revoke', 'invoke'])( - 'returns "Unknown command" for privileged command "%s"', - async (method) => { - await setupRepl(); - const response = await sendRequest({ method }); - - expect(response.ok).toBe(false); - expect(response.error).toContain('Unknown command'); - }, - ); - - it('dispatches self-ref REPL command directly on root', async () => { - const root = await setupRepl(); - const ref = root.issueRef('ko-self', true); - const response = await sendRequest({ ref, method: 'help' }); - - expect(response.ok).toBe(true); - expect(response.result).toStrictEqual({ - commands: [ - 'help - show available commands', - 'status - kernel status', - 'subclusters - list subclusters', - 'launch - launch a subcluster', - 'terminate - terminate a subcluster', - 'ls - list all issued refs', - 'revoke - revoke a ref', - 'invoke [...args] - call a method on a ref', - ], - }); - // Should NOT have called invokeMethod — dispatch was direct - expect(kernelFacet.calls.invokeMethod).toHaveLength(0); - }); - - it('returns error for unknown method on self-ref', async () => { - const root = await setupRepl(); - const ref = root.issueRef('ko-self', true); - const response = await sendRequest({ ref, method: 'nonexistent' }); - - expect(response.ok).toBe(false); - expect(response.error).toContain('Unknown method on root'); - }); - - it('returns error for unknown command', async () => { - await setupRepl(); - const response = await sendRequest({ method: 'bogus' }); - - expect(response.ok).toBe(false); - expect(response.error).toContain('Unknown command'); - }); - - it('returns error for invalid JSON', async () => { - await setupRepl(); - io.deliverLine('not json'); - await new Promise((resolve) => setTimeout(resolve, 50)); - - const lastWrite = io.written[io.written.length - 1]; - const response = JSON.parse(lastWrite!) as { ok: boolean; error: string }; - expect(response.ok).toBe(false); - expect(response.error).toBeDefined(); - }); - - it('returns error for non-object request', async () => { - await setupRepl(); - const response = await sendRequest(42 as never); - expect(response.ok).toBe(false); - expect(response.error).toContain('Request must be a JSON object'); - }); - - it('returns error for request missing method', async () => { - await setupRepl(); - const response = await sendRequest({ ref: 'd-1' }); - expect(response.ok).toBe(false); - expect(response.error).toContain( - 'Request must have a string "method" field', - ); - }); - - it('returns error for non-string ref', async () => { - await setupRepl(); - const response = await sendRequest({ method: 'help', ref: 123 } as never); - expect(response.ok).toBe(false); - expect(response.error).toContain('"ref" must be a string'); - }); - - it('returns error for non-array args', async () => { - await setupRepl(); - const response = await sendRequest({ - method: 'help', - args: 'not-array', - } as never); - expect(response.ok).toBe(false); - expect(response.error).toContain('"args" must be an array'); - }); - - it('continues after EOF (client disconnect)', async () => { - await setupRepl(); - - // Send a command - const response1 = await sendRequest({ method: 'help' }); - expect(response1.ok).toBe(true); - - // Simulate disconnect - io.deliverEOF(); - await new Promise((resolve) => setTimeout(resolve, 20)); - - // Send another command (new connection) - const response2 = await sendRequest({ method: 'status' }); - expect(response2.ok).toBe(true); - }); - }); - - describe('privileged root object methods', () => { - async function setupRoot() { - const root = buildRootObject( - {}, - { name: 'test-console' }, - baggage as never, - ); - await root.bootstrap({}, { kernelFacet: kernelFacet.facet }); - return root; - } - - it('returns help with all privileged commands', async () => { - const root = await setupRoot(); - const result = root.help(); - expect(result.commands).toStrictEqual([ - 'help - show available commands', - 'status - kernel status', - 'subclusters - list subclusters', - 'launch - launch a subcluster', - 'terminate - terminate a subcluster', - 'ls - list all issued refs', - 'revoke - revoke a ref', - 'invoke [...args] - call a method on a ref', - ]); - }); - - it('returns kernel status', async () => { - const root = await setupRoot(); - const result = await root.status(); - - expect(result).toStrictEqual({ - incarnation: 1, - subclusters: 0, - vats: 1, - pendingMessages: 0, - }); - expect(kernelFacet.calls.getStatus).toHaveLength(1); - }); - - it('returns subclusters list', async () => { - const root = await setupRoot(); - const result = await root.subclusters(); - - expect(result).toStrictEqual([]); - expect(kernelFacet.calls.getSubclusters).toHaveLength(1); - }); - - it('launches subcluster and issues ref', async () => { - const root = await setupRoot(); - const config = { - bootstrap: 'test', - vats: { test: { bundleSpec: 'test-bundle' } }, - }; - const result = await root.launch(config); - - expect(result.ref).toMatch(/^d-\d+$/u); - expect(result.subclusterId).toBe('sub-1'); - expect(kernelFacet.calls.launchSubcluster).toHaveLength(1); - }); - - it('terminates subcluster', async () => { - const root = await setupRoot(); - const result = await root.terminate('sub-1'); - - expect(result).toStrictEqual({ ok: true }); - expect(kernelFacet.calls.terminateSubcluster).toHaveLength(1); - }); - - it('revokes a ref', async () => { - const root = await setupRoot(); - root.issueRef('ko1'); - - const result = root.revoke('d-1'); - expect(result).toStrictEqual({ ok: true }); - }); - - it('returns false when revoking unknown ref', async () => { - const root = await setupRoot(); - const result = root.revoke('d-999'); - expect(result).toStrictEqual({ ok: false }); - }); - - it('lists issued refs', async () => { - const root = await setupRoot(); - root.issueRef('ko1'); - root.issueRef('ko2'); - - const result = root.ls(); - expect(result.refs).toHaveLength(2); - expect(result.refs[0]).toMatch(/^d-\d+$/u); - expect(result.refs[1]).toMatch(/^d-\d+$/u); - }); - - it('returns empty list when no refs issued', async () => { - const root = await setupRoot(); - const result = root.ls(); - expect(result).toStrictEqual({ refs: [] }); - }); - - it('throws when invoke target ref is unknown', async () => { - const root = await setupRoot(); - await expect(root.invoke('d-999', 'transfer')).rejects.toThrow( - 'Unknown ref: d-999', - ); - }); - - it('throws when invoke is called without a target ref', async () => { - const root = await setupRoot(); - await expect(root.invoke('', 'transfer')).rejects.toThrow( - 'invoke requires a target ref', - ); - }); - - it('throws when invoke is called without a method', async () => { - const root = await setupRoot(); - const ref = root.issueRef('ko-wallet'); - await expect(root.invoke(ref, '')).rejects.toThrow( - 'invoke requires a method name', - ); - }); - }); - - describe('ref manager', () => { - it('issues idempotent refs for the same kref', async () => { - const root = buildRootObject( - {}, - { name: 'test-console' }, - baggage as never, - ); - await root.bootstrap({}, { kernelFacet: kernelFacet.facet }); - - const ref1 = root.issueRef('ko1'); - const ref2 = root.issueRef('ko1'); - expect(ref1).toBe(ref2); - expect(ref1).toMatch(/^d-\d+$/u); - }); - - it('issues different refs for different krefs', async () => { - const root = buildRootObject( - {}, - { name: 'test-console' }, - baggage as never, - ); - await root.bootstrap({}, { kernelFacet: kernelFacet.facet }); - - const ref1 = root.issueRef('ko1'); - const ref2 = root.issueRef('ko2'); - expect(ref1).not.toBe(ref2); - }); - - it('persists refs in baggage', async () => { - const root = buildRootObject( - {}, - { name: 'test-console' }, - baggage as never, - ); - await root.bootstrap({}, { kernelFacet: kernelFacet.facet }); - - root.issueRef('ko1'); - expect(baggage.has('refs')).toBe(true); - expect(baggage.has('krefToRef')).toBe(true); - }); - }); -}); diff --git a/packages/ocap-kernel/src/vats/system-console-vat.ts b/packages/ocap-kernel/src/vats/system-console-vat.ts deleted file mode 100644 index beb50c373..000000000 --- a/packages/ocap-kernel/src/vats/system-console-vat.ts +++ /dev/null @@ -1,508 +0,0 @@ -// eslint-disable-next-line import-x/no-extraneous-dependencies, n/no-extraneous-import -- vat dependency provided by kernel runtime -import { E } from '@endo/eventual-send'; -import { makeDefaultExo } from '@metamask/kernel-utils/exo'; - -import type { - Baggage, - ClusterConfig, - KernelStatus, - Subcluster, - SubclusterLaunchResult, -} from '../types.ts'; - -/** - * Kernel facet interface for system vat operations. - */ -type KernelFacet = { - getStatus: () => Promise; - getSubclusters: () => Promise; - invokeMethod: ( - target: string, - method: string, - args: unknown[], - ) => Promise; - launchSubcluster: (config: ClusterConfig) => Promise; - terminateSubcluster: (subclusterId: string) => Promise; -}; - -/** - * Services provided to the system console vat during bootstrap. - */ -type BootstrapServices = { - kernelFacet?: KernelFacet; - console?: IOService; -}; - -/** - * IO service interface for reading and writing lines. - */ -type IOService = { - read: () => Promise; - write: (data: string) => Promise; -}; - -/** - * A JSON request from the CLI. - */ -type Request = { - ref?: string; - method: string; - args?: unknown[]; -}; - -/** - * Build function for the system console vat. - * - * This vat manages the REPL loop over an IO channel, dispatching CLI - * commands and managing refs (capability references) in persistent baggage. - * - * @param _vatPowers - The vat powers (unused). - * @param _parameters - The vat parameters (unused). - * @param _parameters.name - Optional name for the console vat. - * @param baggage - The vat's persistent baggage storage. - * @returns The root object for the new vat. - */ -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export function buildRootObject( - _vatPowers: unknown, - _parameters: { name?: string }, - baggage: Baggage, -) { - /** - * Get a value from baggage, or return a fallback if the key is absent. - * - * @param key - The baggage key. - * @param fallback - The value to return if the key is absent. - * @returns The stored value or the fallback. - */ - function baggageGet(key: string, fallback: T): T { - return baggage.has(key) ? (baggage.get(key) as T) : fallback; - } - - /** - * Set a value in baggage, initialising the key if it doesn't exist. - * - * @param key - The baggage key. - * @param value - The value to store. - */ - function baggagePut(key: string, value: unknown): void { - if (baggage.has(key)) { - baggage.set(key, value); - } else { - baggage.init(key, value); - } - } - - // Monotonic counter for generating unique ref identifiers (persisted in baggage) - let refCounter: number = baggageGet('refCounter', 0); - // Restore kernel facet from baggage if available (for resuscitation) - let kernelFacet: KernelFacet | undefined = baggageGet< - KernelFacet | undefined - >('kernelFacet', undefined); - - // Track which kref is the root's own, so isSelf-ref dispatch avoids kernel round-trip - let selfKref: string | undefined = baggageGet( - 'selfKref', - undefined, - ); - - // Ref manager state in baggage: ref → kref and kref → ref maps - // Stored as plain objects since baggage serializes them - const refs: Record = baggageGet( - 'refs', - {} as Record, - ); - const krefToRef: Record = baggageGet( - 'krefToRef', - {} as Record, - ); - - /** - * Persist the current ref state to baggage. - */ - function persistRefs(): void { - baggagePut('refs', harden({ ...refs })); - baggagePut('krefToRef', harden({ ...krefToRef })); - } - - /** - * Issue a ref for a kref. If the kref already has a ref, return it. - * - * @param kref - The kernel reference. - * @param isSelf - If true, marks this kref as the root's own for direct dispatch. - * @returns The issued ref. - */ - function issueRef(kref: string, isSelf?: boolean): string { - if (isSelf) { - selfKref = kref; - baggagePut('selfKref', selfKref); - } - const existing = krefToRef[kref]; - if (existing) { - return existing; - } - refCounter += 1; - baggagePut('refCounter', refCounter); - const ref = `d-${refCounter}`; - refs[ref] = kref; - krefToRef[kref] = ref; - persistRefs(); - return ref; - } - - /** - * Look up the kref for a ref. - * - * @param ref - The ref to look up. - * @returns The kref, or undefined if not found. - */ - function lookupKref(ref: string): string | undefined { - return refs[ref]; - } - - /** - * Revoke a ref, removing it from both maps. - * - * @param ref - The ref to revoke. - * @returns True if the ref was found and revoked. - */ - function revokeRef(ref: string): boolean { - const kref = refs[ref]; - if (!kref) { - return false; - } - delete refs[ref]; - delete krefToRef[kref]; - persistRefs(); - return true; - } - - /** - * List all issued refs. - * - * @returns Array of ref/kref pairs. - */ - function listRefs(): { ref: string; kref: string }[] { - return Object.entries(refs).map(([ref, kref]) => ({ ref, kref })); - } - - /** - * Get the kernel facet, throwing if not yet bootstrapped. - * - * @returns The kernel facet. - */ - function requireKernelFacet(): KernelFacet { - if (!kernelFacet) { - throw new Error('Kernel facet not available (bootstrap not called?)'); - } - return kernelFacet; - } - - /** - * Dispatch a method call on the root exo directly, bypassing the kernel. - * - * @param method - The method name. - * @param args - The method arguments. - * @returns The result of the method call. - */ - function dispatchOnSelf(method: string, args: unknown[]): unknown { - // eslint-disable-next-line @typescript-eslint/no-use-before-define - const fn = root[method as keyof typeof root] as - | ((...a: unknown[]) => unknown) - | undefined; - if (typeof fn !== 'function') { - throw new Error(`Unknown method on root: ${method}`); - } - // eslint-disable-next-line @typescript-eslint/no-use-before-define - return fn.call(root, ...args); - } - - /** - * Dispatch a request that has no ref (daemon-tier commands only). - * - * Only basic liveness commands are available without a capability ref. - * Privileged operations require a ref obtained from the `.ocap` file. - * - * @param method - The method name. - * @param _args - The method arguments (unused for daemon-tier commands). - * @returns The response payload. - */ - async function dispatchConsoleMethod( - method: string, - _args: unknown[], - ): Promise { - switch (method) { - case 'help': - return { - commands: [ - 'help - show available commands', - 'status - daemon status', - ], - }; - - case 'status': - return { running: true }; - - default: - throw new Error(`Unknown command: ${method}`); - } - } - - /** - * Handle a single parsed request and return the response. - * - * @param request - The parsed request. - * @returns The response payload. - */ - async function handleRequest(request: Request): Promise { - const { ref, method, args = [] } = request; - - if (!ref) { - return dispatchConsoleMethod(method, args); - } - - // Ref-based dispatch: resolve ref → kref - const kref = lookupKref(ref); - if (!kref) { - throw new Error(`Unknown ref: ${ref}`); - } - - // Self-ref: dispatch directly to avoid kernel round-trip - if (kref === selfKref) { - return dispatchOnSelf(method, args); - } - - // External ref: dispatch through the kernel's message queue - return E(requireKernelFacet()).invokeMethod(kref, method, args); - } - - /** - * Validate and coerce a parsed JSON value into a {@link Request}. - * - * @param parsed - The raw parsed JSON value. - * @returns The validated request. - */ - function validateRequest(parsed: unknown): Request { - if (typeof parsed !== 'object' || parsed === null) { - throw new Error('Request must be a JSON object'); - } - const obj = parsed as Record; - if (typeof obj.method !== 'string') { - throw new Error('Request must have a string "method" field'); - } - if (obj.ref !== undefined && typeof obj.ref !== 'string') { - throw new Error('"ref" must be a string'); - } - if (obj.args !== undefined && !Array.isArray(obj.args)) { - throw new Error('"args" must be an array'); - } - return { - method: obj.method, - ...(typeof obj.ref === 'string' ? { ref: obj.ref } : {}), - ...(Array.isArray(obj.args) ? { args: obj.args as unknown[] } : {}), - }; - } - - /** - * Run the REPL loop: read a JSON line, dispatch, write response, repeat. - * - * @param ioService - The IO service to read/write from. - */ - async function runReplLoop(ioService: IOService): Promise { - for (;;) { - const line = await E(ioService).read(); - if (line === null) { - // Client disconnected — wait for next connection - continue; - } - - let response: unknown; - try { - const request = validateRequest(JSON.parse(line)); - const result = await handleRequest(request); - response = { ok: true, result }; - } catch (error) { - // Errors crossing vat boundaries may arrive as plain objects. - // Try multiple strategies to extract a human-readable message. - let errorMessage: string; - if (error instanceof Error) { - errorMessage = error.message ?? error.stack ?? String(error); - } else if (typeof error === 'string') { - errorMessage = error; - } else { - try { - errorMessage = JSON.stringify(error); - } catch { - errorMessage = String(error); - } - } - response = { ok: false, error: errorMessage }; - } - - try { - await E(ioService).write(JSON.stringify(response)); - } catch { - // Write failed (client disconnected mid-response) — continue loop - } - } - } - - const root = makeDefaultExo('root', { - /** - * Bootstrap the vat. - * - * @param _vats - The vats object (unused). - * @param services - The services object containing kernelFacet and console IO. - */ - async bootstrap( - _vats: unknown, - services: BootstrapServices, - ): Promise { - if (!kernelFacet && services.kernelFacet) { - kernelFacet = services.kernelFacet; - baggagePut('kernelFacet', kernelFacet); - } - - if (services.console) { - // Fire-and-forget the REPL loop — it runs indefinitely - // eslint-disable-next-line no-console -- vat diagnostic output - runReplLoop(services.console).catch(console.error); - } - }, - - /** - * Issue a ref for a kref. Exposed for the daemon to get the initial console ref. - * - * @param kref - The kernel reference. - * @param isSelf - If true, marks this kref as the root's own for direct dispatch. - * @returns The issued ref. - */ - issueRef(kref: string, isSelf?: boolean): string { - return issueRef(kref, isSelf); - }, - - /** - * Get help information (privileged — lists all available commands). - * - * @returns The help object. - */ - help() { - return harden({ - commands: [ - 'help - show available commands', - 'status - kernel status', - 'subclusters - list subclusters', - 'launch - launch a subcluster', - 'terminate - terminate a subcluster', - 'ls - list all issued refs', - 'revoke - revoke a ref', - 'invoke [...args] - call a method on a ref', - ], - }); - }, - - /** - * Get kernel status. - * - * @returns The kernel status. - */ - async status(): Promise { - return E(requireKernelFacet()).getStatus(); - }, - - /** - * List subclusters. - * - * @returns The subclusters. - */ - async subclusters(): Promise { - return E(requireKernelFacet()).getSubclusters(); - }, - - /** - * Launch a subcluster and issue a ref for its root object. - * - * @param config - The cluster config. - * @returns The issued ref and subcluster ID. - */ - async launch( - config: ClusterConfig, - ): Promise<{ ref: string; subclusterId: string }> { - if (!config) { - throw new Error('launch requires a config argument'); - } - const result = await E(requireKernelFacet()).launchSubcluster(config); - const ref = issueRef(result.rootKref); - return harden({ ref, subclusterId: result.subclusterId }); - }, - - /** - * Terminate a subcluster. - * - * @param subclusterId - The subcluster ID. - * @returns Confirmation. - */ - async terminate(subclusterId: string): Promise<{ ok: true }> { - if (!subclusterId) { - throw new Error('terminate requires a subclusterId argument'); - } - await E(requireKernelFacet()).terminateSubcluster(subclusterId); - return harden({ ok: true as const }); - }, - - /** - * Revoke a ref. - * - * @param ref - The ref to revoke. - * @returns Whether the ref was found and revoked. - */ - revoke(ref: string): { ok: boolean } { - if (!ref) { - throw new Error('revoke requires a ref argument'); - } - return harden({ ok: revokeRef(ref) }); - }, - - /** - * List all issued refs. - * - * @returns Array of ref strings. - */ - ls(): { refs: string[] } { - return harden({ refs: listRefs().map((entry) => entry.ref) }); - }, - - /** - * Invoke a method on a target ref, forwarding pure-data arguments - * through the kernel. - * - * @param targetRef - The ref to invoke the method on. - * @param method - The method name. - * @param args - The method arguments. - * @returns The result of the method call. - */ - async invoke( - targetRef: string, - method: string, - ...args: unknown[] - ): Promise { - if (!targetRef) { - throw new Error('invoke requires a target ref'); - } - if (!method) { - throw new Error('invoke requires a method name'); - } - const kref = lookupKref(targetRef); - if (!kref) { - throw new Error(`Unknown ref: ${targetRef}`); - } - // Self-ref: dispatch directly to avoid kernel round-trip - if (kref === selfKref) { - return dispatchOnSelf(method, args); - } - return E(requireKernelFacet()).invokeMethod(kref, method, args); - }, - }); - - return root; -} diff --git a/packages/omnium-gatherum/package.json b/packages/omnium-gatherum/package.json index 8635e1d6b..7e228ed4b 100644 --- a/packages/omnium-gatherum/package.json +++ b/packages/omnium-gatherum/package.json @@ -68,7 +68,6 @@ "@metamask/eslint-config": "^15.0.0", "@metamask/eslint-config-nodejs": "^15.0.0", "@metamask/eslint-config-typescript": "^15.0.0", - "@ocap/cli": "workspace:^", "@ocap/repo-tools": "workspace:^", "@playwright/test": "^1.57.0", "@testing-library/dom": "^10.4.0", diff --git a/packages/omnium-gatherum/scripts/start.sh b/packages/omnium-gatherum/scripts/start.sh index 7b7d41d9b..c17674d10 100755 --- a/packages/omnium-gatherum/scripts/start.sh +++ b/packages/omnium-gatherum/scripts/start.sh @@ -4,7 +4,7 @@ set -x set -e set -o pipefail -yarn ocap relay & +yarn run -T ocap relay & RELAY_PID=$! function cleanup() { diff --git a/yarn.lock b/yarn.lock index d3b4fa063..ebe1630da 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2658,11 +2658,21 @@ __metadata: resolution: "@metamask/kernel-utils@workspace:packages/kernel-utils" dependencies: "@arethetypeswrong/cli": "npm:^0.17.4" + "@chainsafe/libp2p-noise": "npm:^16.1.3" + "@chainsafe/libp2p-yamux": "patch:@chainsafe/libp2p-yamux@npm%3A7.0.4#~/.yarn/patches/@chainsafe-libp2p-yamux-npm-7.0.4-284c2f6812.patch" "@endo/captp": "npm:^4.4.8" "@endo/errors": "npm:^1.2.13" "@endo/exo": "npm:^1.5.12" "@endo/patterns": "npm:^1.7.0" "@endo/promise-kit": "npm:^1.1.13" + "@libp2p/autonat": "npm:2.0.38" + "@libp2p/circuit-relay-v2": "npm:3.2.24" + "@libp2p/crypto": "npm:5.1.8" + "@libp2p/identify": "npm:3.0.39" + "@libp2p/interface": "npm:2.11.0" + "@libp2p/ping": "npm:2.0.37" + "@libp2p/tcp": "npm:10.1.19" + "@libp2p/websockets": "npm:9.2.19" "@metamask/auto-changelog": "npm:^5.3.0" "@metamask/eslint-config": "npm:^15.0.0" "@metamask/eslint-config-nodejs": "npm:^15.0.0" @@ -2688,6 +2698,7 @@ __metadata: eslint-plugin-prettier: "npm:^5.2.6" eslint-plugin-promise: "npm:^7.2.1" jsdom: "npm:^27.4.0" + libp2p: "npm:2.10.0" prettier: "npm:^3.5.3" rimraf: "npm:^6.0.1" ses: "npm:^1.14.0" @@ -2817,7 +2828,6 @@ __metadata: "@metamask/superstruct": "npm:^3.2.1" "@metamask/utils": "npm:^11.9.0" "@multiformats/multiaddr": "npm:^12.4.4" - "@ocap/cli": "workspace:^" "@ocap/kernel-platforms": "workspace:^" "@ocap/repo-tools": "workspace:^" "@scure/bip39": "npm:^2.0.1" @@ -3443,17 +3453,7 @@ __metadata: resolution: "@ocap/cli@workspace:packages/cli" dependencies: "@arethetypeswrong/cli": "npm:^0.17.4" - "@chainsafe/libp2p-noise": "npm:^16.1.3" - "@chainsafe/libp2p-yamux": "patch:@chainsafe/libp2p-yamux@npm%3A7.0.4#~/.yarn/patches/@chainsafe-libp2p-yamux-npm-7.0.4-284c2f6812.patch" "@endo/promise-kit": "npm:^1.1.13" - "@libp2p/autonat": "npm:2.0.38" - "@libp2p/circuit-relay-v2": "npm:3.2.24" - "@libp2p/crypto": "npm:5.1.8" - "@libp2p/identify": "npm:3.0.39" - "@libp2p/interface": "npm:2.11.0" - "@libp2p/ping": "npm:2.0.37" - "@libp2p/tcp": "npm:10.1.19" - "@libp2p/websockets": "npm:9.2.19" "@metamask/auto-changelog": "npm:^5.3.0" "@metamask/eslint-config": "npm:^15.0.0" "@metamask/eslint-config-nodejs": "npm:^15.0.0" @@ -3462,6 +3462,7 @@ __metadata: "@metamask/kernel-utils": "workspace:^" "@metamask/logger": "workspace:^" "@metamask/utils": "npm:^11.9.0" + "@ocap/nodejs": "workspace:^" "@ocap/repo-tools": "workspace:^" "@ts-bridge/cli": "npm:^0.6.3" "@ts-bridge/shims": "npm:^0.1.1" @@ -3484,7 +3485,6 @@ __metadata: eslint-plugin-promise: "npm:^7.2.1" glob: "npm:^11.0.0" jsdom: "npm:^27.4.0" - libp2p: "npm:2.10.0" prettier: "npm:^3.5.3" rimraf: "npm:^6.0.1" serve-handler: "npm:^6.1.6" @@ -3560,7 +3560,6 @@ __metadata: "@metamask/logger": "workspace:^" "@metamask/ocap-kernel": "workspace:^" "@metamask/streams": "workspace:^" - "@ocap/cli": "workspace:^" "@ocap/kernel-test": "workspace:^" "@ocap/repo-tools": "workspace:^" "@playwright/test": "npm:^1.57.0" @@ -3827,7 +3826,6 @@ __metadata: "@metamask/kernel-utils": "workspace:^" "@metamask/logger": "workspace:^" "@metamask/ocap-kernel": "workspace:^" - "@ocap/cli": "workspace:^" "@ocap/kernel-language-model-service": "workspace:^" "@ocap/nodejs": "workspace:^" "@ocap/nodejs-test-workers": "workspace:^" @@ -3970,13 +3968,13 @@ __metadata: "@metamask/eslint-config": "npm:^15.0.0" "@metamask/eslint-config-nodejs": "npm:^15.0.0" "@metamask/eslint-config-typescript": "npm:^15.0.0" + "@metamask/kernel-rpc-methods": "workspace:^" "@metamask/kernel-shims": "workspace:^" "@metamask/kernel-store": "workspace:^" "@metamask/kernel-utils": "workspace:^" "@metamask/logger": "workspace:^" "@metamask/ocap-kernel": "workspace:^" "@metamask/streams": "workspace:^" - "@ocap/cli": "workspace:^" "@ocap/kernel-platforms": "workspace:^" "@ocap/repo-tools": "workspace:^" "@ts-bridge/cli": "npm:^0.6.3" @@ -4029,7 +4027,6 @@ __metadata: "@metamask/streams": "workspace:^" "@metamask/superstruct": "npm:^3.2.1" "@metamask/utils": "npm:^11.9.0" - "@ocap/cli": "workspace:^" "@ocap/repo-tools": "workspace:^" "@playwright/test": "npm:^1.57.0" "@testing-library/dom": "npm:^10.4.0" From f023fc29e4be9451b99e85efc17f82e0cfa6b1d8 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Wed, 18 Feb 2026 21:03:51 -0800 Subject: [PATCH 07/33] fix(cli,nodejs): daemon design fixes and known-limitations docs - Preserve kernel state across restarts (resetStorage: false) - Clean up stale socket files before listen - Add socket-based shutdown RPC with PID+SIGTERM fallback - Stop daemon before flushing state in begone handler - Narrow sendCommand retry to ECONNREFUSED/ECONNRESET only - Replace bare socket probe with getStatus RPC ping - Use JsonRpcResponse from @metamask/utils with runtime validation - Extract shared readLine/writeLine into socket-line.ts - Document 6 known limitations in CLI readme Co-Authored-By: Claude Opus 4.6 --- packages/cli/README.md | 11 ++ packages/cli/src/commands/daemon-client.ts | 109 ++++-------------- packages/cli/src/commands/daemon-entry.ts | 27 ++--- packages/cli/src/commands/daemon-spawn.ts | 6 +- packages/cli/src/ok.ts | 100 +++++++++------- packages/nodejs/src/daemon/index.ts | 1 + .../nodejs/src/daemon/rpc-socket-server.ts | 58 +++++++++- packages/nodejs/src/daemon/socket-line.ts | 80 +++++++++++++ packages/nodejs/src/daemon/start-daemon.ts | 5 +- packages/nodejs/test/e2e/daemon-stack.test.ts | 61 ++-------- 10 files changed, 257 insertions(+), 201 deletions(-) create mode 100644 packages/nodejs/src/daemon/socket-line.ts diff --git a/packages/cli/README.md b/packages/cli/README.md index d7b7b1ac9..83d02abd6 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -24,6 +24,17 @@ Bundle all `.js` files in the target dir, watch for changes to `.js` files and r Starts a libp2p relay. +## Known Limitations + +The `ok` CLI and its daemon are prototypes. The following limitations apply: + +1. **`executeDBQuery` accepts arbitrary SQL** — any CLI user can execute unrestricted SQL against the kernel database. For production, this should be removed or restricted to read-only queries. +2. **No socket permission enforcement** — the Unix socket is created with default permissions. Any local user can connect and issue commands. For production, socket permissions should be restricted to `0o600`. +3. **No daemon spawn concurrency protection** — if two CLI invocations run simultaneously and neither finds a running daemon, both may attempt to spawn one. A lockfile mechanism would prevent this. +4. **No request size limits** — the RPC server buffers incoming data without a size cap. A malicious client could exhaust daemon memory. +5. **No log rotation** — `daemon.log` grows without bound. Production use should add log rotation. +6. **No `--help` or `--version`** — the `ok` CLI has these explicitly disabled. + ## Contributing This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/ocap-kernel#readme). diff --git a/packages/cli/src/commands/daemon-client.ts b/packages/cli/src/commands/daemon-client.ts index f9b2ce653..98b28f91b 100644 --- a/packages/cli/src/commands/daemon-client.ts +++ b/packages/cli/src/commands/daemon-client.ts @@ -1,3 +1,6 @@ +import type { JsonRpcResponse } from '@metamask/utils'; +import { assertIsJsonRpcResponse } from '@metamask/utils'; +import { readLine, writeLine } from '@ocap/nodejs/daemon'; import { randomUUID } from 'node:crypto'; import { createConnection } from 'node:net'; import type { Socket } from 'node:net'; @@ -31,85 +34,6 @@ async function connectSocket(socketPath: string): Promise { }); } -/** - * Read a single newline-delimited line from a socket. - * - * @param socket - The socket to read from. - * @returns The line read. - */ -async function readLine(socket: Socket): Promise { - return new Promise((resolve, reject) => { - let buffer = ''; - - const timer = setTimeout(() => { - cleanup(); - reject(new Error('Daemon response timed out')); - }, READ_TIMEOUT_MS); - - /** - * Remove all listeners and clear the timeout. - */ - function cleanup(): void { - clearTimeout(timer); - socket.removeAllListeners('data'); - socket.removeAllListeners('error'); - socket.removeAllListeners('end'); - socket.removeAllListeners('close'); - } - - const onData = (data: Buffer): void => { - buffer += data.toString(); - const idx = buffer.indexOf('\n'); - if (idx !== -1) { - cleanup(); - resolve(buffer.slice(0, idx)); - } - }; - - socket.on('data', onData); - socket.once('error', (error) => { - cleanup(); - reject(error); - }); - socket.once('end', () => { - cleanup(); - reject(new Error('Socket closed before response received')); - }); - socket.once('close', () => { - cleanup(); - reject(new Error('Socket closed before response received')); - }); - }); -} - -/** - * Write a newline-delimited line to a socket. - * - * @param socket - The socket to write to. - * @param line - The line to write. - */ -async function writeLine(socket: Socket, line: string): Promise { - return new Promise((resolve, reject) => { - socket.write(`${line}\n`, (error) => { - if (error) { - reject(error); - } else { - resolve(); - } - }); - }); -} - -/** - * A JSON-RPC 2.0 response. - */ -export type JsonRpcResponse = { - jsonrpc: '2.0'; - id: string | null; - result?: unknown; - error?: { code: number; message: string }; -}; - /** * Send a JSON-RPC request to the daemon over a UNIX socket and return the response. * @@ -139,8 +63,10 @@ export async function sendCommand( const socket = await connectSocket(socketPath); try { await writeLine(socket, JSON.stringify(request)); - const responseLine = await readLine(socket); - return JSON.parse(responseLine) as JsonRpcResponse; + const responseLine = await readLine(socket, READ_TIMEOUT_MS); + const parsed: unknown = JSON.parse(responseLine); + assertIsJsonRpcResponse(parsed); + return parsed; } finally { socket.destroy(); } @@ -148,24 +74,29 @@ export async function sendCommand( try { return await attempt(); - } catch { - // Retry once after a short delay — the daemon's socket may - // still be cleaning up a previous probe connection. + } catch (error: unknown) { + // Retry once on connection errors only — the daemon's socket may + // still be cleaning up a previous connection. + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (code !== 'ECONNREFUSED' && code !== 'ECONNRESET') { + throw error; + } await new Promise((resolve) => setTimeout(resolve, 100)); return attempt(); } } /** - * Check whether the daemon is running by probing the socket. + * Check whether the daemon is running by sending a lightweight `getStatus` + * RPC call. Unlike a bare socket probe, this avoids spurious connect/disconnect + * noise on the server. * * @param socketPath - The UNIX socket path. - * @returns True if the daemon socket accepts a connection. + * @returns True if the daemon responds to the RPC call. */ -export async function isDaemonRunning(socketPath: string): Promise { +export async function pingDaemon(socketPath: string): Promise { try { - const socket = await connectSocket(socketPath); - socket.destroy(); + await sendCommand(socketPath, 'getStatus'); return true; } catch { return false; diff --git a/packages/cli/src/commands/daemon-entry.ts b/packages/cli/src/commands/daemon-entry.ts index b277a1653..c2de8d83b 100644 --- a/packages/cli/src/commands/daemon-entry.ts +++ b/packages/cli/src/commands/daemon-entry.ts @@ -42,31 +42,32 @@ async function main(): Promise { process.env.OCAP_SOCKET_PATH ?? join(ocapDir, 'console.sock'); const { kernel, kernelDatabase } = await makeKernel({ - resetStorage: true, + resetStorage: false, logger, }); await kernel.initIdentity(); - const handle = await startDaemon({ - socketPath, - kernel, - kernelDatabase, - }); - - // Write PID file so `ok daemon stop` can signal this process + // Write PID file so `ok daemon stop` can use it as a fallback const pidPath = join(ocapDir, 'daemon.pid'); await writeFile(pidPath, String(process.pid)); - logger.info(`Daemon started. Socket: ${handle.socketPath}`); - - // Keep the process alive - const shutdown = async (signal: string): Promise => { - logger.info(`Received ${signal}, shutting down...`); + const shutdown = async (reason: string): Promise => { + logger.info(`Shutting down (${reason})...`); + // eslint-disable-next-line @typescript-eslint/no-use-before-define -- shutdown is only called async, after handle is initialized await handle.close(); await rm(pidPath, { force: true }); process.exit(0); }; + const handle = await startDaemon({ + socketPath, + kernel, + kernelDatabase, + onShutdown: async () => shutdown('RPC shutdown'), + }); + + logger.info(`Daemon started. Socket: ${handle.socketPath}`); + process.on('SIGTERM', () => { shutdown('SIGTERM').catch(() => process.exit(1)); }); diff --git a/packages/cli/src/commands/daemon-spawn.ts b/packages/cli/src/commands/daemon-spawn.ts index 0b9e128f9..1fcd99050 100644 --- a/packages/cli/src/commands/daemon-spawn.ts +++ b/packages/cli/src/commands/daemon-spawn.ts @@ -2,7 +2,7 @@ import { spawn } from 'node:child_process'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { isDaemonRunning } from './daemon-client.ts'; +import { pingDaemon } from './daemon-client.ts'; const POLL_INTERVAL_MS = 100; const MAX_POLLS = 300; // 30 seconds @@ -14,7 +14,7 @@ const MAX_POLLS = 300; // 30 seconds * @param socketPath - The UNIX socket path. */ export async function ensureDaemon(socketPath: string): Promise { - if (await isDaemonRunning(socketPath)) { + if (await pingDaemon(socketPath)) { return; } @@ -36,7 +36,7 @@ export async function ensureDaemon(socketPath: string): Promise { // Poll until daemon responds for (let i = 0; i < MAX_POLLS; i++) { await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); - if (await isDaemonRunning(socketPath)) { + if (await pingDaemon(socketPath)) { process.stderr.write('Daemon ready.\n'); return; } diff --git a/packages/cli/src/ok.ts b/packages/cli/src/ok.ts index 14b09def0..e9e45c8f7 100644 --- a/packages/cli/src/ok.ts +++ b/packages/cli/src/ok.ts @@ -1,5 +1,6 @@ /* eslint-disable n/no-process-exit */ import '@metamask/kernel-shims/endoify-node'; +import { isJsonRpcFailure } from '@metamask/utils'; import { flushDaemon } from '@ocap/nodejs/daemon'; import { readFile, rm } from 'node:fs/promises'; import { homedir } from 'node:os'; @@ -10,7 +11,7 @@ import { hideBin } from 'yargs/helpers'; import { getSocketPath, - isDaemonRunning, + pingDaemon, sendCommand, } from './commands/daemon-client.ts'; import { ensureDaemon } from './commands/daemon-spawn.ts'; @@ -55,7 +56,7 @@ async function handleInvoke(args: string[], socketPath: string): Promise { const response = await sendCommand(socketPath, method, params); - if (response.error) { + if (isJsonRpcFailure(response)) { process.stderr.write( `Error: ${response.error.message} (code ${String(response.error.code)})\n`, ); @@ -114,64 +115,78 @@ function resolveBundleSpecs(config: { } /** - * Handle daemon management commands. + * Stop the daemon via a `shutdown` RPC call. Falls back to PID + SIGTERM if + * the socket is unresponsive. * - * @param args - CLI arguments after `ok daemon`. * @param socketPath - The daemon socket path. */ -async function handleDaemon(args: string[], socketPath: string): Promise { - const subcommand = args[0]; - - if (subcommand === 'stop') { - if (!(await isDaemonRunning(socketPath))) { - process.stderr.write('Daemon is not running.\n'); - return; - } +async function stopDaemon(socketPath: string): Promise { + if (!(await pingDaemon(socketPath))) { + process.stderr.write('Daemon is not running.\n'); + return; + } - const pidPath = join(homedir(), '.ocap', 'daemon.pid'); + process.stderr.write('Stopping daemon...\n'); - let pid: number | undefined; - try { - pid = Number(await readFile(pidPath, 'utf-8')); - } catch { - // PID file missing — fall back to manual instructions - } + // Try socket-based shutdown first. + try { + await sendCommand(socketPath, 'shutdown'); + } catch { + // Socket unresponsive — fall back to PID + SIGTERM below. + } - if (!pid || Number.isNaN(pid)) { - process.stderr.write( - 'PID file not found. Stop the daemon manually:\n' + - ` kill $(lsof -t ${tildify(socketPath)})\n`, - ); + // Poll until socket stops responding (max 5s). + const pollEnd = Date.now() + 5_000; + while (Date.now() < pollEnd) { + await new Promise((_resolve) => setTimeout(_resolve, 250)); + if (!(await pingDaemon(socketPath))) { + process.stderr.write('Daemon stopped.\n'); return; } + } + + // Fallback: read PID file and send SIGTERM. + const pidPath = join(homedir(), '.ocap', 'daemon.pid'); + let pid: number | undefined; + try { + pid = Number(await readFile(pidPath, 'utf-8')); + } catch { + // PID file missing. + } - process.stderr.write('Stopping daemon...\n'); + if (pid && !Number.isNaN(pid)) { try { process.kill(pid, 'SIGTERM'); } catch { - process.stderr.write( - 'Failed to send SIGTERM (process may already be gone).\n', - ); - await rm(pidPath, { force: true }); - return; + // Process may already be gone. } - // Poll until socket stops responding (max 5s) - const pollEnd = Date.now() + 5_000; - while (Date.now() < pollEnd) { + // Poll again after SIGTERM. + const sigPollEnd = Date.now() + 5_000; + while (Date.now() < sigPollEnd) { await new Promise((_resolve) => setTimeout(_resolve, 250)); - if (!(await isDaemonRunning(socketPath))) { - break; + if (!(await pingDaemon(socketPath))) { + await rm(pidPath, { force: true }); + process.stderr.write('Daemon stopped.\n'); + return; } } + } - await rm(pidPath, { force: true }); + process.stderr.write('Daemon did not stop within timeout.\n'); +} - if (await isDaemonRunning(socketPath)) { - process.stderr.write('Daemon did not stop within 5 seconds.\n'); - } else { - process.stderr.write('Daemon stopped.\n'); - } +/** + * Handle daemon management commands. + * + * @param args - CLI arguments after `ok daemon`. + * @param socketPath - The daemon socket path. + */ +async function handleDaemon(args: string[], socketPath: string): Promise { + const subcommand = args[0]; + + if (subcommand === 'stop') { + await stopDaemon(socketPath); return; } @@ -184,6 +199,9 @@ async function handleDaemon(args: string[], socketPath: string): Promise { ); process.exit(1); } + if (await pingDaemon(socketPath)) { + await stopDaemon(socketPath); + } await flushDaemon({ socketPath }); process.stderr.write('All daemon state flushed.\n'); return; diff --git a/packages/nodejs/src/daemon/index.ts b/packages/nodejs/src/daemon/index.ts index e62d2511a..4b3581144 100644 --- a/packages/nodejs/src/daemon/index.ts +++ b/packages/nodejs/src/daemon/index.ts @@ -4,3 +4,4 @@ export { startRpcSocketServer } from './rpc-socket-server.ts'; export type { RpcSocketServerHandle } from './rpc-socket-server.ts'; export { flushDaemon } from './flush-daemon.ts'; export type { FlushDaemonOptions } from './flush-daemon.ts'; +export { readLine, writeLine } from './socket-line.ts'; diff --git a/packages/nodejs/src/daemon/rpc-socket-server.ts b/packages/nodejs/src/daemon/rpc-socket-server.ts index 0ceb21a00..137800d0a 100644 --- a/packages/nodejs/src/daemon/rpc-socket-server.ts +++ b/packages/nodejs/src/daemon/rpc-socket-server.ts @@ -2,6 +2,7 @@ import { RpcService } from '@metamask/kernel-rpc-methods'; import type { KernelDatabase } from '@metamask/kernel-store'; import type { Kernel } from '@metamask/ocap-kernel'; import { rpcHandlers } from '@metamask/ocap-kernel/rpc'; +import { unlink } from 'node:fs/promises'; import { createServer } from 'node:net'; import type { Server } from 'node:net'; @@ -18,20 +19,26 @@ export type RpcSocketServerHandle = { * Each connection reads one newline-delimited JSON-RPC request, processes it * via the kernel's RPC handlers, writes a JSON-RPC response, and closes. * + * The special `shutdown` method is intercepted before RPC dispatch and triggers + * the provided {@link onShutdown} callback (if any) after responding to the client. + * * @param options - Server options. * @param options.socketPath - The Unix socket path to listen on. * @param options.kernel - The kernel instance. * @param options.kernelDatabase - The kernel database instance. + * @param options.onShutdown - Optional callback invoked when a `shutdown` RPC is received. * @returns A handle with a `close()` function for cleanup. */ export async function startRpcSocketServer({ socketPath, kernel, kernelDatabase, + onShutdown, }: { socketPath: string; kernel: Kernel; kernelDatabase: KernelDatabase; + onShutdown?: (() => Promise) | undefined; }): Promise { const rpcService = new RpcService(rpcHandlers, { kernel, @@ -51,7 +58,7 @@ export async function startRpcSocketServer({ const line = buffer.slice(0, idx); buffer = ''; - processRequest(rpcService, line) + handleRequest(rpcService, line, onShutdown) .then((response) => { socket.end(`${JSON.stringify(response)}\n`); return undefined; @@ -85,6 +92,48 @@ export async function startRpcSocketServer({ }; } +/** + * Handle a single JSON-RPC request line, intercepting the `shutdown` method. + * + * If the method is `shutdown` and an `onShutdown` callback is provided, the + * callback is scheduled (without awaiting) after a successful response is + * returned. All other methods are delegated to {@link processRequest}. + * + * @param rpcService - The RPC service to execute methods against. + * @param line - The raw JSON line from the socket. + * @param onShutdown - Optional shutdown callback. + * @returns A JSON-RPC response object. + */ +async function handleRequest( + rpcService: RpcService, + line: string, + onShutdown?: () => Promise, +): Promise> { + try { + const request = JSON.parse(line) as { + id?: unknown; + method?: string; + }; + + if (request.method === 'shutdown') { + const id = request.id ?? null; + // Schedule shutdown after responding to the client. + if (onShutdown) { + setTimeout(() => { + onShutdown().catch(() => { + // Best-effort shutdown — errors are logged by the caller. + }); + }, 0); + } + return { jsonrpc: '2.0', id, result: { status: 'shutting down' } }; + } + } catch { + // Fall through to processRequest which handles parse errors. + } + + return processRequest(rpcService, line); +} + /** * Process a single JSON-RPC request line and return a JSON-RPC response. * @@ -153,6 +202,13 @@ function isRpcError(error: unknown): error is { code: number } { * @param socketPath - The Unix socket path. */ async function listen(server: Server, socketPath: string): Promise { + // Remove stale socket file from a previous run, if any. + try { + await unlink(socketPath); + } catch { + // Ignore — file may not exist. + } + return new Promise((resolve, reject) => { server.on('error', reject); server.listen(socketPath, () => { diff --git a/packages/nodejs/src/daemon/socket-line.ts b/packages/nodejs/src/daemon/socket-line.ts new file mode 100644 index 000000000..e4df04bdc --- /dev/null +++ b/packages/nodejs/src/daemon/socket-line.ts @@ -0,0 +1,80 @@ +import type { Socket } from 'node:net'; + +/** + * Write a newline-delimited line to a socket. + * + * @param socket - The socket to write to. + * @param line - The line to write (without trailing newline). + */ +export async function writeLine(socket: Socket, line: string): Promise { + return new Promise((resolve, reject) => { + socket.write(`${line}\n`, (error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); +} + +/** + * Read a single newline-delimited line from a socket. + * + * @param socket - The socket to read from. + * @param timeoutMs - Optional timeout in milliseconds. When provided, rejects + * with a timeout error if no complete line is received within the limit. + * @returns The line read (without trailing newline). + */ +export async function readLine( + socket: Socket, + timeoutMs?: number, +): Promise { + return new Promise((resolve, reject) => { + let buffer = ''; + let timer: ReturnType | undefined; + + if (timeoutMs !== undefined) { + timer = setTimeout(() => { + cleanup(); + reject(new Error('Socket read timed out')); + }, timeoutMs); + } + + /** + * Remove all listeners and clear the timeout. + */ + function cleanup(): void { + if (timer !== undefined) { + clearTimeout(timer); + } + socket.removeAllListeners('data'); + socket.removeAllListeners('error'); + socket.removeAllListeners('end'); + socket.removeAllListeners('close'); + } + + const onData = (data: Buffer): void => { + buffer += data.toString(); + const idx = buffer.indexOf('\n'); + if (idx !== -1) { + cleanup(); + resolve(buffer.slice(0, idx)); + } + }; + + socket.on('data', onData); + socket.once('error', (error) => { + cleanup(); + reject(error); + }); + socket.once('end', () => { + cleanup(); + reject(new Error('Socket closed before response received')); + }); + socket.once('close', () => { + cleanup(); + reject(new Error('Socket closed before response received')); + }); + }); +} diff --git a/packages/nodejs/src/daemon/start-daemon.ts b/packages/nodejs/src/daemon/start-daemon.ts index 76315f45b..fa50afa54 100644 --- a/packages/nodejs/src/daemon/start-daemon.ts +++ b/packages/nodejs/src/daemon/start-daemon.ts @@ -13,6 +13,8 @@ export type StartDaemonOptions = { kernel: Kernel; /** The kernel database instance. */ kernelDatabase: KernelDatabase; + /** Optional callback invoked when a `shutdown` RPC is received. */ + onShutdown?: () => Promise; }; /** @@ -36,12 +38,13 @@ export type DaemonHandle = { export async function startDaemon( options: StartDaemonOptions, ): Promise { - const { socketPath, kernel, kernelDatabase } = options; + const { socketPath, kernel, kernelDatabase, onShutdown } = options; const rpcServer = await startRpcSocketServer({ socketPath, kernel, kernelDatabase, + onShutdown, }); const close = async (): Promise => { diff --git a/packages/nodejs/test/e2e/daemon-stack.test.ts b/packages/nodejs/test/e2e/daemon-stack.test.ts index 090a868a3..fb3069619 100644 --- a/packages/nodejs/test/e2e/daemon-stack.test.ts +++ b/packages/nodejs/test/e2e/daemon-stack.test.ts @@ -7,7 +7,11 @@ import { join } from 'node:path'; import { describe, it, expect, afterEach } from 'vitest'; import type { RpcSocketServerHandle } from '../../src/daemon/index.ts'; -import { startRpcSocketServer } from '../../src/daemon/index.ts'; +import { + readLine, + startRpcSocketServer, + writeLine, +} from '../../src/daemon/index.ts'; import { makeTestKernel } from '../helpers/kernel.ts'; /** @@ -38,55 +42,6 @@ async function connectToSocket(socketPath: string): Promise { }); } -/** - * Write a newline-delimited line to a socket. - * - * @param socket - The socket. - * @param line - The line to write. - */ -async function writeLine(socket: net.Socket, line: string): Promise { - return new Promise((resolve, reject) => { - socket.write(`${line}\n`, (error) => { - if (error) { - reject(error); - } else { - resolve(); - } - }); - }); -} - -/** - * Read a newline-delimited line from a socket. - * - * @param socket - The socket. - * @returns The line read. - */ -async function readLine(socket: net.Socket): Promise { - return new Promise((resolve) => { - let buffer = ''; - const onData = (data: Buffer): void => { - buffer += data.toString(); - const idx = buffer.indexOf('\n'); - if (idx !== -1) { - socket.removeListener('data', onData); - resolve(buffer.slice(0, idx)); - } - }; - socket.on('data', onData); - }); -} - -/** - * A JSON-RPC 2.0 response. - */ -type JsonRpcResponse = { - jsonrpc: '2.0'; - id: string | null; - result?: unknown; - error?: { code: number; message: string }; -}; - /** * Send a JSON-RPC request over a socket and read the JSON-RPC response. * @@ -99,7 +54,7 @@ async function sendJsonRpc( socketPath: string, method: string, params?: Record, -): Promise { +): Promise> { const socket = await connectToSocket(socketPath); try { const request = { @@ -110,7 +65,7 @@ async function sendJsonRpc( }; await writeLine(socket, JSON.stringify(request)); const responseLine = await readLine(socket); - return JSON.parse(responseLine) as JsonRpcResponse; + return JSON.parse(responseLine) as Record; } finally { socket.destroy(); } @@ -178,7 +133,7 @@ describe('Daemon Stack (JSON-RPC socket protocol)', { timeout: 30_000 }, () => { const response = await sendJsonRpc(socketPath, 'nonexistentMethod'); expect(response.error).toBeDefined(); - expect(response.error!.code).toBe(-32601); + expect((response.error as { code: number }).code).toBe(-32601); }); it('executes DB query', async () => { From 58a54c2611e9f225a64f14ca63ce02639b91ac8e Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Wed, 18 Feb 2026 21:37:07 -0800 Subject: [PATCH 08/33] refactor(cli): consolidate `ok` CLI into `ocap daemon` command Merge the standalone `ok` binary into the existing `ocap` CLI as nested `daemon` subcommands (start, stop, begone, exec), removing the need for two separate entry points. Co-Authored-By: Claude Opus 4.6 --- packages/cli/README.md | 19 +- packages/cli/package.json | 3 +- packages/cli/src/app.ts | 82 ++++++++ .../cli/src/{ok.ts => commands/daemon.ts} | 189 ++++++------------ 4 files changed, 157 insertions(+), 136 deletions(-) rename packages/cli/src/{ok.ts => commands/daemon.ts} (63%) diff --git a/packages/cli/README.md b/packages/cli/README.md index 83d02abd6..9c2d98464 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -24,16 +24,31 @@ Bundle all `.js` files in the target dir, watch for changes to `.js` files and r Starts a libp2p relay. +### `ocap daemon start` + +Start the daemon or confirm it is already running. + +### `ocap daemon stop` + +Gracefully stop the daemon. + +### `ocap daemon begone --forgood` + +Stop the daemon and delete all state. + +### `ocap daemon exec [method] [params-json]` + +Send an RPC method call to the daemon. Defaults to `getStatus` when `method` is omitted. + ## Known Limitations -The `ok` CLI and its daemon are prototypes. The following limitations apply: +The daemon is a prototype. The following limitations apply: 1. **`executeDBQuery` accepts arbitrary SQL** — any CLI user can execute unrestricted SQL against the kernel database. For production, this should be removed or restricted to read-only queries. 2. **No socket permission enforcement** — the Unix socket is created with default permissions. Any local user can connect and issue commands. For production, socket permissions should be restricted to `0o600`. 3. **No daemon spawn concurrency protection** — if two CLI invocations run simultaneously and neither finds a running daemon, both may attempt to spawn one. A lockfile mechanism would prevent this. 4. **No request size limits** — the RPC server buffers incoming data without a size cap. A malicious client could exhaust daemon memory. 5. **No log rotation** — `daemon.log` grows without bound. Production use should add log rotation. -6. **No `--help` or `--version`** — the `ok` CLI has these explicitly disabled. ## Contributing diff --git a/packages/cli/package.json b/packages/cli/package.json index 238182d98..4076f33be 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -9,8 +9,7 @@ }, "type": "module", "bin": { - "ocap": "./dist/app.mjs", - "ok": "./dist/ok.mjs" + "ocap": "./dist/app.mjs" }, "files": [ "dist/" diff --git a/packages/cli/src/app.ts b/packages/cli/src/app.ts index 15256bc80..b0a192fac 100755 --- a/packages/cli/src/app.ts +++ b/packages/cli/src/app.ts @@ -7,6 +7,14 @@ import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; import { bundleSource } from './commands/bundle.ts'; +import { getSocketPath } from './commands/daemon-client.ts'; +import { ensureDaemon } from './commands/daemon-spawn.ts'; +import { + handleDaemonBegone, + handleDaemonExec, + handleDaemonStart, + stopDaemon, +} from './commands/daemon.ts'; import { getServer } from './commands/serve.ts'; import { watchDir } from './commands/watch.ts'; import { defaultConfig } from './config.ts'; @@ -173,6 +181,80 @@ const yargsInstance = yargs(hideBin(process.argv)) async () => { await startRelay(logger); }, + ) + .command( + 'daemon', + 'Manage the OCAP daemon process', + (_yargs) => { + const socketPath = getSocketPath(); + + return _yargs + .command( + 'start', + 'Start the daemon (or confirm it is running)', + (_y) => _y, + async () => { + await handleDaemonStart(socketPath); + }, + ) + .command( + 'stop', + 'Stop the daemon', + (_y) => _y, + async () => { + await stopDaemon(socketPath); + }, + ) + .command( + 'begone', + 'Stop the daemon and delete all state', + (_y) => + _y.option('forgood', { + describe: 'Confirm state deletion', + type: 'boolean', + demandOption: true, + }), + async () => { + await handleDaemonBegone(socketPath); + }, + ) + .command( + 'exec [method] [params-json]', + 'Send an RPC method call to the daemon', + (_y) => + _y + .positional('method', { + describe: 'RPC method name (defaults to getStatus)', + type: 'string', + }) + .positional('params-json', { + describe: 'JSON-encoded method parameters', + type: 'string', + }), + async (args) => { + const execArgs: string[] = []; + if (args.method) { + execArgs.push(String(args.method)); + } + if (args['params-json']) { + execArgs.push(String(args['params-json'])); + } + await ensureDaemon(socketPath); + await handleDaemonExec(execArgs, socketPath); + }, + ) + .command( + '$0', + false, + (_y) => _y, + async () => { + await handleDaemonStart(socketPath); + }, + ); + }, + () => { + // Handled by subcommands. + }, ); await yargsInstance.help('help').parse(); diff --git a/packages/cli/src/ok.ts b/packages/cli/src/commands/daemon.ts similarity index 63% rename from packages/cli/src/ok.ts rename to packages/cli/src/commands/daemon.ts index e9e45c8f7..d54eba20a 100644 --- a/packages/cli/src/ok.ts +++ b/packages/cli/src/commands/daemon.ts @@ -1,20 +1,13 @@ /* eslint-disable n/no-process-exit */ -import '@metamask/kernel-shims/endoify-node'; import { isJsonRpcFailure } from '@metamask/utils'; import { flushDaemon } from '@ocap/nodejs/daemon'; import { readFile, rm } from 'node:fs/promises'; import { homedir } from 'node:os'; import { join, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; -import yargs from 'yargs'; -import { hideBin } from 'yargs/helpers'; -import { - getSocketPath, - pingDaemon, - sendCommand, -} from './commands/daemon-client.ts'; -import { ensureDaemon } from './commands/daemon-spawn.ts'; +import { pingDaemon, sendCommand } from './daemon-client.ts'; +import { ensureDaemon } from './daemon-spawn.ts'; const home = homedir(); @@ -24,53 +17,10 @@ const home = homedir(); * @param path - An absolute path. * @returns The path with the home prefix replaced. */ -function tildify(path: string): string { +function tildefy(path: string): string { return path.startsWith(home) ? `~${path.slice(home.length)}` : path; } -/** - * Handle the core invocation: call an RPC method on the daemon. - * - * @param args - CLI arguments after `ok`. - * @param socketPath - The daemon socket path. - */ -async function handleInvoke(args: string[], socketPath: string): Promise { - const method = args[0] ?? 'getStatus'; - const rawParams = args[1]; - - // For launchSubcluster: resolve relative bundleSpec paths to file:// URLs. - let params: Record | undefined; - if (rawParams !== undefined) { - try { - const parsed = JSON.parse(rawParams) as Record; - if (method === 'launchSubcluster' && isClusterConfigLike(parsed)) { - params = resolveBundleSpecs(parsed) as Record; - } else { - params = parsed; - } - } catch { - // Not valid JSON — wrap as a simple value - params = { value: rawParams }; - } - } - - const response = await sendCommand(socketPath, method, params); - - if (isJsonRpcFailure(response)) { - process.stderr.write( - `Error: ${response.error.message} (code ${String(response.error.code)})\n`, - ); - process.exit(1); - } - - const isTTY = process.stdout.isTTY ?? false; - if (isTTY) { - process.stdout.write(`${JSON.stringify(response.result, null, 2)}\n`); - } else { - process.stdout.write(`${JSON.stringify(response.result)}\n`); - } -} - /** * Check if a value looks like a cluster config (has bootstrap + vats). * @@ -120,7 +70,7 @@ function resolveBundleSpecs(config: { * * @param socketPath - The daemon socket path. */ -async function stopDaemon(socketPath: string): Promise { +export async function stopDaemon(socketPath: string): Promise { if (!(await pingDaemon(socketPath))) { process.stderr.write('Daemon is not running.\n'); return; @@ -177,95 +127,70 @@ async function stopDaemon(socketPath: string): Promise { } /** - * Handle daemon management commands. + * Ensure the daemon is running and print its socket path. * - * @param args - CLI arguments after `ok daemon`. * @param socketPath - The daemon socket path. */ -async function handleDaemon(args: string[], socketPath: string): Promise { - const subcommand = args[0]; +export async function handleDaemonStart(socketPath: string): Promise { + await ensureDaemon(socketPath); + process.stderr.write(`Daemon running. Socket: ${tildefy(socketPath)}\n`); +} - if (subcommand === 'stop') { +/** + * Stop the daemon (if running) and flush all state. + * + * @param socketPath - The daemon socket path. + */ +export async function handleDaemonBegone(socketPath: string): Promise { + if (await pingDaemon(socketPath)) { await stopDaemon(socketPath); - return; - } - - if (subcommand === 'begone') { - const forGood = args.includes('--forgood'); - if (!forGood) { - process.stderr.write( - 'Usage: ok daemon begone --forgood\n' + - 'This will delete all OCAP daemon state.\n', - ); - process.exit(1); - } - if (await pingDaemon(socketPath)) { - await stopDaemon(socketPath); - } - await flushDaemon({ socketPath }); - process.stderr.write('All daemon state flushed.\n'); - return; } - - // Default: start daemon (or confirm running) - await ensureDaemon(socketPath); - process.stderr.write(`Daemon running. Socket: ${tildify(socketPath)}\n`); + await flushDaemon({ socketPath }); + process.stderr.write('All daemon state flushed.\n'); } -const socketPath = getSocketPath(); - -const cli = yargs(hideBin(process.argv)) - .scriptName('ok') - .usage('$0 [params-json]') - .help(false) +/** + * Send an RPC method call to the daemon. + * + * @param args - Positional arguments: [method, params-json]. + * @param socketPath - The daemon socket path. + */ +export async function handleDaemonExec( + args: string[], + socketPath: string, +): Promise { + const method = args[0] ?? 'getStatus'; + const rawParams = args[1]; - .command( - 'daemon [subcommand]', - 'Manage the daemon process', - (_yargs) => - _yargs - .positional('subcommand', { - describe: 'Subcommand: stop, begone', - type: 'string', - }) - .option('forgood', { - describe: 'Confirm state deletion (for begone)', - type: 'boolean', - }), - async (args) => { - const daemonArgs: string[] = []; - if (args.subcommand) { - daemonArgs.push(String(args.subcommand)); - } - if (args.forgood) { - daemonArgs.push('--forgood'); + // For launchSubcluster: resolve relative bundleSpec paths to file:// URLs. + let params: Record | undefined; + if (rawParams !== undefined) { + try { + const parsed = JSON.parse(rawParams) as Record; + if (method === 'launchSubcluster' && isClusterConfigLike(parsed)) { + params = resolveBundleSpecs(parsed) as Record; + } else { + params = parsed; } - await handleDaemon(daemonArgs, socketPath); - }, - ) + } catch { + // Not valid JSON — wrap as a simple value + params = { value: rawParams }; + } + } - // Default: RPC method dispatch - .command( - '$0 [args..]', - false, - (_yargs) => _yargs.strict(false), - async (args) => { - const invokeArgs = ((args.args ?? []) as string[]).map(String); - await ensureDaemon(socketPath); - await handleInvoke(invokeArgs, socketPath); - }, - ) + const response = await sendCommand(socketPath, method, params); - .version(false) - .fail((message, error) => { - if (error) { - process.stderr.write( - `Error: ${error instanceof Error ? error.message : String(error)}\n`, - ); - } else if (message) { - process.stderr.write(`${message}\n`); - } + if (isJsonRpcFailure(response)) { + process.stderr.write( + `Error: ${response.error.message} (code ${String(response.error.code)})\n`, + ); process.exit(1); - }); + } -await cli.parse(); + const isTTY = process.stdout.isTTY ?? false; + if (isTTY) { + process.stdout.write(`${JSON.stringify(response.result, null, 2)}\n`); + } else { + process.stdout.write(`${JSON.stringify(response.result)}\n`); + } +} From b04a79b09e340b464e2a0cbbac5b8fe99d3bd182 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Wed, 18 Feb 2026 21:43:30 -0800 Subject: [PATCH 09/33] refactor(cli,nodejs): rename daemon socket from console.sock to daemon.sock Co-Authored-By: Claude Opus 4.6 --- packages/cli/src/commands/daemon-client.ts | 2 +- packages/cli/src/commands/daemon-entry.ts | 2 +- packages/nodejs/src/daemon/flush-daemon.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/daemon-client.ts b/packages/cli/src/commands/daemon-client.ts index 98b28f91b..91102c764 100644 --- a/packages/cli/src/commands/daemon-client.ts +++ b/packages/cli/src/commands/daemon-client.ts @@ -15,7 +15,7 @@ const READ_TIMEOUT_MS = 30_000; * @returns The socket path. */ export function getSocketPath(): string { - return join(homedir(), '.ocap', 'console.sock'); + return join(homedir(), '.ocap', 'daemon.sock'); } /** diff --git a/packages/cli/src/commands/daemon-entry.ts b/packages/cli/src/commands/daemon-entry.ts index c2de8d83b..e409cc262 100644 --- a/packages/cli/src/commands/daemon-entry.ts +++ b/packages/cli/src/commands/daemon-entry.ts @@ -39,7 +39,7 @@ async function main(): Promise { try { const socketPath = - process.env.OCAP_SOCKET_PATH ?? join(ocapDir, 'console.sock'); + process.env.OCAP_SOCKET_PATH ?? join(ocapDir, 'daemon.sock'); const { kernel, kernelDatabase } = await makeKernel({ resetStorage: false, diff --git a/packages/nodejs/src/daemon/flush-daemon.ts b/packages/nodejs/src/daemon/flush-daemon.ts index 426cb422e..7adf08a3d 100644 --- a/packages/nodejs/src/daemon/flush-daemon.ts +++ b/packages/nodejs/src/daemon/flush-daemon.ts @@ -6,7 +6,7 @@ import { join } from 'node:path'; * Options for flushing daemon state. */ export type FlushDaemonOptions = { - /** UNIX socket path. Defaults to ~/.ocap/console.sock. */ + /** UNIX socket path. Defaults to ~/.ocap/daemon.sock. */ socketPath?: string; /** SQLite database filename. Defaults to ~/.ocap/kernel.sqlite. */ dbFilename?: string; @@ -19,7 +19,7 @@ export type FlushDaemonOptions = { */ export async function flushDaemon(options?: FlushDaemonOptions): Promise { const ocapDir = join(homedir(), '.ocap'); - const socketPath = options?.socketPath ?? join(ocapDir, 'console.sock'); + const socketPath = options?.socketPath ?? join(ocapDir, 'daemon.sock'); const dbFilename = options?.dbFilename ?? join(ocapDir, 'kernel.sqlite'); const bundlesDir = join(ocapDir, 'bundles'); From 54f238c5e153d8d27219c0285eaf46e4980cd32d Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Wed, 18 Feb 2026 21:45:28 -0800 Subject: [PATCH 10/33] refactor(cli,nodejs): rename flushDaemon to deleteDaemonState Co-Authored-By: Claude Opus 4.6 --- packages/cli/src/commands/daemon.ts | 8 ++++---- .../daemon/{flush-daemon.ts => delete-daemon-state.ts} | 8 +++++--- packages/nodejs/src/daemon/index.ts | 4 ++-- 3 files changed, 11 insertions(+), 9 deletions(-) rename packages/nodejs/src/daemon/{flush-daemon.ts => delete-daemon-state.ts} (83%) diff --git a/packages/cli/src/commands/daemon.ts b/packages/cli/src/commands/daemon.ts index d54eba20a..6771127be 100644 --- a/packages/cli/src/commands/daemon.ts +++ b/packages/cli/src/commands/daemon.ts @@ -1,6 +1,6 @@ /* eslint-disable n/no-process-exit */ import { isJsonRpcFailure } from '@metamask/utils'; -import { flushDaemon } from '@ocap/nodejs/daemon'; +import { deleteDaemonState } from '@ocap/nodejs/daemon'; import { readFile, rm } from 'node:fs/promises'; import { homedir } from 'node:os'; import { join, resolve } from 'node:path'; @@ -137,7 +137,7 @@ export async function handleDaemonStart(socketPath: string): Promise { } /** - * Stop the daemon (if running) and flush all state. + * Stop the daemon (if running) and delete all state. * * @param socketPath - The daemon socket path. */ @@ -145,8 +145,8 @@ export async function handleDaemonBegone(socketPath: string): Promise { if (await pingDaemon(socketPath)) { await stopDaemon(socketPath); } - await flushDaemon({ socketPath }); - process.stderr.write('All daemon state flushed.\n'); + await deleteDaemonState({ socketPath }); + process.stderr.write('All daemon state deleted.\n'); } /** diff --git a/packages/nodejs/src/daemon/flush-daemon.ts b/packages/nodejs/src/daemon/delete-daemon-state.ts similarity index 83% rename from packages/nodejs/src/daemon/flush-daemon.ts rename to packages/nodejs/src/daemon/delete-daemon-state.ts index 7adf08a3d..2194fad60 100644 --- a/packages/nodejs/src/daemon/flush-daemon.ts +++ b/packages/nodejs/src/daemon/delete-daemon-state.ts @@ -3,9 +3,9 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; /** - * Options for flushing daemon state. + * Options for deleting daemon state. */ -export type FlushDaemonOptions = { +export type DeleteDaemonStateOptions = { /** UNIX socket path. Defaults to ~/.ocap/daemon.sock. */ socketPath?: string; /** SQLite database filename. Defaults to ~/.ocap/kernel.sqlite. */ @@ -17,7 +17,9 @@ export type FlushDaemonOptions = { * * @param options - Optional overrides for file paths. */ -export async function flushDaemon(options?: FlushDaemonOptions): Promise { +export async function deleteDaemonState( + options?: DeleteDaemonStateOptions, +): Promise { const ocapDir = join(homedir(), '.ocap'); const socketPath = options?.socketPath ?? join(ocapDir, 'daemon.sock'); const dbFilename = options?.dbFilename ?? join(ocapDir, 'kernel.sqlite'); diff --git a/packages/nodejs/src/daemon/index.ts b/packages/nodejs/src/daemon/index.ts index 4b3581144..604ce1ef6 100644 --- a/packages/nodejs/src/daemon/index.ts +++ b/packages/nodejs/src/daemon/index.ts @@ -2,6 +2,6 @@ export { startDaemon } from './start-daemon.ts'; export type { StartDaemonOptions, DaemonHandle } from './start-daemon.ts'; export { startRpcSocketServer } from './rpc-socket-server.ts'; export type { RpcSocketServerHandle } from './rpc-socket-server.ts'; -export { flushDaemon } from './flush-daemon.ts'; -export type { FlushDaemonOptions } from './flush-daemon.ts'; +export { deleteDaemonState } from './delete-daemon-state.ts'; +export type { DeleteDaemonStateOptions } from './delete-daemon-state.ts'; export { readLine, writeLine } from './socket-line.ts'; From 082351ebe71b929ff0388626cdd8a6cd57fe6fe2 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Wed, 18 Feb 2026 21:48:25 -0800 Subject: [PATCH 11/33] feat(cli): add usage examples to ocap daemon exec help Co-Authored-By: Claude Opus 4.6 --- packages/cli/src/app.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/app.ts b/packages/cli/src/app.ts index b0a192fac..0083dd0fc 100755 --- a/packages/cli/src/app.ts +++ b/packages/cli/src/app.ts @@ -230,7 +230,24 @@ const yargsInstance = yargs(hideBin(process.argv)) .positional('params-json', { describe: 'JSON-encoded method parameters', type: 'string', - }), + }) + .example('$0 daemon exec', 'Get daemon status') + .example( + '$0 daemon exec getStatus', + 'Get daemon status (explicit)', + ) + .example( + '$0 daemon exec pingVat \'{"vatId":"v1"}\'', + 'Ping a vat', + ) + .example( + '$0 daemon exec executeDBQuery \'{"sql":"SELECT * FROM kv LIMIT 5"}\'', + 'Run a SQL query', + ) + .example( + '$0 daemon exec terminateVat \'{"vatId":"v1"}\'', + 'Terminate a vat', + ), async (args) => { const execArgs: string[] = []; if (args.method) { From f2b1450981209c7e511b7c60b856e9ce5b98bdfd Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Wed, 18 Feb 2026 21:54:46 -0800 Subject: [PATCH 12/33] refactor(cli): rename begone to purge (with begone alias) and --forgood to --force Co-Authored-By: Claude Opus 4.6 --- packages/cli/README.md | 2 +- packages/cli/src/app.ts | 14 +++++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 9c2d98464..a5b805129 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -32,7 +32,7 @@ Start the daemon or confirm it is already running. Gracefully stop the daemon. -### `ocap daemon begone --forgood` +### `ocap daemon purge --force` Stop the daemon and delete all state. diff --git a/packages/cli/src/app.ts b/packages/cli/src/app.ts index 0083dd0fc..1a846e5bb 100755 --- a/packages/cli/src/app.ts +++ b/packages/cli/src/app.ts @@ -206,15 +206,23 @@ const yargsInstance = yargs(hideBin(process.argv)) }, ) .command( - 'begone', + ['purge', 'begone'], 'Stop the daemon and delete all state', (_y) => - _y.option('forgood', { + _y.option('force', { describe: 'Confirm state deletion', type: 'boolean', demandOption: true, }), - async () => { + async (args) => { + if (!args.force) { + process.stderr.write( + 'Usage: ocap daemon purge --force\n' + + 'This will delete all OCAP daemon state.\n', + ); + process.exitCode = 1; + return; + } await handleDaemonBegone(socketPath); }, ) From c182292ffc3476d708784447ccb55487df5bbf67 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Wed, 18 Feb 2026 22:01:33 -0800 Subject: [PATCH 13/33] refactor(cli): replace process.exit with process.exitCode and clean up eslint disables Move n/no-process-env exemption for cli package to eslint config, replace process.exit() calls with process.exitCode to allow pending I/O to complete, and simplify daemon-entry.ts error handling. Co-Authored-By: Claude Opus 4.6 --- eslint.config.mjs | 1 + packages/cli/src/commands/daemon-entry.ts | 103 ++++++++++------------ packages/cli/src/commands/daemon.ts | 4 +- 3 files changed, 51 insertions(+), 57 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 5daea9601..cc80025cd 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -256,6 +256,7 @@ const config = createConfig([ files: [ '**/vite.config.ts', '**/vitest.config.ts', + 'packages/cli/**/*', 'packages/extension/**/*', 'packages/nodejs/**/*-worker.ts', 'packages/nodejs/test/workers/**/*', diff --git a/packages/cli/src/commands/daemon-entry.ts b/packages/cli/src/commands/daemon-entry.ts index e409cc262..6abbb327c 100644 --- a/packages/cli/src/commands/daemon-entry.ts +++ b/packages/cli/src/commands/daemon-entry.ts @@ -1,4 +1,3 @@ -/* eslint-disable n/no-process-exit, n/no-process-env */ import '@metamask/kernel-shims/endoify-node'; import { Logger } from '@metamask/logger'; import type { LogEntry } from '@metamask/logger'; @@ -8,21 +7,10 @@ import { mkdir, rm, writeFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import { join } from 'node:path'; -/** - * Create a file transport that writes logs to a file. - * - * @param logPath - The log file path. - * @returns A log transport function. - */ -function makeFileTransport(logPath: string) { - // eslint-disable-next-line @typescript-eslint/no-require-imports, n/global-require -- need sync fs for log transport - const fs = require('node:fs') as typeof import('node:fs'); - return (entry: LogEntry): void => { - const line = `[${new Date().toISOString()}] [${entry.level}] ${entry.message ?? ''} ${(entry.data ?? []).map(String).join(' ')}\n`; - // eslint-disable-next-line n/no-sync -- synchronous write needed for log transport reliability - fs.appendFileSync(logPath, line); - }; -} +main().catch((error) => { + process.stderr.write(`Daemon fatal: ${String(error)}\n`); + process.exitCode = 1; +}); /** * Main daemon entry point. Starts the daemon process and keeps it running. @@ -37,50 +25,55 @@ async function main(): Promise { transports: [makeFileTransport(logPath)], }); - try { - const socketPath = - process.env.OCAP_SOCKET_PATH ?? join(ocapDir, 'daemon.sock'); + const socketPath = + process.env.OCAP_SOCKET_PATH ?? join(ocapDir, 'daemon.sock'); - const { kernel, kernelDatabase } = await makeKernel({ - resetStorage: false, - logger, - }); - await kernel.initIdentity(); + const { kernel, kernelDatabase } = await makeKernel({ + resetStorage: false, + logger, + }); + await kernel.initIdentity(); - // Write PID file so `ok daemon stop` can use it as a fallback - const pidPath = join(ocapDir, 'daemon.pid'); - await writeFile(pidPath, String(process.pid)); + // Write PID file so we can use it as a fallback when stopping the daemon + const pidPath = join(ocapDir, 'daemon.pid'); + await writeFile(pidPath, String(process.pid)); - const shutdown = async (reason: string): Promise => { - logger.info(`Shutting down (${reason})...`); - // eslint-disable-next-line @typescript-eslint/no-use-before-define -- shutdown is only called async, after handle is initialized - await handle.close(); - await rm(pidPath, { force: true }); - process.exit(0); - }; + const shutdown = async (reason: string): Promise => { + logger.info(`Shutting down (${reason})...`); + // eslint-disable-next-line @typescript-eslint/no-use-before-define -- shutdown is only called async, after handle is initialized + await handle.close(); + await rm(pidPath, { force: true }); + }; - const handle = await startDaemon({ - socketPath, - kernel, - kernelDatabase, - onShutdown: async () => shutdown('RPC shutdown'), - }); + const handle = await startDaemon({ + socketPath, + kernel, + kernelDatabase, + onShutdown: async () => shutdown('RPC shutdown'), + }); - logger.info(`Daemon started. Socket: ${handle.socketPath}`); + logger.info(`Daemon started. Socket: ${handle.socketPath}`); - process.on('SIGTERM', () => { - shutdown('SIGTERM').catch(() => process.exit(1)); - }); - process.on('SIGINT', () => { - shutdown('SIGINT').catch(() => process.exit(1)); - }); - } catch (error) { - logger.error('Daemon startup failed:', error); - process.exit(1); - } + process.on('SIGTERM', () => { + shutdown('SIGTERM').catch(() => (process.exitCode = 1)); + }); + process.on('SIGINT', () => { + shutdown('SIGINT').catch(() => (process.exitCode = 1)); + }); } -main().catch((error) => { - process.stderr.write(`Daemon fatal: ${String(error)}\n`); - process.exit(1); -}); +/** + * Create a file transport that writes logs to a file. + * + * @param logPath - The log file path. + * @returns A log transport function. + */ +function makeFileTransport(logPath: string) { + // eslint-disable-next-line @typescript-eslint/no-require-imports, n/global-require -- need sync fs for log transport + const fs = require('node:fs') as typeof import('node:fs'); + return (entry: LogEntry): void => { + const line = `[${new Date().toISOString()}] [${entry.level}] ${entry.message ?? ''} ${(entry.data ?? []).map(String).join(' ')}\n`; + // eslint-disable-next-line n/no-sync -- synchronous write needed for log transport reliability + fs.appendFileSync(logPath, line); + }; +} diff --git a/packages/cli/src/commands/daemon.ts b/packages/cli/src/commands/daemon.ts index 6771127be..3f9ae85ab 100644 --- a/packages/cli/src/commands/daemon.ts +++ b/packages/cli/src/commands/daemon.ts @@ -1,4 +1,3 @@ -/* eslint-disable n/no-process-exit */ import { isJsonRpcFailure } from '@metamask/utils'; import { deleteDaemonState } from '@ocap/nodejs/daemon'; import { readFile, rm } from 'node:fs/promises'; @@ -184,7 +183,8 @@ export async function handleDaemonExec( process.stderr.write( `Error: ${response.error.message} (code ${String(response.error.code)})\n`, ); - process.exit(1); + process.exitCode = 1; + return; } const isTTY = process.stdout.isTTY ?? false; From 4d986aa289577e87fb0ac6f6dc9545ffea8068fb Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Wed, 18 Feb 2026 22:06:50 -0800 Subject: [PATCH 14/33] chore: update yarn.lock Co-Authored-By: Claude Opus 4.6 --- yarn.lock | 1 - 1 file changed, 1 deletion(-) diff --git a/yarn.lock b/yarn.lock index ebe1630da..1b56f7924 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3498,7 +3498,6 @@ __metadata: yargs: "npm:^17.7.2" bin: ocap: ./dist/app.mjs - ok: ./dist/ok.mjs languageName: unknown linkType: soft From 3731aff8189f257182de475082dc0a335dc6472d Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Wed, 18 Feb 2026 22:11:14 -0800 Subject: [PATCH 15/33] refactor: Remove Kernel.invokeMethod() --- packages/ocap-kernel/src/Kernel.ts | 23 +------------------ packages/ocap-kernel/src/kernel-facet.test.ts | 2 -- packages/ocap-kernel/src/kernel-facet.ts | 1 - 3 files changed, 1 insertion(+), 25 deletions(-) diff --git a/packages/ocap-kernel/src/Kernel.ts b/packages/ocap-kernel/src/Kernel.ts index 01465bbb0..4a5296aa4 100644 --- a/packages/ocap-kernel/src/Kernel.ts +++ b/packages/ocap-kernel/src/Kernel.ts @@ -11,7 +11,7 @@ import { KernelRouter } from './KernelRouter.ts'; import { KernelServiceManager } from './KernelServiceManager.ts'; import type { KernelService } from './KernelServiceManager.ts'; import type { SlotValue } from './liveslots/kernel-marshal.ts'; -import { kslot, kunser } from './liveslots/kernel-marshal.ts'; +import { kslot } from './liveslots/kernel-marshal.ts'; import { OcapURLManager } from './remotes/kernel/OcapURLManager.ts'; import { RemoteManager } from './remotes/kernel/RemoteManager.ts'; import type { RemoteCommsOptions } from './remotes/types.ts'; @@ -379,27 +379,6 @@ export class Kernel { return this.#kernelQueue.enqueueMessage(target, method, args); } - /** - * Send a message to an object in a vat and return the deserialized result. - * - * Unlike {@link queueMessage}, which returns raw CapData, this method - * deserializes the result so that it can be returned through a kernel - * service and re-serialized by liveslots for the calling vat. - * - * @param target - The kref of the object to invoke. - * @param method - The method name. - * @param args - The method arguments. - * @returns The deserialized result of the method invocation. - */ - async invokeMethod( - target: KRef, - method: string, - args: unknown[], - ): Promise { - const capData = await this.queueMessage(target, method, args); - return kunser(capData); - } - /** * Issue an OCAP URL for a kernel reference. * diff --git a/packages/ocap-kernel/src/kernel-facet.test.ts b/packages/ocap-kernel/src/kernel-facet.test.ts index ddb4ddddf..4e53f8a58 100644 --- a/packages/ocap-kernel/src/kernel-facet.test.ts +++ b/packages/ocap-kernel/src/kernel-facet.test.ts @@ -11,7 +11,6 @@ const makeMockKernel = (): KernelFacetSource => ({ getSubcluster: () => undefined, getSubclusters: () => [], getSystemSubclusterRoot: () => 'ko99', - invokeMethod: async () => Promise.resolve(null), launchSubcluster: async () => Promise.resolve({ subclusterId: 's1', @@ -33,7 +32,6 @@ describe('makeKernelFacet', () => { expect(typeof facet.getSubcluster).toBe('function'); expect(typeof facet.getSubclusters).toBe('function'); expect(typeof facet.getSystemSubclusterRoot).toBe('function'); - expect(typeof facet.invokeMethod).toBe('function'); expect(typeof facet.launchSubcluster).toBe('function'); expect(typeof facet.ping).toBe('function'); expect(typeof facet.pingVat).toBe('function'); diff --git a/packages/ocap-kernel/src/kernel-facet.ts b/packages/ocap-kernel/src/kernel-facet.ts index 13ff05321..79ecbe443 100644 --- a/packages/ocap-kernel/src/kernel-facet.ts +++ b/packages/ocap-kernel/src/kernel-facet.ts @@ -8,7 +8,6 @@ const kernelFacetMethodNames = [ 'getSubcluster', 'getSubclusters', 'getSystemSubclusterRoot', - 'invokeMethod', 'launchSubcluster', 'pingVat', 'queueMessage', From 935aa030759291e41bc8daeccfffd239f3dfa584 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Wed, 18 Feb 2026 22:21:25 -0800 Subject: [PATCH 16/33] chore: Restore @ocap/cli dev dep to kernel-test --- packages/kernel-test/package.json | 1 + yarn.lock | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/kernel-test/package.json b/packages/kernel-test/package.json index c5e4b56be..2cb1cdea7 100644 --- a/packages/kernel-test/package.json +++ b/packages/kernel-test/package.json @@ -69,6 +69,7 @@ "@metamask/eslint-config-nodejs": "^15.0.0", "@metamask/eslint-config-typescript": "^15.0.0", "@metamask/kernel-shims": "workspace:^", + "@ocap/cli": "workspace:^", "@ocap/repo-tools": "workspace:^", "@typescript-eslint/eslint-plugin": "^8.29.0", "@typescript-eslint/parser": "^8.29.0", diff --git a/yarn.lock b/yarn.lock index 1b56f7924..6ae8e4fab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3825,6 +3825,7 @@ __metadata: "@metamask/kernel-utils": "workspace:^" "@metamask/logger": "workspace:^" "@metamask/ocap-kernel": "workspace:^" + "@ocap/cli": "workspace:^" "@ocap/kernel-language-model-service": "workspace:^" "@ocap/nodejs": "workspace:^" "@ocap/nodejs-test-workers": "workspace:^" From e10fe7940f2d61cff4bd60586124cad9142198ac Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 19 Feb 2026 03:08:40 -0800 Subject: [PATCH 17/33] fix(cli): make daemon shutdown idempotent Guard the shutdown function with a stored promise so concurrent calls from RPC shutdown, SIGTERM, and SIGINT coalesce into a single handle.close() instead of throwing ERR_SERVER_NOT_RUNNING. Co-Authored-By: Claude Opus 4.6 --- packages/cli/src/commands/daemon-entry.ts | 13 +++++++++---- packages/cli/src/commands/daemon-spawn.ts | 2 +- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/commands/daemon-entry.ts b/packages/cli/src/commands/daemon-entry.ts index 6abbb327c..f4cf6f5a3 100644 --- a/packages/cli/src/commands/daemon-entry.ts +++ b/packages/cli/src/commands/daemon-entry.ts @@ -38,11 +38,16 @@ async function main(): Promise { const pidPath = join(ocapDir, 'daemon.pid'); await writeFile(pidPath, String(process.pid)); + let shutdownPromise: Promise | undefined; const shutdown = async (reason: string): Promise => { - logger.info(`Shutting down (${reason})...`); - // eslint-disable-next-line @typescript-eslint/no-use-before-define -- shutdown is only called async, after handle is initialized - await handle.close(); - await rm(pidPath, { force: true }); + if (shutdownPromise === undefined) { + logger.info(`Shutting down (${reason})...`); + // eslint-disable-next-line @typescript-eslint/no-use-before-define -- shutdown is only called async, after handle is initialized + shutdownPromise = handle + .close() + .then(async () => rm(pidPath, { force: true })); + } + return shutdownPromise; }; const handle = await startDaemon({ diff --git a/packages/cli/src/commands/daemon-spawn.ts b/packages/cli/src/commands/daemon-spawn.ts index 1fcd99050..8a4cebc67 100644 --- a/packages/cli/src/commands/daemon-spawn.ts +++ b/packages/cli/src/commands/daemon-spawn.ts @@ -27,7 +27,7 @@ export async function ensureDaemon(socketPath: string): Promise { detached: true, stdio: 'ignore', env: { - ...process.env, // eslint-disable-line n/no-process-env -- pass env to child + ...process.env, OCAP_SOCKET_PATH: socketPath, }, }); From 5302d7254033784ae3059d48f24025352b691146 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 19 Feb 2026 10:24:00 -0800 Subject: [PATCH 18/33] fix(cli): persist daemon kernel state to ~/.ocap/kernel.sqlite Pass an explicit dbFilename to makeKernel so the daemon uses a on-disk SQLite database instead of the default in-memory one. This matches the path deleteDaemonState already expects. Co-Authored-By: Claude Opus 4.6 --- packages/cli/src/commands/daemon-entry.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/cli/src/commands/daemon-entry.ts b/packages/cli/src/commands/daemon-entry.ts index f4cf6f5a3..a43c3005b 100644 --- a/packages/cli/src/commands/daemon-entry.ts +++ b/packages/cli/src/commands/daemon-entry.ts @@ -28,8 +28,10 @@ async function main(): Promise { const socketPath = process.env.OCAP_SOCKET_PATH ?? join(ocapDir, 'daemon.sock'); + const dbFilename = join(ocapDir, 'kernel.sqlite'); const { kernel, kernelDatabase } = await makeKernel({ resetStorage: false, + dbFilename, logger, }); await kernel.initIdentity(); From 42f6f57b2141778f1837171ce08e39ea4542e26f Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 19 Feb 2026 10:49:59 -0800 Subject: [PATCH 19/33] fix(cli): clean up PID file even if daemon shutdown fails Use .finally() instead of .then() for PID file removal so stale daemon.pid files do not persist after a failed shutdown. Co-Authored-By: Claude Opus 4.6 --- packages/cli/src/commands/daemon-entry.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/daemon-entry.ts b/packages/cli/src/commands/daemon-entry.ts index a43c3005b..d0d395f1a 100644 --- a/packages/cli/src/commands/daemon-entry.ts +++ b/packages/cli/src/commands/daemon-entry.ts @@ -45,9 +45,9 @@ async function main(): Promise { if (shutdownPromise === undefined) { logger.info(`Shutting down (${reason})...`); // eslint-disable-next-line @typescript-eslint/no-use-before-define -- shutdown is only called async, after handle is initialized - shutdownPromise = handle - .close() - .then(async () => rm(pidPath, { force: true })); + shutdownPromise = handle.close().finally(() => { + rm(pidPath, { force: true }).catch(() => undefined); + }); } return shutdownPromise; }; From 7731a5b90ed188e415ff488230dfd293276fd83d Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 19 Feb 2026 10:51:36 -0800 Subject: [PATCH 20/33] fix(nodejs): close kernel database on daemon shutdown Co-Authored-By: Claude Opus 4.6 --- packages/nodejs/src/daemon/start-daemon.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/nodejs/src/daemon/start-daemon.ts b/packages/nodejs/src/daemon/start-daemon.ts index fa50afa54..fbaa20f06 100644 --- a/packages/nodejs/src/daemon/start-daemon.ts +++ b/packages/nodejs/src/daemon/start-daemon.ts @@ -50,6 +50,7 @@ export async function startDaemon( const close = async (): Promise => { await rpcServer.close(); await kernel.stop(); + kernelDatabase.close(); }; return { From 9c0a0b082c57da674df55c1062fbf7b7dff8995f Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 19 Feb 2026 11:23:17 -0800 Subject: [PATCH 21/33] fix(cli): make stopDaemon return success status and simplify SIGTERM path Return a boolean from stopDaemon so callers (purge, stop) can react to failure. Replace the SIGTERM poll loop with a short sleep since SIGTERM delivery is reliable. Refuse to delete state in purge if the daemon failed to stop. Co-Authored-By: Claude Opus 4.6 --- packages/cli/src/app.ts | 5 ++++- packages/cli/src/commands/daemon.ts | 34 +++++++++++++++-------------- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/packages/cli/src/app.ts b/packages/cli/src/app.ts index 1a846e5bb..cb670b6b9 100755 --- a/packages/cli/src/app.ts +++ b/packages/cli/src/app.ts @@ -202,7 +202,10 @@ const yargsInstance = yargs(hideBin(process.argv)) 'Stop the daemon', (_y) => _y, async () => { - await stopDaemon(socketPath); + const stopped = await stopDaemon(socketPath); + if (!stopped) { + process.exitCode = 1; + } }, ) .command( diff --git a/packages/cli/src/commands/daemon.ts b/packages/cli/src/commands/daemon.ts index 3f9ae85ab..1d7e95d69 100644 --- a/packages/cli/src/commands/daemon.ts +++ b/packages/cli/src/commands/daemon.ts @@ -68,11 +68,13 @@ function resolveBundleSpecs(config: { * the socket is unresponsive. * * @param socketPath - The daemon socket path. + * @returns True if the daemon was stopped (or was not running), false if it + * failed to stop within the timeout. */ -export async function stopDaemon(socketPath: string): Promise { +export async function stopDaemon(socketPath: string): Promise { if (!(await pingDaemon(socketPath))) { process.stderr.write('Daemon is not running.\n'); - return; + return true; } process.stderr.write('Stopping daemon...\n'); @@ -90,7 +92,7 @@ export async function stopDaemon(socketPath: string): Promise { await new Promise((_resolve) => setTimeout(_resolve, 250)); if (!(await pingDaemon(socketPath))) { process.stderr.write('Daemon stopped.\n'); - return; + return true; } } @@ -109,20 +111,15 @@ export async function stopDaemon(socketPath: string): Promise { } catch { // Process may already be gone. } - - // Poll again after SIGTERM. - const sigPollEnd = Date.now() + 5_000; - while (Date.now() < sigPollEnd) { - await new Promise((_resolve) => setTimeout(_resolve, 250)); - if (!(await pingDaemon(socketPath))) { - await rm(pidPath, { force: true }); - process.stderr.write('Daemon stopped.\n'); - return; - } - } + // Give the process a moment to exit. + await new Promise((_resolve) => setTimeout(_resolve, 500)); + await rm(pidPath, { force: true }); + process.stderr.write('Daemon stopped.\n'); + return true; } process.stderr.write('Daemon did not stop within timeout.\n'); + return false; } /** @@ -141,8 +138,13 @@ export async function handleDaemonStart(socketPath: string): Promise { * @param socketPath - The daemon socket path. */ export async function handleDaemonBegone(socketPath: string): Promise { - if (await pingDaemon(socketPath)) { - await stopDaemon(socketPath); + const stopped = await stopDaemon(socketPath); + if (!stopped) { + process.stderr.write( + 'Refusing to delete state while the daemon is still running.\n', + ); + process.exitCode = 1; + return; } await deleteDaemonState({ socketPath }); process.stderr.write('All daemon state deleted.\n'); From 6efb29c7aa64aff6bd92fabb2a3d15f93c78078e Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 19 Feb 2026 12:30:19 -0800 Subject: [PATCH 22/33] fix(cli): reject invalid JSON params instead of wrapping them Co-Authored-By: Claude Opus 4.6 --- packages/cli/src/commands/daemon.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/daemon.ts b/packages/cli/src/commands/daemon.ts index 1d7e95d69..e5705e4c0 100644 --- a/packages/cli/src/commands/daemon.ts +++ b/packages/cli/src/commands/daemon.ts @@ -174,8 +174,9 @@ export async function handleDaemonExec( params = parsed; } } catch { - // Not valid JSON — wrap as a simple value - params = { value: rawParams }; + process.stderr.write('Error: params-json must be valid JSON.\n'); + process.exitCode = 1; + return; } } From 3fabf3ef0e39b07ec981931901095279b38c51f5 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 19 Feb 2026 14:21:18 -0800 Subject: [PATCH 23/33] fix(cli): clean up kernel and database on daemon startup failure Wrap post-makeKernel initialization in try/catch so that kernel workers, database connections, and PID files are cleaned up if initIdentity, writeFile, or startDaemon throws. Co-Authored-By: Claude Opus 4.6 --- packages/cli/src/commands/daemon-entry.ts | 48 ++++++++++++++++------- 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/commands/daemon-entry.ts b/packages/cli/src/commands/daemon-entry.ts index d0d395f1a..d95d835e9 100644 --- a/packages/cli/src/commands/daemon-entry.ts +++ b/packages/cli/src/commands/daemon-entry.ts @@ -3,6 +3,7 @@ import { Logger } from '@metamask/logger'; import type { LogEntry } from '@metamask/logger'; import { makeKernel } from '@ocap/nodejs'; import { startDaemon } from '@ocap/nodejs/daemon'; +import type { DaemonHandle } from '@ocap/nodejs/daemon'; import { mkdir, rm, writeFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import { join } from 'node:path'; @@ -34,32 +35,49 @@ async function main(): Promise { dbFilename, logger, }); - await kernel.initIdentity(); - // Write PID file so we can use it as a fallback when stopping the daemon const pidPath = join(ocapDir, 'daemon.pid'); - await writeFile(pidPath, String(process.pid)); + + let handle: DaemonHandle; + try { + await kernel.initIdentity(); + await writeFile(pidPath, String(process.pid)); + + handle = await startDaemon({ + socketPath, + kernel, + kernelDatabase, + onShutdown: async () => shutdown('RPC shutdown'), + }); + } catch (error) { + try { + kernel.stop().catch(() => undefined); + kernelDatabase.close(); + } catch { + // Best-effort cleanup. + } + rm(pidPath, { force: true }).catch(() => undefined); + throw error; + } + + logger.info(`Daemon started. Socket: ${handle.socketPath}`); let shutdownPromise: Promise | undefined; - const shutdown = async (reason: string): Promise => { + /** + * Shut down the daemon idempotently. Concurrent calls coalesce. + * + * @param reason - A label describing why shutdown was triggered. + * @returns A promise that resolves when shutdown completes. + */ + async function shutdown(reason: string): Promise { if (shutdownPromise === undefined) { logger.info(`Shutting down (${reason})...`); - // eslint-disable-next-line @typescript-eslint/no-use-before-define -- shutdown is only called async, after handle is initialized shutdownPromise = handle.close().finally(() => { rm(pidPath, { force: true }).catch(() => undefined); }); } return shutdownPromise; - }; - - const handle = await startDaemon({ - socketPath, - kernel, - kernelDatabase, - onShutdown: async () => shutdown('RPC shutdown'), - }); - - logger.info(`Daemon started. Socket: ${handle.socketPath}`); + } process.on('SIGTERM', () => { shutdown('SIGTERM').catch(() => (process.exitCode = 1)); From 192861708d674cf66fcb59ad75e2bd4c7b7867ba Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 19 Feb 2026 14:28:50 -0800 Subject: [PATCH 24/33] fix(nodejs): reject multiple requests on single RPC socket connection Instead of silently dropping extra newline-delimited requests on a single connection, return a JSON-RPC error (-32600) so the client knows only one request per connection is allowed. Co-Authored-By: Claude Opus 4.6 --- packages/nodejs/src/daemon/rpc-socket-server.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/nodejs/src/daemon/rpc-socket-server.ts b/packages/nodejs/src/daemon/rpc-socket-server.ts index 137800d0a..11168f6f9 100644 --- a/packages/nodejs/src/daemon/rpc-socket-server.ts +++ b/packages/nodejs/src/daemon/rpc-socket-server.ts @@ -48,16 +48,27 @@ export async function startRpcSocketServer({ const server = createServer((socket) => { let buffer = ''; - socket.on('data', (data) => { + const onData = (data: Buffer): void => { buffer += data.toString(); const idx = buffer.indexOf('\n'); if (idx === -1) { return; } + // One request per connection — stop listening for further data. + socket.removeListener('data', onData); + const line = buffer.slice(0, idx); + const remaining = buffer.slice(idx + 1); buffer = ''; + if (remaining.length > 0) { + socket.end( + `${JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32600, message: 'Only one request per connection is allowed' } })}\n`, + ); + return; + } + handleRequest(rpcService, line, onShutdown) .then((response) => { socket.end(`${JSON.stringify(response)}\n`); @@ -68,7 +79,8 @@ export async function startRpcSocketServer({ `${JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32603, message: 'Internal error' } })}\n`, ); }); - }); + }; + socket.on('data', onData); socket.on('error', () => { // Ignore client socket errors (e.g. broken pipe from probe connections) From 04c4fdcaba04026826cf1af1fbe35cc8bb0d6c03 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 19 Feb 2026 14:30:37 -0800 Subject: [PATCH 25/33] fix(nodejs): remove redundant kernelDatabase.close() from start-daemon kernel.stop() now closes the database (ee4fed95), making the explicit close call in the daemon's shutdown path redundant. Co-Authored-By: Claude Opus 4.6 --- packages/nodejs/src/daemon/start-daemon.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/nodejs/src/daemon/start-daemon.ts b/packages/nodejs/src/daemon/start-daemon.ts index fbaa20f06..fa50afa54 100644 --- a/packages/nodejs/src/daemon/start-daemon.ts +++ b/packages/nodejs/src/daemon/start-daemon.ts @@ -50,7 +50,6 @@ export async function startDaemon( const close = async (): Promise => { await rpcServer.close(); await kernel.stop(); - kernelDatabase.close(); }; return { From 87b6b554154325cddd6ef1db8aeb51abf6e26b5c Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 19 Feb 2026 14:54:04 -0800 Subject: [PATCH 26/33] fix(cli): improve stopDaemon to handle stuck daemon processes Check both socket responsiveness and PID-based process liveness before declaring the daemon stopped. Escalate through socket shutdown, SIGTERM, and SIGKILL with proper exit verification at each stage. Also refactor sendCommand to use an options bag and give pingDaemon a 3s timeout instead of the default 30s so stuck-daemon detection is fast. Co-Authored-By: Claude Opus 4.6 --- packages/cli/src/commands/daemon-client.ts | 39 +++++-- packages/cli/src/commands/daemon.ts | 125 +++++++++++++++------ 2 files changed, 120 insertions(+), 44 deletions(-) diff --git a/packages/cli/src/commands/daemon-client.ts b/packages/cli/src/commands/daemon-client.ts index 91102c764..b9b313c0f 100644 --- a/packages/cli/src/commands/daemon-client.ts +++ b/packages/cli/src/commands/daemon-client.ts @@ -7,8 +7,6 @@ import type { Socket } from 'node:net'; import { homedir } from 'node:os'; import { join } from 'node:path'; -const READ_TIMEOUT_MS = 30_000; - /** * Get the default daemon socket path. * @@ -34,6 +32,20 @@ async function connectSocket(socketPath: string): Promise { }); } +/** + * Options for {@link sendCommand}. + */ +type SendCommandOptions = { + /** The UNIX socket path. */ + socketPath: string; + /** The RPC method name. */ + method: string; + /** Optional method parameters. */ + params?: Record | undefined; + /** Read timeout in milliseconds (default: 30 000). */ + timeoutMs?: number | undefined; +}; + /** * Send a JSON-RPC request to the daemon over a UNIX socket and return the response. * @@ -41,16 +53,19 @@ async function connectSocket(socketPath: string): Promise { * response line, then closes the connection. Retries once after a short delay * if the connection is rejected (e.g. due to a probe connection race). * - * @param socketPath - The UNIX socket path. - * @param method - The RPC method name. - * @param params - Optional method parameters. + * @param options - Command options. + * @param options.socketPath - The UNIX socket path. + * @param options.method - The RPC method name. + * @param options.params - Optional method parameters. + * @param options.timeoutMs - Read timeout in milliseconds (default: 30 000). * @returns The parsed JSON-RPC response. */ -export async function sendCommand( - socketPath: string, - method: string, - params?: Record, -): Promise { +export async function sendCommand({ + socketPath, + method, + params, + timeoutMs = 30_000, +}: SendCommandOptions): Promise { const id = randomUUID(); const request = { jsonrpc: '2.0', @@ -63,7 +78,7 @@ export async function sendCommand( const socket = await connectSocket(socketPath); try { await writeLine(socket, JSON.stringify(request)); - const responseLine = await readLine(socket, READ_TIMEOUT_MS); + const responseLine = await readLine(socket, timeoutMs); const parsed: unknown = JSON.parse(responseLine); assertIsJsonRpcResponse(parsed); return parsed; @@ -96,7 +111,7 @@ export async function sendCommand( */ export async function pingDaemon(socketPath: string): Promise { try { - await sendCommand(socketPath, 'getStatus'); + await sendCommand({ socketPath, method: 'getStatus', timeoutMs: 3_000 }); return true; } catch { return false; diff --git a/packages/cli/src/commands/daemon.ts b/packages/cli/src/commands/daemon.ts index e5705e4c0..39a2fdaf6 100644 --- a/packages/cli/src/commands/daemon.ts +++ b/packages/cli/src/commands/daemon.ts @@ -65,61 +65,71 @@ function resolveBundleSpecs(config: { /** * Stop the daemon via a `shutdown` RPC call. Falls back to PID + SIGTERM if - * the socket is unresponsive. + * the socket is unresponsive, and escalates to SIGKILL if SIGTERM is ignored. * * @param socketPath - The daemon socket path. * @returns True if the daemon was stopped (or was not running), false if it * failed to stop within the timeout. */ export async function stopDaemon(socketPath: string): Promise { - if (!(await pingDaemon(socketPath))) { + const pidPath = join(homedir(), '.ocap', 'daemon.pid'); + const pid = await readPidFile(pidPath); + const processAlive = pid !== undefined && isProcessAlive(pid); + const socketResponsive = await pingDaemon(socketPath); + + if (!socketResponsive && !processAlive) { + if (pid !== undefined) { + await rm(pidPath, { force: true }); + } process.stderr.write('Daemon is not running.\n'); return true; } process.stderr.write('Stopping daemon...\n'); - // Try socket-based shutdown first. - try { - await sendCommand(socketPath, 'shutdown'); - } catch { - // Socket unresponsive — fall back to PID + SIGTERM below. - } + let stopped = false; - // Poll until socket stops responding (max 5s). - const pollEnd = Date.now() + 5_000; - while (Date.now() < pollEnd) { - await new Promise((_resolve) => setTimeout(_resolve, 250)); - if (!(await pingDaemon(socketPath))) { - process.stderr.write('Daemon stopped.\n'); - return true; + // Strategy 1: Graceful socket-based shutdown. + if (socketResponsive) { + try { + await sendCommand({ socketPath, method: 'shutdown' }); + } catch { + // Socket became unresponsive. } + stopped = await waitFor(async () => !(await pingDaemon(socketPath)), 5_000); } - // Fallback: read PID file and send SIGTERM. - const pidPath = join(homedir(), '.ocap', 'daemon.pid'); - let pid: number | undefined; - try { - pid = Number(await readFile(pidPath, 'utf-8')); - } catch { - // PID file missing. + // Strategy 2: SIGTERM. + if (!stopped && pid !== undefined) { + try { + process.kill(pid, 'SIGTERM'); + } catch { + stopped = true; + } + if (!stopped) { + stopped = await waitFor(() => !isProcessAlive(pid), 5_000); + } } - if (pid && !Number.isNaN(pid)) { + // Strategy 3: SIGKILL. + if (!stopped && pid !== undefined) { try { - process.kill(pid, 'SIGTERM'); + process.kill(pid, 'SIGKILL'); } catch { - // Process may already be gone. + stopped = true; + } + if (!stopped) { + stopped = await waitFor(() => !isProcessAlive(pid), 2_000); } - // Give the process a moment to exit. - await new Promise((_resolve) => setTimeout(_resolve, 500)); + } + + if (stopped) { await rm(pidPath, { force: true }); process.stderr.write('Daemon stopped.\n'); - return true; + } else { + process.stderr.write('Daemon did not stop within timeout.\n'); } - - process.stderr.write('Daemon did not stop within timeout.\n'); - return false; + return stopped; } /** @@ -180,7 +190,7 @@ export async function handleDaemonExec( } } - const response = await sendCommand(socketPath, method, params); + const response = await sendCommand({ socketPath, method, params }); if (isJsonRpcFailure(response)) { process.stderr.write( @@ -197,3 +207,54 @@ export async function handleDaemonExec( process.stdout.write(`${JSON.stringify(response.result)}\n`); } } + +/** + * Read a PID from a file. + * + * @param pidPath - The PID file path. + * @returns The PID, or undefined if the file is missing or invalid. + */ +async function readPidFile(pidPath: string): Promise { + try { + const pid = Number(await readFile(pidPath, 'utf-8')); + return pid > 0 && !Number.isNaN(pid) ? pid : undefined; + } catch { + return undefined; + } +} + +/** + * Check whether a process is alive by sending signal 0. + * + * @param pid - The process ID to check. + * @returns True if the process exists. + */ +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +/** + * Poll until a condition is met or the timeout elapses. + * + * @param check - A function that returns true when the condition is met. + * @param timeoutMs - Maximum time to wait in milliseconds. + * @returns True if the condition was met, false on timeout. + */ +async function waitFor( + check: () => boolean | Promise, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await check()) { + return true; + } + await new Promise((resolveTimeout) => setTimeout(resolveTimeout, 250)); + } + return await check(); +} From d239ffe1f537f56fd880ac504519c1bb84a9c683 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 19 Feb 2026 14:56:45 -0800 Subject: [PATCH 27/33] fix(cli): resolve bundleSpec paths at correct nesting level launchSubcluster params are shaped { config: ClusterConfig }, but isClusterConfigLike was checking the top-level params object. Check parsed.config instead so relative bundleSpec paths are resolved. Co-Authored-By: Claude Opus 4.6 --- packages/cli/src/commands/daemon.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/daemon.ts b/packages/cli/src/commands/daemon.ts index 39a2fdaf6..1d0dc6e3f 100644 --- a/packages/cli/src/commands/daemon.ts +++ b/packages/cli/src/commands/daemon.ts @@ -178,8 +178,12 @@ export async function handleDaemonExec( if (rawParams !== undefined) { try { const parsed = JSON.parse(rawParams) as Record; - if (method === 'launchSubcluster' && isClusterConfigLike(parsed)) { - params = resolveBundleSpecs(parsed) as Record; + const { config } = parsed as { config?: unknown }; + if (method === 'launchSubcluster' && isClusterConfigLike(config)) { + params = { + ...parsed, + config: resolveBundleSpecs(config), + }; } else { params = parsed; } From 4537512d20dabd572df61cb0c9463dfab2d178a7 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:01:52 -0800 Subject: [PATCH 28/33] fix(cli): use Unix octal notation in README, simplify socket check Use conventional 0600 notation instead of JS 0o600 in documentation. Simplify post-shutdown socket check to a single ping. Co-Authored-By: Claude Opus 4.6 --- packages/cli/README.md | 2 +- packages/cli/src/commands/daemon.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index a5b805129..1d9c13661 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -45,7 +45,7 @@ Send an RPC method call to the daemon. Defaults to `getStatus` when `method` is The daemon is a prototype. The following limitations apply: 1. **`executeDBQuery` accepts arbitrary SQL** — any CLI user can execute unrestricted SQL against the kernel database. For production, this should be removed or restricted to read-only queries. -2. **No socket permission enforcement** — the Unix socket is created with default permissions. Any local user can connect and issue commands. For production, socket permissions should be restricted to `0o600`. +2. **No socket permission enforcement** — the Unix socket is created with default permissions. Any local user can connect and issue commands. For production, socket permissions should be restricted to `0600`. 3. **No daemon spawn concurrency protection** — if two CLI invocations run simultaneously and neither finds a running daemon, both may attempt to spawn one. A lockfile mechanism would prevent this. 4. **No request size limits** — the RPC server buffers incoming data without a size cap. A malicious client could exhaust daemon memory. 5. **No log rotation** — `daemon.log` grows without bound. Production use should add log rotation. diff --git a/packages/cli/src/commands/daemon.ts b/packages/cli/src/commands/daemon.ts index 1d0dc6e3f..d92471039 100644 --- a/packages/cli/src/commands/daemon.ts +++ b/packages/cli/src/commands/daemon.ts @@ -96,7 +96,7 @@ export async function stopDaemon(socketPath: string): Promise { } catch { // Socket became unresponsive. } - stopped = await waitFor(async () => !(await pingDaemon(socketPath)), 5_000); + stopped = !(await pingDaemon(socketPath)); } // Strategy 2: SIGTERM. From e03047a1d394575e0b2b90939c17f0235e46b2c5 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:48:32 -0800 Subject: [PATCH 29/33] refactor: Increase graceful socket shutdown timeout to 5s --- packages/cli/src/commands/daemon.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/commands/daemon.ts b/packages/cli/src/commands/daemon.ts index d92471039..1d0dc6e3f 100644 --- a/packages/cli/src/commands/daemon.ts +++ b/packages/cli/src/commands/daemon.ts @@ -96,7 +96,7 @@ export async function stopDaemon(socketPath: string): Promise { } catch { // Socket became unresponsive. } - stopped = !(await pingDaemon(socketPath)); + stopped = await waitFor(async () => !(await pingDaemon(socketPath)), 5_000); } // Strategy 2: SIGTERM. From 1751590572bd97fdada3442cfdd4dcf6918fdf38 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 19 Feb 2026 18:39:22 -0800 Subject: [PATCH 30/33] docs(cli): document PID reuse limitation in known limitations Co-Authored-By: Claude Opus 4.6 --- packages/cli/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/README.md b/packages/cli/README.md index 1d9c13661..c36158921 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -49,6 +49,7 @@ The daemon is a prototype. The following limitations apply: 3. **No daemon spawn concurrency protection** — if two CLI invocations run simultaneously and neither finds a running daemon, both may attempt to spawn one. A lockfile mechanism would prevent this. 4. **No request size limits** — the RPC server buffers incoming data without a size cap. A malicious client could exhaust daemon memory. 5. **No log rotation** — `daemon.log` grows without bound. Production use should add log rotation. +6. **PID file is vulnerable to PID reuse** — if the daemon crashes without cleaning up `daemon.pid` and the OS reassigns that PID to an unrelated process, `stopDaemon` may signal the wrong process. A lockfile (`flock`) mechanism would eliminate this risk (and also solve limitation #3). ## Contributing From d17955f6363695e54cb8a895a97ae209c78665c2 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 19 Feb 2026 18:57:05 -0800 Subject: [PATCH 31/33] fix(nodejs): delete daemon.log in deleteDaemonState Co-Authored-By: Claude Opus 4.6 --- packages/nodejs/src/daemon/delete-daemon-state.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/nodejs/src/daemon/delete-daemon-state.ts b/packages/nodejs/src/daemon/delete-daemon-state.ts index 2194fad60..ab47b03ff 100644 --- a/packages/nodejs/src/daemon/delete-daemon-state.ts +++ b/packages/nodejs/src/daemon/delete-daemon-state.ts @@ -26,11 +26,13 @@ export async function deleteDaemonState( const bundlesDir = join(ocapDir, 'bundles'); const pidPath = join(ocapDir, 'daemon.pid'); + const logPath = join(ocapDir, 'daemon.log'); await Promise.all([ rm(dbFilename, { force: true }), rm(socketPath, { force: true }), rm(bundlesDir, { recursive: true, force: true }), rm(pidPath, { force: true }), + rm(logPath, { force: true }), ]); } From 17c06a9c77aba32ed9f77bd31c1af1f88771f413 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 19 Feb 2026 18:57:51 -0800 Subject: [PATCH 32/33] fix(nodejs): only remove readLine's own socket listeners on cleanup Use removeListener with specific handler references instead of removeAllListeners, so callers' listeners are preserved. Co-Authored-By: Claude Opus 4.6 --- packages/nodejs/src/daemon/socket-line.ts | 46 +++++++++++++---------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/packages/nodejs/src/daemon/socket-line.ts b/packages/nodejs/src/daemon/socket-line.ts index e4df04bdc..1dc267391 100644 --- a/packages/nodejs/src/daemon/socket-line.ts +++ b/packages/nodejs/src/daemon/socket-line.ts @@ -41,19 +41,6 @@ export async function readLine( }, timeoutMs); } - /** - * Remove all listeners and clear the timeout. - */ - function cleanup(): void { - if (timer !== undefined) { - clearTimeout(timer); - } - socket.removeAllListeners('data'); - socket.removeAllListeners('error'); - socket.removeAllListeners('end'); - socket.removeAllListeners('close'); - } - const onData = (data: Buffer): void => { buffer += data.toString(); const idx = buffer.indexOf('\n'); @@ -63,18 +50,37 @@ export async function readLine( } }; - socket.on('data', onData); - socket.once('error', (error) => { + const onError = (error: Error): void => { cleanup(); reject(error); - }); - socket.once('end', () => { + }; + + const onEnd = (): void => { cleanup(); reject(new Error('Socket closed before response received')); - }); - socket.once('close', () => { + }; + + const onClose = (): void => { cleanup(); reject(new Error('Socket closed before response received')); - }); + }; + + /** + * Remove listeners registered by this call and clear the timeout. + */ + function cleanup(): void { + if (timer !== undefined) { + clearTimeout(timer); + } + socket.removeListener('data', onData); + socket.removeListener('error', onError); + socket.removeListener('end', onEnd); + socket.removeListener('close', onClose); + } + + socket.on('data', onData); + socket.once('error', onError); + socket.once('end', onEnd); + socket.once('close', onClose); }); } From c0adc264cc40b29aa8b6b4554ddd11feaecf0b49 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 19 Feb 2026 19:03:35 -0800 Subject: [PATCH 33/33] test(nodejs): add unit tests for socket-line readLine and writeLine Covers line parsing, buffering, error/end/close rejection, timeout, and verifies that readLine only removes its own socket listeners. Co-Authored-By: Claude Opus 4.6 --- .../nodejs/src/daemon/socket-line.test.ts | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 packages/nodejs/src/daemon/socket-line.test.ts diff --git a/packages/nodejs/src/daemon/socket-line.test.ts b/packages/nodejs/src/daemon/socket-line.test.ts new file mode 100644 index 000000000..1fdf35659 --- /dev/null +++ b/packages/nodejs/src/daemon/socket-line.test.ts @@ -0,0 +1,144 @@ +import EventEmitter from 'node:events'; +import type { Socket } from 'node:net'; +import { vi, describe, it, expect } from 'vitest'; + +import { readLine, writeLine } from './socket-line.ts'; + +/** + * Create a minimal mock socket backed by an EventEmitter. + * + * @returns A mock socket with a spied `write` and `removeListener`. + */ +function makeMockSocket(): Socket { + const emitter = new EventEmitter(); + const socket = emitter as unknown as Socket; + + socket.write = vi.fn( + (_data: string, done?: (error?: Error | null) => void) => { + done?.(); + return true; + }, + ) as Socket['write']; + vi.spyOn(emitter, 'removeListener'); + return socket; +} + +describe('writeLine', () => { + it('writes data with a trailing newline', async () => { + const socket = makeMockSocket(); + await writeLine(socket, 'hello'); + expect(socket.write).toHaveBeenCalledWith('hello\n', expect.any(Function)); + }); + + it('rejects when write fails', async () => { + const socket = makeMockSocket(); + + socket.write = vi.fn( + (_data: string, done?: (error?: Error | null) => void) => { + done?.(new Error('write failed')); + return false; + }, + ) as Socket['write']; + await expect(writeLine(socket, 'hello')).rejects.toThrow('write failed'); + }); +}); + +describe('readLine', () => { + it('resolves with a complete line', async () => { + const socket = makeMockSocket(); + const promise = readLine(socket); + socket.emit('data', Buffer.from('hello\n')); + expect(await promise).toBe('hello'); + }); + + it('buffers partial data until newline arrives', async () => { + const socket = makeMockSocket(); + const promise = readLine(socket); + socket.emit('data', Buffer.from('hel')); + socket.emit('data', Buffer.from('lo\n')); + expect(await promise).toBe('hello'); + }); + + it('returns only the first line when multiple lines arrive', async () => { + const socket = makeMockSocket(); + const promise = readLine(socket); + socket.emit('data', Buffer.from('first\nsecond\n')); + expect(await promise).toBe('first'); + }); + + it('rejects on socket error', async () => { + const socket = makeMockSocket(); + const promise = readLine(socket); + socket.emit('error', new Error('connection reset')); + await expect(promise).rejects.toThrow('connection reset'); + }); + + it('rejects on socket end', async () => { + const socket = makeMockSocket(); + const promise = readLine(socket); + socket.emit('end'); + await expect(promise).rejects.toThrow( + 'Socket closed before response received', + ); + }); + + it('rejects on socket close', async () => { + const socket = makeMockSocket(); + const promise = readLine(socket); + socket.emit('close'); + await expect(promise).rejects.toThrow( + 'Socket closed before response received', + ); + }); + + it('rejects on timeout', async () => { + const socket = makeMockSocket(); + const promise = readLine(socket, 50); + await expect(promise).rejects.toThrow('Socket read timed out'); + }); + + it('does not time out if data arrives before deadline', async () => { + const socket = makeMockSocket(); + const promise = readLine(socket, 5_000); + socket.emit('data', Buffer.from('fast\n')); + expect(await promise).toBe('fast'); + }); + + it('removes only its own listeners on success', async () => { + const socket = makeMockSocket(); + const externalListener = vi.fn(); + socket.on('data', externalListener); + + const promise = readLine(socket); + socket.emit('data', Buffer.from('line\n')); + await promise; + + expect(socket.listenerCount('data')).toBe(1); + expect(socket.listeners('data')).toContain(externalListener); + }); + + it('removes only its own listeners on error', async () => { + const socket = makeMockSocket(); + const externalListener = vi.fn(); + socket.on('error', externalListener); + + const promise = readLine(socket); + socket.emit('error', new Error('boom')); + await promise.catch(() => undefined); + + expect(socket.listenerCount('error')).toBe(1); + expect(socket.listeners('error')).toContain(externalListener); + }); + + it('removes only its own listeners on timeout', async () => { + const socket = makeMockSocket(); + const externalListener = vi.fn(); + socket.on('data', externalListener); + + const promise = readLine(socket, 50); + await promise.catch(() => undefined); + + expect(socket.listenerCount('data')).toBe(1); + expect(socket.listeners('data')).toContain(externalListener); + }); +});