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/package.json b/package.json index 92b01f2b3..bd178f3cf 100644 --- a/package.json +++ b/package.json @@ -113,7 +113,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, @@ -124,7 +123,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/README.md b/packages/cli/README.md index d7b7b1ac9..c36158921 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -24,6 +24,33 @@ 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 purge --force` + +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 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 `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. +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 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/package.json b/packages/cli/package.json index 4a6c8317b..4076f33be 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -34,26 +34,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" @@ -95,7 +85,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..cb670b6b9 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'; @@ -6,11 +7,18 @@ 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'; import type { Config } from './config.ts'; -import { startRelay } from './relay.ts'; import { withTimeout } from './utils.ts'; /** @@ -173,6 +181,108 @@ 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 () => { + const stopped = await stopDaemon(socketPath); + if (!stopped) { + process.exitCode = 1; + } + }, + ) + .command( + ['purge', 'begone'], + 'Stop the daemon and delete all state', + (_y) => + _y.option('force', { + describe: 'Confirm state deletion', + type: 'boolean', + demandOption: true, + }), + 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); + }, + ) + .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', + }) + .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) { + 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/commands/daemon-client.ts b/packages/cli/src/commands/daemon-client.ts new file mode 100644 index 000000000..b9b313c0f --- /dev/null +++ b/packages/cli/src/commands/daemon-client.ts @@ -0,0 +1,119 @@ +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'; +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', 'daemon.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); + }); +} + +/** + * 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. + * + * 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 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, + method, + params, + timeoutMs = 30_000, +}: SendCommandOptions): 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, timeoutMs); + const parsed: unknown = JSON.parse(responseLine); + assertIsJsonRpcResponse(parsed); + return parsed; + } finally { + socket.destroy(); + } + }; + + try { + return await attempt(); + } 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 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 responds to the RPC call. + */ +export async function pingDaemon(socketPath: string): Promise { + try { + await sendCommand({ socketPath, method: 'getStatus', timeoutMs: 3_000 }); + return true; + } catch { + return false; + } +} diff --git a/packages/cli/src/commands/daemon-entry.ts b/packages/cli/src/commands/daemon-entry.ts new file mode 100644 index 000000000..d95d835e9 --- /dev/null +++ b/packages/cli/src/commands/daemon-entry.ts @@ -0,0 +1,104 @@ +import '@metamask/kernel-shims/endoify-node'; +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'; + +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. + */ +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)], + }); + + 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, + }); + + const pidPath = join(ocapDir, 'daemon.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; + /** + * 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})...`); + shutdownPromise = handle.close().finally(() => { + rm(pidPath, { force: true }).catch(() => undefined); + }); + } + return shutdownPromise; + } + + process.on('SIGTERM', () => { + shutdown('SIGTERM').catch(() => (process.exitCode = 1)); + }); + process.on('SIGINT', () => { + shutdown('SIGINT').catch(() => (process.exitCode = 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-spawn.ts b/packages/cli/src/commands/daemon-spawn.ts new file mode 100644 index 000000000..8a4cebc67 --- /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 { pingDaemon } 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 pingDaemon(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, + 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 pingDaemon(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/commands/daemon.ts b/packages/cli/src/commands/daemon.ts new file mode 100644 index 000000000..1d0dc6e3f --- /dev/null +++ b/packages/cli/src/commands/daemon.ts @@ -0,0 +1,264 @@ +import { isJsonRpcFailure } from '@metamask/utils'; +import { deleteDaemonState } 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 { pingDaemon, sendCommand } from './daemon-client.ts'; +import { ensureDaemon } from './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 tildefy(path: string): string { + return path.startsWith(home) ? `~${path.slice(home.length)}` : path; +} + +/** + * 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. + * @param config.vats - The vat configurations with optional bundleSpec paths. + * @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 }; +} + +/** + * Stop the daemon via a `shutdown` RPC call. Falls back to PID + SIGTERM if + * 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 { + 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'); + + let stopped = false; + + // 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); + } + + // Strategy 2: SIGTERM. + if (!stopped && pid !== undefined) { + try { + process.kill(pid, 'SIGTERM'); + } catch { + stopped = true; + } + if (!stopped) { + stopped = await waitFor(() => !isProcessAlive(pid), 5_000); + } + } + + // Strategy 3: SIGKILL. + if (!stopped && pid !== undefined) { + try { + process.kill(pid, 'SIGKILL'); + } catch { + stopped = true; + } + if (!stopped) { + stopped = await waitFor(() => !isProcessAlive(pid), 2_000); + } + } + + if (stopped) { + await rm(pidPath, { force: true }); + process.stderr.write('Daemon stopped.\n'); + } else { + process.stderr.write('Daemon did not stop within timeout.\n'); + } + return stopped; +} + +/** + * Ensure the daemon is running and print its socket path. + * + * @param socketPath - The daemon socket path. + */ +export async function handleDaemonStart(socketPath: string): Promise { + await ensureDaemon(socketPath); + process.stderr.write(`Daemon running. Socket: ${tildefy(socketPath)}\n`); +} + +/** + * Stop the daemon (if running) and delete all state. + * + * @param socketPath - The daemon socket path. + */ +export async function handleDaemonBegone(socketPath: string): Promise { + 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'); +} + +/** + * 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]; + + // For launchSubcluster: resolve relative bundleSpec paths to file:// URLs. + let params: Record | undefined; + if (rawParams !== undefined) { + try { + const parsed = JSON.parse(rawParams) as Record; + const { config } = parsed as { config?: unknown }; + if (method === 'launchSubcluster' && isClusterConfigLike(config)) { + params = { + ...parsed, + config: resolveBundleSpecs(config), + }; + } else { + params = parsed; + } + } catch { + process.stderr.write('Error: params-json must be valid JSON.\n'); + process.exitCode = 1; + return; + } + } + + const response = await sendCommand({ socketPath, method, params }); + + if (isJsonRpcFailure(response)) { + process.stderr.write( + `Error: ${response.error.message} (code ${String(response.error.code)})\n`, + ); + process.exitCode = 1; + return; + } + + 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`); + } +} + +/** + * 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(); +} 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/packages/extension/package.json b/packages/extension/package.json index 18df82812..a1d8ef404 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.58.2", 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-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/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/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/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/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. * 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/delete-daemon-state.ts b/packages/nodejs/src/daemon/delete-daemon-state.ts new file mode 100644 index 000000000..ab47b03ff --- /dev/null +++ b/packages/nodejs/src/daemon/delete-daemon-state.ts @@ -0,0 +1,38 @@ +import { rm } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +/** + * Options for deleting daemon state. + */ +export type DeleteDaemonStateOptions = { + /** UNIX socket path. Defaults to ~/.ocap/daemon.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 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'); + 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 }), + ]); +} diff --git a/packages/nodejs/src/daemon/index.ts b/packages/nodejs/src/daemon/index.ts new file mode 100644 index 000000000..604ce1ef6 --- /dev/null +++ b/packages/nodejs/src/daemon/index.ts @@ -0,0 +1,7 @@ +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 { deleteDaemonState } from './delete-daemon-state.ts'; +export type { DeleteDaemonStateOptions } from './delete-daemon-state.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 new file mode 100644 index 000000000..11168f6f9 --- /dev/null +++ b/packages/nodejs/src/daemon/rpc-socket-server.ts @@ -0,0 +1,231 @@ +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'; + +/** + * 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. + * + * 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, + executeDBQuery: (sql: string) => kernelDatabase.executeQuery(sql), + }); + + const server = createServer((socket) => { + let buffer = ''; + + 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`); + return undefined; + }) + .catch(() => { + socket.end( + `${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) + }); + }); + + await listen(server, socketPath); + + return { + close: async () => { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + }, + }; +} + +/** + * 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. + * + * @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 { + // 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, () => { + server.removeListener('error', reject); + resolve(); + }); + }); +} 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); + }); +}); diff --git a/packages/nodejs/src/daemon/socket-line.ts b/packages/nodejs/src/daemon/socket-line.ts new file mode 100644 index 000000000..1dc267391 --- /dev/null +++ b/packages/nodejs/src/daemon/socket-line.ts @@ -0,0 +1,86 @@ +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); + } + + const onData = (data: Buffer): void => { + buffer += data.toString(); + const idx = buffer.indexOf('\n'); + if (idx !== -1) { + cleanup(); + resolve(buffer.slice(0, idx)); + } + }; + + const onError = (error: Error): void => { + cleanup(); + reject(error); + }; + + const onEnd = (): void => { + cleanup(); + reject(new Error('Socket closed before response received')); + }; + + 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); + }); +} 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..d4ac55af5 --- /dev/null +++ b/packages/nodejs/src/daemon/start-daemon.test.ts @@ -0,0 +1,86 @@ +import { vi, describe, it, expect, afterEach } from 'vitest'; + +import { startDaemon } from './start-daemon.ts'; +import type { DaemonHandle } from './start-daemon.ts'; + +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, + }), +})); + +const mockKernel = { + stop: vi.fn().mockResolvedValue(undefined), +}; + +const mockKernelDatabase = { + executeQuery: vi.fn().mockReturnValue([]), +}; + +describe('startDaemon', () => { + let handle: DaemonHandle | undefined; + + afterEach(async () => { + if (handle) { + const toClose = handle; + handle = undefined; + await toClose.close(); + } + vi.clearAllMocks(); + }); + + 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({ + socketPath: tmpSocket, + kernel: mockKernel as never, + kernelDatabase: mockKernelDatabase as never, + }); + + expect(mockedStartRpc).toHaveBeenCalledWith({ + socketPath: tmpSocket, + kernel: mockKernel, + kernelDatabase: mockKernelDatabase, + }); + }); + + 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({ + socketPath: tmpSocket, + kernel: mockKernel as never, + kernelDatabase: mockKernelDatabase as never, + }); + + expect(handle.socketPath).toBe(tmpSocket); + expect(handle.kernel).toBe(mockKernel); + expect(typeof handle.close).toBe('function'); + }); + + 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({ + socketPath: tmpSocket, + kernel: mockKernel as never, + kernelDatabase: mockKernelDatabase as never, + }); + + const toClose = handle; + handle = undefined; + await toClose.close(); + + 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 new file mode 100644 index 000000000..fa50afa54 --- /dev/null +++ b/packages/nodejs/src/daemon/start-daemon.ts @@ -0,0 +1,60 @@ +import type { KernelDatabase } from '@metamask/kernel-store'; +import type { Kernel } from '@metamask/ocap-kernel'; + +import { startRpcSocketServer } from './rpc-socket-server.ts'; + +/** + * Options for starting the daemon. + */ +export type StartDaemonOptions = { + /** UNIX socket path for the RPC server. */ + socketPath: string; + /** A running kernel instance. */ + kernel: Kernel; + /** The kernel database instance. */ + kernelDatabase: KernelDatabase; + /** Optional callback invoked when a `shutdown` RPC is received. */ + onShutdown?: () => Promise; +}; + +/** + * Handle returned by {@link startDaemon}. + */ +export type DaemonHandle = { + kernel: Kernel; + socketPath: string; + close: () => Promise; +}; + +/** + * Start the OCAP 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. + */ +export async function startDaemon( + options: StartDaemonOptions, +): Promise { + const { socketPath, kernel, kernelDatabase, onShutdown } = options; + + const rpcServer = await startRpcSocketServer({ + socketPath, + kernel, + kernelDatabase, + onShutdown, + }); + + const close = async (): Promise => { + await rpcServer.close(); + await kernel.stop(); + }; + + return { + kernel, + socketPath, + close, + }; +} diff --git a/packages/nodejs/src/index.ts b/packages/nodejs/src/index.ts index 49c133fdf..1a1eeb323 100644 --- a/packages/nodejs/src/index.ts +++ b/packages/nodejs/src/index.ts @@ -1,4 +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'; 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..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'); @@ -132,9 +140,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.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 00f6353b4..81e4a37e4 100644 --- a/packages/nodejs/src/kernel/make-kernel.ts +++ b/packages/nodejs/src/kernel/make-kernel.ts @@ -1,11 +1,23 @@ +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'; -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'; +/** + * Result of {@link makeKernel}. + */ +export type MakeKernelResult = { + kernel: Kernel; + kernelDatabase: KernelDatabase; +}; + /** * The main function for the kernel worker. * @@ -16,7 +28,8 @@ 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. - * @returns The kernel, initialized. + * @param options.systemSubclusters - Optional system subcluster configurations. + * @returns The kernel and its database. */ export async function makeKernel({ workerFilePath, @@ -25,6 +38,7 @@ export async function makeKernel({ logger, keySeed, ioChannelFactory, + systemSubclusters, }: { workerFilePath?: string; resetStorage?: boolean; @@ -32,7 +46,8 @@ export async function makeKernel({ logger?: Logger; keySeed?: string | undefined; ioChannelFactory?: IOChannelFactory; -}): Promise { + systemSubclusters?: SystemSubclusterConfig[]; +}): Promise { const rootLogger = logger ?? new Logger('kernel-worker'); const platformServicesClient = new NodejsPlatformServices({ workerFilePath, @@ -48,7 +63,8 @@ export async function makeKernel({ logger: rootLogger.subLogger({ tags: ['kernel'] }), keySeed, ioChannelFactory: ioChannelFactory ?? makeIOChannelFactory(), + ...(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 new file mode 100644 index 000000000..fb3069619 --- /dev/null +++ b/packages/nodejs/test/e2e/daemon-stack.test.ts @@ -0,0 +1,184 @@ +import type { KernelDatabase } from '@metamask/kernel-store'; +import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/nodejs'; +import type { Kernel } from '@metamask/ocap-kernel'; +import * as net from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it, expect, afterEach } from 'vitest'; + +import type { RpcSocketServerHandle } from '../../src/daemon/index.ts'; +import { + readLine, + startRpcSocketServer, + writeLine, +} from '../../src/daemon/index.ts'; +import { makeTestKernel } from '../helpers/kernel.ts'; + +/** + * 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); + }); +} + +/** + * Send a JSON-RPC request over a socket and read the JSON-RPC response. + * + * @param socketPath - The socket path. + * @param method - The RPC method name. + * @param params - Optional method parameters. + * @returns The parsed JSON-RPC response. + */ +async function sendJsonRpc( + socketPath: 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 Record; + } finally { + socket.destroy(); + } +} + +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 an RPC socket server. + * + * @returns The socket path. + */ + async function bootDaemonStack(): Promise { + const socketPath = tempSocketPath(); + + kernelDatabase = await makeSQLKernelDatabase({ dbFilename: ':memory:' }); + kernel = await makeTestKernel(kernelDatabase); + await kernel.initIdentity(); + + 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; + await stopResult; + } + if (kernelDatabase) { + kernelDatabase.close(); + kernelDatabase = undefined; + } + }); + + it('returns kernel status via getStatus', async () => { + const socketPath = await bootDaemonStack(); + + const response = await sendJsonRpc(socketPath, 'getStatus'); + + 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'); + }); + + it('returns error for unknown method', async () => { + const socketPath = await bootDaemonStack(); + + const response = await sendJsonRpc(socketPath, 'nonexistentMethod'); + + expect(response.error).toBeDefined(); + expect((response.error as { code: number }).code).toBe(-32601); + }); + + it('executes DB query', async () => { + const socketPath = await bootDaemonStack(); + + const response = await sendJsonRpc(socketPath, 'executeDBQuery', { + sql: 'SELECT key, value FROM kv LIMIT 5', + }); + + expect(response.error).toBeUndefined(); + expect(Array.isArray(response.result)).toBe(true); + }); + + it('handles sequential requests on separate connections', async () => { + const socketPath = await bootDaemonStack(); + + const response1 = await sendJsonRpc(socketPath, 'getStatus'); + expect(response1.error).toBeUndefined(); + expect(response1.result).toBeDefined(); + + const response2 = await sendJsonRpc(socketPath, 'getStatus'); + expect(response2.error).toBeUndefined(); + expect(response2.result).toBeDefined(); + }); + + it('terminates all vats', async () => { + const socketPath = await bootDaemonStack(); + + const response = await sendJsonRpc(socketPath, 'terminateAllVats'); + + expect(response.error).toBeUndefined(); + }); + + it('returns proper JSON-RPC error structure', async () => { + const socketPath = await bootDaemonStack(); + + const response = await sendJsonRpc(socketPath, 'nonexistent'); + + 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 c44075080..4dc9ef864 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 { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; 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/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/omnium-gatherum/package.json b/packages/omnium-gatherum/package.json index 8239833a0..390e371e9 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.58.2", "@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 343b32705..345e9bbea 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" @@ -3559,7 +3559,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.58.2" @@ -3970,13 +3969,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 +4028,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.58.2" "@testing-library/dom": "npm:^10.4.0"