Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
cde2516
refactor(kernel-utils): move ifDefined from kernel-agents to kernel-u…
grypez Feb 17, 2026
9b29f26
feat(ocap-kernel): add console vat and system console vat with IO dis…
grypez Feb 17, 2026
8e9367f
feat(nodejs): add daemon orchestration with IO channel support
grypez Feb 17, 2026
2806502
feat(cli): add ok binary with yargs CLI for kernel interaction
grypez Feb 17, 2026
5f8feba
feat(ocap-kernel,cli,nodejs): two-tier access for system console vat
grypez Feb 17, 2026
21d6581
refactor(cli,nodejs,kernel-utils): replace system-console-vat with di…
rekmarks Feb 19, 2026
f023fc2
fix(cli,nodejs): daemon design fixes and known-limitations docs
rekmarks Feb 19, 2026
58a54c2
refactor(cli): consolidate `ok` CLI into `ocap daemon` command
rekmarks Feb 19, 2026
b04a79b
refactor(cli,nodejs): rename daemon socket from console.sock to daemo…
rekmarks Feb 19, 2026
54f238c
refactor(cli,nodejs): rename flushDaemon to deleteDaemonState
rekmarks Feb 19, 2026
082351e
feat(cli): add usage examples to ocap daemon exec help
rekmarks Feb 19, 2026
f2b1450
refactor(cli): rename begone to purge (with begone alias) and --forgo…
rekmarks Feb 19, 2026
c182292
refactor(cli): replace process.exit with process.exitCode and clean u…
rekmarks Feb 19, 2026
4d986aa
chore: update yarn.lock
rekmarks Feb 19, 2026
3731aff
refactor: Remove Kernel.invokeMethod()
rekmarks Feb 19, 2026
935aa03
chore: Restore @ocap/cli dev dep to kernel-test
rekmarks Feb 19, 2026
e10fe79
fix(cli): make daemon shutdown idempotent
rekmarks Feb 19, 2026
5302d72
fix(cli): persist daemon kernel state to ~/.ocap/kernel.sqlite
rekmarks Feb 19, 2026
42f6f57
fix(cli): clean up PID file even if daemon shutdown fails
rekmarks Feb 19, 2026
7731a5b
fix(nodejs): close kernel database on daemon shutdown
rekmarks Feb 19, 2026
9c0a0b0
fix(cli): make stopDaemon return success status and simplify SIGTERM …
rekmarks Feb 19, 2026
8972943
Merge branch 'main' into rekm/grypez/daemon
rekmarks Feb 19, 2026
6efb29c
fix(cli): reject invalid JSON params instead of wrapping them
rekmarks Feb 19, 2026
3fabf3e
fix(cli): clean up kernel and database on daemon startup failure
rekmarks Feb 19, 2026
673f7f7
Merge branch 'main' into rekm/grypez/daemon
rekmarks Feb 19, 2026
1928617
fix(nodejs): reject multiple requests on single RPC socket connection
rekmarks Feb 19, 2026
04c4fdc
fix(nodejs): remove redundant kernelDatabase.close() from start-daemon
rekmarks Feb 19, 2026
87b6b55
fix(cli): improve stopDaemon to handle stuck daemon processes
rekmarks Feb 19, 2026
d239ffe
fix(cli): resolve bundleSpec paths at correct nesting level
rekmarks Feb 19, 2026
4537512
fix(cli): use Unix octal notation in README, simplify socket check
rekmarks Feb 19, 2026
e03047a
refactor: Increase graceful socket shutdown timeout to 5s
rekmarks Feb 19, 2026
98d80e9
Merge branch 'main' into rekm/grypez/daemon
rekmarks Feb 20, 2026
1751590
docs(cli): document PID reuse limitation in known limitations
rekmarks Feb 20, 2026
d17955f
fix(nodejs): delete daemon.log in deleteDaemonState
rekmarks Feb 20, 2026
17c06a9
fix(nodejs): only remove readLine's own socket listeners on cleanup
rekmarks Feb 20, 2026
c0adc26
test(nodejs): add unit tests for socket-line readLine and writeLine
rekmarks Feb 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/**/*',
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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": {
Expand Down
27 changes: 27 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
15 changes: 2 additions & 13 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -95,7 +85,6 @@
"node": ">=22"
},
"exports": {
"./package.json": "./package.json",
"./relay": "./dist/relay.mjs"
"./package.json": "./package.json"
}
}
112 changes: 111 additions & 1 deletion packages/cli/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,24 @@
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';
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';

/**
Expand Down Expand Up @@ -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();
119 changes: 119 additions & 0 deletions packages/cli/src/commands/daemon-client.ts
Original file line number Diff line number Diff line change
@@ -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<Socket> {
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<string, unknown> | 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<JsonRpcResponse> {
const id = randomUUID();
const request = {
jsonrpc: '2.0',
id,
method,
...(params === undefined ? {} : { params }),
};

const attempt = async (): Promise<JsonRpcResponse> => {
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<boolean> {
try {
await sendCommand({ socketPath, method: 'getStatus', timeoutMs: 3_000 });
return true;
} catch {
return false;
}
}
Loading
Loading