diff --git a/packages/caprock/.claude-plugin/plugin.json b/packages/caprock/.claude-plugin/plugin.json new file mode 100644 index 0000000000..15ac7835de --- /dev/null +++ b/packages/caprock/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", + "name": "caprock", + "version": "0.1.0", + "description": "Routes Claude Code tool invocations through an ocap-kernel permission vat (POLA enforcement).", + "repository": "https://github.com/MetaMask/ocap-kernel", + "license": "MIT" +} diff --git a/packages/caprock/CHANGELOG.md b/packages/caprock/CHANGELOG.md new file mode 100644 index 0000000000..0c82cb1ed6 --- /dev/null +++ b/packages/caprock/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +[Unreleased]: https://github.com/MetaMask/ocap-kernel/ diff --git a/packages/caprock/README.md b/packages/caprock/README.md new file mode 100644 index 0000000000..32a4ee316d --- /dev/null +++ b/packages/caprock/README.md @@ -0,0 +1,15 @@ +# `@ocap/caprock` + +Claude Code plugin: routes tool invocations through an ocap-kernel permission vat (POLA enforcement) + +## Installation + +`yarn add @ocap/caprock` + +or + +`npm install @ocap/caprock` + +## 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/caprock/bin/harden-shim.ts b/packages/caprock/bin/harden-shim.ts new file mode 100644 index 0000000000..cf12347c0d --- /dev/null +++ b/packages/caprock/bin/harden-shim.ts @@ -0,0 +1,12 @@ +/* + * No-op harden shim for the hook process. + * + * The hook is not a vat — it must not run SES lockdown because full lockdown + * is incompatible with native tree-sitter bindings. @endo modules call + * harden() at module-evaluation time, so we install a benign identity + * function as the global before any @endo import evaluates. + * + * ESM evaluates modules depth-first in import order, so placing this as + * the first import in hook.ts guarantees it runs before @endo/promise-kit. + */ +(globalThis as { harden?: (value: T) => T }).harden ??= (value) => value; diff --git a/packages/caprock/bin/hook.test.ts b/packages/caprock/bin/hook.test.ts new file mode 100644 index 0000000000..1c024b872a --- /dev/null +++ b/packages/caprock/bin/hook.test.ts @@ -0,0 +1,115 @@ +/* eslint-disable n/no-process-env */ +import { execFile, spawn } from 'node:child_process'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const execFileAsync = promisify(execFile); + +const HOOK_BIN = fileURLToPath( + new URL('../dist/bin/hook.mjs', import.meta.url), +); +const PKG_DIR = fileURLToPath(new URL('..', import.meta.url)); + +/** + * Spawn hook.mjs with a JSON payload on stdin and collect all output. + * + * @param payload - The hook event payload to send. + * @param env - Extra environment variables. + * @param timeoutMs - Kill timeout in milliseconds. + * @returns stdout, stderr, and exit code. + */ +async function runHook( + payload: unknown, + env: NodeJS.ProcessEnv, + timeoutMs: number, +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + return new Promise((resolve, reject) => { + const child = spawn('node', [HOOK_BIN], { + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, ...env }, + }); + + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (chunk: Buffer) => { + stdout += chunk.toString(); + }); + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + + const timer = setTimeout(() => { + child.kill(); + reject(new Error(`Hook timed out after ${timeoutMs}ms`)); + }, timeoutMs); + + child.on('close', (code) => { + clearTimeout(timer); + resolve({ stdout, stderr, exitCode: code ?? -1 }); + }); + + child.on('error', (error) => { + clearTimeout(timer); + reject(error); + }); + + child.stdin.write(JSON.stringify(payload)); + child.stdin.end(); + }); +} + +describe('hook binary', () => { + let ocapHome: string; + + beforeAll(async () => { + await execFileAsync('yarn', ['build'], { cwd: PKG_DIR }); + ocapHome = await mkdtemp(join(tmpdir(), 'caprock-hook-test-')); + }, 60_000); + + afterAll(async () => { + await rm(ocapHome, { recursive: true, force: true }); + }); + + it('loads without SES globals (SessionStart)', async () => { + const { stderr, exitCode } = await runHook( + { + hook_event_name: 'SessionStart', + session_id: 'hook-integration-test', + transcript_path: '/dev/null', + }, + { OCAP_HOME: ocapHome }, + 8_000, + ); + + expect(exitCode).toBe(0); + expect(stderr).not.toMatch(/harden is not defined/u); + expect(stderr).not.toMatch(/Cannot initialize @endo\/errors/u); + expect(stderr).not.toMatch(/missing globalThis\.assert/u); + }, 8_000); + + it('loads without SES globals (PreToolUse)', async () => { + const { stdout, stderr, exitCode } = await runHook( + { + hook_event_name: 'PreToolUse', + session_id: 'hook-integration-test', + transcript_path: '/dev/null', + tool_name: 'Bash', + tool_input: { command: 'ls -la' }, + }, + { OCAP_HOME: ocapHome }, + 8_000, + ); + + expect(exitCode).toBe(0); + expect(stderr).not.toMatch(/harden is not defined/u); + expect(stderr).not.toMatch(/Cannot initialize @endo\/errors/u); + expect(stderr).not.toMatch(/missing globalThis\.assert/u); + // With no daemon running the hook must not block — it passes through. + expect(stdout).toContain('"continue":true'); + }, 8_000); +}); diff --git a/packages/caprock/bin/hook.ts b/packages/caprock/bin/hook.ts new file mode 100644 index 0000000000..3bd85d0076 --- /dev/null +++ b/packages/caprock/bin/hook.ts @@ -0,0 +1,873 @@ +/* eslint-disable camelcase */ +/* eslint-disable n/no-process-env */ +/** + * caprock — Claude Code CLI hook handler + * + * Invoked by Claude Code for each hook event. Reads JSON from stdin, dispatches + * to the appropriate handler, writes control JSON to stdout if needed. + */ + +import './harden-shim.ts'; + +import type { + ParsedInvocation, + Provision, +} from '@metamask/kernel-utils/session/provision'; +import { invocationToProvision } from '@metamask/kernel-utils/session/provision'; +import { isJsonRpcFailure } from '@metamask/utils'; +import { spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { readFile, writeFile, access, mkdir } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { decompose } from '../src/bash.ts'; +import { + getCaprockDir, + getSocketPath, + getOcapBinPath, +} from '../src/paths/ocap-kernel.ts'; +import { + getPluginRoot, + getVatBundlePath, + getProjectSettingsLocalPath, +} from '../src/paths/plugin.ts'; +import { getClaudeDir, getClaudeSettingsPath } from '../src/paths/user.ts'; +import { + pingDaemon, + sendCommand, + decodeCapData, + decodeSmallcapsStrings, + createKernelSession, + authorizeRequest, + recordProvisioned, +} from '../src/rpc.ts'; +import { + loadSessionState, + saveSessionState, + appendEvent, + readEvents, + readSettingsAllowList, + caprockOutputPath, +} from '../src/session.ts'; +import type { + AnyHookPayload, + CapData, + Decision, + SessionState, + SessionStartPayload, + PreToolUsePayload, + PostToolUsePayload, + PermissionRequestPayload, + PermissionDeniedPayload, + FileChangedPayload, + SessionEndPayload, +} from '../src/types.ts'; + +// ─── Constants ────────────────────────────────────────────────────────────── + +const SOCKET_PATH = getSocketPath(); +const BIN_DIR = import.meta.dirname; +const VAT_BUNDLE = getVatBundlePath(BIN_DIR); + +// CLAUDE_PROJECT_DIR is exported by Claude Code to hook processes and points +// at the workspace root; fall back to the plugin root for standalone use. +const SETTINGS_PATHS = [ + getClaudeSettingsPath(), + getProjectSettingsLocalPath(BIN_DIR), +]; + +// ─── Utilities ────────────────────────────────────────────────────────────── + +/** + * Returns the current time as an ISO 8601 string. + * + * @returns ISO 8601 timestamp. + */ +function now(): string { + return new Date().toISOString(); +} + +/** + * Compute a short hash of the tool input for use as a grant key. + * + * @param toolInput - The raw tool input object. + * @returns A 16-character hex digest. + */ +function inputSha(toolInput: Record): string { + return createHash('sha256') + .update(JSON.stringify(toolInput)) + .digest('hex') + .slice(0, 16); +} + +/** + * Read all bytes from stdin and return them as a UTF-8 string. + * + * @returns The stdin content. + */ +async function readStdin(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(chunk as Buffer); + } + return Buffer.concat(chunks).toString('utf8'); +} + +// ─── Plugin self-registration ──────────────────────────────────────────────── + +/** + * Add the allow rule for this plugin's status skill to `~/.claude/settings.json`. + * Runs from the SessionStart hook so no permission check applies to the write. + * Uses a glob over the version segment so the rule survives plugin updates. + */ +async function registerSkillPermissions(): Promise { + if (!process.env.CLAUDE_PLUGIN_ROOT) { + return; + } + const pluginRoot = getPluginRoot(BIN_DIR); + + const settingsPath = getClaudeSettingsPath(); + let settings: { permissions?: { allow?: string[] } } = {}; + try { + settings = JSON.parse( + await readFile(settingsPath, 'utf8'), + ) as typeof settings; + } catch { + /* file absent or unparseable — start fresh */ + } + + const current = settings.permissions?.allow ?? []; + const versionGlob = pluginRoot.replace(/\/\d+\.\d+\.\d+$/u, '/*'); + const newEntries = [ + `Bash(${versionGlob}/scripts/status.sh *)`, + `Bash(${versionGlob}/scripts/setup.sh)`, + ].filter((entry) => !current.includes(entry)); + + if (newEntries.length === 0) { + return; + } + + settings.permissions ??= {}; + settings.permissions.allow = [...current, ...newEntries]; + await mkdir(getClaudeDir(), { recursive: true }); + await writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`); +} + +// ─── Daemon lifecycle ──────────────────────────────────────────────────────── + +/** Ensure the ocap-kernel daemon is running, starting it if not. */ +async function ensureDaemon(): Promise { + if (await pingDaemon(SOCKET_PATH)) { + return; + } + + const ocapBin = getOcapBinPath(BIN_DIR); + + let resolvedBin = ocapBin; + try { + await access(ocapBin); + } catch { + resolvedBin = 'ocap'; // fall back to PATH + } + + const isScript = resolvedBin.endsWith('.mjs') || resolvedBin.endsWith('.cjs'); + const cmd = isScript ? 'node' : resolvedBin; + const cmdArgs = isScript + ? [resolvedBin, 'daemon', 'start'] + : ['daemon', 'start']; + + const child = spawn(cmd, cmdArgs, { + env: { ...process.env, OCAP_SOCKET_PATH: SOCKET_PATH }, + detached: true, + stdio: 'ignore', + }); + child.on('error', () => { + process.stderr.write( + '[caprock] `ocap` binary not found. Install @metamask/kernel-cli or set OCAP_BIN.\n', + ); + }); + child.unref(); +} + +// ─── Vat interaction ───────────────────────────────────────────────────────── + +/** + * Launch a fresh permission-tracker vat and return its root kref. + * + * @returns The root kref and subcluster ID for the new vat. + */ +async function launchPermissionVat(): Promise<{ + rootKref: string; + subclusterId: string; +}> { + const bundleUrl = `file://${VAT_BUNDLE}`; + const response = await sendCommand({ + socketPath: SOCKET_PATH, + method: 'launchSubcluster', + params: { + config: { + bootstrap: 'tracker', + vats: { tracker: { bundleSpec: bundleUrl } }, + }, + }, + }); + if (isJsonRpcFailure(response)) { + throw new Error(`launchSubcluster: ${response.error.message}`); + } + const { rootKref, subclusterId } = response.result as { + rootKref: string; + subclusterId: string; + }; + return { rootKref, subclusterId }; +} + +/** + * Dispatch the permission sheaf: returns 'allow' if any section covers this + * invocation, 'ask' otherwise. + * + * @param rootKref - The vat's root kref. + * @param tool - The tool name. + * @param invocations - The parsed command components. + * @returns 'allow' or 'ask'. + */ +async function vatRoute( + rootKref: string, + tool: string, + invocations: ParsedInvocation[], +): Promise { + const response = await sendCommand({ + socketPath: SOCKET_PATH, + method: 'queueMessage', + params: [rootKref, 'route', [tool, invocations]], + }); + if (isJsonRpcFailure(response)) { + throw new Error(`vatRoute: ${response.error.message}`); + } + return decodeCapData(response.result as CapData) as string; +} + +/** + * Add a section to the permission sheaf. Used for both exact single-invocation + * grants and standing provisions. + * + * @param rootKref - The vat's root kref. + * @param provision - The Provision to add as a new section. + */ +async function vatAddSection( + rootKref: string, + provision: Provision, +): Promise { + await sendCommand({ + socketPath: SOCKET_PATH, + method: 'queueMessage', + params: [rootKref, 'addSection', [provision]], + }); +} + +/** + * Return the first provision that matches the given tool and invocations, + * or null if none match. + * + * @param rootKref - The vat's root kref. + * @param tool - The tool name. + * @param invocations - The parsed command components. + * @returns The matching provision, or null. + */ +async function vatFindMatch( + rootKref: string, + tool: string, + invocations: ParsedInvocation[], +): Promise { + const response = await sendCommand({ + socketPath: SOCKET_PATH, + method: 'queueMessage', + params: [rootKref, 'findMatch', [tool, invocations]], + }); + if (isJsonRpcFailure(response)) { + return null; + } + const raw = decodeCapData(response.result as CapData); + return decodeSmallcapsStrings(raw) as Provision | null; +} + +/** + * Return the number of entries in the permission vat's allow set. + * + * @param rootKref - The vat's root kref. + * @returns The number of granted (toolName, sha) pairs. + */ +async function vatSize(rootKref: string): Promise { + const response = await sendCommand({ + socketPath: SOCKET_PATH, + method: 'queueMessage', + params: [rootKref, 'size', []], + }); + if (isJsonRpcFailure(response)) { + throw new Error(`vatSize: ${response.error.message}`); + } + return decodeCapData(response.result as CapData) as number; +} + +/** + * Parse a tool invocation into a list of ParsedInvocation objects suitable for + * sheaf dispatch. For Bash, uses tree-sitter to decompose the pipeline into + * component commands. For other tools, treats the tool as a single command with + * string field values as argv. + * + * Returns null when the command is dynamic or unparseable (no provision possible). + * + * @param toolName - The Claude Code tool name. + * @param toolInput - The raw tool input object. + * @returns Parsed invocations, or null for dynamic/unparseable Bash. + */ +function buildInvocations( + toolName: string, + toolInput: Record, +): ParsedInvocation[] | null { + if (toolName === 'Bash') { + const command = + typeof toolInput.command === 'string' ? toolInput.command : ''; + const result = decompose(command); + if (!result.ok) { + return null; + } + return result.commands.map(({ name, argv }) => ({ name, argv })); + } + const argv = Object.values(toolInput).filter( + (val): val is string => typeof val === 'string', + ); + return [{ name: toolName, argv }]; +} + +// ─── Session initialization ────────────────────────────────────────────────── + +/** + * Load or create a session state. Handles the case where the hook fires before + * SessionStart (e.g., if the plugin was installed mid-session). + * + * @param payload - The hook payload carrying session_id. + * @param payload.session_id - The Claude Code session ID. + * @param payload.transcript_path - Path to the session transcript. + * @returns The session state, or null if the daemon is unavailable. + */ +async function getOrInitSession(payload: { + session_id: string; + transcript_path: string; +}): Promise { + const { session_id } = payload; + const existing = await loadSessionState(session_id); + if (existing) { + if (typeof existing.kernelSessionId !== 'string') { + await ensureDaemon(); + if (!(await pingDaemon(SOCKET_PATH))) { + return existing; + } + const ks = await createKernelSession(SOCKET_PATH, session_id); + existing.kernelSessionId = ks.sessionId; + existing.ocapUrl = ks.ocapUrl; + await saveSessionState(session_id, existing); + } + return existing; + } + + await ensureDaemon(); + if (!(await pingDaemon(SOCKET_PATH))) { + return null; + } + + const [ + snapshot, + { rootKref, subclusterId }, + { sessionId: kernelSessionId, ocapUrl }, + ] = await Promise.all([ + collectSettingsSnapshot(), + launchPermissionVat(), + createKernelSession(SOCKET_PATH, session_id), + ]); + + const state: SessionState = { + sessionId: session_id, + kernelSessionId, + ocapUrl, + rootKref, + subclusterId, + startedAt: now(), + settingsSnapshot: snapshot, + }; + await saveSessionState(session_id, state); + return state; +} + +/** + * Collect the current union of all watched settings allow-lists. + * + * @returns The deduplicated list of permission allow entries. + */ +async function collectSettingsSnapshot(): Promise { + const lists = await Promise.all(SETTINGS_PATHS.map(readSettingsAllowList)); + return [...new Set(lists.flat())]; +} + +// ─── Hook output helpers ────────────────────────────────────────────────────── + +/** + * Produce a PermissionRequest hook output that grants the request. + * + * @returns Serialized hook output JSON. + */ +function permissionAllow(): string { + return JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'PermissionRequest', + decision: { behavior: 'allow' }, + }, + }); +} + +/** + * Produce a PreToolUse hook output that denies the tool call. + * + * @param reason - Human-readable reason shown to Claude Code. + * @returns Serialized hook output JSON. + */ +function preToolUseDeny(reason: string): string { + return JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: reason, + }, + }); +} + +// ─── Hook handlers ─────────────────────────────────────────────────────────── + +/** + * Handle the SessionStart hook event: initialize daemon and permission vat. + * + * @param payload - The SessionStart hook payload. + */ +async function onSessionStart(payload: SessionStartPayload): Promise { + const { session_id, transcript_path } = payload; + + await registerSkillPermissions().catch((error) => + process.stderr.write( + `[caprock] registerSkillPermissions: ${String(error)}\n`, + ), + ); + + await ensureDaemon(); + if (!(await pingDaemon(SOCKET_PATH))) { + process.stderr.write('[caprock] Daemon not available, skipping init\n'); + process.stdout.write( + `${JSON.stringify({ output: '[caprock] daemon unavailable — authority tracking inactive' })}\n`, + ); + return; + } + + const [ + snapshot, + { rootKref, subclusterId }, + { sessionId: kernelSessionId, ocapUrl }, + ] = await Promise.all([ + collectSettingsSnapshot(), + launchPermissionVat(), + createKernelSession(SOCKET_PATH, session_id), + ]); + + const state: SessionState = { + sessionId: session_id, + kernelSessionId, + ocapUrl, + rootKref, + subclusterId, + startedAt: now(), + settingsSnapshot: snapshot, + }; + await saveSessionState(session_id, state); + + await appendEvent(session_id, { + t: now(), + event: 'session_start', + sessionId: session_id, + kernelSessionId, + rootKref, + transcriptPath: transcript_path, + settingsAllowCount: snapshot.length, + }); + + const connectCmd = `ocap modal ${kernelSessionId}`; + await writeFile(join(getCaprockDir(), 'connect'), `${connectCmd}\n`); + process.stderr.write(`[caprock] TUI: ${connectCmd}\n`); + + const caprockFile = join(getCaprockDir(), `${session_id}.jsonl`); + process.stdout.write( + `${JSON.stringify({ + output: + `[caprock] tracking authority → ${caprockFile} (${snapshot.length} rules in allowlist)\n` + + `[caprock] TUI: run \`ocap tui\` (session appears automatically) or \`${connectCmd}\` to connect directly`, + })}\n`, + ); +} + +/** + * Handle the PreToolUse hook event: check the vat and block for TUI decision. + * + * @param payload - The PreToolUse hook payload. + */ +async function onPreToolUse(payload: PreToolUsePayload): Promise { + const { session_id, tool_name, tool_input } = payload; + const sha = inputSha(tool_input); + const invocations = buildInvocations(tool_name, tool_input); + + const state = await getOrInitSession(payload); + if (!state) { + process.stdout.write(JSON.stringify({ continue: true })); + return; + } + + let vatResponse = 'unknown'; + try { + if (invocations !== null) { + vatResponse = await vatRoute(state.rootKref, tool_name, invocations); + } + } catch (error) { + process.stderr.write(`[caprock] vatRoute failed: ${String(error)}\n`); + } + + await appendEvent(session_id, { + t: now(), + event: 'check', + sessionId: session_id, + toolName: tool_name, + inputSha: sha, + vatResponse, + }); + + if (vatResponse === 'allow') { + if (state.kernelSessionId && invocations !== null) { + const autoDescription = `Allow ${tool_name}(${JSON.stringify(tool_input)})`; + vatFindMatch(state.rootKref, tool_name, invocations) + .then(async (matched) => + recordProvisioned( + SOCKET_PATH, + state.kernelSessionId, + autoDescription, + { + invocations, + ...(matched === null ? {} : { provision: matched }), + }, + ), + ) + .catch(() => undefined); + } + process.stdout.write(JSON.stringify({ continue: true })); + return; + } + + if (!state.kernelSessionId) { + process.stdout.write(JSON.stringify({ continue: true })); + return; + } + + const description = `Allow ${tool_name}(${JSON.stringify(tool_input)})`; + + let decision: Decision; + try { + decision = await authorizeRequest( + SOCKET_PATH, + state.kernelSessionId, + description, + invocations === null ? undefined : { invocations }, + ); + } catch (error) { + const errorStr = String(error); + const isNoSubscriber = + (error as { code?: string }).code === 'NO_SUBSCRIBER' || + errorStr.includes('No subscriber'); + + let connectId = state.kernelSessionId; + if (!isNoSubscriber && errorStr.includes('Session not found')) { + try { + const ks = await createKernelSession(SOCKET_PATH, session_id); + // eslint-disable-next-line require-atomic-updates + state.kernelSessionId = ks.sessionId; + // eslint-disable-next-line require-atomic-updates + state.ocapUrl = ks.ocapUrl; + await saveSessionState(session_id, state); + connectId = ks.sessionId; + } catch { + /* recovery failed */ + } + } + + process.stdout.write( + `${preToolUseDeny( + `[caprock] TUI not connected. Run \`ocap tui\` (session appears automatically) or \`ocap modal ${connectId}\` to connect directly, then retry.`, + )}\n`, + ); + return; + } + + if (decision.verdict === 'accept') { + if (decision.provision !== undefined) { + await vatAddSection(state.rootKref, decision.provision).catch( + () => undefined, + ); + } else if (invocations !== null) { + await vatAddSection( + state.rootKref, + invocationToProvision(tool_name, invocations), + ).catch(() => undefined); + } + await appendEvent(session_id, { + t: now(), + event: 'tui_accept', + sessionId: session_id, + toolName: tool_name, + inputSha: sha, + feedback: decision.feedback, + }); + process.stdout.write(JSON.stringify({ continue: true })); + } else { + await appendEvent(session_id, { + t: now(), + event: 'tui_reject', + sessionId: session_id, + toolName: tool_name, + inputSha: sha, + feedback: decision.feedback, + }); + process.stdout.write( + `${preToolUseDeny(decision.feedback || 'Rejected via TUI')}\n`, + ); + } +} + +/** + * Handle the PostToolUse hook event: grant the invocation in the permission vat. + * + * @param payload - The PostToolUse hook payload. + */ +async function onPostToolUse(payload: PostToolUsePayload): Promise { + const { session_id, tool_name, tool_input } = payload; + const sha = inputSha(tool_input); + + const state = await loadSessionState(session_id); + if (!state) { + return; + } + + const invocations = buildInvocations(tool_name, tool_input); + if (invocations !== null) { + try { + await vatAddSection( + state.rootKref, + invocationToProvision(tool_name, invocations), + ); + } catch (error) { + process.stderr.write( + `[caprock] vatAddSection failed: ${String(error)}\n`, + ); + } + } + + await appendEvent(session_id, { + t: now(), + event: 'grant', + sessionId: session_id, + toolName: tool_name, + inputSha: sha, + grantType: 'invocation', + }); +} + +/** + * Handle the PermissionRequest hook event: fast-path via the vat if already granted. + * + * @param payload - The PermissionRequest hook payload. + */ +async function onPermissionRequest( + payload: PermissionRequestPayload, +): Promise { + const { session_id, tool_name, tool_input } = payload; + const sha = tool_input ? inputSha(tool_input) : null; + + await appendEvent(session_id, { + t: now(), + event: 'prompted', + sessionId: session_id, + toolName: tool_name ?? null, + inputSha: sha, + }); + + const state = await loadSessionState(session_id); + if (!state?.kernelSessionId) { + return; + } + + if (tool_name && tool_input) { + const invocations = buildInvocations(tool_name, tool_input); + if (invocations !== null) { + try { + const vatResponse = await vatRoute( + state.rootKref, + tool_name, + invocations, + ); + if (vatResponse === 'allow') { + process.stdout.write(`${permissionAllow()}\n`); + } + } catch { + /* vat error — defer to Claude Code native dialog */ + } + } + } +} + +/** + * Handle the PermissionDenied hook event: record the denial. + * + * @param payload - The PermissionDenied hook payload. + */ +async function onPermissionDenied( + payload: PermissionDeniedPayload, +): Promise { + const { session_id, tool_name, tool_input } = payload; + await appendEvent(session_id, { + t: now(), + event: 'denied', + sessionId: session_id, + toolName: tool_name ?? null, + inputSha: tool_input ? inputSha(tool_input) : null, + }); +} + +/** + * Handle the FileChanged hook event: detect new allow-list entries and record them. + * + * @param payload - The FileChanged hook payload. + */ +async function onFileChanged(payload: FileChangedPayload): Promise { + const { session_id, file_path, change_type } = payload; + if (change_type === 'delete') { + return; + } + + const state = await loadSessionState(session_id); + if (!state) { + return; + } + + const current = await readSettingsAllowList(file_path); + const prev = new Set(state.settingsSnapshot); + const newEntries = current.filter((entry) => !prev.has(entry)); + + for (const pattern of newEntries) { + await appendEvent(session_id, { + t: now(), + event: 'rule_grant', + sessionId: session_id, + pattern, + filePath: file_path, + }); + } + + if (newEntries.length > 0) { + state.settingsSnapshot = [ + ...new Set([...state.settingsSnapshot, ...current]), + ]; + await saveSessionState(session_id, state); + } +} + +/** + * Handle the SessionEnd hook event: finalize the event log and write a trace. + * + * @param payload - The SessionEnd hook payload. + */ +async function onSessionEnd(payload: SessionEndPayload): Promise { + const { session_id, transcript_path } = payload; + + const state = await loadSessionState(session_id); + let allowCount = 0; + if (state) { + try { + allowCount = await vatSize(state.rootKref); + } catch { + const events = await readEvents(session_id); + allowCount = events.filter((event) => event.event === 'grant').length; + } + } + + await appendEvent(session_id, { + t: now(), + event: 'session_end', + sessionId: session_id, + allowCount, + }); + + const events = await readEvents(session_id); + const outputPath = caprockOutputPath(transcript_path); + await writeFile( + outputPath, + `${events.map((event) => JSON.stringify(event)).join('\n')}\n`, + ); + process.stderr.write(`[caprock] Session trace → ${outputPath}\n`); +} + +// ─── Main ──────────────────────────────────────────────────────────────────── + +/** Read stdin, dispatch to the matching hook handler, and write the response. */ +async function main(): Promise { + const raw = await readStdin(); + if (!raw.trim()) { + return; + } + + let payload: AnyHookPayload; + try { + payload = JSON.parse(raw) as AnyHookPayload; + } catch { + process.stderr.write( + `[caprock] Invalid JSON on stdin: ${raw.slice(0, 80)}\n`, + ); + return; + } + + const event = payload.hook_event_name; + + try { + switch (event) { + case 'SessionStart': + await onSessionStart(payload); + break; + case 'PreToolUse': + await onPreToolUse(payload); + break; + case 'PostToolUse': + await onPostToolUse(payload); + break; + case 'PermissionRequest': + await onPermissionRequest(payload); + break; + case 'PermissionDenied': + await onPermissionDenied(payload); + break; + case 'FileChanged': + await onFileChanged(payload); + break; + case 'SessionEnd': + await onSessionEnd(payload); + break; + default: + break; + } + } catch (error) { + process.stderr.write(`[caprock] Error in ${event}: ${String(error)}\n`); + } +} + +main().catch((error) => { + process.stderr.write(`[caprock] Fatal: ${String(error)}\n`); +}); diff --git a/packages/caprock/bin/setup.ts b/packages/caprock/bin/setup.ts new file mode 100644 index 0000000000..bf1b510a2c --- /dev/null +++ b/packages/caprock/bin/setup.ts @@ -0,0 +1,174 @@ +/* eslint-disable no-console */ +import { execSync } from 'node:child_process'; +import { readFile, access } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { getSocketPath } from '../src/paths/ocap-kernel.ts'; +import { getPluginManifestPath } from '../src/paths/plugin.ts'; +import { getClaudeSettingsPath } from '../src/paths/user.ts'; +import { pingDaemon } from '../src/rpc.ts'; + +const BIN_DIR = import.meta.dirname; +const PLUGIN_ROOT = join(BIN_DIR, '..'); +const SOCKET_PATH = getSocketPath(); + +/** + * Print a success check line to stdout. + * + * @param message - The message to display. + */ +function ok(message: string): void { + console.log(` ✓ ${message}`); +} + +/** + * Print a failure check line to stdout. + * + * @param message - The message to display. + */ +function fail(message: string): void { + console.log(` ✗ ${message}`); +} + +/** + * Print an info line to stdout. + * + * @param message - The message to display. + */ +function info(message: string): void { + console.log(` ${message}`); +} + +/** + * Read the plugin version from its manifest. + * + * @returns The version string, or 'unknown' if the manifest is unreadable. + */ +async function readVersion(): Promise { + try { + const manifest = JSON.parse( + await readFile(getPluginManifestPath(BIN_DIR), 'utf8'), + ) as { version?: string }; + return manifest.version ?? 'unknown'; + } catch { + return 'unknown'; + } +} + +/** + * Check that the tree-sitter native binding is compiled. + * + * @returns True if the binding is present or was successfully rebuilt. + */ +async function checkTreeSitter(): Promise { + const bindingPath = join( + PLUGIN_ROOT, + 'node_modules/tree-sitter/build/Release/tree_sitter_runtime_binding.node', + ); + try { + await access(bindingPath); + ok('tree-sitter native binding compiled'); + return true; + } catch { + fail('tree-sitter native binding missing — attempting npm rebuild...'); + try { + // eslint-disable-next-line n/no-sync + execSync('npm rebuild tree-sitter tree-sitter-bash', { + cwd: PLUGIN_ROOT, + stdio: 'pipe', + }); + await access(bindingPath); + ok('tree-sitter rebuilt successfully'); + return true; + } catch { + fail('npm rebuild failed'); + info( + 'Ensure Xcode Command Line Tools are installed: xcode-select --install', + ); + return false; + } + } +} + +/** + * Check that the ocap-kernel daemon is reachable. + * + * @returns True if the daemon responds to a ping. + */ +async function checkDaemon(): Promise { + if (await pingDaemon(SOCKET_PATH)) { + ok(`ocap-kernel daemon running (${SOCKET_PATH})`); + return true; + } + fail('ocap-kernel daemon not running'); + info( + 'It starts automatically at SessionStart — open a new Claude Code session to trigger it.', + ); + return false; +} + +/** + * Check that the caprock status.sh allow entry is in Claude settings. + * + * @returns True if the allow entry is present. + */ +async function checkAllowEntry(): Promise { + let settings: { permissions?: { allow?: string[] } } = {}; + try { + settings = JSON.parse( + await readFile(getClaudeSettingsPath(), 'utf8'), + ) as typeof settings; + } catch { + fail('Could not read ~/.claude/settings.json'); + return false; + } + const allow = settings.permissions?.allow ?? []; + const hasEntry = allow.some( + (entry) => entry.includes('/caprock/') && entry.includes('status.sh'), + ); + if (hasEntry) { + ok('status.sh allow entry registered in ~/.claude/settings.json'); + return true; + } + fail('status.sh allow entry not found in ~/.claude/settings.json'); + info( + 'It is registered automatically at SessionStart — open a new session to trigger it.', + ); + return false; +} + +/** + * Run all setup checks and print results. + */ +async function main(): Promise { + console.log(`caprock v${await readVersion()} — setup check`); + console.log(`Plugin root: ${PLUGIN_ROOT}`); + console.log(); + + const tsOk = await checkTreeSitter(); + const daemonOk = await checkDaemon(); + const allowOk = await checkAllowEntry(); + + console.log(); + const allOk = tsOk && daemonOk && allowOk; + if (allOk) { + console.log('All checks passed — caprock is ready.'); + } else { + console.log( + 'Some checks failed. Address the items above, then run /caprock:setup again.', + ); + if (!tsOk) { + console.log(); + console.log('To rebuild tree-sitter manually:'); + console.log( + ` cd ${PLUGIN_ROOT} && npm rebuild tree-sitter tree-sitter-bash`, + ); + } + } +} + +main().catch((error) => { + process.stderr.write(`[caprock:setup] ${String(error)}\n`); + // eslint-disable-next-line n/no-process-exit + process.exit(1); +}); diff --git a/packages/caprock/bin/status.ts b/packages/caprock/bin/status.ts new file mode 100644 index 0000000000..33b6e1f1df --- /dev/null +++ b/packages/caprock/bin/status.ts @@ -0,0 +1,221 @@ +/* eslint-disable no-console */ +/* eslint-disable n/no-process-env */ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { decompose } from '../src/bash.ts'; +import { getCaprockDir } from '../src/paths/ocap-kernel.ts'; +import { getPluginManifestPath } from '../src/paths/plugin.ts'; +import { readEvents, loadSessionState } from '../src/session.ts'; +import { findTranscript, readTranscriptToolUses } from '../src/transcript.ts'; +import type { CaprockEvent } from '../src/types.ts'; + +const BIN_DIR = import.meta.dirname; + +/** + * Read the plugin version from its manifest. + * + * @returns The version string, or 'unknown' if the manifest is unreadable. + */ +async function readVersion(): Promise { + try { + const manifest = JSON.parse( + await readFile(getPluginManifestPath(BIN_DIR), 'utf8'), + ) as { version?: string }; + return manifest.version ?? 'unknown'; + } catch { + return 'unknown'; + } +} + +/** + * Count occurrences of each name and return pairs sorted descending by count. + * + * @param names - The list of names to count. + * @returns Sorted `[count, name]` pairs, highest count first. + */ +function countByName(names: string[]): [number, string][] { + const map = new Map(); + for (const toolName of names) { + map.set(toolName, (map.get(toolName) ?? 0) + 1); + } + return [...map.entries()] + .map(([name, count]) => [count, name] as [number, string]) + .sort((a, b) => b[0] - a[0] || a[1].localeCompare(b[1])); +} + +/** + * Print a frequency table of tool or command names to stdout. + * + * @param names - The list of names to tally. + */ +function printToolCounts(names: string[]): void { + for (const [count, name] of countByName(names)) { + console.log(` ${String(count).padStart(3)} ${name}`); + } + console.log(' ──────────────────────'); + console.log(` ${names.length} total`); +} + +/** + * Extract all bash subcommand names from the session transcript. + * + * @param sessionId - The Claude session ID. + * @returns The list of parsed command names from all Bash tool uses. + */ +async function getBashSubcommands(sessionId: string): Promise { + const transcriptPath = await findTranscript(sessionId); + if (!transcriptPath) { + return []; + } + const toolUses = await readTranscriptToolUses(transcriptPath); + const names: string[] = []; + for (const use of toolUses) { + if (use.name !== 'Bash') { + continue; + } + const cmd = use.input?.command; + if (typeof cmd !== 'string') { + continue; + } + for (const parsed of decompose(cmd).commands) { + names.push(parsed.name); + } + } + return names; +} + +/** + * Report authority stats using the caprock event trace. + * + * @param sessionId - The Claude session ID. + * @param events - The caprock events for this session. + */ +async function reportFromCaprock( + sessionId: string, + events: CaprockEvent[], +): Promise { + console.log(`Trace: ${join(getCaprockDir(), `${sessionId}.jsonl`)}`); + + const state = await loadSessionState(sessionId); + if (state?.kernelSessionId) { + console.log(`TUI: ocap modal ${state.kernelSessionId}`); + } else { + console.log(`TUI: cat ~/.ocap/caprock/connect`); + } + + console.log(); + + const sessionStart = events.find((ev) => ev.event === 'session_start'); + const endowed = sessionStart + ? `${sessionStart.settingsAllowCount as number} allowlist rules at session start` + : 'not recorded'; + console.log(`Endowed authority: ${endowed}`); + + console.log(); + console.log('Invoked authority (tool uses):'); + printToolCounts( + events + .filter((ev) => ev.event === 'grant') + .map((ev) => ev.toolName as string), + ); + + console.log(); + const prompted = events.filter((ev) => ev.event === 'prompted').length; + const denied = events.filter((ev) => ev.event === 'denied').length; + console.log(`Prompted (beyond allowlist): ${prompted} | Denied: ${denied}`); + + const ruleGrants = events.filter((ev) => ev.event === 'rule_grant'); + if (ruleGrants.length > 0) { + console.log(); + console.log('Allowlist rules added this session:'); + for (const ev of ruleGrants) { + console.log(` ${ev.pattern as string}`); + } + } + + const bashCmds = await getBashSubcommands(sessionId); + if (bashCmds.length > 0) { + console.log(); + console.log('Bash commands invoked:'); + printToolCounts(bashCmds); + } +} + +/** + * Report authority stats using only the Claude transcript (no caprock trace). + * + * @param sessionId - The Claude session ID. + */ +async function reportFromTranscript(sessionId: string): Promise { + const transcriptPath = await findTranscript(sessionId); + if (!transcriptPath) { + console.log( + `No caprock trace or transcript found for session ${sessionId}.`, + ); + return; + } + + const toolUses = await readTranscriptToolUses(transcriptPath); + console.log(`Transcript: ${transcriptPath}`); + console.log('(caprock authority tracking was not active for this session)'); + console.log(); + + console.log('Invoked authority (tool uses):'); + printToolCounts(toolUses.map((use) => use.name)); + + const bashCmds: string[] = []; + for (const use of toolUses) { + if (use.name !== 'Bash') { + continue; + } + const cmd = use.input?.command; + if (typeof cmd !== 'string') { + continue; + } + for (const parsed of decompose(cmd).commands) { + bashCmds.push(parsed.name); + } + } + if (bashCmds.length > 0) { + console.log(); + console.log('Bash commands invoked:'); + printToolCounts(bashCmds); + } + + console.log(); + console.log('Endowed authority: not tracked this session'); + console.log('Prompted / Denied: not tracked this session'); +} + +/** + * Display the session authority report. + */ +async function main(): Promise { + console.log(`caprock v${await readVersion()}`); + console.log(); + + const sessionId = process.argv[2] ?? process.env.CLAUDE_SESSION_ID; + if (!sessionId) { + console.log( + 'Session ID not provided — run this from within a Claude Code session.', + ); + return; + } + + const events = await readEvents(sessionId); + const hasTracking = events.some( + (ev) => ev.event === 'session_start' || ev.event === 'grant', + ); + if (hasTracking) { + await reportFromCaprock(sessionId, events); + } else { + await reportFromTranscript(sessionId); + } +} + +main().catch((error) => { + process.stderr.write(`[caprock:status] ${String(error)}\n`); + // eslint-disable-next-line n/no-process-exit + process.exit(1); +}); diff --git a/packages/caprock/hooks/hooks.json b/packages/caprock/hooks/hooks.json new file mode 100644 index 0000000000..a237cdae79 --- /dev/null +++ b/packages/caprock/hooks/hooks.json @@ -0,0 +1,77 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/bin/hook.mjs\"" + } + ] + } + ], + "PreToolUse": [ + { + "timeout_ms": 300000, + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/bin/hook.mjs\"" + } + ] + } + ], + "PostToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/bin/hook.mjs\"" + } + ] + } + ], + "PermissionRequest": [ + { + "timeout_ms": 300000, + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/bin/hook.mjs\"" + } + ] + } + ], + "PermissionDenied": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/bin/hook.mjs\"" + } + ] + } + ], + "FileChanged": [ + { + "matcher": ".claude/settings.json|.claude/settings.local.json", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/bin/hook.mjs\"" + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/bin/hook.mjs\"" + } + ] + } + ] + } +} diff --git a/packages/caprock/package.json b/packages/caprock/package.json new file mode 100644 index 0000000000..222577a178 --- /dev/null +++ b/packages/caprock/package.json @@ -0,0 +1,93 @@ +{ + "name": "@ocap/caprock", + "version": "0.1.0", + "private": true, + "description": "Claude Code plugin: routes tool invocations through an ocap-kernel permission vat (POLA enforcement)", + "homepage": "https://github.com/MetaMask/ocap-kernel/tree/main/packages/caprock#readme", + "bugs": { + "url": "https://github.com/MetaMask/ocap-kernel/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/ocap-kernel.git" + }, + "type": "module", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "files": [ + "dist/", + "vat/", + "hooks/", + ".claude-plugin/" + ], + "scripts": { + "build": "ocap bundle vat/permission-tracker.ts && ts-bridge --project tsconfig.build.json --no-references --clean", + "build:docs": "typedoc", + "changelog:validate": "../../scripts/validate-changelog.sh @ocap/caprock", + "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo ./logs ./vat/*.bundle", + "lint": "yarn lint:eslint && yarn lint:misc --check && yarn constraints && yarn lint:dependencies", + "lint:dependencies": "depcheck --quiet", + "lint:eslint": "eslint . --cache", + "lint:fix": "yarn lint:eslint --fix && yarn lint:misc --write && yarn constraints --fix && yarn lint:dependencies", + "lint:misc": "prettier --no-error-on-unmatched-pattern '**/*.json' '**/*.md' '**/*.html' '!**/CHANGELOG.old.md' '**/*.yml' '!.yarnrc.yml' '!merged-packages/**' --ignore-path ../../.gitignore --log-level error", + "test": "vitest run --config vitest.config.ts", + "test:clean": "yarn test --no-cache --coverage.clean", + "test:dev": "yarn test --mode development", + "test:verbose": "yarn test --reporter verbose", + "test:watch": "vitest --config vitest.config.ts", + "test:dev:quiet": "yarn test:dev --reporter @ocap/repo-tools/vitest-reporters/silent" + }, + "dependencies": { + "@endo/patterns": "^1.7.0", + "@metamask/kernel-utils": "workspace:^", + "@metamask/sheaves": "workspace:^", + "@metamask/utils": "^11.9.0", + "tree-sitter": "^0.25.0", + "tree-sitter-bash": "^0.25.1" + }, + "devDependencies": { + "@arethetypeswrong/cli": "^0.17.4", + "@metamask/auto-changelog": "^5.3.0", + "@metamask/eslint-config": "^15.0.0", + "@metamask/eslint-config-nodejs": "^15.0.0", + "@metamask/eslint-config-typescript": "^15.0.0", + "@metamask/kernel-cli": "workspace:^", + "@ocap/repo-tools": "workspace:^", + "@ts-bridge/cli": "^0.6.3", + "@ts-bridge/shims": "^0.1.1", + "@types/node": "^22.13.1", + "@typescript-eslint/eslint-plugin": "^8.29.0", + "@typescript-eslint/parser": "^8.29.0", + "@typescript-eslint/utils": "^8.29.0", + "@vitest/eslint-plugin": "^1.6.14", + "depcheck": "^1.4.7", + "eslint": "^9.23.0", + "eslint-config-prettier": "^10.1.1", + "eslint-import-resolver-typescript": "^4.3.1", + "eslint-plugin-import-x": "^4.10.0", + "eslint-plugin-jsdoc": "^50.6.9", + "eslint-plugin-n": "^17.17.0", + "eslint-plugin-prettier": "^5.2.6", + "eslint-plugin-promise": "^7.2.1", + "prettier": "^3.5.3", + "rimraf": "^6.0.1", + "turbo": "^2.9.1", + "typescript": "~5.8.2", + "typescript-eslint": "^8.29.0", + "vitest": "^4.1.3" + }, + "engines": { + "node": ">=22" + } +} diff --git a/packages/caprock/scripts/setup.sh b/packages/caprock/scripts/setup.sh new file mode 100755 index 0000000000..a5146454bc --- /dev/null +++ b/packages/caprock/scripts/setup.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +PLUGIN_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +node "${PLUGIN_ROOT}/dist/bin/setup.mjs" "$@" diff --git a/packages/caprock/scripts/status.sh b/packages/caprock/scripts/status.sh new file mode 100755 index 0000000000..2374edfd4f --- /dev/null +++ b/packages/caprock/scripts/status.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +PLUGIN_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +node "${PLUGIN_ROOT}/dist/bin/status.mjs" "$@" diff --git a/packages/caprock/src/bash.test.ts b/packages/caprock/src/bash.test.ts new file mode 100644 index 0000000000..5546efd6fc --- /dev/null +++ b/packages/caprock/src/bash.test.ts @@ -0,0 +1,255 @@ +import { describe, expect, it } from 'vitest'; + +import { decompose } from './bash.ts'; + +describe('decompose', () => { + describe('empty input', () => { + it('returns empty for empty string', () => { + expect(decompose('')).toStrictEqual({ + ok: false, + reason: 'empty', + commands: [], + }); + }); + + it('returns empty for whitespace-only string', () => { + expect(decompose(' \n ')).toStrictEqual({ + ok: false, + reason: 'empty', + commands: [], + }); + }); + }); + + describe('command names', () => { + it('extracts a bare command name', () => { + const result = decompose('ls'); + expect(result).toStrictEqual({ + ok: true, + commands: [ + { name: 'ls', argv: [], pipePosition: 'alone', redirects: [] }, + ], + }); + }); + + it('marks a variable-expanded command name as dynamic_command', () => { + expect(decompose('$CMD arg')).toHaveProperty('reason', 'dynamic_command'); + }); + }); + + describe('argument extraction', () => { + it('collects positional arguments', () => { + const result = decompose('ls -la /tmp'); + expect(result.ok).toBe(true); + expect(result.commands[0]).toStrictEqual({ + name: 'ls', + argv: ['-la', '/tmp'], + pipePosition: 'alone', + redirects: [], + }); + }); + + it('strips double quotes from string arguments', () => { + const result = decompose('echo "hello world"'); + expect(result.commands[0]?.argv).toStrictEqual(['hello world']); + }); + + it('strips single quotes from string arguments', () => { + const result = decompose("echo 'hello'"); + expect(result.commands[0]?.argv).toStrictEqual(['hello']); + }); + + it('marks variable expansion as dynamic', () => { + const result = decompose('echo $VAR'); + expect(result.commands[0]?.argv).toStrictEqual(['']); + }); + + it('marks command substitution as dynamic', () => { + const result = decompose('echo $(date)'); + expect(result.commands[0]?.argv).toStrictEqual(['']); + }); + + it('marks double-quoted string containing expansion as dynamic', () => { + const result = decompose('echo "prefix-$VAR-suffix"'); + expect(result.commands[0]?.argv).toStrictEqual(['']); + }); + + it('preserves a static git commit message', () => { + const result = decompose('git commit -m "fix: thing"'); + expect(result.ok).toBe(true); + // argv starts after the command name 'git'; 'commit' is the first arg + expect(result.commands[0]?.argv).toStrictEqual([ + 'commit', + '-m', + 'fix: thing', + ]); + }); + }); + + describe('pipe positions', () => { + it('labels a solo command as alone', () => { + const result = decompose('ls'); + expect(result.commands[0]?.pipePosition).toBe('alone'); + }); + + it('labels commands in a two-stage pipeline', () => { + const result = decompose('ls | grep foo'); + expect(result.ok).toBe(true); + expect(result.commands.map((cmd) => cmd.pipePosition)).toStrictEqual([ + 'first', + 'downstream', + ]); + }); + + it('labels commands in a three-stage pipeline', () => { + const result = decompose('ls | grep foo | sort'); + expect(result.ok).toBe(true); + expect(result.commands.map((cmd) => cmd.pipePosition)).toStrictEqual([ + 'first', + 'downstream', + 'downstream', + ]); + }); + + it('labels both sides of && as alone', () => { + const result = decompose('ls && pwd'); + expect(result.ok).toBe(true); + expect(result.commands.map((cmd) => cmd.pipePosition)).toStrictEqual([ + 'alone', + 'alone', + ]); + }); + + it('labels both sides of ; as alone', () => { + const result = decompose('ls; pwd'); + expect(result.ok).toBe(true); + expect(result.commands.map((cmd) => cmd.pipePosition)).toStrictEqual([ + 'alone', + 'alone', + ]); + }); + }); + + describe('redirect classification', () => { + it.each([ + ['ls > /tmp/out', 'out', '/tmp/out'], + ['ls >> /tmp/out', 'append', '/tmp/out'], + ['cmd 2> /dev/null', 'err', '/dev/null'], + ['cmd 2>> /dev/null', 'err-append', '/dev/null'], + ['cmd &> /tmp/out', 'out-err', '/tmp/out'], + ['cmd &>> /tmp/out', 'out-err-append', '/tmp/out'], + ['cmd < /tmp/in', 'in', '/tmp/in'], + ])('%s produces redirect kind %s targeting %s', (source, kind, target) => { + const result = decompose(source); + expect(result.ok).toBe(true); + expect(result.commands[0]?.redirects).toStrictEqual([{ kind, target }]); + }); + + it('classifies fd duplication (2>&1)', () => { + const result = decompose('cmd 2>&1'); + expect(result.ok).toBe(true); + expect(result.commands[0]?.redirects[0]?.kind).toBe('fd-dup'); + }); + + it('classifies herestring (<<<)', () => { + const result = decompose('cmd <<< "foo"'); + expect(result.ok).toBe(true); + expect(result.commands[0]?.redirects[0]).toStrictEqual({ + kind: 'herestring', + target: '', + }); + }); + + it('classifies heredoc (<<)', () => { + const result = decompose('cat << EOF\nfoo\nEOF'); + expect(result.ok).toBe(true); + expect(result.commands[0]?.redirects[0]).toStrictEqual({ + kind: 'heredoc', + target: '', + }); + }); + + it('marks a variable-expanded redirect target as dynamic', () => { + // $OUT inside double quotes is a string node; containsExpansion catches it + const result = decompose('ls > "$OUT"'); + expect(result.ok).toBe(true); + expect(result.commands[0]?.redirects[0]?.target).toBe(''); + }); + }); + + describe('curl pipe shell detection', () => { + it.each([ + ['curl', 'bash'], + ['curl', 'sh'], + ['curl', 'zsh'], + ['curl', 'ksh'], + ['curl', 'dash'], + ['wget', 'bash'], + ['wget', 'sh'], + ['fetch', 'bash'], + ])('detects %s | %s as curl_pipe_shell', (net, shell) => { + expect(decompose(`${net} https://example.com | ${shell}`)).toHaveProperty( + 'reason', + 'curl_pipe_shell', + ); + }); + + it('does not flag a network cmd piped to a non-shell', () => { + expect(decompose('curl https://example.com | grep foo').ok).toBe(true); + }); + + it('does not flag a non-network cmd piped to a shell', () => { + expect(decompose('cat script.sh | bash').ok).toBe(true); + }); + }); + + describe('eval dynamic detection', () => { + it('returns eval_dynamic for eval with a variable argument', () => { + expect(decompose('eval $SOME_VAR')).toHaveProperty( + 'reason', + 'eval_dynamic', + ); + }); + + it('returns eval_dynamic for eval with a command substitution argument', () => { + expect(decompose('eval "$(echo foo)"')).toHaveProperty( + 'reason', + 'eval_dynamic', + ); + }); + + it('does not flag eval with a static string argument', () => { + expect(decompose('eval "ls -la"').ok).toBe(true); + }); + + it('does not flag eval with a static word argument', () => { + expect(decompose('eval ls').ok).toBe(true); + }); + }); + + describe('multiple commands', () => { + it('collects names from both sides of &&', () => { + const result = decompose('ls && pwd'); + expect(result.ok).toBe(true); + expect(result.commands.map((cmd) => cmd.name)).toStrictEqual([ + 'ls', + 'pwd', + ]); + }); + + it('collects names from both sides of ;', () => { + const result = decompose('ls; pwd'); + expect(result.ok).toBe(true); + expect(result.commands.map((cmd) => cmd.name)).toStrictEqual([ + 'ls', + 'pwd', + ]); + }); + }); + + describe('parse error', () => { + it('returns parse_error for an unclosed quote', () => { + expect(decompose("ls '")).toHaveProperty('reason', 'parse_error'); + }); + }); +}); diff --git a/packages/caprock/src/bash.ts b/packages/caprock/src/bash.ts new file mode 100644 index 0000000000..a3a67250b0 --- /dev/null +++ b/packages/caprock/src/bash.ts @@ -0,0 +1,531 @@ +import Parser from 'tree-sitter'; +import Bash from 'tree-sitter-bash'; + +export type PipePosition = 'alone' | 'first' | 'downstream'; + +export type RedirectKind = + | 'out' + | 'append' + | 'err' + | 'err-append' + | 'out-err' + | 'out-err-append' + | 'in' + | 'herestring' + | 'heredoc' + | 'fd-dup' + | 'unknown'; + +export type Redirect = { kind: RedirectKind; target: string }; + +export type ParsedCommand = { + name: string; + argv: string[]; + pipePosition: PipePosition; + redirects: Redirect[]; +}; + +export type DropReason = + | 'parse_error' + | 'dynamic_command' + | 'curl_pipe_shell' + | 'eval_dynamic' + | 'empty'; + +export type DecomposeResult = + | { ok: true; commands: ParsedCommand[] } + | { ok: false; reason: DropReason; commands: ParsedCommand[] }; + +let cachedParser: Parser | null = null; + +/** + * Return a lazily-initialized shared tree-sitter parser for Bash. + * + * @returns The shared Parser instance. + */ +function getParser(): Parser { + if (cachedParser !== null) { + return cachedParser; + } + const parser = new Parser(); + parser.setLanguage(Bash as Parser.Language); + cachedParser = parser; + return parser; +} + +const NETWORK_CMDS = new Set(['curl', 'wget', 'fetch']); +const SHELL_INTERPRETERS = new Set(['bash', 'sh', 'zsh', 'ksh', 'dash']); + +/** + * Parse a bash source string and decompose it into a list of commands. + * + * Returns `ok: false` with a reason when the input is unsafe or unparseable. + * + * @param source - The raw bash command string to parse. + * @returns A DecomposeResult with the parsed commands and an ok/reason flag. + */ +export function decompose(source: string): DecomposeResult { + const trimmed = source.trim(); + if (trimmed.length === 0) { + return { ok: false, reason: 'empty', commands: [] }; + } + + const parser = getParser(); + const tree = parser.parse(source); + + if (hasErrorNode(tree.rootNode)) { + return { + ok: false, + reason: 'parse_error', + commands: collectCommands(tree.rootNode), + }; + } + + const commands = collectCommands(tree.rootNode); + + if (commands.some((cmd) => cmd.name === '')) { + return { ok: false, reason: 'dynamic_command', commands }; + } + if (hasCurlPipeShell(tree.rootNode)) { + return { ok: false, reason: 'curl_pipe_shell', commands }; + } + if (hasEvalDynamic(commands)) { + return { ok: false, reason: 'eval_dynamic', commands }; + } + + return { ok: true, commands }; +} + +/** + * Return true if the syntax tree contains any ERROR or missing node. + * + * @param node - The root node to inspect recursively. + * @returns True if any descendant is an error or missing node. + */ +function hasErrorNode(node: Parser.SyntaxNode): boolean { + if (node.type === 'ERROR' || node.isMissing) { + return true; + } + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child !== null && hasErrorNode(child)) { + return true; + } + } + return false; +} + +/** + * Collect all `command` nodes found under the given syntax node. + * + * @param node - The root of the subtree to walk. + * @returns An array of ParsedCommand objects extracted from command nodes. + */ +function collectCommands(node: Parser.SyntaxNode): ParsedCommand[] { + const out: ParsedCommand[] = []; + walk(node, (nd) => { + if (nd.type === 'command') { + out.push(extractCommand(nd)); + } + }); + return out; +} + +/** + * Determine where in a pipeline a command sits. + * + * @param commandNode - The command node whose position is to be determined. + * @returns 'first' if the command starts a pipeline, 'downstream' if it follows + * one, or 'alone' if it is not part of a pipeline. + */ +function computePipePosition(commandNode: Parser.SyntaxNode): PipePosition { + let child: Parser.SyntaxNode = commandNode; + let { parent } = commandNode; + while (parent !== null) { + if (parent.type === 'pipeline') { + for (let i = 0; i < parent.namedChildCount; i++) { + if (parent.namedChild(i) === child) { + return i === 0 ? 'first' : 'downstream'; + } + } + return 'alone'; + } + child = parent; + parent = parent.parent; + } + return 'alone'; +} + +/** + * Depth-first walk of a syntax tree, calling `visit` on each named node. + * + * @param node - The node to start from. + * @param visit - Callback invoked for every node in the subtree. + */ +function walk( + node: Parser.SyntaxNode, + visit: (n: Parser.SyntaxNode) => void, +): void { + visit(node); + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child !== null) { + walk(child, visit); + } + } +} + +/** + * Extract a ParsedCommand from a `command` syntax node. + * + * @param commandNode - The `command` node to extract details from. + * @returns A ParsedCommand with name, argv, pipe position, and redirects. + */ +function extractCommand(commandNode: Parser.SyntaxNode): ParsedCommand { + const nameNode = commandNode.childForFieldName('name'); + const name = nameNode === null ? '' : extractCommandName(nameNode); + + const argv: string[] = []; + const redirects: Redirect[] = []; + for (let i = 0; i < commandNode.namedChildCount; i++) { + const child = commandNode.namedChild(i); + if (child === null || child === nameNode) { + continue; + } + if (child.type === 'variable_assignment') { + continue; + } + if (child.type === 'file_redirect' || child.type === 'redirect') { + const redirect = parseFileRedirect(child); + if (redirect !== null) { + redirects.push(redirect); + } + continue; + } + if (child.type === 'herestring_redirect') { + redirects.push({ kind: 'herestring', target: '' }); + continue; + } + if (child.type === 'heredoc_redirect') { + redirects.push({ kind: 'heredoc', target: '' }); + continue; + } + argv.push(extractArgText(child)); + } + + const { parent } = commandNode; + if (parent !== null && parent.type === 'redirected_statement') { + for (let i = 0; i < parent.namedChildCount; i++) { + const sib = parent.namedChild(i); + if (sib === null || sib === commandNode) { + continue; + } + if (sib.type === 'file_redirect') { + const redirect = parseFileRedirect(sib); + if (redirect !== null) { + redirects.push(redirect); + } + } else if (sib.type === 'heredoc_redirect') { + redirects.push({ kind: 'heredoc', target: '' }); + } else if (sib.type === 'herestring_redirect') { + redirects.push({ kind: 'herestring', target: '' }); + } + } + } + + return { + name, + argv, + pipePosition: computePipePosition(commandNode), + redirects, + }; +} + +/** + * Parse a `file_redirect` or `redirect` syntax node into a Redirect object. + * + * @param node - The redirect syntax node to parse. + * @returns A Redirect object, or null if no operator could be found. + */ +function parseFileRedirect(node: Parser.SyntaxNode): Redirect | null { + let descriptor: string | null = null; + let operator: string | null = null; + let targetNode: Parser.SyntaxNode | null = null; + for (let i = 0; i < node.childCount; i++) { + const childNode = node.child(i); + if (childNode === null) { + continue; + } + if (childNode.isNamed) { + if (childNode.type === 'file_descriptor') { + descriptor = childNode.text; + } else { + targetNode ??= childNode; + } + } else { + operator ??= childNode.text; + } + } + if (operator === null) { + return null; + } + const target = + targetNode === null ? '' : extractRedirectTarget(targetNode); + return { kind: classifyRedirectOperator(operator, descriptor), target }; +} + +/** + * Extract a text representation of a redirect target node. + * + * @param node - The syntax node representing the redirect target. + * @returns A string for the target, or '' for shell expansions. + */ +function extractRedirectTarget(node: Parser.SyntaxNode): string { + if (node.type === 'word') { + return node.text; + } + if (node.type === 'number') { + return node.text; + } + if (node.type === 'raw_string') { + return stripQuotes(node.text); + } + if (node.type === 'string') { + if (containsExpansion(node)) { + return ''; + } + return stripQuotes(node.text); + } + if ( + node.type === 'simple_expansion' || + node.type === 'expansion' || + node.type === 'command_substitution' || + node.type === 'process_substitution' || + node.type === 'arithmetic_expansion' + ) { + return ''; + } + return node.text; +} + +/** + * Map a redirect operator string to a RedirectKind. + * + * @param operator - The redirect operator token (e.g. `>`, `>>`, `&>`). + * @param descriptor - The optional file descriptor digit (e.g. `'2'` for stderr). + * @returns The corresponding RedirectKind. + */ +function classifyRedirectOperator( + operator: string, + descriptor: string | null, +): RedirectKind { + if (operator === '>&' || operator === '<&') { + return 'fd-dup'; + } + if (operator === '<') { + return 'in'; + } + if (operator === '>' || operator === '>|') { + return descriptor === '2' ? 'err' : 'out'; + } + if (operator === '>>') { + return descriptor === '2' ? 'err-append' : 'append'; + } + if (operator === '&>') { + return 'out-err'; + } + if (operator === '&>>') { + return 'out-err-append'; + } + return 'unknown'; +} + +/** + * Extract the command name string from a command name syntax node. + * + * @param node - The name node from a `command` syntax node. + * @returns The command name string, or '' for unexpandable names. + */ +function extractCommandName(node: Parser.SyntaxNode): string { + const inner = node.namedChild(0) ?? node; + if (inner.type === 'word') { + return inner.text; + } + if (inner.type === 'string') { + return stripQuotes(inner.text); + } + if (inner.type === 'raw_string') { + return stripQuotes(inner.text); + } + return ''; +} + +/** + * Extract the text of an argument syntax node. + * + * @param node - The argument syntax node to extract text from. + * @returns The argument text, or '' for unexpandable arguments. + */ +function extractArgText(node: Parser.SyntaxNode): string { + if (node.type === 'word') { + return node.text; + } + if (node.type === 'raw_string') { + return stripQuotes(node.text); + } + if (node.type === 'string') { + if (containsExpansion(node)) { + return ''; + } + return stripQuotes(node.text); + } + if (node.type === 'concatenation') { + if (containsExpansion(node)) { + return ''; + } + let acc = ''; + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child !== null) { + acc += extractArgText(child); + } + } + return acc; + } + if ( + node.type === 'simple_expansion' || + node.type === 'expansion' || + node.type === 'command_substitution' || + node.type === 'process_substitution' || + node.type === 'arithmetic_expansion' + ) { + return ''; + } + return node.text; +} + +/** + * Return true if the node or any descendant is a shell expansion. + * + * @param node - The syntax node to inspect. + * @returns True if the node subtree contains any shell expansion. + */ +function containsExpansion(node: Parser.SyntaxNode): boolean { + if ( + node.type === 'simple_expansion' || + node.type === 'expansion' || + node.type === 'command_substitution' || + node.type === 'process_substitution' || + node.type === 'arithmetic_expansion' + ) { + return true; + } + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child !== null && containsExpansion(child)) { + return true; + } + } + return false; +} + +/** + * Strip a single layer of surrounding single or double quotes from a string. + * + * @param text - The string to strip quotes from. + * @returns The unquoted string, or the original if not quoted. + */ +function stripQuotes(text: string): string { + if (text.length < 2) { + return text; + } + const first = text[0]; + const last = text[text.length - 1]; + if ((first === '"' || first === "'") && first === last) { + return text.slice(1, -1); + } + return text; +} + +/** + * Return true if the tree contains a `curl | shell-interpreter` pipeline. + * + * @param node - The root syntax node to scan. + * @returns True if any pipeline pipes a network command into a shell. + */ +function hasCurlPipeShell(node: Parser.SyntaxNode): boolean { + let found = false; + walk(node, (nd) => { + if (found || nd.type !== 'pipeline') { + return; + } + const stages: Parser.SyntaxNode[] = []; + for (let i = 0; i < nd.namedChildCount; i++) { + const stageChild = nd.namedChild(i); + if (stageChild !== null) { + stages.push(stageChild); + } + } + if (stages.length < 2) { + return; + } + const first = stages[0]; + const last = stages[stages.length - 1]; + if (first === undefined || last === undefined) { + return; + } + const firstName = firstCommandName(first); + const lastName = firstCommandName(last); + if ( + firstName !== null && + lastName !== null && + NETWORK_CMDS.has(firstName) && + SHELL_INTERPRETERS.has(lastName) + ) { + found = true; + } + }); + return found; +} + +/** + * Return the name of the first command found in a syntax node subtree. + * + * @param node - The syntax node to search. + * @returns The command name string, or null if no command was found. + */ +function firstCommandName(node: Parser.SyntaxNode): string | null { + if (node.type === 'command') { + const nameNode = node.childForFieldName('name'); + if (nameNode === null) { + return null; + } + return extractCommandName(nameNode); + } + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child === null) { + continue; + } + const name = firstCommandName(child); + if (name !== null) { + return name; + } + } + return null; +} + +/** + * Return true if any `eval` command has a dynamic (unexpandable) argument. + * + * @param commands - The list of parsed commands to inspect. + * @returns True if eval is called with a dynamic argument. + */ +function hasEvalDynamic(commands: ParsedCommand[]): boolean { + for (const cmd of commands) { + if (cmd.name === 'eval' && cmd.argv.some((a) => a.includes(''))) { + return true; + } + } + return false; +} diff --git a/packages/caprock/src/index.test.ts b/packages/caprock/src/index.test.ts new file mode 100644 index 0000000000..be9e49dce1 --- /dev/null +++ b/packages/caprock/src/index.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest'; + +import { caprockOutputPath } from './session.ts'; + +describe('caprockOutputPath', () => { + it('appends .caprock.jsonl to a .jsonl path', () => { + expect( + caprockOutputPath('/home/user/.claude/projects/foo/abc123.jsonl'), + ).toBe('/home/user/.claude/projects/foo/abc123.caprock.jsonl'); + }); + + it('appends .caprock.jsonl to a non-.jsonl path', () => { + expect(caprockOutputPath('/some/transcript')).toBe( + '/some/transcript.caprock.jsonl', + ); + }); +}); diff --git a/packages/caprock/src/index.ts b/packages/caprock/src/index.ts new file mode 100644 index 0000000000..db6146901a --- /dev/null +++ b/packages/caprock/src/index.ts @@ -0,0 +1,3 @@ +export type * from './types.ts'; +export * from './session.ts'; +export * from './rpc.ts'; diff --git a/packages/caprock/src/paths/ocap-kernel.ts b/packages/caprock/src/paths/ocap-kernel.ts new file mode 100644 index 0000000000..6522aaf31e --- /dev/null +++ b/packages/caprock/src/paths/ocap-kernel.ts @@ -0,0 +1,40 @@ +/* eslint-disable n/no-process-env */ + +import { getOcapHome } from '@metamask/kernel-utils/nodejs'; +import { join } from 'node:path'; + +import { getPluginDataDir } from './plugin.ts'; + +export { getOcapHome }; + +/** + * Get the default daemon socket path. + * + * @returns The socket path. + */ +export function getSocketPath(): string { + return join(getOcapHome(), 'daemon.sock'); +} + +/** + * Absolute path to the `~/.ocap/caprock/` state directory. + * + * @returns The caprock plugin state directory. + */ +export function getCaprockDir(): string { + return join(getOcapHome(), 'caprock'); +} + +/** + * Preferred ocap binary path: `OCAP_BIN` env var, then the copy installed in + * `CLAUDE_PLUGIN_DATA`, then falls back to `ocap` on `PATH`. + * + * @param pluginBinDir - The directory containing the running bin script. + * @returns Absolute path to the ocap binary. + */ +export function getOcapBinPath(pluginBinDir: string): string { + return ( + process.env.OCAP_BIN ?? + join(getPluginDataDir(pluginBinDir), 'node_modules', '.bin', 'ocap') + ); +} diff --git a/packages/caprock/src/paths/plugin.ts b/packages/caprock/src/paths/plugin.ts new file mode 100644 index 0000000000..3475d5409d --- /dev/null +++ b/packages/caprock/src/paths/plugin.ts @@ -0,0 +1,62 @@ +/* eslint-disable n/no-process-env */ +import { join } from 'node:path'; + +/** + * The plugin root directory: `CLAUDE_PLUGIN_ROOT` env var, or parent of the bin dir. + * + * @param pluginBinDir - The directory containing the running bin script. + * @returns Absolute path to the plugin root. + */ +export function getPluginRoot(pluginBinDir: string): string { + return process.env.CLAUDE_PLUGIN_ROOT ?? join(pluginBinDir, '..'); +} + +/** + * The plugin data directory (npm install cache etc.): `CLAUDE_PLUGIN_DATA` or plugin root. + * + * @param pluginBinDir - The directory containing the running bin script. + * @returns Absolute path to the plugin data directory. + */ +export function getPluginDataDir(pluginBinDir: string): string { + return process.env.CLAUDE_PLUGIN_DATA ?? getPluginRoot(pluginBinDir); +} + +/** + * The project directory (workspace root): `CLAUDE_PROJECT_DIR` or plugin root. + * + * @param pluginBinDir - The directory containing the running bin script. + * @returns Absolute path to the project directory. + */ +export function getProjectDir(pluginBinDir: string): string { + return process.env.CLAUDE_PROJECT_DIR ?? getPluginRoot(pluginBinDir); +} + +/** + * Absolute path to the compiled permission-tracker vat bundle. + * + * @param pluginBinDir - The directory containing the running bin script. + * @returns Absolute path to `vat/permission-tracker.bundle`. + */ +export function getVatBundlePath(pluginBinDir: string): string { + return join(getPluginRoot(pluginBinDir), 'vat', 'permission-tracker.bundle'); +} + +/** + * Project-local settings file watched for FileChanged rule grants. + * + * @param pluginBinDir - The directory containing the running bin script. + * @returns Absolute path to `.claude/settings.local.json` in the project dir. + */ +export function getProjectSettingsLocalPath(pluginBinDir: string): string { + return join(getProjectDir(pluginBinDir), '.claude', 'settings.local.json'); +} + +/** + * Path to the plugin manifest (`plugin.json`), which carries the canonical version. + * + * @param pluginBinDir - The directory containing the running bin script. + * @returns Absolute path to `.claude-plugin/plugin.json`. + */ +export function getPluginManifestPath(pluginBinDir: string): string { + return join(getPluginRoot(pluginBinDir), '.claude-plugin', 'plugin.json'); +} diff --git a/packages/caprock/src/paths/user.ts b/packages/caprock/src/paths/user.ts new file mode 100644 index 0000000000..930cf226fa --- /dev/null +++ b/packages/caprock/src/paths/user.ts @@ -0,0 +1,29 @@ +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +/** + * Absolute path to the `~/.claude` directory. + * + * @returns The Claude Code home directory. + */ +export function getClaudeDir(): string { + return join(homedir(), '.claude'); +} + +/** + * Absolute path to `~/.claude/projects/`. + * + * @returns The directory containing per-project transcript files. + */ +export function getClaudeProjectsDir(): string { + return join(getClaudeDir(), 'projects'); +} + +/** + * Absolute path to `~/.claude/settings.json`. + * + * @returns The global Claude Code settings file path. + */ +export function getClaudeSettingsPath(): string { + return join(getClaudeDir(), 'settings.json'); +} diff --git a/packages/caprock/src/rpc.ts b/packages/caprock/src/rpc.ts new file mode 100644 index 0000000000..97c3eb0d04 --- /dev/null +++ b/packages/caprock/src/rpc.ts @@ -0,0 +1,341 @@ +import type { + ParsedInvocation, + Provision, +} from '@metamask/kernel-utils/session/provision'; +import type { JsonRpcResponse } from '@metamask/utils'; +import { assertIsJsonRpcResponse, isJsonRpcFailure } from '@metamask/utils'; +import { randomUUID } from 'node:crypto'; +import { createConnection } from 'node:net'; +import type { Socket } from 'node:net'; + +import type { CapData, Decision } from './types.ts'; + +// ─── Minimal socket-RPC client (no @endo dependencies) ─────────────────────── + +/** + * Options for {@link sendCommand}. + */ +export type SendCommandOptions = { + /** The UNIX socket path. */ + socketPath: string; + /** The RPC method name. */ + method: string; + /** Optional method parameters. */ + params?: Record | unknown[] | undefined; + /** Read timeout in milliseconds (default: no timeout). */ + timeoutMs?: number | undefined; +}; + +/** + * @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); + }); +} + +/** + * @param socket - The socket to write to. + * @param line - The line to write (without trailing newline). + */ +async function writeLine(socket: Socket, line: string): Promise { + return new Promise((resolve, reject) => { + socket.write(`${line}\n`, (error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); +} + +/** + * @param socket - The socket to read from. + * @param timeoutMs - Optional timeout in milliseconds. + * @returns The line read (without trailing newline). + */ +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); + }); +} + +/** + * 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. + * + * @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: no timeout). + * @returns The parsed JSON-RPC response. + */ +export async function sendCommand({ + socketPath, + method, + params, + timeoutMs, +}: 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) { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (code !== 'ECONNREFUSED' && code !== 'ECONNRESET') { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + return attempt(); + } +} + +// ─── RPC helpers ────────────────────────────────────────────────────────────── + +/** + * Check whether the daemon is running. + * + * @param socketPath - The UNIX socket path. + * @returns True if the daemon responds to the RPC call. + */ +export async function pingDaemon(socketPath: string): Promise { + try { + const response = await sendCommand({ + socketPath, + method: 'getStatus', + timeoutMs: 3_000, + }); + return !isJsonRpcFailure(response); + } catch { + return false; + } +} + +/** + * Create a new kernel session and return its ID and OCAP URL. + * + * @param socketPath - The UNIX socket path. + * @param name - Optional session name hint. + * @returns The new session's ID and OCAP URL. + */ +export async function createKernelSession( + socketPath: string, + name?: string, +): Promise<{ sessionId: string; ocapUrl: string }> { + const params: Record = {}; + if (name !== undefined) { + params.name = name; + } + const response = await sendCommand({ + socketPath, + method: 'session.create', + params, + }); + if (isJsonRpcFailure(response)) { + throw new Error(`session.create: ${response.error.message}`); + } + return response.result as { sessionId: string; ocapUrl: string }; +} + +/** + * Block until the TUI renders a decision for the described authorization request. + * + * @param socketPath - The UNIX socket path. + * @param kernelSessionId - The kernel session to route the request through. + * @param description - Human-readable description of the requested operation. + * @param options - Optional request metadata. + * @param options.reason - Optional reason for the request. + * @param options.timeoutMs - Optional client-side timeout in milliseconds. + * @param options.invocations - Parsed invocations to forward to the TUI for the provision editor. + * @returns The TUI's decision. + */ +export async function authorizeRequest( + socketPath: string, + kernelSessionId: string, + description: string, + options?: { + reason?: string; + timeoutMs?: number; + invocations?: ParsedInvocation[]; + }, +): Promise { + const params: Record = { + sessionId: kernelSessionId, + description, + }; + if (options?.reason !== undefined) { + params.reason = options.reason; + } + if (options?.timeoutMs !== undefined) { + params.timeoutMs = options.timeoutMs; + } + if (options?.invocations !== undefined) { + params.invocations = options.invocations; + } + const response = await sendCommand({ + socketPath, + method: 'session.authorize', + params, + // No client-side timeout — waits for user decision. + }); + if (isJsonRpcFailure(response)) { + const error = new Error(response.error.message) as Error & { + code?: string; + }; + if (response.error.code !== undefined) { + error.code = String(response.error.code); + } + throw error; + } + return response.result as Decision; +} + +/** + * Record a request that was auto-accepted by a standing provision. + * + * @param socketPath - The UNIX socket path. + * @param sessionId - The kernel session ID. + * @param description - Human-readable description of the auto-accepted operation. + * @param options - Optional parameters. + * @param options.invocations - Parsed invocations to forward to the TUI. + * @param options.provision - The standing provision that approved the request. + */ +export async function recordProvisioned( + socketPath: string, + sessionId: string, + description: string, + options?: { invocations?: ParsedInvocation[]; provision?: Provision }, +): Promise { + const params: Record = { sessionId, description }; + if (options?.invocations !== undefined) { + params.invocations = options.invocations; + } + if (options?.provision !== undefined) { + params.provision = options.provision; + } + await sendCommand({ socketPath, method: 'session.record', params }); +} + +/** + * Decode a CapData body to a JavaScript value. + * + * The kernel uses JSBI encoding via @endo/marshal. For primitive values + * returned by the permission vat ('allow', 'ask', undefined), the body is + * prefixed with '#' and then JSON-encoded: string 'allow' → body '#"allow"'. + * + * @param capData - The CapData object to decode. + * @returns The decoded JavaScript value. + */ +export function decodeCapData(capData: CapData): unknown { + const { body } = capData; + if (body.startsWith('#')) { + return JSON.parse(body.slice(1)); + } + throw new Error(`Unexpected CapData body format: ${body.slice(0, 40)}`); +} + +/** + * Recursively strip the smallcaps `!` escape prefix from string values in a + * decoded CapData object. In smallcaps encoding, strings that begin with a + * sigil character (including `-` for negative special floats) are prefixed + * with `!` to distinguish them from encoding markers. This reversal is needed + * when decoding complex objects like Provision (whose argv may contain flags + * like `--oneline` that become `!--oneline` after encoding). + * + * @param value - A JSON-parsed smallcaps value. + * @returns The value with all `!`-escaped strings decoded. + */ +export function decodeSmallcapsStrings(value: unknown): unknown { + if (typeof value === 'string') { + return value.startsWith('!') ? value.slice(1) : value; + } + if (Array.isArray(value)) { + return value.map(decodeSmallcapsStrings); + } + if (typeof value === 'object' && value !== null) { + const result: Record = {}; + for (const [key, val] of Object.entries(value as Record)) { + result[key] = decodeSmallcapsStrings(val); + } + return result; + } + return value; +} diff --git a/packages/caprock/src/session.test.ts b/packages/caprock/src/session.test.ts new file mode 100644 index 0000000000..c31089a86b --- /dev/null +++ b/packages/caprock/src/session.test.ts @@ -0,0 +1,176 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { CaprockEvent, SessionState } from './types.ts'; + +const mockDir = vi.hoisted(() => ({ value: '' })); + +vi.mock('./paths/ocap-kernel.ts', () => ({ + getCaprockDir: () => mockDir.value, +})); + +// Imported after vi.mock so the mock is in place at module load time. +const { + loadSessionState, + saveSessionState, + appendEvent, + readEvents, + readSettingsAllowList, +} = await import('./session.ts'); + +const SESSION_ID = 'test-session-abc123'; + +const makeState = (): SessionState => ({ + sessionId: SESSION_ID, + kernelSessionId: 'kernel-sess-xyz', + ocapUrl: 'ocap://localhost/xyz', + rootKref: 'ko42', + subclusterId: 'sub-1', + startedAt: '2026-01-01T00:00:00.000Z', + settingsSnapshot: ['Bash(ls)', 'Read(**/*)', 'Write(**/*.ts)'], +}); + +const makeEvent = (extra: Record = {}): CaprockEvent => ({ + t: '2026-01-01T00:01:00.000Z', + event: 'grant', + sessionId: SESSION_ID, + toolName: 'Bash', + ...extra, +}); + +describe('session state persistence', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'caprock-test-')); + mockDir.value = tmpDir; + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + it('returns null for a session that has never been saved', async () => { + expect(await loadSessionState('no-such-session')).toBeNull(); + }); + + it('round-trips a session state through save and load', async () => { + const state = makeState(); + await saveSessionState(SESSION_ID, state); + expect(await loadSessionState(SESSION_ID)).toStrictEqual(state); + }); + + it('overwrites a previously saved state on re-save', async () => { + const original = makeState(); + await saveSessionState(SESSION_ID, original); + + const updated = { ...original, kernelSessionId: 'kernel-sess-updated' }; + await saveSessionState(SESSION_ID, updated); + + expect(await loadSessionState(SESSION_ID)).toStrictEqual(updated); + }); + + it('creates the caprock directory if it does not exist', async () => { + const deepDir = join(tmpDir, 'nested', 'caprock'); + mockDir.value = deepDir; + + await saveSessionState(SESSION_ID, makeState()); + expect(await loadSessionState(SESSION_ID)).not.toBeNull(); + }); +}); + +describe('event log', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'caprock-test-')); + mockDir.value = tmpDir; + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + it('returns an empty array when no log exists', async () => { + expect(await readEvents('no-such-session')).toStrictEqual([]); + }); + + it('round-trips a single event through append and read', async () => { + const event = makeEvent(); + await appendEvent(SESSION_ID, event); + expect(await readEvents(SESSION_ID)).toStrictEqual([event]); + }); + + it('preserves event order across multiple appends', async () => { + // Omit toolName entirely on session_start — JSON.stringify drops undefined + // values, so toStrictEqual would fail if the key is present with undefined. + const first: CaprockEvent = { + t: '2026-01-01T00:01:00.000Z', + event: 'session_start', + sessionId: SESSION_ID, + }; + const second = makeEvent({ event: 'grant', toolName: 'Bash' }); + const third = makeEvent({ event: 'prompted', toolName: 'Write' }); + + await appendEvent(SESSION_ID, first); + await appendEvent(SESSION_ID, second); + await appendEvent(SESSION_ID, third); + + expect(await readEvents(SESSION_ID)).toStrictEqual([first, second, third]); + }); + + it('ignores blank lines in the event log', async () => { + const logPath = join(tmpDir, `${SESSION_ID}.jsonl`); + const event = makeEvent(); + await writeFile( + logPath, + `${JSON.stringify(event)}\n\n \n${JSON.stringify(event)}\n`, + ); + expect(await readEvents(SESSION_ID)).toStrictEqual([event, event]); + }); +}); + +describe('readSettingsAllowList', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'caprock-settings-')); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + it('returns an empty array for a missing file', async () => { + expect( + await readSettingsAllowList(join(tmpDir, 'nonexistent.json')), + ).toStrictEqual([]); + }); + + it('returns an empty array for a file with no permissions key', async () => { + const path = join(tmpDir, 'settings.json'); + await writeFile(path, JSON.stringify({ theme: 'dark' })); + expect(await readSettingsAllowList(path)).toStrictEqual([]); + }); + + it('returns an empty array for a file with no allow list', async () => { + const path = join(tmpDir, 'settings.json'); + await writeFile(path, JSON.stringify({ permissions: {} })); + expect(await readSettingsAllowList(path)).toStrictEqual([]); + }); + + it('returns the allow list when present', async () => { + const allow = ['Bash(ls)', 'Read(**/*.ts)']; + const path = join(tmpDir, 'settings.json'); + await writeFile(path, JSON.stringify({ permissions: { allow } })); + expect(await readSettingsAllowList(path)).toStrictEqual(allow); + }); + + it('returns an empty array for a malformed JSON file', async () => { + const path = join(tmpDir, 'settings.json'); + await writeFile(path, 'not json {{{'); + expect(await readSettingsAllowList(path)).toStrictEqual([]); + }); +}); diff --git a/packages/caprock/src/session.ts b/packages/caprock/src/session.ts new file mode 100644 index 0000000000..cac14cd64c --- /dev/null +++ b/packages/caprock/src/session.ts @@ -0,0 +1,128 @@ +import { readFile, writeFile, appendFile, mkdir } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { getCaprockDir } from './paths/ocap-kernel.ts'; +import type { SessionState, CaprockEvent } from './types.ts'; + +/** Create the caprock state directory if it does not exist. */ +async function ensureCaprockDir(): Promise { + await mkdir(getCaprockDir(), { recursive: true }); +} + +/** + * Absolute path to the JSON state file for a session. + * + * @param sessionId - The Claude Code session ID. + * @returns Absolute path to the `.json` state file. + */ +function statePath(sessionId: string): string { + return join(getCaprockDir(), `${sessionId}.json`); +} + +/** + * Absolute path to the JSONL event log for a session. + * + * @param sessionId - The Claude Code session ID. + * @returns Absolute path to the `.jsonl` event log. + */ +function eventLogPath(sessionId: string): string { + return join(getCaprockDir(), `${sessionId}.jsonl`); +} + +/** + * Load the persisted session state for a Claude Code session. + * + * @param sessionId - The Claude Code session ID. + * @returns The session state, or null if none exists. + */ +export async function loadSessionState( + sessionId: string, +): Promise { + try { + return JSON.parse( + await readFile(statePath(sessionId), 'utf8'), + ) as SessionState; + } catch { + return null; + } +} + +/** + * Persist the session state for a Claude Code session. + * + * @param sessionId - The Claude Code session ID. + * @param state - The session state to save. + */ +export async function saveSessionState( + sessionId: string, + state: SessionState, +): Promise { + await ensureCaprockDir(); + await writeFile(statePath(sessionId), JSON.stringify(state, null, 2)); +} + +/** + * Append an event to the session event log. + * + * @param sessionId - The Claude Code session ID. + * @param event - The event to record. + */ +export async function appendEvent( + sessionId: string, + event: CaprockEvent, +): Promise { + await ensureCaprockDir(); + await appendFile(eventLogPath(sessionId), `${JSON.stringify(event)}\n`); +} + +/** + * Read all events from the session event log. + * + * @param sessionId - The Claude Code session ID. + * @returns The list of recorded events, or an empty array if none exist. + */ +export async function readEvents(sessionId: string): Promise { + let raw: string; + try { + raw = await readFile(eventLogPath(sessionId), 'utf8'); + } catch { + return []; + } + return raw + .split('\n') + .filter((line) => line.trim().length > 0) + .map((line) => JSON.parse(line) as CaprockEvent); +} + +/** + * Read the permissions.allow list from a Claude Code settings file. + * + * @param settingsPath - Absolute path to the settings JSON file. + * @returns The allow list, or an empty array if the file is absent or unreadable. + */ +export async function readSettingsAllowList( + settingsPath: string, +): Promise { + try { + const raw = JSON.parse(await readFile(settingsPath, 'utf8')) as { + permissions?: { allow?: string[] }; + }; + return raw.permissions?.allow ?? []; + } catch { + return []; + } +} + +/** + * Derive the colocated caprock output path from the session transcript path. + * e.g. `~/.claude/projects/.../.jsonl` → `.caprock.jsonl` + * + * @param transcriptPath - The path to the Claude Code transcript file. + * @returns The derived caprock output path. + */ +export function caprockOutputPath(transcriptPath: string): string { + if (transcriptPath.endsWith('.jsonl')) { + return `${transcriptPath.slice(0, -6)}.caprock.jsonl`; + } + return `${transcriptPath}.caprock.jsonl`; +} diff --git a/packages/caprock/src/transcript.ts b/packages/caprock/src/transcript.ts new file mode 100644 index 0000000000..3a9c546eb6 --- /dev/null +++ b/packages/caprock/src/transcript.ts @@ -0,0 +1,81 @@ +import { readFile, readdir, stat } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { getClaudeProjectsDir } from './paths/user.ts'; + +export type TranscriptToolUse = { + name: string; + input?: Record; +}; + +/** + * Search `~/.claude/projects/` for a transcript matching the given session ID. + * The transcript lives at `//.jsonl`, but we + * don't know the cwd-encoded segment here, so we scan all project dirs. + * + * @param sessionId - The Claude Code session ID to locate. + * @returns The absolute path to the transcript, or null if not found. + */ +export async function findTranscript( + sessionId: string, +): Promise { + let dirs: string[]; + try { + dirs = await readdir(getClaudeProjectsDir()); + } catch { + return null; + } + for (const dir of dirs) { + const candidate = join(getClaudeProjectsDir(), dir, `${sessionId}.jsonl`); + try { + await stat(candidate); + return candidate; + } catch { + // file doesn't exist, continue + } + } + return null; +} + +type ContentItem = { + type: string; + name?: string; + input?: Record; +}; +type TranscriptLine = { message?: { content?: ContentItem[] } }; + +/** + * Extract tool invocations from a Claude Code transcript JSONL. + * Each line is a JSON object; tool uses appear as content items with + * `type === 'tool_use'` inside `.message.content` arrays. + * + * @param transcriptPath - The path to the transcript JSONL file. + * @returns The list of tool use records found in the transcript. + */ +export async function readTranscriptToolUses( + transcriptPath: string, +): Promise { + const raw = await readFile(transcriptPath, 'utf8'); + const results: TranscriptToolUse[] = []; + for (const line of raw.split('\n').filter((ln) => ln.trim().length > 0)) { + let parsed: TranscriptLine; + try { + parsed = JSON.parse(line) as TranscriptLine; + } catch { + continue; + } + const content = parsed.message?.content; + if (Array.isArray(content)) { + for (const item of content) { + if (item.type === 'tool_use' && item.name) { + const toolUse: TranscriptToolUse = { name: item.name }; + if (item.input !== undefined) { + toolUse.input = item.input; + } + results.push(toolUse); + } + } + } + } + return results; +} diff --git a/packages/caprock/src/types.ts b/packages/caprock/src/types.ts new file mode 100644 index 0000000000..9d25e145f9 --- /dev/null +++ b/packages/caprock/src/types.ts @@ -0,0 +1,96 @@ +export type { Decision } from '@metamask/kernel-utils/session'; + +export type SessionState = { + sessionId: string; + kernelSessionId: string; + ocapUrl: string; + rootKref: string; + subclusterId: string; + startedAt: string; + settingsSnapshot: string[]; +}; + +export type CaprockEventKind = + | 'session_start' + | 'session_end' + | 'check' + | 'grant' + | 'prompted' + | 'denied' + | 'rule_grant' + | 'tui_accept' + | 'tui_reject' + | 'connect_hint'; + +export type CaprockEvent = { + t: string; + event: CaprockEventKind; + sessionId: string; +} & Record; + +export type CapData = { + body: string; + slots: string[]; +}; + +// Stdin payloads from Claude Code CLI hooks + +export type HookPayloadBase = { + session_id: string; + transcript_path: string; + hook_event_name: string; + cwd?: string; +}; + +export type PreToolUsePayload = HookPayloadBase & { + hook_event_name: 'PreToolUse'; + tool_name: string; + tool_input: Record; +}; + +export type PostToolUsePayload = HookPayloadBase & { + hook_event_name: 'PostToolUse'; + tool_name: string; + tool_input: Record; + tool_response: { + output?: string; + error?: string | null; + interrupted?: boolean; + }; + duration_ms?: number; +}; + +export type PermissionRequestPayload = HookPayloadBase & { + hook_event_name: 'PermissionRequest'; + tool_name?: string; + tool_input?: Record; +}; + +export type PermissionDeniedPayload = HookPayloadBase & { + hook_event_name: 'PermissionDenied'; + tool_name?: string; + tool_input?: Record; +}; + +export type FileChangedPayload = HookPayloadBase & { + hook_event_name: 'FileChanged'; + file_path: string; + change_type: 'create' | 'modify' | 'delete'; +}; + +export type SessionEndPayload = HookPayloadBase & { + hook_event_name: 'SessionEnd'; +}; + +export type SessionStartPayload = HookPayloadBase & { + hook_event_name: 'SessionStart'; +}; + +export type AnyHookPayload = + | SessionStartPayload + | PreToolUsePayload + | PostToolUsePayload + | PermissionRequestPayload + | PermissionDeniedPayload + | FileChangedPayload + | SessionEndPayload; diff --git a/packages/caprock/tsconfig.build.json b/packages/caprock/tsconfig.build.json new file mode 100644 index 0000000000..0c02f89975 --- /dev/null +++ b/packages/caprock/tsconfig.build.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": ".", + "types": ["node"] + }, + "references": [ + { "path": "../kernel-utils/tsconfig.build.json" }, + { "path": "../sheaves/tsconfig.build.json" } + ], + "files": [], + "include": ["./src", "./bin"] +} diff --git a/packages/caprock/tsconfig.json b/packages/caprock/tsconfig.json new file mode 100644 index 0000000000..2c969d2f0c --- /dev/null +++ b/packages/caprock/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./", + "lib": ["ES2022"], + "types": ["vitest", "node"] + }, + "references": [ + { "path": "../repo-tools" }, + { "path": "../kernel-utils" }, + { "path": "../sheaves" } + ], + "include": [ + "../../vitest.config.ts", + "./src", + "./bin", + "./vat", + "./vitest.config.ts" + ] +} diff --git a/packages/caprock/typedoc.json b/packages/caprock/typedoc.json new file mode 100644 index 0000000000..f8eb78ae1a --- /dev/null +++ b/packages/caprock/typedoc.json @@ -0,0 +1,8 @@ +{ + "entryPoints": [], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json", + "projectDocuments": ["documents/*.md"] +} diff --git a/packages/caprock/vat/permission-tracker.test.ts b/packages/caprock/vat/permission-tracker.test.ts new file mode 100644 index 0000000000..b99653a439 --- /dev/null +++ b/packages/caprock/vat/permission-tracker.test.ts @@ -0,0 +1,257 @@ +import { invocationToProvision } from '@metamask/kernel-utils/session'; +import type { + InvocationPattern, + Provision, +} from '@metamask/kernel-utils/session'; +import { describe, expect, it } from 'vitest'; + +import { buildRootObject } from './permission-tracker.ts'; + +// ─── helpers ────────────────────────────────────────────────────────────────── + +const inv = (name: string, ...argv: string[]) => ({ name, argv }); + +const makeRoot = () => { + const root = buildRootObject(); + root.bootstrap(); + return root; +}; + +const prefixPat = (name: string, pfx: string): InvocationPattern => ({ + name, + argPatterns: [{ kind: 'prefix', prefix: pfx }], +}); + +const wildcardPat = (name: string): InvocationPattern => ({ + name, + argPatterns: [{ kind: 'wildcard' }], +}); + +const prov = (tool: string, ...patterns: InvocationPattern[]): Provision => ({ + tool, + patterns, +}); + +// ─── empty sheaf ────────────────────────────────────────────────────────────── + +describe('empty tracker', () => { + it('returns ask with no sections', async () => { + const root = makeRoot(); + expect(await root.route('Bash', [inv('ls')])).toBe('ask'); + }); + + it('has size 0', () => { + expect(makeRoot().size()).toBe(0); + }); +}); + +// ─── size tracking ──────────────────────────────────────────────────────────── + +describe('size', () => { + it('grows with each addSection', () => { + const root = makeRoot(); + root.addSection(invocationToProvision('Bash', [inv('ls', '/tmp')])); + expect(root.size()).toBe(1); + root.addSection(invocationToProvision('Bash', [inv('cat', '/etc/hosts')])); + expect(root.size()).toBe(2); + }); +}); + +// ─── exact provisions ───────────────────────────────────────────────────────── + +describe('exact provision', () => { + it('allows an exact match', async () => { + const root = makeRoot(); + root.addSection(invocationToProvision('Bash', [inv('ls', '/tmp')])); + expect(await root.route('Bash', [inv('ls', '/tmp')])).toBe('allow'); + }); + + it('asks for a different tool', async () => { + const root = makeRoot(); + root.addSection(invocationToProvision('Bash', [inv('ls', '/tmp')])); + expect(await root.route('Read', [inv('ls', '/tmp')])).toBe('ask'); + }); + + it('asks for a different command name', async () => { + const root = makeRoot(); + root.addSection(invocationToProvision('Bash', [inv('ls', '/tmp')])); + expect(await root.route('Bash', [inv('cat', '/tmp')])).toBe('ask'); + }); + + it('asks when an argument differs', async () => { + const root = makeRoot(); + root.addSection(invocationToProvision('Bash', [inv('ls', '/tmp')])); + expect(await root.route('Bash', [inv('ls', '/home')])).toBe('ask'); + }); + + it('asks for a different invocation count', async () => { + const root = makeRoot(); + root.addSection(invocationToProvision('Bash', [inv('ls', '/tmp')])); + // provision has 1 pattern but we send 2 invocations + expect( + await root.route('Bash', [inv('ls', '/tmp'), inv('grep', 'foo')]), + ).toBe('ask'); + }); +}); + +// ─── prefix provisions ──────────────────────────────────────────────────────── + +describe('prefix provision', () => { + it('allows invocations under the prefix', async () => { + const root = makeRoot(); + root.addSection(prov('Bash', prefixPat('ls', '/tmp/'))); + expect(await root.route('Bash', [inv('ls', '/tmp/foo')])).toBe('allow'); + expect(await root.route('Bash', [inv('ls', '/tmp/a/b/c')])).toBe('allow'); + }); + + it('asks for paths outside the prefix', async () => { + const root = makeRoot(); + root.addSection(prov('Bash', prefixPat('ls', '/tmp/'))); + expect(await root.route('Bash', [inv('ls', '/home/user')])).toBe('ask'); + expect(await root.route('Bash', [inv('ls', '/tmp')])).toBe('ask'); // exact '/tmp' doesn't start with '/tmp/' + }); +}); + +// ─── wildcard provisions ────────────────────────────────────────────────────── + +describe('wildcard provision', () => { + it('allows any invocation with a first arg', async () => { + const root = makeRoot(); + root.addSection(prov('Bash', wildcardPat('ls'))); + expect(await root.route('Bash', [inv('ls', '/any/path')])).toBe('allow'); + expect(await root.route('Bash', [inv('ls', '/other')])).toBe('allow'); + }); + + it('provision with no argPatterns matches invocations of any arity', async () => { + const root = makeRoot(); + root.addSection(prov('Bash', { name: 'ls', argPatterns: [] })); + expect(await root.route('Bash', [inv('ls')])).toBe('allow'); + expect(await root.route('Bash', [inv('ls', '-la')])).toBe('allow'); + }); + + it('still asks for a different tool', async () => { + const root = makeRoot(); + root.addSection(prov('Bash', wildcardPat('ls'))); + expect(await root.route('Read', [inv('ls')])).toBe('ask'); + }); + + it('asks for a different command name even with wildcard arg', async () => { + const root = makeRoot(); + root.addSection(prov('Bash', wildcardPat('ls'))); + expect(await root.route('Bash', [inv('cat', '/tmp')])).toBe('ask'); + }); +}); + +// ─── truncated-arg provisions ───────────────────────────────────────────────── + +describe('truncated-arg provision (fewer patterns than argv)', () => { + it('allows when the specified args match regardless of trailing args', async () => { + const root = makeRoot(); + // pattern specifies only the first arg (command name only, any flags) + root.addSection( + prov('Bash', { + name: 'ls', + argPatterns: [{ kind: 'exact', value: '/tmp' }], + }), + ); + expect( + await root.route('Bash', [inv('ls', '/tmp', '-la', '--color')]), + ).toBe('allow'); + }); +}); + +// ─── pipeline provisions (multi-command Bash) ───────────────────────────────── + +describe('pipeline provisions', () => { + it('allows a matching two-command pipeline', async () => { + const root = makeRoot(); + root.addSection( + invocationToProvision('Bash', [inv('ls', '/tmp'), inv('grep', 'foo')]), + ); + expect( + await root.route('Bash', [inv('ls', '/tmp'), inv('grep', 'foo')]), + ).toBe('allow'); + }); + + it('asks when pipeline has fewer commands than the provision', async () => { + const root = makeRoot(); + root.addSection( + invocationToProvision('Bash', [inv('ls', '/tmp'), inv('grep', 'foo')]), + ); + expect(await root.route('Bash', [inv('ls', '/tmp')])).toBe('ask'); + }); + + it('asks when pipeline has more commands than the provision', async () => { + const root = makeRoot(); + root.addSection(invocationToProvision('Bash', [inv('ls', '/tmp')])); + expect( + await root.route('Bash', [inv('ls', '/tmp'), inv('grep', 'foo')]), + ).toBe('ask'); + }); +}); + +// ─── non-Bash tools ─────────────────────────────────────────────────────────── + +describe('non-Bash tool (Read)', () => { + it('allows a matching Read invocation', async () => { + const root = makeRoot(); + root.addSection( + invocationToProvision('Read', [inv('Read', '/tmp/foo.ts')]), + ); + expect(await root.route('Read', [inv('Read', '/tmp/foo.ts')])).toBe( + 'allow', + ); + }); + + it('asks when the file path differs', async () => { + const root = makeRoot(); + root.addSection( + invocationToProvision('Read', [inv('Read', '/tmp/foo.ts')]), + ); + expect(await root.route('Read', [inv('Read', '/tmp/bar.ts')])).toBe('ask'); + }); +}); + +// ─── multiple provisions ────────────────────────────────────────────────────── + +describe('multiple provisions', () => { + it('allows an invocation that matches any added provision', async () => { + const root = makeRoot(); + root.addSection(invocationToProvision('Bash', [inv('ls', '/tmp')])); + root.addSection(invocationToProvision('Bash', [inv('cat', '/etc/hosts')])); + expect(await root.route('Bash', [inv('ls', '/tmp')])).toBe('allow'); + expect(await root.route('Bash', [inv('cat', '/etc/hosts')])).toBe('allow'); + }); + + it('narrow provision allows its match even when a wider provision also exists', async () => { + const root = makeRoot(); + // wildcard provision added first, then narrow exact + root.addSection(prov('Bash', wildcardPat('ls'))); + root.addSection(invocationToProvision('Bash', [inv('ls', '/tmp')])); + expect(await root.route('Bash', [inv('ls', '/tmp')])).toBe('allow'); + }); + + it('falls through from narrow to wide when narrow does not match', async () => { + const root = makeRoot(); + root.addSection(invocationToProvision('Bash', [inv('ls', '/tmp')])); + root.addSection(prov('Bash', wildcardPat('ls'))); + // '/home' doesn't match exact '/tmp' but matches wildcard + expect(await root.route('Bash', [inv('ls', '/home')])).toBe('allow'); + }); + + it('asks when no provision matches', async () => { + const root = makeRoot(); + root.addSection(invocationToProvision('Bash', [inv('ls', '/tmp')])); + root.addSection(invocationToProvision('Bash', [inv('cat', '/etc/hosts')])); + expect(await root.route('Bash', [inv('rm', '-rf', '/')])).toBe('ask'); + }); + + it('adding the same provision twice still allows on match', async () => { + const root = makeRoot(); + const provision = invocationToProvision('Bash', [inv('ls', '/tmp')]); + root.addSection(provision); + root.addSection(provision); + expect(await root.route('Bash', [inv('ls', '/tmp')])).toBe('allow'); + expect(root.size()).toBe(2); + }); +}); diff --git a/packages/caprock/vat/permission-tracker.ts b/packages/caprock/vat/permission-tracker.ts new file mode 100644 index 0000000000..2345e5626a --- /dev/null +++ b/packages/caprock/vat/permission-tracker.ts @@ -0,0 +1,204 @@ +/** + * Permission tracker vat for the caprock plugin. + * + * Runs inside the ocap-kernel and maintains the permission sheaf for a single + * Claude Code session. Launched fresh per-session; authority only grows. + * + * Sheaf model: + * - Each section is a Provider<{ authority: number }>. + * - The guard restricts to the provision's tool; the identity handler checks + * patterns and throws on mismatch (enabling drivePolicy to try next). + * - Authority values embed the partial order into (0, 1) via midpoint + * insertion (see computeAuthority). The leastAuthority policy sorts + * candidates ascending so the most-restricted matching section wins. + * + * Build: run `yarn workspace @ocap/caprock build:vat` to produce + * `vat/permission-tracker.bundle`, which is committed alongside this source. + */ + +import { M } from '@endo/patterns'; +import { makeDefaultExo } from '@metamask/kernel-utils/exo'; +import type { + Provision, + ParsedInvocation, +} from '@metamask/kernel-utils/session'; +import { + computeAuthority, + matchPattern, + matchProvision, +} from '@metamask/kernel-utils/session'; +import { + constant, + leastAuthority, + makeHandler, + sheafify, +} from '@metamask/sheaves'; +import type { Provider } from '@metamask/sheaves'; + +type SectionRecord = { provision: Provision; authority: number }; +// idx is included so collapseEquivalent never merges distinct providers +// that happen to share the same authority value (incomparable provisions). +type Meta = { authority: number; idx: number }; +type PermissionSection = { + route: ( + tool: string, + invocations: ParsedInvocation[], + ) => Promise; +}; + +// Permissive outer guard for the dispatch section. +const SECTION_GUARD = harden( + M.interface('permissions:section', { + route: M.call(M.string(), M.arrayOf(M.any())).returns(M.any()), + }), +); + +/** + * Build a Provider for one Provision with its computed authority value. + * + * The guard restricts to invocations targeting the provision's tool. The + * identity handler returns the invocations unchanged if all patterns match, + * or throws if they do not — enabling leastAuthority / drivePolicy to try the + * next candidate on failure. + * + * @param provision - The Provision to encode. + * @param idx - Index used to name the handler exo. + * @param authority - Pre-computed authority value in (0, 1). + * @returns A Provider with guard, identity handler, and authority metadata. + */ +function provisionToProvider( + provision: Provision, + idx: number, + authority: number, +): Provider { + const guard = M.interface(`permission:${idx}`, { + route: M.call(M.eq(provision.tool), M.arrayOf(M.any())).returns(M.any()), + }); + + const handler = makeHandler(`permission:${idx}`, harden(guard), { + route(_tool: string, invocations: ParsedInvocation[]): ParsedInvocation[] { + if (invocations.length !== provision.patterns.length) { + throw new Error( + `invocation count mismatch: expected ${provision.patterns.length}, got ${invocations.length}`, + ); + } + for (let i = 0; i < provision.patterns.length; i++) { + const pattern = provision.patterns[ + i + ] as (typeof provision.patterns)[number]; + const inv = invocations[i] as ParsedInvocation; + if (!matchPattern(pattern, inv.name, inv.argv)) { + throw new Error(`pattern mismatch at index ${i}`); + } + } + return invocations; + }, + }); + + return harden({ handler, metadata: constant({ authority, idx }) }); +} + +/** + * Build the root object for the permission-tracker vat. + * + * @returns The exo capability object exposed as the vat's bootstrap. + */ +export function buildRootObject(): ReturnType { + let sectionRecords: SectionRecord[] = []; + let providers: Provider[] = []; + let currentSection: PermissionSection | null = null; + + /** + * + */ + function rebuildSection(): void { + if (providers.length === 0) { + currentSection = null; + return; + } + const sheaf = sheafify({ name: 'permissions', providers }); + currentSection = sheaf.getSection({ + guard: SECTION_GUARD, + lift: leastAuthority, + }) as unknown as PermissionSection; + } + + return makeDefaultExo('permission-tracker', { + // eslint-disable-next-line no-empty-function + bootstrap(): void {}, + + /** + * Dispatch the permission sheaf: returns 'allow' if any section's handler + * accepts this invocation (identity), 'ask' if all throw or sheaf is empty. + * leastAuthority ensures the most-restricted matching section is tried first. + * + * @param tool - The tool name. + * @param invocations - The parsed command components. + * @returns 'allow' or 'ask'. + */ + async route( + tool: string, + invocations: ParsedInvocation[], + ): Promise { + if (currentSection === null) { + return 'ask'; + } + try { + await currentSection.route(tool, invocations); + return 'allow'; + } catch { + return 'ask'; + } + }, + + /** + * Add a section to the sheaf. Computes the authority value by embedding + * the provision's position in the partial order into (0, 1). + * + * @param provision - The Provision to add. + */ + addSection(provision: Provision): void { + const hardened = harden(provision); + const authority = computeAuthority(hardened, sectionRecords); + const idx = providers.length; + sectionRecords = [...sectionRecords, { provision: hardened, authority }]; + providers = [...providers, provisionToProvider(hardened, idx, authority)]; + rebuildSection(); + }, + + /** + * Return the first provision that matches the given tool and invocations, + * or null if none match. + * + * @param tool - The tool name. + * @param invocations - The parsed command components. + * @returns The matching provision, or null. + */ + findMatch(tool: string, invocations: ParsedInvocation[]): Provision | null { + for (const { provision } of sectionRecords) { + if (matchProvision(provision, tool, invocations)) { + return provision; + } + } + return null; + }, + + /** + * Return all provisions currently in the sheaf. + * + * @returns Array of provisions, oldest first. + */ + listProvisions(): Provision[] { + return sectionRecords.map(({ provision }) => provision); + }, + + /** + * Return the current section count (for session_end stats). + * + * @returns The number of sections in the sheaf. + */ + size(): number { + return providers.length; + }, + }); +} diff --git a/packages/caprock/vitest.config.ts b/packages/caprock/vitest.config.ts new file mode 100644 index 0000000000..2edaa7a445 --- /dev/null +++ b/packages/caprock/vitest.config.ts @@ -0,0 +1,22 @@ +import { mergeConfig } from '@ocap/repo-tools/vitest-config'; +import { fileURLToPath } from 'node:url'; +import { defineConfig, defineProject } from 'vitest/config'; + +import defaultConfig from '../../vitest.config.ts'; + +export default defineConfig((args) => { + return mergeConfig( + args, + defaultConfig, + defineProject({ + test: { + name: 'caprock', + setupFiles: [ + fileURLToPath( + import.meta.resolve('@ocap/repo-tools/test-utils/mock-endoify'), + ), + ], + }, + }), + ); +}); diff --git a/packages/kernel-cli/CHANGELOG.md b/packages/kernel-cli/CHANGELOG.md index 5982bc394f..1b89f6ab25 100644 --- a/packages/kernel-cli/CHANGELOG.md +++ b/packages/kernel-cli/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `ocap session` subcommands: `list`, `get`, `requests`, and `decide` +- Add `ocap tui` and `ocap modal` commands to launch the terminal UI + ## [0.1.0] ### Added diff --git a/packages/kernel-cli/package.json b/packages/kernel-cli/package.json index b5031b6901..2b3a59333c 100644 --- a/packages/kernel-cli/package.json +++ b/packages/kernel-cli/package.json @@ -66,6 +66,7 @@ "@metamask/eslint-config": "^15.0.0", "@metamask/eslint-config-nodejs": "^15.0.0", "@metamask/eslint-config-typescript": "^15.0.0", + "@ocap/kernel-tui": "workspace:^", "@ocap/repo-tools": "workspace:^", "@ts-bridge/cli": "^0.6.3", "@ts-bridge/shims": "^0.1.1", diff --git a/packages/kernel-cli/src/app.ts b/packages/kernel-cli/src/app.ts index cee2127d50..c487697ad8 100755 --- a/packages/kernel-cli/src/app.ts +++ b/packages/kernel-cli/src/app.ts @@ -1,6 +1,9 @@ import '@metamask/kernel-shims/endoify-node'; import { Logger } from '@metamask/logger'; import type { LogEntry } from '@metamask/logger'; +import { spawn } from 'node:child_process'; +import { access } from 'node:fs/promises'; +import { createRequire } from 'node:module'; import path from 'node:path'; import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; @@ -22,6 +25,7 @@ import { stopRelay, } from './commands/relay.ts'; import { getServer } from './commands/serve.ts'; +import { buildSessionCommands, resolveSessionUrl } from './commands/session.ts'; import { watchDir } from './commands/watch.ts'; import { defaultConfig } from './config.ts'; import type { Config } from './config.ts'; @@ -43,6 +47,25 @@ function consoleTransport(entry: LogEntry): void { const logger = new Logger({ tags: ['cli'], transports: [consoleTransport] }); +/** + * Resolve the built ocap-tui binary from the @ocap/kernel-tui workspace package. + * Returns undefined if the package cannot be resolved or its dist output hasn't + * been built yet (run `yarn build` from the repo root to fix the latter). + * + * @returns The absolute path to dist/app.mjs, or undefined if not found. + */ +async function findTuiBinPath(): Promise { + try { + const resolve = createRequire(import.meta.url); + const pkgPath = resolve.resolve('@ocap/kernel-tui/package.json'); + const binPath = path.join(path.dirname(pkgPath), 'dist', 'app.mjs'); + await access(binPath); + return binPath; + } catch { + return undefined; + } +} + const yargsInstance = yargs(hideBin(process.argv)) .scriptName('ocap') .usage('$0 [options]') @@ -426,6 +449,78 @@ const yargsInstance = yargs(hideBin(process.argv)) () => { // Handled by subcommands. }, + ) + .command( + 'session', + 'Manage authorization sessions', + (_yargs) => buildSessionCommands(_yargs), + () => { + // Handled by subcommands. + }, + ) + .command( + 'modal ', + 'Open an interactive TUI for a session', + (_yargs) => + _yargs.positional('sid', { + type: 'string', + demandOption: true, + describe: 'Session ID (from ocap session create)', + }), + async (args) => { + const socketPath = getSocketPath(); + const binPath = await findTuiBinPath(); + if (binPath === undefined) { + process.stderr.write( + 'Error: kernel-tui binary not found.\n' + + 'Run `yarn build` from the repository root to build it first.\n', + ); + process.exitCode = 1; + return; + } + await ensureDaemon(socketPath); + const ocapUrl = await resolveSessionUrl(socketPath, String(args.sid)); + if (ocapUrl === undefined) { + return; + } + await new Promise((resolve, reject) => { + const child = spawn(process.execPath, [binPath, 'modal', ocapUrl], { + stdio: 'inherit', + }); + child.on('close', (code) => { + process.exitCode = code ?? 0; + resolve(); + }); + child.on('error', reject); + }); + }, + ) + .command( + 'tui', + 'Open the full interactive kernel TUI', + (_yargs) => _yargs, + async () => { + const socketPath = getSocketPath(); + const binPath = await findTuiBinPath(); + if (binPath === undefined) { + process.stderr.write( + 'Error: kernel-tui binary not found.\n' + + 'Run `yarn build` from the repository root to build it first.\n', + ); + process.exitCode = 1; + return; + } + await ensureDaemon(socketPath); + await new Promise((resolve, reject) => { + const child = spawn(process.execPath, [binPath, 'tui'], { + stdio: 'inherit', + }); + child.on('close', (code) => { + process.exitCode = code ?? 0; + resolve(); + }); + child.on('error', reject); + }); + }, ); - await yargsInstance.help('help').parse(); diff --git a/packages/kernel-cli/src/commands/daemon-client.ts b/packages/kernel-cli/src/commands/daemon-client.ts index 230c3c9e47..2563dcdf67 100644 --- a/packages/kernel-cli/src/commands/daemon-client.ts +++ b/packages/kernel-cli/src/commands/daemon-client.ts @@ -1,106 +1,9 @@ -import { readLine, writeLine } from '@metamask/kernel-node-runtime/daemon'; -import type { JsonRpcResponse } from '@metamask/utils'; -import { assertIsJsonRpcResponse } from '@metamask/utils'; -import { randomUUID } from 'node:crypto'; -import { createConnection } from 'node:net'; -import type { Socket } from 'node:net'; -import { join } from 'node:path'; +import { + getSocketPath, + sendCommand, +} from '@metamask/kernel-node-runtime/daemon'; -import { getOcapHome } from '../ocap-home.ts'; - -/** - * Get the default daemon socket path. - * - * @returns The socket path. - */ -export function getSocketPath(): string { - return join(getOcapHome(), '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 (object or positional array). */ - params?: Record | unknown[] | undefined; - /** Read timeout in milliseconds (default: no timeout). */ - 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: no timeout). - * @returns The parsed JSON-RPC response. - */ -export async function sendCommand({ - socketPath, - method, - params, - timeoutMs, -}: 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(); - } -} +export { getSocketPath, sendCommand }; /** * Check whether the daemon is running by sending a lightweight `getStatus` diff --git a/packages/kernel-cli/src/commands/daemon-entry.ts b/packages/kernel-cli/src/commands/daemon-entry.ts index b86b2b8c62..f1ec8bb0d0 100644 --- a/packages/kernel-cli/src/commands/daemon-entry.ts +++ b/packages/kernel-cli/src/commands/daemon-entry.ts @@ -1,6 +1,10 @@ import '@metamask/kernel-shims/endoify-node'; -import { makeKernel } from '@metamask/kernel-node-runtime'; -import { startDaemon } from '@metamask/kernel-node-runtime/daemon'; +import { makeKernel, makeChannelFactory } from '@metamask/kernel-node-runtime'; +import { + startDaemon, + getStreamSocketPath, + makeSessionRegistry, +} from '@metamask/kernel-node-runtime/daemon'; import type { DaemonHandle } from '@metamask/kernel-node-runtime/daemon'; import type { LogEntry } from '@metamask/logger'; import { Logger } from '@metamask/logger'; @@ -29,6 +33,7 @@ async function main(): Promise { const socketPath = process.env.OCAP_SOCKET_PATH ?? join(ocapDir, 'daemon.sock'); + const streamSocketPath = getStreamSocketPath(); const dbFilename = join(ocapDir, 'kernel.sqlite'); const { kernel, kernelDatabase } = await makeKernel({ @@ -42,12 +47,19 @@ async function main(): Promise { let handle: DaemonHandle; try { await kernel.initIdentity(); + const channelFactoryBundle = makeChannelFactory(kernel); + const { channelFactory } = channelFactoryBundle; + kernel.registerKernelServiceObject('channelFactory', channelFactory); + const sessionRegistry = makeSessionRegistry(channelFactoryBundle); await writeFile(pidPath, String(process.pid)); handle = await startDaemon({ socketPath, + streamSocketPath, kernel, kernelDatabase, + channelFactory, + sessionRegistry, onShutdown: async () => shutdown('RPC shutdown'), }); } catch (error) { diff --git a/packages/kernel-cli/src/commands/session.ts b/packages/kernel-cli/src/commands/session.ts new file mode 100644 index 0000000000..64e88c079a --- /dev/null +++ b/packages/kernel-cli/src/commands/session.ts @@ -0,0 +1,358 @@ +import { ifDefined } from '@metamask/kernel-utils'; +import type { JsonRpcFailure } from '@metamask/utils'; +import { isJsonRpcFailure } from '@metamask/utils'; +import type { Argv } from 'yargs'; + +import { getSocketPath, sendCommand } from './daemon-client.ts'; +import { ensureDaemon } from './daemon-spawn.ts'; + +/** + * Write a JSON-RPC error to stderr and set exit code 1. + * + * @param response - The failed JSON-RPC response. + */ +function writeRpcError(response: JsonRpcFailure): void { + process.stderr.write( + `Error: ${response.error.message} (code ${String(response.error.code)})\n`, + ); + process.exitCode = 1; +} + +/** + * Create a new session and print its ID and OCAP URL. + * + * @param socketPath - The daemon socket path. + * @param name - Optional session name. Defaults to alice, bob, carol, etc. + */ +async function handleSessionCreate( + socketPath: string, + name?: string, +): Promise { + const response = await sendCommand({ + socketPath, + method: 'session.create', + params: name === undefined ? {} : { name }, + timeoutMs: 10_000, + }); + if (isJsonRpcFailure(response)) { + writeRpcError(response); + return; + } + const { sessionId, ocapUrl } = response.result as { + sessionId: string; + ocapUrl: string; + }; + process.stdout.write(`sessionId: ${sessionId}\nocapUrl: ${ocapUrl}\n`); +} + +/** + * List all sessions and print them in a compact table. + * + * @param socketPath - The daemon socket path. + */ +async function handleSessionList(socketPath: string): Promise { + const response = await sendCommand({ + socketPath, + method: 'session.list', + timeoutMs: 10_000, + }); + if (isJsonRpcFailure(response)) { + writeRpcError(response); + return; + } + const sessions = response.result as { + sessionId: string; + ocapUrl: string; + }[]; + if (sessions.length === 0) { + process.stdout.write('No sessions.\n'); + return; + } + for (const { sessionId, ocapUrl } of sessions) { + process.stdout.write(`${sessionId.padEnd(12)} ${ocapUrl}\n`); + } +} + +/** + * Resolve a session ID to its OCAP URL via the daemon. + * + * @param socketPath - The daemon socket path. + * @param sessionId - The session ID to look up. + * @returns The OCAP URL, or undefined on error (exit code already set). + */ +export async function resolveSessionUrl( + socketPath: string, + sessionId: string, +): Promise { + const response = await sendCommand({ + socketPath, + method: 'session.get', + params: { sessionId }, + timeoutMs: 10_000, + }); + if (isJsonRpcFailure(response)) { + writeRpcError(response); + return undefined; + } + return (response.result as { ocapUrl: string }).ocapUrl; +} + +/** + * List pending authorization requests for a session. + * + * @param socketPath - The daemon socket path. + * @param sessionId - The session to query. + */ +async function handleSessionRequests( + socketPath: string, + sessionId: string, +): Promise { + const response = await sendCommand({ + socketPath, + method: 'session.requests', + params: { sessionId }, + timeoutMs: 10_000, + }); + if (isJsonRpcFailure(response)) { + writeRpcError(response); + return; + } + const pending = response.result as { + token: string; + description: string; + }[]; + if (pending.length === 0) { + process.stdout.write('No pending requests.\n'); + return; + } + for (const { token, description } of pending) { + process.stdout.write(`${token.padEnd(16)} ${description}\n`); + } +} + +/** + * Queue a synthetic authorization request on a session for testing. + * + * @param socketPath - The daemon socket path. + * @param sessionId - The session ID. + * @param description - Human-readable description of the request. + * @param reason - Optional reason for the request. + */ +async function handleSessionQueue( + socketPath: string, + sessionId: string, + description: string, + reason?: string, +): Promise { + const params: Record = { sessionId, description }; + if (reason !== undefined) { + params.reason = reason; + } + const response = await sendCommand({ + socketPath, + method: 'session.queue', + params, + timeoutMs: 10_000, + }); + if (isJsonRpcFailure(response)) { + writeRpcError(response); + return; + } + const { token } = response.result as { token: string }; + process.stdout.write(`Queued: ${token}\n`); +} + +/** + * Send an approve or reject decision for a pending request. + * + * @param socketPath - The daemon socket path. + * @param sessionId - The session ID. + * @param token - The request token. + * @param verdict - 'accept' or 'reject'. + * @param options - Optional guard body and feedback text. + * @param options.guard - Serialized InterfaceGuard body (accept only, overrides default). + * @param options.feedback - Human-readable note attached to the decision. + */ +async function handleSessionDecide( + socketPath: string, + sessionId: string, + token: string, + verdict: 'accept' | 'reject', + { guard, feedback }: { guard?: string; feedback?: string } = {}, +): Promise { + const params: Record = { + sessionId, + token, + verdict, + feedback: feedback ?? '', + }; + if (verdict === 'accept' && guard !== undefined) { + params.guard = { body: guard, slots: [] }; + } + + const response = await sendCommand({ + socketPath, + method: 'session.decide', + params, + timeoutMs: 10_000, + }); + if (isJsonRpcFailure(response)) { + writeRpcError(response); + return; + } + process.stdout.write( + `${verdict === 'accept' ? 'Approved' : 'Rejected'}: ${token}\n`, + ); +} + +/** + * Build the `session` yargs subcommand tree. + * + * @param yargs - The parent yargs instance to attach subcommands to. + * @returns The augmented yargs instance. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function buildSessionCommands(yargs: Argv): Argv { + const socketPath = getSocketPath(); + + return yargs + .command( + 'create', + 'Create a new authorization session', + (_y) => + _y.option('name', { + type: 'string', + describe: 'Session name (default: alice, bob, carol, ...)', + }), + async (args) => { + await ensureDaemon(socketPath); + await handleSessionCreate(socketPath, args.name); + }, + ) + .command( + 'list', + 'List all active sessions', + (_y) => _y, + async () => { + await ensureDaemon(socketPath); + await handleSessionList(socketPath); + }, + ) + .command( + 'requests', + 'List pending authorization requests for a session', + (_y) => + _y.option('session', { + alias: 's', + type: 'string', + demandOption: true, + describe: 'Session ID', + }), + async (args) => { + await ensureDaemon(socketPath); + await handleSessionRequests(socketPath, args.session); + }, + ) + .command( + 'queue', + 'Queue a synthetic authorization request for testing', + (_y) => + _y + .option('session', { + alias: 's', + type: 'string', + demandOption: true, + describe: 'Session ID', + }) + .option('description', { + alias: 'd', + type: 'string', + demandOption: true, + describe: 'Human-readable description of the request', + }) + .option('reason', { + alias: 'r', + type: 'string', + describe: 'Optional reason for the request', + }), + async (args) => { + await ensureDaemon(socketPath); + await handleSessionQueue( + socketPath, + args.session as string, + args.description as string, + args.reason, + ); + }, + ) + .command( + 'approve ', + 'Approve a pending authorization request', + (_y) => + _y + .positional('token', { + type: 'string', + demandOption: true, + describe: 'Request token', + }) + .option('session', { + alias: 's', + type: 'string', + demandOption: true, + describe: 'Session ID', + }) + .option('guard', { + type: 'string', + describe: + 'InterfaceGuard body override (absent = minimal approval)', + }) + .option('feedback', { + type: 'string', + describe: 'Optional note attached to the decision', + }), + async (args) => { + await ensureDaemon(socketPath); + await handleSessionDecide( + socketPath, + args.session as string, + String(args.token), + 'accept', + ifDefined({ + guard: args.guard as string | undefined, + feedback: args.feedback, + }), + ); + }, + ) + .command( + 'reject ', + 'Reject a pending authorization request', + (_y) => + _y + .positional('token', { + type: 'string', + demandOption: true, + describe: 'Request token', + }) + .option('session', { + alias: 's', + type: 'string', + demandOption: true, + describe: 'Session ID', + }) + .option('feedback', { + type: 'string', + describe: 'Optional note attached to the rejection', + }), + async (args) => { + await ensureDaemon(socketPath); + await handleSessionDecide( + socketPath, + args.session as string, + String(args.token), + 'reject', + ifDefined({ feedback: args.feedback }), + ); + }, + ); +} diff --git a/packages/kernel-node-runtime/CHANGELOG.md b/packages/kernel-node-runtime/CHANGELOG.md index e05f20b8c6..f45207c958 100644 --- a/packages/kernel-node-runtime/CHANGELOG.md +++ b/packages/kernel-node-runtime/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add session registry, channel factory, and Unix-socket stream server for CLI-driven authorization +- Add `session.*` RPC methods: `session.create`, `session.list`, `session.get`, `session.requests`, `session.decide`, `session.history`, and `session.authorize` +- Add `DaemonClient` for connecting to the daemon stream socket + ### Changed - **BREAKING:** Drop `platformOptions.fetch` from `makeNodeJsVatSupervisor` ([#942](https://github.com/MetaMask/ocap-kernel/pull/942)) diff --git a/packages/kernel-node-runtime/package.json b/packages/kernel-node-runtime/package.json index 6540dff953..eee4349101 100644 --- a/packages/kernel-node-runtime/package.json +++ b/packages/kernel-node-runtime/package.json @@ -83,6 +83,7 @@ "@metamask/logger": "workspace:^", "@metamask/ocap-kernel": "workspace:^", "@metamask/streams": "workspace:^", + "@metamask/utils": "^11.9.0", "ses": "^1.14.0" }, "devDependencies": { diff --git a/packages/kernel-node-runtime/src/daemon/daemon-client.ts b/packages/kernel-node-runtime/src/daemon/daemon-client.ts new file mode 100644 index 0000000000..4df01f5683 --- /dev/null +++ b/packages/kernel-node-runtime/src/daemon/daemon-client.ts @@ -0,0 +1,141 @@ +import { getOcapHome } from '@metamask/kernel-utils/nodejs'; +import type { + Decision, + SectionNotification, +} from '@metamask/kernel-utils/session'; +import { NodeSocketDuplexStream } from '@metamask/streams'; +import type { JsonRpcResponse } from '@metamask/utils'; +import { assertIsJsonRpcResponse } from '@metamask/utils'; +import { randomUUID } from 'node:crypto'; +import { createConnection } from 'node:net'; +import type { Socket } from 'node:net'; +import { join } from 'node:path'; + +import { readLine, writeLine } from './socket-line.ts'; + +/** + * Get the default daemon socket path. + * + * @returns The socket path. + */ +export function getSocketPath(): string { + return join(getOcapHome(), 'daemon.sock'); +} + +/** + * Get the default daemon stream socket path. + * + * @returns The stream socket path. + */ +export function getStreamSocketPath(): string { + return join(getOcapHome(), 'daemon-stream.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}. + */ +export type SendCommandOptions = { + /** The UNIX socket path. */ + socketPath: string; + /** The RPC method name. */ + method: string; + /** Optional method parameters (object or positional array). */ + params?: Record | unknown[] | undefined; + /** Read timeout in milliseconds (default: no timeout). */ + 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: no timeout). + * @returns The parsed JSON-RPC response. + */ +export async function sendCommand({ + socketPath, + method, + params, + timeoutMs, +}: 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(); + } +} + +/** + * Connect to the daemon's stream socket and return a typed duplex stream for + * receiving {@link SectionNotification} values and sending {@link Decision} + * values. + * + * Sends a one-line JSON handshake carrying the OCAP URL, then performs the + * SYN/ACK synchronization required by {@link NodeSocketDuplexStream}. + * + * @param streamSocketPath - The stream server socket path. + * @param ocapUrl - The OCAP URL identifying the target channel. + * @returns A synchronized duplex stream. + */ +export async function connectModalStream( + streamSocketPath: string, + ocapUrl: string, +): Promise> { + const socket = await connectSocket(streamSocketPath); + await writeLine(socket, JSON.stringify({ ocapUrl })); + // Wait for server ACK before starting stream synchronize — prevents the + // SYN bytes from being consumed by the server's readLine handshake buffer. + await readLine(socket); + return NodeSocketDuplexStream.make(socket); +} diff --git a/packages/kernel-node-runtime/src/daemon/index.ts b/packages/kernel-node-runtime/src/daemon/index.ts index 604ce1ef64..5be30028db 100644 --- a/packages/kernel-node-runtime/src/daemon/index.ts +++ b/packages/kernel-node-runtime/src/daemon/index.ts @@ -5,3 +5,14 @@ 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'; +export { + getSocketPath, + getStreamSocketPath, + sendCommand, + connectModalStream, +} from './daemon-client.ts'; +export type { SendCommandOptions } from './daemon-client.ts'; +export { startStreamSocketServer } from './stream-socket-server.ts'; +export type { StreamSocketServerHandle } from './stream-socket-server.ts'; +export { makeSessionRegistry } from './session-registry.ts'; +export type { Session, SessionRegistry } from './session-registry.ts'; diff --git a/packages/kernel-node-runtime/src/daemon/rpc-socket-server.test.ts b/packages/kernel-node-runtime/src/daemon/rpc-socket-server.test.ts new file mode 100644 index 0000000000..61fdd1aefc --- /dev/null +++ b/packages/kernel-node-runtime/src/daemon/rpc-socket-server.test.ts @@ -0,0 +1,415 @@ +import { createConnection } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { vi, describe, it, expect, afterEach } from 'vitest'; + +import type { RpcSocketServerHandle } from './rpc-socket-server.ts'; +import type { Session, SessionRegistry } from './session-registry.ts'; + +// Mock @metamask/kernel-rpc-methods and @metamask/ocap-kernel/rpc so no real +// kernel initialisation occurs. The factory must be self-contained (no outer +// references) because vi.mock factories are hoisted before other imports. +vi.mock('@metamask/kernel-rpc-methods', () => { + class MockRpcService { + assertHasMethod = vi.fn(); + + execute = vi.fn().mockResolvedValue(null); + } + return { RpcService: MockRpcService }; +}); + +vi.mock('@metamask/ocap-kernel/rpc', () => ({ + rpcHandlers: {}, +})); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +type JsonRpcResponse = { + jsonrpc: string; + id: number; + result?: unknown; + error?: { code: number; message: string }; +}; + +async function sendRequest( + socketPath: string, + method: string, + params: Record = {}, +): Promise { + return new Promise((resolve, reject) => { + const socket = createConnection(socketPath, () => { + const request = JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }); + socket.write(`${request}\n`); + }); + + let buffer = ''; + socket.on('data', (chunk: Buffer) => { + buffer += chunk.toString(); + }); + socket.on('end', () => { + try { + resolve(JSON.parse(buffer.trim()) as JsonRpcResponse); + } catch (parseError) { + reject(parseError); + } + }); + socket.on('error', reject); + }); +} + +function makeTestSession(overrides: Partial = {}): Session { + return { + sessionId: 'alice', + ocapUrl: 'ocap://test-url', + startedAt: '2026-01-01T00:00:00.000Z', + listPending: vi.fn().mockReturnValue([]), + listHistory: vi.fn().mockReturnValue([]), + decide: vi.fn(), + queueRequest: vi.fn().mockReturnValue('req-0'), + authorizeRequest: vi.fn().mockResolvedValue({ + token: 'req-0', + verdict: 'accept' as const, + feedback: '', + }), + recordProvisioned: vi.fn(), + subscribe: vi.fn(), + ...overrides, + }; +} + +function makeTestRegistry( + initial: Session[] = [], +): SessionRegistry & { _sessions: Map } { + const sessions = new Map( + initial.map((session) => [session.sessionId, session]), + ); + let nameIndex = 0; + const names = ['alice', 'bob', 'carol']; + + return { + _sessions: sessions, + async createSession( + options: { name?: string; cwd?: string } = {}, + ): Promise { + const sessionId = + options.name ?? names[nameIndex] ?? `session-${nameIndex}`; + nameIndex += 1; + const session = makeTestSession({ + sessionId, + ocapUrl: `ocap://${sessionId}`, + startedAt: '2026-01-01T00:00:00.000Z', + ...(options.cwd === undefined ? {} : { cwd: options.cwd }), + }); + sessions.set(sessionId, session); + return session; + }, + getSession(sessionId: string): Session | undefined { + return sessions.get(sessionId); + }, + listSessions(): Session[] { + return Array.from(sessions.values()); + }, + getChannelByUrl(_url: string) { + return undefined; + }, + }; +} + +function makeSocketPath(): string { + const suffix = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + return join(tmpdir(), `rpc-server-test-${suffix}.sock`); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('startRpcSocketServer — session.* methods', () => { + let handle: RpcSocketServerHandle | undefined; + + afterEach(async () => { + if (handle) { + const toClose = handle; + handle = undefined; + await toClose.close(); + } + vi.clearAllMocks(); + }); + + it('session.create response includes sessionId, ocapUrl, startedAt', async () => { + const { startRpcSocketServer } = await import('./rpc-socket-server.ts'); + const socketPath = makeSocketPath(); + const registry = makeTestRegistry(); + + handle = await startRpcSocketServer({ + socketPath, + kernel: {} as never, + kernelDatabase: { executeQuery: vi.fn() } as never, + channelFactory: {} as never, + sessionRegistry: registry, + }); + + const response = await sendRequest(socketPath, 'session.create', {}); + + expect(response.result).toStrictEqual({ + sessionId: 'alice', + ocapUrl: 'ocap://alice', + startedAt: '2026-01-01T00:00:00.000Z', + }); + }); + + it('session.create with cwd param includes cwd in response', async () => { + const { startRpcSocketServer } = await import('./rpc-socket-server.ts'); + const socketPath = makeSocketPath(); + const registry = makeTestRegistry(); + + handle = await startRpcSocketServer({ + socketPath, + kernel: {} as never, + kernelDatabase: { executeQuery: vi.fn() } as never, + channelFactory: {} as never, + sessionRegistry: registry, + }); + + const response = await sendRequest(socketPath, 'session.create', { + cwd: '/home/user', + }); + + expect(response.result).toStrictEqual({ + sessionId: 'alice', + ocapUrl: 'ocap://alice', + cwd: '/home/user', + startedAt: '2026-01-01T00:00:00.000Z', + }); + }); + + it('session.create without cwd param omits cwd from response', async () => { + const { startRpcSocketServer } = await import('./rpc-socket-server.ts'); + const socketPath = makeSocketPath(); + const registry = makeTestRegistry(); + + handle = await startRpcSocketServer({ + socketPath, + kernel: {} as never, + kernelDatabase: { executeQuery: vi.fn() } as never, + channelFactory: {} as never, + sessionRegistry: registry, + }); + + const response = await sendRequest(socketPath, 'session.create', {}); + + expect(response.result).not.toHaveProperty('cwd'); + }); + + it('session.list returns sessions with sessionId, ocapUrl, startedAt', async () => { + const { startRpcSocketServer } = await import('./rpc-socket-server.ts'); + const socketPath = makeSocketPath(); + const existing = makeTestSession({ + sessionId: 'alice', + ocapUrl: 'ocap://alice', + startedAt: '2026-01-01T00:00:00.000Z', + }); + const registry = makeTestRegistry([existing]); + + handle = await startRpcSocketServer({ + socketPath, + kernel: {} as never, + kernelDatabase: { executeQuery: vi.fn() } as never, + channelFactory: {} as never, + sessionRegistry: registry, + }); + + const response = await sendRequest(socketPath, 'session.list', {}); + + expect(response.result).toStrictEqual([ + { + sessionId: 'alice', + ocapUrl: 'ocap://alice', + startedAt: '2026-01-01T00:00:00.000Z', + }, + ]); + }); + + it('session.get returns session with sessionId, ocapUrl, startedAt', async () => { + const { startRpcSocketServer } = await import('./rpc-socket-server.ts'); + const socketPath = makeSocketPath(); + const existing = makeTestSession({ + sessionId: 'alice', + ocapUrl: 'ocap://alice', + startedAt: '2026-01-01T00:00:00.000Z', + }); + const registry = makeTestRegistry([existing]); + + handle = await startRpcSocketServer({ + socketPath, + kernel: {} as never, + kernelDatabase: { executeQuery: vi.fn() } as never, + channelFactory: {} as never, + sessionRegistry: registry, + }); + + const response = await sendRequest(socketPath, 'session.get', { + sessionId: 'alice', + }); + + expect(response.result).toStrictEqual({ + sessionId: 'alice', + ocapUrl: 'ocap://alice', + startedAt: '2026-01-01T00:00:00.000Z', + }); + }); + + it('session.get with unknown sessionId returns error code -32602', async () => { + const { startRpcSocketServer } = await import('./rpc-socket-server.ts'); + const socketPath = makeSocketPath(); + const registry = makeTestRegistry(); + + handle = await startRpcSocketServer({ + socketPath, + kernel: {} as never, + kernelDatabase: { executeQuery: vi.fn() } as never, + channelFactory: {} as never, + sessionRegistry: registry, + }); + + const response = await sendRequest(socketPath, 'session.get', { + sessionId: 'nonexistent', + }); + + expect(response.error).toStrictEqual({ + code: -32602, + message: 'Session not found: nonexistent', + }); + }); + + it('session.history returns listHistory() result for an existing session', async () => { + const { startRpcSocketServer } = await import('./rpc-socket-server.ts'); + const socketPath = makeSocketPath(); + const historyEntries = [ + { + token: 'req-0', + description: 'Test request', + reason: 'Testing', + guard: { body: '#{}', slots: [] as string[] }, + queuedAt: '2026-01-01T00:01:00.000Z', + status: 'accepted' as const, + decidedAt: '2026-01-01T00:01:05.000Z', + }, + ]; + const existing = makeTestSession({ + sessionId: 'alice', + ocapUrl: 'ocap://alice', + startedAt: '2026-01-01T00:00:00.000Z', + listHistory: vi.fn().mockReturnValue(historyEntries), + }); + const registry = makeTestRegistry([existing]); + + handle = await startRpcSocketServer({ + socketPath, + kernel: {} as never, + kernelDatabase: { executeQuery: vi.fn() } as never, + channelFactory: {} as never, + sessionRegistry: registry, + }); + + const response = await sendRequest(socketPath, 'session.history', { + sessionId: 'alice', + }); + + expect(response.result).toStrictEqual(historyEntries); + }); + + it('session.history with unknown sessionId returns error code -32602', async () => { + const { startRpcSocketServer } = await import('./rpc-socket-server.ts'); + const socketPath = makeSocketPath(); + const registry = makeTestRegistry(); + + handle = await startRpcSocketServer({ + socketPath, + kernel: {} as never, + kernelDatabase: { executeQuery: vi.fn() } as never, + channelFactory: {} as never, + sessionRegistry: registry, + }); + + const response = await sendRequest(socketPath, 'session.history', { + sessionId: 'unknown-session', + }); + + expect(response.error).toStrictEqual({ + code: -32602, + message: 'Session not found: unknown-session', + }); + }); + + it('session.record calls recordProvisioned with description and invocations', async () => { + const { startRpcSocketServer } = await import('./rpc-socket-server.ts'); + const socketPath = makeSocketPath(); + const existing = makeTestSession({ + sessionId: 'alice', + ocapUrl: 'ocap://alice', + startedAt: '2026-01-01T00:00:00.000Z', + }); + const registry = makeTestRegistry([existing]); + + handle = await startRpcSocketServer({ + socketPath, + kernel: {} as never, + kernelDatabase: { executeQuery: vi.fn() } as never, + channelFactory: {} as never, + sessionRegistry: registry, + }); + + const invocations = [{ name: 'git', argv: ['status'] }]; + const response = await sendRequest(socketPath, 'session.record', { + sessionId: 'alice', + description: 'Allow Bash({"command":"git status"})', + invocations, + }); + + expect(response.result).toBeNull(); + expect(existing.recordProvisioned).toHaveBeenCalledWith( + 'Allow Bash({"command":"git status"})', + { invocations }, + ); + }); + + it('session.authorize returns the decision from authorizeRequest()', async () => { + const { startRpcSocketServer } = await import('./rpc-socket-server.ts'); + const socketPath = makeSocketPath(); + const decision = { + token: 'req-0', + verdict: 'accept' as const, + feedback: 'Looks good', + }; + const existing = makeTestSession({ + sessionId: 'alice', + ocapUrl: 'ocap://alice', + startedAt: '2026-01-01T00:00:00.000Z', + authorizeRequest: vi.fn().mockResolvedValue(decision), + }); + const registry = makeTestRegistry([existing]); + + handle = await startRpcSocketServer({ + socketPath, + kernel: {} as never, + kernelDatabase: { executeQuery: vi.fn() } as never, + channelFactory: {} as never, + sessionRegistry: registry, + }); + + const response = await sendRequest(socketPath, 'session.authorize', { + sessionId: 'alice', + description: 'Allow read access', + reason: 'Needed for operation', + }); + + expect(response.result).toStrictEqual(decision); + expect(existing.authorizeRequest).toHaveBeenCalledWith( + 'Allow read access', + { reason: 'Needed for operation' }, + ); + }); +}); diff --git a/packages/kernel-node-runtime/src/daemon/rpc-socket-server.ts b/packages/kernel-node-runtime/src/daemon/rpc-socket-server.ts index 11168f6f90..462a4997a9 100644 --- a/packages/kernel-node-runtime/src/daemon/rpc-socket-server.ts +++ b/packages/kernel-node-runtime/src/daemon/rpc-socket-server.ts @@ -1,11 +1,19 @@ import { RpcService } from '@metamask/kernel-rpc-methods'; import type { KernelDatabase } from '@metamask/kernel-store'; +import { ifDefined } from '@metamask/kernel-utils'; +import type { + ParsedInvocation, + Provision, +} from '@metamask/kernel-utils/session'; 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'; +import type { Session, SessionRegistry } from './session-registry.ts'; +import type { ChannelFactory } from '../modal/index.ts'; + /** * Handle returned by {@link startRpcSocketServer}. */ @@ -26,22 +34,29 @@ export type RpcSocketServerHandle = { * @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.channelFactory - The channel factory for modal sessions. * @param options.onShutdown - Optional callback invoked when a `shutdown` RPC is received. + * @param options.sessionRegistry - The session registry for `session.*` RPC methods. * @returns A handle with a `close()` function for cleanup. */ export async function startRpcSocketServer({ socketPath, kernel, kernelDatabase, + channelFactory, + sessionRegistry, onShutdown, }: { socketPath: string; kernel: Kernel; kernelDatabase: KernelDatabase; + channelFactory: ChannelFactory; + sessionRegistry: SessionRegistry; onShutdown?: (() => Promise) | undefined; }): Promise { const rpcService = new RpcService(rpcHandlers, { kernel, + channelFactory, executeDBQuery: (sql: string) => kernelDatabase.executeQuery(sql), }); @@ -69,7 +84,7 @@ export async function startRpcSocketServer({ return; } - handleRequest(rpcService, line, onShutdown) + handleRequest(rpcService, sessionRegistry, line, onShutdown) .then((response) => { socket.end(`${JSON.stringify(response)}\n`); return undefined; @@ -105,19 +120,20 @@ export async function startRpcSocketServer({ } /** - * Handle a single JSON-RPC request line, intercepting the `shutdown` method. + * Handle a single JSON-RPC request line, intercepting `shutdown` and `session.*` methods. * - * 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}. + * `shutdown` is handled inline; `session.*` are dispatched to the session registry; + * all other methods are delegated to {@link processRequest}. * * @param rpcService - The RPC service to execute methods against. + * @param sessionRegistry - The session registry for session namespace methods. * @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, + sessionRegistry: SessionRegistry, line: string, onShutdown?: () => Promise, ): Promise> { @@ -125,10 +141,11 @@ async function handleRequest( const request = JSON.parse(line) as { id?: unknown; method?: string; + params?: unknown; }; + const id = request.id ?? null; if (request.method === 'shutdown') { - const id = request.id ?? null; // Schedule shutdown after responding to the client. if (onShutdown) { setTimeout(() => { @@ -139,6 +156,18 @@ async function handleRequest( } return { jsonrpc: '2.0', id, result: { status: 'shutting down' } }; } + + if ( + typeof request.method === 'string' && + request.method.startsWith('session.') + ) { + return handleSessionRequest( + sessionRegistry, + id, + request.method, + request.params, + ); + } } catch { // Fall through to processRequest which handles parse errors. } @@ -146,6 +175,212 @@ async function handleRequest( return processRequest(rpcService, line); } +/** + * Error thrown by session RPC helpers when input is invalid or the session is + * not found. Carries a JSON-RPC error code so the outer handler can preserve + * the specific code rather than collapsing it to -32603. + */ +class SessionRpcError extends Error { + readonly code: number; + + /** + * @param code - JSON-RPC error code. + * @param message - Human-readable error message. + */ + constructor(code: number, message: string) { + super(message); + this.code = code; + } +} + +/** + * Dispatch a `session.*` RPC method to the session registry. + * + * @param sessionRegistry - The session registry. + * @param id - The JSON-RPC request id. + * @param method - The full method name (e.g. `session.create`). + * @param params - The raw params from the request. + * @returns A JSON-RPC response object. + */ +async function handleSessionRequest( + sessionRegistry: SessionRegistry, + id: unknown, + method: string, + params: unknown, +): Promise> { + const ok = (result: unknown): Record => ({ + jsonrpc: '2.0', + id, + result: result ?? null, + }); + const fail = (code: number, message: string): Record => ({ + jsonrpc: '2.0', + id, + error: { code, message }, + }); + + try { + const args = (params ?? {}) as Record; + + const requireSession = (sessionId: unknown): Session => { + if (typeof sessionId !== 'string') { + throw new SessionRpcError( + -32602, + `${method} requires string sessionId`, + ); + } + const found = sessionRegistry.getSession(sessionId); + if (found === undefined) { + throw new SessionRpcError(-32602, `Session not found: ${sessionId}`); + } + return found; + }; + + switch (method) { + case 'session.create': { + const name = typeof args.name === 'string' ? args.name : undefined; + const cwd = typeof args.cwd === 'string' ? args.cwd : undefined; + const session = await sessionRegistry.createSession({ + ...ifDefined({ name }), + ...ifDefined({ cwd }), + }); + return ok({ + sessionId: session.sessionId, + ocapUrl: session.ocapUrl, + ...ifDefined({ cwd: session.cwd }), + startedAt: session.startedAt, + }); + } + + case 'session.list': { + return ok( + sessionRegistry.listSessions().map((sess) => ({ + sessionId: sess.sessionId, + ocapUrl: sess.ocapUrl, + ...ifDefined({ cwd: sess.cwd }), + startedAt: sess.startedAt, + })), + ); + } + + case 'session.get': { + const session = requireSession(args.sessionId); + return ok({ + sessionId: session.sessionId, + ocapUrl: session.ocapUrl, + ...ifDefined({ cwd: session.cwd }), + startedAt: session.startedAt, + }); + } + + case 'session.requests': { + const session = requireSession(args.sessionId); + return ok(session.listPending()); + } + + case 'session.history': { + const session = requireSession(args.sessionId); + return ok(session.listHistory()); + } + + case 'session.queue': { + const session = requireSession(args.sessionId); + const description = + typeof args.description === 'string' + ? args.description + : 'Test request'; + const reason = + typeof args.reason === 'string' ? args.reason : undefined; + const token = session.queueRequest(description, reason); + return ok({ token }); + } + + case 'session.authorize': { + const session = requireSession(args.sessionId); + const description = + typeof args.description === 'string' + ? args.description + : 'Authorization request'; + const reason = + typeof args.reason === 'string' ? args.reason : undefined; + const timeoutMs = + typeof args.timeoutMs === 'number' ? args.timeoutMs : undefined; + const invocations = Array.isArray(args.invocations) + ? (args.invocations as ParsedInvocation[]) + : undefined; + const decision = await session.authorizeRequest(description, { + ...ifDefined({ reason }), + ...ifDefined({ timeoutMs }), + ...ifDefined({ invocations }), + }); + return ok(decision); + } + + case 'session.record': { + const session = requireSession(args.sessionId); + const description = + typeof args.description === 'string' + ? args.description + : 'Auto-accepted request'; + const invocations = Array.isArray(args.invocations) + ? (args.invocations as ParsedInvocation[]) + : undefined; + const provision = + typeof args.provision === 'object' && args.provision !== null + ? (args.provision as Provision) + : undefined; + session.recordProvisioned(description, { + ...ifDefined({ invocations }), + ...ifDefined({ provision }), + }); + return ok(null); + } + + case 'session.decide': { + const session = requireSession(args.sessionId); + const { token } = args; + const { verdict } = args; + const feedback = typeof args.feedback === 'string' ? args.feedback : ''; + const guard = + typeof args.guard === 'object' && args.guard !== null + ? (args.guard as { body: string; slots: string[] }) + : undefined; + const provision = + typeof args.provision === 'object' && args.provision !== null + ? (args.provision as Provision) + : undefined; + + if ( + typeof token !== 'string' || + (verdict !== 'accept' && verdict !== 'reject') + ) { + throw new SessionRpcError( + -32602, + 'session.decide requires string token and verdict ("accept"|"reject")', + ); + } + session.decide({ + token, + verdict, + feedback, + ...ifDefined({ guard }), + ...ifDefined({ provision }), + }); + return ok(null); + } + + default: + throw new SessionRpcError(-32601, `Method not found: ${method}`); + } + } catch (error) { + if (error instanceof SessionRpcError) { + return fail(error.code, error.message); + } + const message = error instanceof Error ? error.message : 'Internal error'; + return fail(-32603, message); + } +} + /** * Process a single JSON-RPC request line and return a JSON-RPC response. * diff --git a/packages/kernel-node-runtime/src/daemon/session-registry.ts b/packages/kernel-node-runtime/src/daemon/session-registry.ts new file mode 100644 index 0000000000..42b032cc74 --- /dev/null +++ b/packages/kernel-node-runtime/src/daemon/session-registry.ts @@ -0,0 +1,2 @@ +export type { Session, SessionRegistry } from '@metamask/kernel-utils/session'; +export { makeSessionRegistry } from '@metamask/kernel-utils/session'; diff --git a/packages/kernel-node-runtime/src/daemon/start-daemon.test.ts b/packages/kernel-node-runtime/src/daemon/start-daemon.test.ts index d4ac55af5c..d2cae588dc 100644 --- a/packages/kernel-node-runtime/src/daemon/start-daemon.test.ts +++ b/packages/kernel-node-runtime/src/daemon/start-daemon.test.ts @@ -3,16 +3,22 @@ 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(() => ({ +const { mockRpcServerClose, mockStreamServerClose } = vi.hoisted(() => ({ mockRpcServerClose: vi.fn().mockResolvedValue(undefined), + mockStreamServerClose: vi.fn().mockResolvedValue(undefined), })); -// Mock RPC socket server to avoid real socket creation +// Mock socket servers to avoid real socket creation vi.mock('./rpc-socket-server.ts', () => ({ startRpcSocketServer: vi.fn().mockResolvedValue({ close: mockRpcServerClose, }), })); +vi.mock('./stream-socket-server.ts', () => ({ + startStreamSocketServer: vi.fn().mockResolvedValue({ + close: mockStreamServerClose, + }), +})); const mockKernel = { stop: vi.fn().mockResolvedValue(undefined), @@ -22,6 +28,26 @@ const mockKernelDatabase = { executeQuery: vi.fn().mockReturnValue([]), }; +const mockChannelFactory = { + createChannel: vi.fn().mockResolvedValue('ocap://test'), +}; + +const mockSessionRegistry = { + createSession: vi.fn(), + getSession: vi.fn(), + listSessions: vi.fn().mockReturnValue([]), + getChannelByUrl: vi.fn(), +}; + +const makeTestOptions = (socketPath: string) => ({ + socketPath, + streamSocketPath: `${socketPath}-stream`, + kernel: mockKernel as never, + kernelDatabase: mockKernelDatabase as never, + channelFactory: mockChannelFactory, + sessionRegistry: mockSessionRegistry, +}); + describe('startDaemon', () => { let handle: DaemonHandle | undefined; @@ -40,47 +66,40 @@ describe('startDaemon', () => { 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, - }); + handle = await startDaemon(makeTestOptions(tmpSocket)); + + expect(mockedStartRpc).toHaveBeenCalledWith( + expect.objectContaining({ + socketPath: tmpSocket, + kernel: mockKernel, + kernelDatabase: mockKernelDatabase, + channelFactory: mockChannelFactory, + sessionRegistry: mockSessionRegistry, + }), + ); }); 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, - }); + handle = await startDaemon(makeTestOptions(tmpSocket)); 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 () => { + it('closes both servers 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, - }); + handle = await startDaemon(makeTestOptions(tmpSocket)); const toClose = handle; handle = undefined; await toClose.close(); expect(mockRpcServerClose).toHaveBeenCalled(); + expect(mockStreamServerClose).toHaveBeenCalled(); expect(mockKernel.stop).toHaveBeenCalled(); }); }); diff --git a/packages/kernel-node-runtime/src/daemon/start-daemon.ts b/packages/kernel-node-runtime/src/daemon/start-daemon.ts index fa50afa54c..e5caadf739 100644 --- a/packages/kernel-node-runtime/src/daemon/start-daemon.ts +++ b/packages/kernel-node-runtime/src/daemon/start-daemon.ts @@ -2,6 +2,9 @@ import type { KernelDatabase } from '@metamask/kernel-store'; import type { Kernel } from '@metamask/ocap-kernel'; import { startRpcSocketServer } from './rpc-socket-server.ts'; +import type { SessionRegistry } from './session-registry.ts'; +import { startStreamSocketServer } from './stream-socket-server.ts'; +import type { ChannelFactory } from '../modal/index.ts'; /** * Options for starting the daemon. @@ -9,10 +12,16 @@ import { startRpcSocketServer } from './rpc-socket-server.ts'; export type StartDaemonOptions = { /** UNIX socket path for the RPC server. */ socketPath: string; + /** UNIX socket path for the stream server (persistent TUI connections). */ + streamSocketPath: string; /** A running kernel instance. */ kernel: Kernel; /** The kernel database instance. */ kernelDatabase: KernelDatabase; + /** Channel factory exo for modal session channels. */ + channelFactory: ChannelFactory; + /** Session registry for CLI-created sessions. */ + sessionRegistry: SessionRegistry; /** Optional callback invoked when a `shutdown` RPC is received. */ onShutdown?: () => Promise; }; @@ -23,14 +32,16 @@ export type StartDaemonOptions = { export type DaemonHandle = { kernel: Kernel; socketPath: string; + streamSocketPath: string; close: () => Promise; }; /** * Start the OCAP daemon. * - * Starts a JSON-RPC socket server that exposes kernel control methods - * on a UNIX domain socket. + * Starts a JSON-RPC socket server that exposes kernel control methods on a + * UNIX domain socket, and a separate stream socket server that accepts + * persistent TUI subscriber connections. * * @param options - Configuration options. * @returns A daemon handle. @@ -38,23 +49,40 @@ export type DaemonHandle = { export async function startDaemon( options: StartDaemonOptions, ): Promise { - const { socketPath, kernel, kernelDatabase, onShutdown } = options; - - const rpcServer = await startRpcSocketServer({ + const { socketPath, + streamSocketPath, kernel, kernelDatabase, + channelFactory, + sessionRegistry, onShutdown, - }); + } = options; + + const [rpcServer, streamServer] = await Promise.all([ + startRpcSocketServer({ + socketPath, + kernel, + kernelDatabase, + channelFactory, + sessionRegistry, + onShutdown, + }), + startStreamSocketServer({ + socketPath: streamSocketPath, + getChannelByUrl: (url) => sessionRegistry.getChannelByUrl(url), + }), + ]); const close = async (): Promise => { - await rpcServer.close(); + await Promise.all([rpcServer.close(), streamServer.close()]); await kernel.stop(); }; return { kernel, socketPath, + streamSocketPath, close, }; } diff --git a/packages/kernel-node-runtime/src/daemon/stream-socket-server.ts b/packages/kernel-node-runtime/src/daemon/stream-socket-server.ts new file mode 100644 index 0000000000..371386be30 --- /dev/null +++ b/packages/kernel-node-runtime/src/daemon/stream-socket-server.ts @@ -0,0 +1,113 @@ +import type { + Channel, + Decision, + SectionNotification, +} from '@metamask/kernel-utils/session'; +import { NodeSocketDuplexStream } from '@metamask/streams'; +import { unlink } from 'node:fs/promises'; +import { createServer } from 'node:net'; +import type { Server } from 'node:net'; + +import { readLine, writeLine } from './socket-line.ts'; + +/** + * Handle returned by {@link startStreamSocketServer}. + */ +export type StreamSocketServerHandle = { + close: () => Promise; +}; + +/** + * Start a Unix socket server that accepts persistent TUI subscriber connections. + * + * Each connection performs a one-line handshake carrying the OCAP URL that + * identifies the target channel, then upgrades to a + * {@link NodeSocketDuplexStream}<{@link SectionNotification}, {@link Decision}> + * and calls `channel.subscribe(stream)`. + * + * Multiple concurrent connections are supported; each is routed to the correct + * channel independently, so broadcasts from different sessions do not interfere. + * + * @param options - Server options. + * @param options.socketPath - The Unix socket path to listen on. + * @param options.getChannelByUrl - Resolves an OCAP URL to the corresponding channel. + * @returns A handle with a `close()` function for cleanup. + */ +export async function startStreamSocketServer({ + socketPath, + getChannelByUrl, +}: { + socketPath: string; + getChannelByUrl: (url: string) => Channel | undefined; +}): Promise { + const server: Server = createServer((socket) => { + (async () => { + try { + // Phase 1: read the one-line JSON handshake to identify the channel. + const handshakeLine = await readLine(socket, 10_000); + const handshake = JSON.parse(handshakeLine) as { ocapUrl?: unknown }; + const { ocapUrl } = handshake; + if (typeof ocapUrl !== 'string') { + socket.destroy(new Error('Stream handshake missing ocapUrl')); + return; + } + + const channel = getChannelByUrl(ocapUrl); + if (channel === undefined) { + socket.destroy(new Error(`No channel for URL: ${ocapUrl}`)); + return; + } + + // Phase 2: ACK the handshake so the client knows readLine is done, + // then upgrade to a typed duplex stream and subscribe. + await writeLine(socket, 'ok'); + const stream = await NodeSocketDuplexStream.make< + Decision, + SectionNotification + >(socket); + channel.subscribe(stream); + } catch { + socket.destroy(); + } + })().catch(() => undefined); + }); + + await listen(server, socketPath); + + return { + close: async () => { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + await unlink(socketPath).catch(() => undefined); + }, + }; +} + +/** + * Start listening on a Unix socket path, removing a stale socket file first. + * + * @param server - The net.Server instance. + * @param socketPath - The Unix socket path. + */ +async function listen(server: Server, socketPath: string): Promise { + 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/kernel-node-runtime/src/index.ts b/packages/kernel-node-runtime/src/index.ts index 1a1eeb323c..4a1ccf9703 100644 --- a/packages/kernel-node-runtime/src/index.ts +++ b/packages/kernel-node-runtime/src/index.ts @@ -3,3 +3,4 @@ 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 { makeChannelFactory } from './modal/index.ts'; diff --git a/packages/kernel-node-runtime/src/modal/channel-factory.test.ts b/packages/kernel-node-runtime/src/modal/channel-factory.test.ts new file mode 100644 index 0000000000..1eaeea9c2e --- /dev/null +++ b/packages/kernel-node-runtime/src/modal/channel-factory.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest'; + +import { makeChannelFactory } from './channel-factory.ts'; + +const makeMockKernel = () => { + const services = new Map< + string, + { name: string; kref: string; service: object; systemOnly: boolean } + >(); + let krefCounter = 1; + + return { + registerKernelServiceObject(name: string, service: object) { + const kref = `ko${krefCounter}`; + krefCounter += 1; + const entry = { name, kref, service, systemOnly: false }; + services.set(name, entry); + return entry; + }, + async issueOcapURL(kref: string): Promise { + return Promise.resolve(`ocap:${kref}@mock`); + }, + hasService(name: string) { + return services.has(name); + }, + }; +}; + +describe('makeChannelFactory', () => { + it('createChannel registers a channel service and returns an ocap URL', async () => { + const kernel = makeMockKernel(); + const { channelFactory } = makeChannelFactory(kernel); + + const url = await channelFactory.createChannel(); + + expect(url).toBe('ocap:ko1@mock'); + expect(kernel.hasService('channel:0')).toBe(true); + }); + + it('each createChannel call registers a distinct channel', async () => { + const kernel = makeMockKernel(); + const { channelFactory } = makeChannelFactory(kernel); + + const url1 = await channelFactory.createChannel(); + const url2 = await channelFactory.createChannel(); + + expect(url1).not.toBe(url2); + expect(kernel.hasService('channel:0')).toBe(true); + expect(kernel.hasService('channel:1')).toBe(true); + }); +}); diff --git a/packages/kernel-node-runtime/src/modal/channel-factory.ts b/packages/kernel-node-runtime/src/modal/channel-factory.ts new file mode 100644 index 0000000000..7ef539f2b8 --- /dev/null +++ b/packages/kernel-node-runtime/src/modal/channel-factory.ts @@ -0,0 +1,67 @@ +import { makeDefaultExo } from '@metamask/kernel-utils'; +import { makeChannel } from '@metamask/kernel-utils/session'; +import type { Channel } from '@metamask/kernel-utils/session'; +import type { Kernel } from '@metamask/ocap-kernel'; + +type KernelDeps = Pick; + +/** + * The remotable facet of a channel factory — only exposes what vats need + * to call via CapTP. `getChannelByUrl` is kept as a plain closure because + * it returns a non-passable `Channel` object (plain harden'd record with + * function-valued properties), which would fail Endo's passability guard + * if placed on an exo method. + */ +export type ChannelFactory = { + createChannel(): Promise; +}; + +export type ChannelFactoryBundle = { + channelFactory: ChannelFactory; + getChannelByUrl: (url: string) => Channel | undefined; + /** Create a channel directly (bypassing the exo), returning both the URL and the Channel object. */ + createChannelInternal: () => Promise<{ ocapUrl: string; channel: Channel }>; +}; + +/** + * Create a channel factory exo and a companion lookup function. + * + * The exo is registered as a kernel service so vats can call `createChannel`. + * The returned `getChannelByUrl` closure is passed directly to the stream + * socket server, bypassing exo passability checks. + * + * @param kernel - Kernel dependency for registering services and issuing URLs. + * @returns A bundle containing the exo and the lookup function. + */ +export function makeChannelFactory(kernel: KernelDeps): ChannelFactoryBundle { + let channelCount = 0; + const channels = new Map(); + + /** + * @returns The OCAP URL and the created channel. + */ + async function createChannelInternal(): Promise<{ + ocapUrl: string; + channel: Channel; + }> { + const channelName = `channel:${channelCount}`; + channelCount += 1; + const channel = makeChannel(); + const service = kernel.registerKernelServiceObject(channelName, channel); + const ocapUrl = await kernel.issueOcapURL(service.kref); + channels.set(ocapUrl, channel); + return { ocapUrl, channel }; + } + + const channelFactory = makeDefaultExo('ChannelFactory', { + async createChannel(): Promise { + const { ocapUrl } = await createChannelInternal(); + return ocapUrl; + }, + }); + + const getChannelByUrl = (url: string): Channel | undefined => + channels.get(url); + + return { channelFactory, getChannelByUrl, createChannelInternal }; +} diff --git a/packages/kernel-node-runtime/src/modal/index.ts b/packages/kernel-node-runtime/src/modal/index.ts new file mode 100644 index 0000000000..214ebcd922 --- /dev/null +++ b/packages/kernel-node-runtime/src/modal/index.ts @@ -0,0 +1,5 @@ +export { makeChannelFactory } from './channel-factory.ts'; +export type { + ChannelFactory, + ChannelFactoryBundle, +} from './channel-factory.ts'; diff --git a/packages/kernel-tui/CHANGELOG.md b/packages/kernel-tui/CHANGELOG.md new file mode 100644 index 0000000000..739468200f --- /dev/null +++ b/packages/kernel-tui/CHANGELOG.md @@ -0,0 +1,17 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Initial release with terminal UI for OCAP kernel session management +- Multi-view TUI with files, objects, invoke, log, and sessions views +- Sessions view with per-session authorization request list and drillable session detail view +- `ocap tui` and `ocap modal` commands in `kernel-cli` launch the TUI + +[Unreleased]: https://github.com/MetaMask/ocap-kernel/ diff --git a/packages/kernel-tui/README.md b/packages/kernel-tui/README.md new file mode 100644 index 0000000000..c4859dd3b0 --- /dev/null +++ b/packages/kernel-tui/README.md @@ -0,0 +1,15 @@ +# `@ocap/kernel-tui` + +Interactive terminal UI for the OCAP kernel + +## Installation + +`yarn add @ocap/kernel-tui` + +or + +`npm install @ocap/kernel-tui` + +## 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/kernel-tui/package.json b/packages/kernel-tui/package.json new file mode 100644 index 0000000000..7122d963ba --- /dev/null +++ b/packages/kernel-tui/package.json @@ -0,0 +1,96 @@ +{ + "name": "@ocap/kernel-tui", + "version": "0.0.0", + "private": true, + "description": "Interactive terminal UI for the OCAP kernel", + "homepage": "https://github.com/MetaMask/ocap-kernel/tree/main/packages/kernel-tui#readme", + "bugs": { + "url": "https://github.com/MetaMask/ocap-kernel/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/ocap-kernel.git" + }, + "type": "module", + "bin": { + "ocap-tui": "./dist/app.mjs" + }, + "exports": { + "./package.json": "./package.json" + }, + "files": [ + "dist/" + ], + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --no-references --clean && chmod +x dist/app.mjs", + "build:docs": "typedoc", + "changelog:validate": "../../scripts/validate-changelog.sh @ocap/kernel-tui", + "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo ./logs", + "lint": "yarn lint:eslint && yarn lint:misc --check && yarn constraints && yarn lint:dependencies", + "lint:dependencies": "depcheck --quiet", + "lint:eslint": "eslint . --cache", + "lint:fix": "yarn lint:eslint --fix && yarn lint:misc --write && yarn constraints --fix && yarn lint:dependencies", + "lint:misc": "prettier --no-error-on-unmatched-pattern '**/*.json' '**/*.md' '**/*.html' '!**/CHANGELOG.old.md' '**/*.yml' '!.yarnrc.yml' '!merged-packages/**' --ignore-path ../../.gitignore --log-level error", + "publish:preview": "yarn npm publish --tag preview", + "test": "vitest run --config vitest.config.ts", + "test:clean": "yarn test --no-cache --coverage.clean", + "test:dev": "yarn test --mode development", + "test:verbose": "yarn test --reporter verbose", + "test:watch": "vitest --config vitest.config.ts", + "test:dev:quiet": "yarn test:dev --reporter @ocap/repo-tools/vitest-reporters/silent" + }, + "devDependencies": { + "@arethetypeswrong/cli": "^0.17.4", + "@metamask/auto-changelog": "^5.3.0", + "@metamask/eslint-config": "^15.0.0", + "@metamask/eslint-config-nodejs": "^15.0.0", + "@metamask/eslint-config-typescript": "^15.0.0", + "@ocap/repo-tools": "workspace:^", + "@testing-library/react": "^16.3.0", + "@ts-bridge/cli": "^0.6.3", + "@ts-bridge/shims": "^0.1.1", + "@types/node": "^22.13.1", + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@types/yargs": "^17.0.33", + "@typescript-eslint/eslint-plugin": "^8.29.0", + "@typescript-eslint/parser": "^8.29.0", + "@typescript-eslint/utils": "^8.29.0", + "@vitest/eslint-plugin": "^1.6.14", + "depcheck": "^1.4.7", + "eslint": "^9.23.0", + "eslint-config-prettier": "^10.1.1", + "eslint-import-resolver-typescript": "^4.3.1", + "eslint-plugin-import-x": "^4.10.0", + "eslint-plugin-jsdoc": "^50.6.9", + "eslint-plugin-n": "^17.17.0", + "eslint-plugin-prettier": "^5.2.6", + "eslint-plugin-promise": "^7.2.1", + "jsdom": "^29.0.2", + "prettier": "^3.5.3", + "react-dom": "^18.3.1", + "rimraf": "^6.0.1", + "turbo": "^2.9.1", + "typedoc": "^0.28.1", + "typescript": "~5.8.2", + "typescript-eslint": "^8.29.0", + "vite": "^8.0.6", + "vitest": "^4.1.3" + }, + "engines": { + "node": ">=22" + }, + "dependencies": { + "@metamask/kernel-node-runtime": "workspace:^", + "@metamask/kernel-shims": "workspace:^", + "@metamask/kernel-utils": "workspace:^", + "@metamask/streams": "workspace:^", + "glob": "^11.0.0", + "ink": "^5.2.1", + "ink-select-input": "^6.0.0", + "ink-spinner": "^5.0.0", + "ink-text-input": "^6.0.0", + "react": "^18.3.1", + "yargs": "^17.7.2" + } +} diff --git a/packages/kernel-tui/src/app.ts b/packages/kernel-tui/src/app.ts new file mode 100644 index 0000000000..d15b3c06c2 --- /dev/null +++ b/packages/kernel-tui/src/app.ts @@ -0,0 +1,43 @@ +import '@metamask/kernel-shims/endoify-node'; +import { getSocketPath } from '@metamask/kernel-node-runtime/daemon'; +import yargs from 'yargs'; +import { hideBin } from 'yargs/helpers'; + +import { makeDaemonKernelApi } from './hooks/use-kernel.ts'; +import { runModal } from './modal.tsx'; +import { startTui } from './start-tui.ts'; + +const yargsInstance = yargs(hideBin(process.argv)) + .scriptName('ocap-tui') + .usage('$0 [options]') + .demandCommand(1) + .strict() + .command( + 'tui', + 'Open the full interactive kernel TUI (connected to the daemon)', + (_yargs) => + _yargs.option('socket-path', { + type: 'string', + describe: 'Daemon socket path (defaults to standard path)', + default: getSocketPath(), + }), + async (args) => { + const kernelApi = makeDaemonKernelApi(args['socket-path']); + await startTui({ cwd: process.cwd(), kernelApi }); + }, + ) + .command( + 'modal ', + 'Open an interactive TUI for a modal channel', + (_yargs) => + _yargs.positional('ocap-url', { + type: 'string', + demandOption: true, + describe: 'OCAP URL of the channel (from `ocap session create`)', + }), + async (args) => { + await runModal(args['ocap-url']); + }, + ); + +await yargsInstance.help('help').parse(); diff --git a/packages/kernel-tui/src/components/file-browser.tsx b/packages/kernel-tui/src/components/file-browser.tsx new file mode 100644 index 0000000000..9b04d65b51 --- /dev/null +++ b/packages/kernel-tui/src/components/file-browser.tsx @@ -0,0 +1,106 @@ +import { glob } from 'glob'; +import { Box, Text } from 'ink'; +import SelectInput from 'ink-select-input'; +import Spinner from 'ink-spinner'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import React, { useEffect, useState } from 'react'; + +import type { KernelApi } from '../types.ts'; + +type FileBrowserProps = { + cwd: string; + kernelApi: KernelApi; + onLog: (message: string) => void; +}; + +type FileItem = { + label: string; + value: string; +}; + +/** + * File browser for discovering and launching .bundle and subcluster.json files. + * + * @param props - Component props. + * @param props.cwd - Current working directory to scan. + * @param props.kernelApi - Kernel API for launching subclusters. + * @param props.onLog - Callback to add a log message. + * @returns The FileBrowser component. + */ +export function FileBrowser({ + cwd, + kernelApi, + onLog, +}: FileBrowserProps): React.ReactElement { + const [files, setFiles] = useState([]); + const [loading, setLoading] = useState(false); + const [result, setResult] = useState(null); + + useEffect(() => { + Promise.all([ + glob('**/*.bundle', { cwd, maxDepth: 3 }), + glob('**/subcluster.json', { cwd, maxDepth: 3 }), + ]) + .then(([bundleFiles, jsonFiles]) => { + const items = [...bundleFiles, ...jsonFiles].map((file) => ({ + label: file, + value: path.resolve(cwd, file), + })); + setFiles(items); + return undefined; + }) + .catch(() => undefined); + }, [cwd]); + + const handleSelect = (item: FileItem): void => { + setLoading(true); + setResult(null); + const filePath = item.value; + + (async () => { + const content = await readFile(filePath, 'utf-8'); + let config: Record; + + if (filePath.endsWith('.json')) { + config = JSON.parse(content) as Record; + } else { + config = { + bootstrap: 'main', + vats: { main: { bundleSpec: `file://${filePath}` } }, + }; + } + + const launchResult = await kernelApi.launchSubcluster(config); + const logMessage = `Launched ${item.label} → kref: ${launchResult.bootstrapRootKref}`; + setResult(logMessage); + onLog(logMessage); + })() + .catch((error: Error) => { + const logMessage = `Error launching ${item.label}: ${error.message}`; + setResult(logMessage); + onLog(logMessage); + }) + .finally(() => setLoading(false)); + }; + + return ( + + File Browser + Select a .bundle or subcluster.json to launch + {files.length === 0 ? ( + + No .bundle or subcluster.json files found in {cwd} + + ) : ( + + )} + {loading && ( + + Launching... + + )} + {result && {result}} + + ); +} diff --git a/packages/kernel-tui/src/components/invoke-view.tsx b/packages/kernel-tui/src/components/invoke-view.tsx new file mode 100644 index 0000000000..a677272c30 --- /dev/null +++ b/packages/kernel-tui/src/components/invoke-view.tsx @@ -0,0 +1,143 @@ +import { Box, Text, useInput } from 'ink'; +import TextInput from 'ink-text-input'; +import React, { useState } from 'react'; + +import type { KernelApi } from '../types.ts'; + +type InvokeViewProps = { + kernelApi: KernelApi; + onLog: (message: string) => void; +}; + +type InputField = 'kref' | 'method' | 'args'; + +/** + * View for invoking methods on kernel objects. + * + * @param props - Component props. + * @param props.kernelApi - Kernel API for sending messages. + * @param props.onLog - Callback to add a log message. + * @returns The InvokeView component. + */ +export function InvokeView({ + kernelApi, + onLog, +}: InvokeViewProps): React.ReactElement { + const [kref, setKref] = useState(''); + const [method, setMethod] = useState('__getMethodNames__'); + const [args, setArgs] = useState('[]'); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const [activeField, setActiveField] = useState('kref'); + const [loading, setLoading] = useState(false); + + const handleSubmit = (): void => { + if (!kref || !method) { + setError('kref and method are required'); + return; + } + + setLoading(true); + setError(null); + setResult(null); + + let parsedArgs: unknown[]; + try { + parsedArgs = JSON.parse(args); + } catch { + setError('Invalid JSON for args'); + setLoading(false); + return; + } + + kernelApi + .queueMessage(kref, method, parsedArgs) + .then((res) => { + const formatted = JSON.stringify(res, null, 2); + setResult(formatted); + onLog(`Invoked ${kref}.${method}(${args})`); + return undefined; + }) + .catch((caught: Error) => { + setError(caught.message); + onLog(`Error invoking ${kref}.${method}: ${caught.message}`); + }) + .finally(() => setLoading(false)); + }; + + useInput((_input, key) => { + if (key.tab) { + setActiveField((prev) => { + if (prev === 'kref') { + return 'method'; + } + if (prev === 'method') { + return 'args'; + } + return 'kref'; + }); + } + if (key.return && activeField === 'args') { + handleSubmit(); + } + }); + + return ( + + Invoke Method + + + + Target (kref):{' '} + + {activeField === 'kref' ? ( + + ) : ( + {kref || ''} + )} + + + + + Method:{' '} + + {activeField === 'method' ? ( + + ) : ( + {method} + )} + + + + + Args (JSON):{' '} + + {activeField === 'args' ? ( + + ) : ( + {args} + )} + + + {loading && Sending...} + {error && Error: {error}} + {result && ( + + + Result: + + {result} + + )} + + ); +} diff --git a/packages/kernel-tui/src/components/log-view.tsx b/packages/kernel-tui/src/components/log-view.tsx new file mode 100644 index 0000000000..921c94c815 --- /dev/null +++ b/packages/kernel-tui/src/components/log-view.tsx @@ -0,0 +1,44 @@ +import { Box, Text } from 'ink'; +import React from 'react'; + +type LogViewProps = { + messages: string[]; + maxLines?: number; +}; + +/** + * Scrolling log output display. + * + * @param props - Component props. + * @param props.messages - Log messages to display. + * @param props.maxLines - Maximum number of lines to show. + * @returns The LogView component. + */ +export function LogView({ + messages, + maxLines = 8, +}: LogViewProps): React.ReactElement { + const visibleMessages = messages.slice(-maxLines); + + return ( + + + Log + + {visibleMessages.length === 0 ? ( + No log messages + ) : ( + visibleMessages.map((line, idx) => ( + + {line} + + )) + )} + + ); +} diff --git a/packages/kernel-tui/src/components/object-registry-view.tsx b/packages/kernel-tui/src/components/object-registry-view.tsx new file mode 100644 index 0000000000..c9f42ce54b --- /dev/null +++ b/packages/kernel-tui/src/components/object-registry-view.tsx @@ -0,0 +1,130 @@ +import { Box, Text } from 'ink'; +import Spinner from 'ink-spinner'; +import React, { useEffect, useState } from 'react'; + +import type { KernelApi, RegistryEntry } from '../types.ts'; + +type ObjectRegistryViewProps = { + kernelApi: KernelApi; +}; + +/** + * Display the kernel object registry grouped by key prefix. + * + * @param props - Component props. + * @param props.kernelApi - Kernel API for querying the registry. + * @returns The ObjectRegistryView component. + */ +export function ObjectRegistryView({ + kernelApi, +}: ObjectRegistryViewProps): React.ReactElement { + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const refresh = (): void => { + setLoading(true); + kernelApi + .getObjectRegistry() + .then((result) => { + setEntries(result); + setLoading(false); + return undefined; + }) + .catch((caught: Error) => { + setError(caught.message); + setLoading(false); + }); + }; + + useEffect(() => { + refresh(); + }, []); + + if (loading) { + return ( + + + Loading object registry... + + + ); + } + + if (error) { + return ( + + Error: {error} + + ); + } + + const objects = entries.filter((entry) => entry.key.startsWith('ko')); + const promises = entries.filter((entry) => entry.key.startsWith('kp')); + const vatEntries = entries.filter((entry) => /^v\d/u.test(entry.key)); + + return ( + + Object Registry + r: refresh + + {entries.length === 0 ? ( + No entries in kernel registry + ) : ( + <> + {objects.length > 0 && ( + + + Objects ({objects.length}) + + {objects.map((entry) => ( + + {' '} + {entry.key} = {entry.value} + + ))} + + )} + + {promises.length > 0 && ( + + + Promises ({promises.length}) + + {promises.slice(0, 20).map((entry) => ( + + {' '} + {entry.key} = {entry.value} + + ))} + {promises.length > 20 && ( + + {' '}... and {promises.length - 20} more + + )} + + )} + + {vatEntries.length > 0 && ( + + + Vat Entries ({vatEntries.length}) + + {vatEntries.slice(0, 20).map((entry) => ( + + {' '} + {entry.key} = {entry.value} + + ))} + {vatEntries.length > 20 && ( + + {' '}... and {vatEntries.length - 20} more + + )} + + )} + + )} + + ); +} diff --git a/packages/kernel-tui/src/components/session-detail-view.tsx b/packages/kernel-tui/src/components/session-detail-view.tsx new file mode 100644 index 0000000000..87cdfa5e32 --- /dev/null +++ b/packages/kernel-tui/src/components/session-detail-view.tsx @@ -0,0 +1,1041 @@ +import type { + ArgPattern, + ParsedInvocation, + Provision, +} from '@metamask/kernel-utils/session'; +import { + argInterval, + argPatternDisplay, + invocationToProvision, +} from '@metamask/kernel-utils/session'; +import { Box, Text, useInput, useStdout } from 'ink'; +import React, { useEffect, useMemo, useState } from 'react'; + +import type { + KernelApi, + SessionHistoryEntry, + SessionSummary, +} from '../types.ts'; + +type SessionDetailViewProps = { + session: SessionSummary; + entries: SessionHistoryEntry[]; + kernelApi: KernelApi; + onBack: () => void; + onDecided: () => void; +}; + +const STATUS_ICON: Record = { + pending: '…', + accepted: '✓', + rejected: '✗', + provisioned: '→', +}; + +const STATUS_COLOR: Record< + SessionHistoryEntry['status'], + 'yellow' | 'green' | 'red' +> = { + pending: 'yellow', + accepted: 'green', + rejected: 'red', + provisioned: 'green', +}; + +/** + * Format an ISO timestamp as `HH:mm:ss`. + * + * @param iso - ISO 8601 string. + * @returns Formatted time string. + */ +function formatTime(iso: string): string { + const date = new Date(iso); + return [date.getHours(), date.getMinutes(), date.getSeconds()] + .map((part) => String(part).padStart(2, '0')) + .join(':'); +} + +/** + * Split a shell command string on ` && `, ` | `, and ` ; ` into segments, + * keeping each operator as a prefix on its following segment so the parts + * can be rendered as a list. + * + * @param command - The raw shell command string. + * @returns Array of segments, e.g. `['cmd1', '&& cmd2', '| cmd3']`. + */ +function splitShellCommand(command: string): string[] { + const operatorPattern = / (&&|\|(?!\|)|;) /gu; + const parts: string[] = []; + let lastCut = 0; + let match: RegExpExecArray | null; + while ((match = operatorPattern.exec(command)) !== null) { + parts.push(command.slice(lastCut, match.index).trim()); + lastCut = match.index + 1; // operator starts right after the leading space + } + parts.push(command.slice(lastCut).trim()); + return parts.filter(Boolean); +} + +type ParsedDescription = { + /** The part before the opening `(`, e.g. `"Allow Bash"`. */ + label: string; + /** The JSON object parsed from inside the parens, or `null` if absent/unparseable. */ + params: Record | null; +}; + +/** + * Attempt to parse a string as a JSON object (not an array or primitive). + * + * @param str - String to parse. + * @returns The parsed object, or `null` if parsing fails or the result is not a plain object. + */ +function tryParseJsonObject(str: string): Record | null { + try { + const parsed: unknown = JSON.parse(str); + if ( + typeof parsed === 'object' && + parsed !== null && + !Array.isArray(parsed) + ) { + return parsed as Record; + } + } catch { + // ignore + } + return null; +} + +/** + * Escape literal control characters (newlines, tabs, etc.) that appear inside + * JSON string values, while leaving structural whitespace outside strings + * untouched. A bare `replace(/[\x00-\x1f]/g, …)` would corrupt structural + * whitespace in pretty-printed JSON, making it unparseable. + * + * @param str - Raw params string, potentially with unescaped control chars. + * @returns String with control chars inside JSON strings properly escaped. + */ +function escapeControlCharsInStrings(str: string): string { + let out = ''; + let inString = false; + let i = 0; + while (i < str.length) { + const ch = str[i]; + if (ch === '\\' && inString) { + // Consume the escape sequence as-is. + out += ch; + i += 1; + if (i < str.length) { + out += str[i]; + i += 1; + } + continue; + } + if (ch === '"') { + inString = !inString; + out += ch; + i += 1; + continue; + } + if (inString && ch !== undefined) { + const code = ch.charCodeAt(0); + if (code < 32) { + if (ch === '\n') { + out += '\\n'; + } else if (ch === '\r') { + out += '\\r'; + } else if (ch === '\t') { + out += '\\t'; + } else { + out += `\\u${code.toString(16).padStart(4, '0')}`; + } + i += 1; + continue; + } + } + out += ch; + i += 1; + } + return out; +} + +/** + * Split a description of the form `Label({...json...})` into a short label and + * a params object. Extracts the label even when JSON parsing fails. + * + * @param description - Raw entry description string. + * @returns Parsed label and params. + */ +export function parseDescription(description: string): ParsedDescription { + const parenIdx = description.indexOf('('); + if (parenIdx === -1) { + return { label: description, params: null }; + } + // Always extract the label before `(`, even if JSON parsing below fails. + const label = description.slice(0, parenIdx).trim(); + if (!description.endsWith(')')) { + return { label, params: null }; + } + const paramsStr = description.slice(parenIdx + 1, -1); + + // Fast path: properly encoded JSON (compact or pretty-printed with valid whitespace). + const direct = tryParseJsonObject(paramsStr); + if (direct !== null) { + return { label, params: direct }; + } + + // Slow path: escape literal control chars inside string values only, then retry. + const fallback = tryParseJsonObject(escapeControlCharsInStrings(paramsStr)); + return { label, params: fallback }; +} + +/** + * Render a Provision as a compact one-liner, e.g. `git log --oneline * | head *`. + * + * @param provision - The provision to format. + * @returns Compact string representation. + */ +function formatProvisionCompact(provision: Provision): string { + return provision.patterns + .map((patt) => + [patt.name, ...patt.argPatterns.map(argPatternDisplay)].join(' '), + ) + .join(' | '); +} + +const MAX_STRING_LENGTH = 200; + +/** + * Extract top-level string-valued fields from a potentially-invalid JSON object + * string. Useful when the outer JSON fails to parse (e.g. due to unescaped + * double quotes inside a string value). Non-string fields are skipped. + * + * @param raw - Raw params string, e.g. `{"cmd":"...","desc":"..."}`. + * @returns `[key, value]` pairs found, or `null` if the input is not object-shaped. + */ +function extractStringFields(raw: string): [string, string][] | null { + if (!raw.startsWith('{')) { + return null; + } + const fields: [string, string][] = []; + // Match: "key" : " (opening of a string value; skips non-string fields) + const keyRegex = /"([^"\\]+)"\s*:\s*"/gu; + let match: RegExpExecArray | null; + while ((match = keyRegex.exec(raw)) !== null) { + const key = match[1]; + if (key === undefined) { + continue; + } + let value = ''; + let idx = keyRegex.lastIndex; + while (idx < raw.length) { + const ch = raw[idx]; + if (ch === '\\') { + idx += 1; + const next = raw[idx]; + if (next === 'n') { + value += '\n'; + } else if (next === 't') { + value += '\t'; + } else if (next === 'r') { + value += '\r'; + } else if (next !== undefined) { + value += next; + } + idx += 1; + } else if (ch === '"') { + idx += 1; + break; + } else if (ch === undefined) { + break; + } else { + value += ch; + idx += 1; + } + } + keyRegex.lastIndex = idx; + fields.push([key, value]); + } + return fields.length > 0 ? fields : null; +} + +/** + * Format an entry description as compact plain text suitable for inline display. + * + * For Bash entries: shows just the command, split into one line per shell + * operator segment (or per heredoc line) so the terminal stays readable. + * For all other entries: shows `key: value` pairs, one per line. + * Falls back to truncated raw params when JSON parsing and lenient extraction + * both fail. + * + * @param description - The raw description string from the history entry or pending request. + * @returns Newline-separated string for display. + */ +export function formatExpandedContent(description: string): string { + const { label, params } = parseDescription(description); + + // Extract the raw params string (content inside the outer parens). + const parenIdx = description.indexOf('('); + const raw = + parenIdx !== -1 && description.endsWith(')') + ? description.slice(parenIdx + 1, -1) + : description; + + // Resolve the best available field set: parsed JSON first, lenient extraction second. + let fields: Record | null = params; + if (fields === null) { + const extracted = extractStringFields(raw); + if (extracted !== null) { + fields = Object.fromEntries(extracted); + } + } + + if (fields === null) { + // Last resort: truncated raw string + return raw.length > MAX_STRING_LENGTH * 2 + ? `${raw.slice(0, MAX_STRING_LENGTH * 2)}…` + : raw; + } + + // For Bash: show only the command, split into readable segments. + // Split BEFORE truncating so each segment is limited independently — + // a long command with short segments must not be cut mid-segment. + if (label.includes('Bash') && typeof fields.command === 'string') { + const segments = fields.command.includes('\n') + ? fields.command.split('\n').filter(Boolean) + : splitShellCommand(fields.command); + return segments + .map((segment) => + segment.length > MAX_STRING_LENGTH + ? `${segment.slice(0, MAX_STRING_LENGTH)}…` + : segment, + ) + .join('\n'); + } + + // Generic: compact `key: value` pairs, one per line. + return Object.entries(fields) + .map(([key, value]) => { + if (typeof value === 'string') { + const truncated = + value.length > MAX_STRING_LENGTH + ? `${value.slice(0, MAX_STRING_LENGTH)}…` + : value; + return `${key}: ${truncated}`; + } + const json = JSON.stringify(value); + const truncated = + json.length > MAX_STRING_LENGTH + ? `${json.slice(0, MAX_STRING_LENGTH)}…` + : json; + return `${key}: ${truncated}`; + }) + .join('\n'); +} + +/** + * Number of terminal rows a single entry occupies when rendered, accounting + * for lines that wrap because they exceed the effective content width. + * + * @param entry - The history entry. + * @param exp - Set of currently-expanded entry tokens. + * @param columns - Terminal column count used to compute wrap boundaries. + * @returns Row count for the entry. + */ +function entryRowCount( + entry: SessionHistoryEntry, + exp: Set, + columns: number, +): number { + if (!exp.has(entry.token)) { + return 1; + } + // paddingX={1} on outer box (2 chars) + paddingLeft={4} on content box = 6 chars overhead. + const effectiveWidth = Math.max(20, columns - 6); + const contentLines = formatExpandedContent(entry.description).split('\n'); + const contentRows = contentLines.reduce((sum, line) => { + return sum + Math.max(1, Math.ceil(line.length / effectiveWidth)); + }, 0); + const provisionRows = + entry.provision === undefined ? 0 : 1 + entry.provision.patterns.length; + const extras = + (entry.decidedAt === undefined ? 0 : 1) + + (entry.guard.body === '#{}' ? 0 : 1) + + provisionRows; + return 1 + contentRows + extras; +} + +/** + * Exclusive end index of the visible window that begins at `offset` and fits + * within `maxRows` terminal rows. + * + * @param entries - All display entries. + * @param offset - Index of the first visible entry. + * @param exp - Set of currently-expanded entry tokens. + * @param maxRows - Maximum rows available for entries. + * @param columns - Terminal column count passed through to {@link entryRowCount}. + * @returns One past the index of the last visible entry. + */ +function windowEndIdx( + entries: SessionHistoryEntry[], + offset: number, + exp: Set, + maxRows: number, + columns: number, +): number { + if (entries.length === 0) { + return 0; + } + const start = Math.max(0, Math.min(offset, entries.length - 1)); + let rows = 0; + let i = start; + while (i < entries.length) { + const rowHeight = entryRowCount( + entries[i] as SessionHistoryEntry, + exp, + columns, + ); + if (rows + rowHeight > maxRows && i > start) { + break; + } + rows += rowHeight; + i += 1; + } + return i; +} + +/** + * Minimum scroll offset that keeps the cursor entry within the visible window. + * + * @param cursor - Index of the focused entry. + * @param currentOffset - Current scroll offset. + * @param entries - All display entries. + * @param exp - Set of currently-expanded entry tokens. + * @param maxRows - Maximum rows available for entries. + * @param columns - Terminal column count passed through to {@link windowEndIdx}. + * @returns Adjusted scroll offset. + */ +function clampScroll( + cursor: number, + currentOffset: number, + entries: SessionHistoryEntry[], + exp: Set, + maxRows: number, + columns: number, +): number { + if (cursor < currentOffset) { + return cursor; + } + if (cursor < windowEndIdx(entries, currentOffset, exp, maxRows, columns)) { + return currentOffset; + } + let newOffset = currentOffset; + while (newOffset < cursor) { + newOffset += 1; + if (cursor < windowEndIdx(entries, newOffset, exp, maxRows, columns)) { + break; + } + } + return newOffset; +} + +type FlatArg = { + invIdx: number; + argIdx: number; + value: string; + interval: ArgPattern[]; +}; + +type ProvisionEditorProps = { + toolName: string; + invocations: ParsedInvocation[]; + onSubmit: (provision: Provision) => void; + onCancel: () => void; +}; + +/** + * Interactive editor that lets the user tune each arg in a pending invocation + * to a wider pattern (prefix or wildcard) before granting a standing provision. + * + * Keybinds: ←/→ navigate args, ↑ widen, ↓ narrow, Enter submit, Esc cancel. + * + * @param props - Component props. + * @param props.toolName - The tool name (e.g. "Bash"). + * @param props.invocations - The parsed invocations for the pending request. + * @param props.onSubmit - Called with the resulting Provision when Enter is pressed. + * @param props.onCancel - Called when Esc is pressed. + * @returns The ProvisionEditor component. + */ +function ProvisionEditor({ + toolName, + invocations, + onSubmit, + onCancel, +}: ProvisionEditorProps): React.ReactElement { + const flatArgs = useMemo(() => { + const result: FlatArg[] = []; + for (let i = 0; i < invocations.length; i++) { + const inv = invocations[i]; + if (inv === undefined) { + continue; + } + for (let j = 0; j < inv.argv.length; j++) { + const value = inv.argv[j]; + if (value !== undefined) { + result.push({ + invIdx: i, + argIdx: j, + value, + interval: argInterval(value), + }); + } + } + } + return result; + }, [invocations]); + + const [cursor, setCursor] = useState(0); + const [sels, setSels] = useState(() => flatArgs.map(() => 0)); + + const currentFlatArg = flatArgs[cursor]; + const currentSel = sels[cursor] ?? 0; + const currentPattern = currentFlatArg?.interval[currentSel]; + + useInput((_input, key) => { + if (key.escape) { + onCancel(); + } else if (key.return) { + const provision = + flatArgs.length === 0 + ? invocationToProvision(toolName, invocations) + : buildProvision(toolName, invocations, flatArgs, sels); + onSubmit(provision); + } else if (key.rightArrow) { + setCursor((idx) => Math.min(flatArgs.length - 1, idx + 1)); + } else if (key.leftArrow) { + setCursor((idx) => Math.max(0, idx - 1)); + } else if (key.upArrow && currentFlatArg !== undefined) { + setSels((prev) => { + const next = [...prev]; + next[cursor] = Math.min( + currentFlatArg.interval.length - 1, + (next[cursor] ?? 0) + 1, + ); + return next; + }); + } else if (key.downArrow) { + setSels((prev) => { + const next = [...prev]; + next[cursor] = Math.max(0, (next[cursor] ?? 0) - 1); + return next; + }); + } + }); + + // Render invocations as a flat line with each arg colored by its pattern scope. + // Cursor arg is highlighted; widened args appear in a different color. + let flatIdx = 0; + const invocationLines = invocations.map((inv, invIdx) => { + const argNodes = inv.argv.map((val, argIdx) => { + const fi = flatIdx; + flatIdx += 1; + const sel = sels[fi] ?? 0; + const interval = flatArgs[fi]?.interval ?? argInterval(val); + const pat = interval[sel]; + const display = pat === undefined ? val : argPatternDisplay(pat); + const isCursor = fi === cursor; + const isWidened = sel > 0; + let argColor: 'cyan' | 'yellow' | undefined; + if (isCursor) { + argColor = 'cyan'; + } else if (isWidened) { + argColor = 'yellow'; + } + return ( + + {' '} + {display} + + ); + }); + return ( + + {invIdx > 0 && |} + {inv.name} + {argNodes} + + ); + }); + + return ( + + + {invocationLines} + + {currentFlatArg !== undefined && currentPattern !== undefined && ( + + + {argPatternDisplay(currentPattern)} + + ({currentFlatArg.interval.indexOf(currentPattern) + 1}/ + {currentFlatArg.interval.length}) + + + )} + {flatArgs.length === 0 && ( + + {' '} + (no args — will match any invocation of {toolName}) + + )} + + + ←/→ navigate · ↑ widen · ↓ narrow · Enter grant · Esc cancel + + + + ); +} + +/** + * Build a Provision from the editor's current selections. + * + * @param toolName - The tool name. + * @param invocations - The original parsed invocations. + * @param flatArgs - Flattened arg list with intervals. + * @param sels - Per-flat-arg selection indices into each interval. + * @returns The constructed Provision. + */ +function buildProvision( + toolName: string, + invocations: ParsedInvocation[], + flatArgs: FlatArg[], + sels: number[], +): Provision { + let flatIdx = 0; + return { + tool: toolName, + patterns: invocations.map((inv) => ({ + name: inv.name, + argPatterns: inv.argv.map((val) => { + const fi = flatIdx; + flatIdx += 1; + const sel = sels[fi] ?? 0; + const interval = flatArgs[fi]?.interval ?? argInterval(val); + return interval[sel] ?? ({ kind: 'wildcard' } as const); + }), + })), + }; +} + +/** + * Derive the list of unique active provisions from the session history. + * Includes provisions from both user-granted (◆) and auto-accepted (→) entries. + * Deduplicates by JSON-serialized content. + * + * @param entries - The full session history. + * @returns Unique provisions, in the order they first appeared. + */ +function deriveActiveProvisions(entries: SessionHistoryEntry[]): Provision[] { + const seen = new Set(); + const result: Provision[] = []; + for (const entry of entries) { + if (entry.provision === undefined) { + continue; + } + const key = JSON.stringify(entry.provision); + if (!seen.has(key)) { + seen.add(key); + result.push(entry.provision); + } + } + return result; +} + +/** + * Panel listing the active standing provisions for a session. + * + * @param props - Component props. + * @param props.provisions - The list of active provisions. + * @param props.onClose - Callback to close the panel. + * @returns The ProvisionsPanel component. + */ +function ProvisionsPanel({ + provisions, + onClose, +}: { + provisions: Provision[]; + onClose: () => void; +}): React.ReactElement { + useInput((_input, key) => { + if (key.escape) { + onClose(); + } + }); + + return ( + + + + Active provisions + + — Esc to close + + {provisions.length === 0 ? ( + No standing provisions yet. + ) : ( + provisions.map((prov, idx) => ( + + + {prov.tool} + {formatProvisionCompact(prov)} + + )) + )} + + ); +} + +/** + * Detail view for a single session showing a reverse-chronological timeline of + * authorization requests (most recent at top). Each entry can be expanded with + * the right arrow key and collapsed with the left arrow key. Left arrow on a + * collapsed entry navigates back to the session list. + * + * Keybindings: ↑/↓ navigate, → expand, ← collapse/back, 1 accept, 2 grant with provision, 3 reject. + * + * @param props - Component props. + * @param props.session - The session being viewed. + * @param props.entries - Chronological history entries (oldest first). + * @param props.kernelApi - Kernel API for deciding on pending entries. + * @param props.onBack - Callback to return to the session list. + * @param props.onDecided - Callback to trigger a refresh after a decision. + * @returns The SessionDetailView component. + */ +export function SessionDetailView({ + session, + entries, + kernelApi, + onBack, + onDecided, +}: SessionDetailViewProps): React.ReactElement { + const [focusedToken, setFocusedToken] = useState(null); + const [expanded, setExpanded] = useState>( + () => + new Set( + entries + .filter((entry) => entry.status === 'pending') + .map((entry) => entry.token), + ), + ); + const [scrollOffset, setScrollOffset] = useState(0); + const [deciding, setDeciding] = useState(false); + const [error, setError] = useState(null); + const [editingProvision, setEditingProvision] = useState(false); + const [showProvisions, setShowProvisions] = useState(false); + + const activeProvisions = useMemo( + () => deriveActiveProvisions(entries), + [entries], + ); + + const { stdout } = useStdout(); + const columns = stdout.columns ?? 80; + // StatusBar uses borderStyle="single" (3 rows). LogView uses height={maxLines+2}={6} rows. + // Session header (1) + scroll indicators (2) = 3 more. Total overhead = 12. + const maxRows = Math.max(4, (stdout.rows ?? 24) - 12); + + // Auto-expand any pending entries that arrive after the initial render (via polling). + useEffect(() => { + const pendingTokens = entries + .filter((entry) => entry.status === 'pending') + .map((entry) => entry.token); + if (pendingTokens.length > 0) { + setExpanded((prev) => { + const next = new Set(prev); + let changed = false; + for (const token of pendingTokens) { + if (!next.has(token)) { + next.add(token); + changed = true; + } + } + return changed ? next : prev; + }); + } + }, [entries]); + + // Reverse so newest (including all pending) appears at the top. + const displayEntries = useMemo(() => [...entries].reverse(), [entries]); + + // Derive the cursor index from the focused token — survives new items + // arriving above the current focus without shifting what's highlighted. + const cursorIdx = useMemo(() => { + if (focusedToken === null || displayEntries.length === 0) { + return 0; + } + const idx = displayEntries.findIndex( + (entry) => entry.token === focusedToken, + ); + return idx === -1 ? 0 : idx; + }, [focusedToken, displayEntries]); + + // Lock onto the first visible entry on arrival so the cursor is stable. + useEffect(() => { + if (focusedToken === null && displayEntries.length > 0) { + setFocusedToken(displayEntries[0]?.token ?? null); + } + }, [displayEntries, focusedToken]); + + // Re-clamp scroll whenever the effective cursor position changes (new + // items arriving, terminal resize, or item expansion). + useEffect(() => { + setScrollOffset((off) => + clampScroll(cursorIdx, off, displayEntries, expanded, maxRows, columns), + ); + }, [cursorIdx, displayEntries, expanded, maxRows, columns]); + + const focused = displayEntries[cursorIdx]; + + const visEnd = windowEndIdx( + displayEntries, + scrollOffset, + expanded, + maxRows, + columns, + ); + const visibleEntries = displayEntries.slice(scrollOffset, visEnd); + const countAbove = scrollOffset; + const countBelow = displayEntries.length - visEnd; + + useInput((input, key) => { + if (editingProvision) { + return; // ProvisionEditor handles its own input + } + if (showProvisions) { + return; // ProvisionsPanel handles its own input + } + if (input === 'P') { + setShowProvisions(true); + return; + } + if (key.upArrow) { + const nextIdx = Math.max(0, cursorIdx - 1); + setFocusedToken(displayEntries[nextIdx]?.token ?? null); + setScrollOffset((off) => + clampScroll(nextIdx, off, displayEntries, expanded, maxRows, columns), + ); + } else if (key.downArrow) { + const nextIdx = Math.min(displayEntries.length - 1, cursorIdx + 1); + setFocusedToken(displayEntries[nextIdx]?.token ?? null); + setScrollOffset((off) => + clampScroll(nextIdx, off, displayEntries, expanded, maxRows, columns), + ); + } else if (key.rightArrow && focused !== undefined) { + const next = new Set([...expanded, focused.token]); + setExpanded(next); + setScrollOffset((off) => + clampScroll(cursorIdx, off, displayEntries, next, maxRows, columns), + ); + } else if (key.leftArrow) { + if (focused !== undefined && expanded.has(focused.token)) { + const next = new Set(expanded); + next.delete(focused.token); + setExpanded(next); + setScrollOffset((off) => + clampScroll(cursorIdx, off, displayEntries, next, maxRows, columns), + ); + } else { + onBack(); + } + } else if (input === '2' && !deciding) { + if (focused === undefined || focused.status !== 'pending') { + return; + } + setEditingProvision(true); + } else if ((input === '1' || input === '3') && !deciding) { + if (focused === undefined || focused.status !== 'pending') { + return; + } + const verdict = input === '1' ? 'accept' : 'reject'; + setDeciding(true); + kernelApi + .decide(session.sessionId, focused.token, verdict) + .then(() => { + onDecided(); + return undefined; + }) + .catch((caught: Error) => { + setError(caught.message); + }) + .finally(() => { + setDeciding(false); + }); + } + }); + + const handleProvisionSubmit = (provision: Provision): void => { + if (focused === undefined || focused.status !== 'pending') { + return; + } + setEditingProvision(false); + setDeciding(true); + kernelApi + .decide(session.sessionId, focused.token, 'accept', provision) + .then(() => { + onDecided(); + return undefined; + }) + .catch((caught: Error) => { + setError(caught.message); + }) + .finally(() => { + setDeciding(false); + }); + }; + + return ( + + + + + {session.sessionId} + + {deciding && (submitting…)} + + {error !== null && {error}} + + {showProvisions ? ( + setShowProvisions(false)} + /> + ) : ( + <> + {countAbove > 0 && ↑ {countAbove} more} + + {displayEntries.length === 0 ? ( + No requests yet. + ) : ( + visibleEntries.map((entry) => { + const idx = displayEntries.indexOf(entry); + const isFocused = idx === cursorIdx; + const isExpanded = expanded.has(entry.token); + const isEditingThis = + editingProvision && isFocused && entry.status === 'pending'; + const icon = + entry.status === 'accepted' && entry.provision !== undefined + ? '◆' + : STATUS_ICON[entry.status]; + const color = STATUS_COLOR[entry.status]; + const isDimStatus = entry.status === 'provisioned'; + + const { label } = parseDescription(entry.description); + + const expandedLines = + isExpanded && !isEditingThis + ? formatExpandedContent(entry.description).split('\n') + : []; + + // Extract the tool name from the description label, e.g. "Allow Bash" → "Bash" + const toolName = label.startsWith('Allow ') + ? label.slice('Allow '.length) + : label; + + return ( + + + {isFocused ? '►' : ' '} + + {icon} + + + {formatTime(entry.queuedAt)} + + + {label} + {isEditingThis && ( + (grant with provision…) + )} + + + {isEditingThis && ( + setEditingProvision(false)} + /> + )} + {expandedLines.map((line, lineIdx) => ( + + + {line} + + + ))} + {isExpanded && + !isEditingThis && + entry.provision !== undefined && + entry.status !== 'provisioned' && ( + + provision: + {entry.provision.patterns.map((pattern, patIdx) => ( + + {pattern.name} + {pattern.argPatterns.map((argPat, argIdx) => ( + + {argPatternDisplay(argPat)} + + ))} + + ))} + + )} + {isExpanded && + !isEditingThis && + entry.status === 'provisioned' && ( + + + {entry.provision === undefined + ? '→ standing provision' + : `→ by provision: ${formatProvisionCompact(entry.provision)}`} + + + )} + {isExpanded && + !isEditingThis && + entry.decidedAt !== undefined && + entry.status !== 'provisioned' && ( + + + decided {formatTime(entry.decidedAt)} + + + )} + {isExpanded && + !isEditingThis && + entry.guard.body !== '#{}' && ( + + guard: {entry.guard.body} + + )} + + ); + }) + )} + + {countBelow > 0 && ↓ {countBelow} more} + + )} + + ); +} diff --git a/packages/kernel-tui/src/components/sessions-view.tsx b/packages/kernel-tui/src/components/sessions-view.tsx new file mode 100644 index 0000000000..a884eb8146 --- /dev/null +++ b/packages/kernel-tui/src/components/sessions-view.tsx @@ -0,0 +1,244 @@ +import { Box, Text, useInput } from 'ink'; +import Spinner from 'ink-spinner'; +import { homedir } from 'node:os'; +import React, { useState } from 'react'; + +import { useSessionData } from '../hooks/use-session-data.ts'; +import type { KernelApi, SessionSummary } from '../types.ts'; +import { + formatExpandedContent, + parseDescription, + SessionDetailView, +} from './session-detail-view.tsx'; + +type SessionsViewProps = { + kernelApi: KernelApi; +}; + +/** + * Tildefy an absolute path by replacing the home directory prefix with `~`. + * + * @param dir - Absolute path. + * @returns Tildefied path string. + */ +function tildify(dir: string): string { + const home = homedir(); + return home.length > 0 && dir.startsWith(home) + ? `~${dir.slice(home.length)}` + : dir; +} + +/** + * Format an ISO 8601 timestamp as `YYYY.MM.DD..HH:mm`. + * + * @param iso - ISO 8601 string. + * @returns Formatted date-time string. + */ +function formatStartedAt(iso: string): string { + const date = new Date(iso); + const year = date.getFullYear(); + const mo = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hour = String(date.getHours()).padStart(2, '0'); + const min = String(date.getMinutes()).padStart(2, '0'); + return `${year}.${mo}.${day}..${hour}:${min}`; +} + +/** + * Renders session metadata as a parenthesised suffix string. + * + * @param session - The session summary. + * @returns Formatted metadata string, or empty string if none. + */ +function sessionMetaSuffix(session: SessionSummary): string { + const meta: string[] = []; + if (session.cwd !== undefined) { + meta.push(tildify(session.cwd)); + } + if (session.startedAt !== undefined) { + meta.push(formatStartedAt(session.startedAt)); + } + return meta.length > 0 ? ` (${meta.join(' ')})` : ''; +} + +/** + * View showing all sessions and their pending authorization requests. + * + * Top-level navigation: ↑/↓ between sessions, → to drill into a session. + * Session detail navigation: ↑/↓ between timeline entries, → expand, ← collapse/back. + * + * Data fetching is delegated to {@link useSessionData}; this component owns + * only the cursor position. + * + * @param props - Component props. + * @param props.kernelApi - Kernel API for session operations. + * @returns The SessionsView component. + */ +export function SessionsView({ + kernelApi, +}: SessionsViewProps): React.ReactElement { + const [cursor, setCursor] = useState(0); + const [deciding, setDeciding] = useState(false); + const [decideError, setDecideError] = useState(null); + const { + sessions, + loading, + error, + detailSession, + detailHistory, + openDetail, + closeDetail, + refresh, + onDecided, + } = useSessionData(kernelApi); + + useInput((input, key) => { + if (detailSession !== null) { + return; + } + if (key.upArrow) { + setCursor((prev) => Math.max(0, prev - 1)); + } else if (key.downArrow) { + setCursor((prev) => Math.min(sessions.length - 1, prev + 1)); + } else if (key.rightArrow) { + const focused = sessions[cursor]; + if (focused !== undefined) { + openDetail(focused); + } + } else if ((input === '1' || input === '3') && !deciding) { + const session = sessions[cursor]; + const oldest = session?.requests[0]; + if (session === undefined || oldest === undefined) { + return; + } + const verdict = input === '1' ? 'accept' : 'reject'; + setDecideError(null); + setDeciding(true); + kernelApi + .decide(session.sessionId, oldest.token, verdict) + .then(() => { + onDecided(); + return undefined; + }) + .catch((caught: Error) => { + setDecideError(caught.message); + }) + .finally(() => { + setDeciding(false); + }); + } else if (input === 'R') { + refresh(); + } + }); + + if (detailSession !== null) { + return ( + + ); + } + + if (loading && sessions.length === 0) { + return ( + + + Loading sessions... + + + ); + } + + if (error) { + const isMethodMissing = + error.includes('not exist') || error.includes('not available'); + return ( + + Error: {error} + {isMethodMissing && ( + + Session RPCs are not available on this daemon. Rebuild and restart + with the sessions branch. + + )} + + ); + } + + if (sessions.length === 0) { + return ( + + + No sessions — use `ocap session create` to start one + + + ); + } + + return ( + + Sessions + {deciding && ( + + (submitting…) + + )} + {decideError !== null && {decideError}} + {sessions.map((session, idx) => { + const isFocused = idx === cursor; + const pendingCount = session.requests.length; + const metaSuffix = sessionMetaSuffix(session); + const oldest = session.requests[0]; + + let pendingSection: React.ReactElement; + if (pendingCount === 0) { + pendingSection = (no pending requests); + } else if (!isFocused || oldest === undefined) { + pendingSection = {pendingCount} pending; + } else { + const requestLabel = parseDescription(oldest.description).label; + const requestLines = formatExpandedContent(oldest.description).split( + '\n', + ); + pendingSection = ( + <> + + + {requestLabel} + + {requestLines.map((line, lineIdx) => ( + + + {line} + + + ))} + {pendingCount > 1 && ( + +{pendingCount - 1} more pending + )} + + ); + } + + return ( + + + {isFocused ? '►' : ' '} + + {session.sessionId} + + {metaSuffix.length > 0 && {metaSuffix}} + + + {pendingSection} + + + ); + })} + + ); +} diff --git a/packages/kernel-tui/src/components/status-bar.tsx b/packages/kernel-tui/src/components/status-bar.tsx new file mode 100644 index 0000000000..3c4aa2ed57 --- /dev/null +++ b/packages/kernel-tui/src/components/status-bar.tsx @@ -0,0 +1,52 @@ +import { Box, Text } from 'ink'; +import React from 'react'; + +import type { KernelStatus, ViewMode } from '../types.ts'; + +type StatusBarProps = { + status: KernelStatus | null; + currentView: ViewMode; +}; + +const VIEW_HINTS: Record = { + sessions: + '↑/↓: navigate | 1: accept | 2: provision | 3: reject | P: provisions | R: refresh', + files: 'Select a bundle to launch', + objects: 'r: refresh', + invoke: 'Tab: next field | Enter on args: send', + log: '', +}; + +/** + * Status bar displaying kernel state and view-specific navigation hints. + * + * @param props - Component props. + * @param props.status - Current kernel status. + * @param props.currentView - The currently active view name. + * @returns The StatusBar component. + */ +export function StatusBar({ + status, + currentView, +}: StatusBarProps): React.ReactElement { + const hint = VIEW_HINTS[currentView]; + return ( + + + Kernel:{' '} + {status ? ( + + {status.active ? 'active' : 'inactive'} | Vats: {status.vatCount} | + Subclusters: {status.subclusterCount} + + ) : ( + connecting... + )} + + + {currentView} | Tab: switch view | q: quit + {hint ? ` | ${hint}` : ''} + + + ); +} diff --git a/packages/kernel-tui/src/hooks/use-kernel.ts b/packages/kernel-tui/src/hooks/use-kernel.ts new file mode 100644 index 0000000000..c3d12591d6 --- /dev/null +++ b/packages/kernel-tui/src/hooks/use-kernel.ts @@ -0,0 +1,155 @@ +import { + getSocketPath, + sendCommand, +} from '@metamask/kernel-node-runtime/daemon'; +import type { ParsedInvocation } from '@metamask/kernel-utils/session'; +import { useEffect, useRef, useState } from 'react'; + +import type { KernelApi, KernelStatus } from '../types.ts'; + +/** + * Create a {@link KernelApi} that communicates with the daemon over a UNIX + * domain socket using JSON-RPC. + * + * @param socketPath - The daemon socket path (defaults to the standard path). + * @returns A {@link KernelApi} backed by the daemon. + */ +export function makeDaemonKernelApi( + socketPath: string = getSocketPath(), +): KernelApi { + const send = async ( + method: string, + params?: Record, + ): Promise => { + const response = await sendCommand({ socketPath, method, params }); + if ('error' in response) { + const rpcError = response.error as { message: string }; + throw new Error(rpcError.message); + } + return response.result as T; + }; + + return { + async launchSubcluster(config) { + return send<{ + subclusterId: string; + bootstrapRootKref: string; + bootstrapResult?: unknown; + }>('launchSubcluster', config); + }, + + async queueMessage(target, method, args) { + return send('queueMessage', { target, method, args }); + }, + + async getStatus() { + const result = await send<{ vats: unknown[]; subclusters: unknown[] }>( + 'getStatus', + ); + return { + active: result.vats.length > 0, + vatCount: result.vats.length, + subclusterCount: result.subclusters.length, + }; + }, + + async getObjectRegistry() { + const rows = await send[]>('executeDBQuery', { + sql: 'SELECT key, value FROM kv', + }); + return rows.map((row) => ({ + key: row.key ?? '', + value: row.value ?? '', + })); + }, + + async stop() { + await send('shutdown'); + }, + + async listSessions() { + return send< + { + sessionId: string; + ocapUrl: string; + cwd?: string; + startedAt?: string; + }[] + >('session.list'); + }, + + async listRequests(sessionId) { + return send<{ token: string; description: string; reason: string }[]>( + 'session.requests', + { sessionId }, + ); + }, + + async listHistory(sessionId) { + return send< + { + token: string; + description: string; + reason: string; + guard: { body: string; slots: string[] }; + queuedAt: string; + status: 'pending' | 'accepted' | 'rejected'; + decidedAt?: string; + invocations?: ParsedInvocation[]; + }[] + >('session.history', { sessionId }); + }, + + async decide(sessionId, token, verdict, provision) { + await send('session.decide', { + sessionId, + token, + verdict, + feedback: '', + ...(provision === undefined ? {} : { provision }), + }); + }, + }; +} + +/** + * React hook that fetches and tracks kernel status. + * + * @param kernelApi - The kernel API to use. + * @returns Kernel status, any error string, and a manual refresh callback. + */ +export function useKernel(kernelApi: KernelApi): { + status: KernelStatus | null; + error: string | null; + refreshStatus: () => void; +} { + const [status, setStatus] = useState(null); + const [error, setError] = useState(null); + const mountedRef = useRef(true); + + const refreshStatus = (): void => { + kernelApi + .getStatus() + .then((newStatus) => { + if (mountedRef.current) { + setStatus(newStatus); + } + return undefined; + }) + .catch((caught: Error) => { + if (mountedRef.current) { + setError(caught.message); + } + }); + }; + + useEffect(() => { + mountedRef.current = true; + refreshStatus(); + return () => { + mountedRef.current = false; + }; + }, []); + + return { status, error, refreshStatus }; +} diff --git a/packages/kernel-tui/src/hooks/use-session-data.test.ts b/packages/kernel-tui/src/hooks/use-session-data.test.ts new file mode 100644 index 0000000000..1bb527cb56 --- /dev/null +++ b/packages/kernel-tui/src/hooks/use-session-data.test.ts @@ -0,0 +1,190 @@ +// @vitest-environment jsdom + +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { KernelApi } from '../types.ts'; +import { useSessionData } from './use-session-data.ts'; + +function makeKernelApi(): KernelApi { + return { + listSessions: vi.fn().mockResolvedValue([]), + listRequests: vi.fn().mockResolvedValue([]), + listHistory: vi.fn().mockResolvedValue([]), + decide: vi.fn().mockResolvedValue(undefined), + launchSubcluster: vi.fn().mockResolvedValue(null as never), + queueMessage: vi.fn().mockResolvedValue(null as never), + getStatus: vi.fn().mockResolvedValue(null as never), + getObjectRegistry: vi.fn().mockResolvedValue(null as never), + stop: vi.fn().mockResolvedValue(undefined), + }; +} + +describe('useSessionData', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('starts with loading=true', () => { + const kernelApi = makeKernelApi(); + const { result } = renderHook(() => useSessionData(kernelApi)); + expect(result.current.loading).toBe(true); + }); + + it('sets loading=false after first fetch', async () => { + const kernelApi = makeKernelApi(); + const { result } = renderHook(() => useSessionData(kernelApi)); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + expect(result.current.loading).toBe(false); + }); + + it('calls listSessions on mount', async () => { + const kernelApi = makeKernelApi(); + renderHook(() => useSessionData(kernelApi)); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + expect(kernelApi.listSessions).toHaveBeenCalledTimes(1); + }); + + it('sets sessions from API', async () => { + const kernelApi = makeKernelApi(); + vi.mocked(kernelApi.listSessions).mockResolvedValue([ + { sessionId: 'alice', ocapUrl: 'ocap://alice' }, + ]); + vi.mocked(kernelApi.listRequests).mockResolvedValue([ + { token: 't0', description: 'x', reason: 'y' }, + ]); + + const { result } = renderHook(() => useSessionData(kernelApi)); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + expect(result.current.sessions).toHaveLength(1); + expect(result.current.sessions[0]?.requests).toHaveLength(1); + }); + + it('sets error when listSessions throws', async () => { + const kernelApi = makeKernelApi(); + vi.mocked(kernelApi.listSessions).mockRejectedValue( + new Error('network failure'), + ); + + const { result } = renderHook(() => useSessionData(kernelApi)); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + expect(result.current.error).toBe('network failure'); + expect(result.current.loading).toBe(false); + }); + + it('openDetail fetches history and sets detailSession', async () => { + const kernelApi = makeKernelApi(); + const session = { sessionId: 'alice', ocapUrl: 'ocap://alice' }; + vi.mocked(kernelApi.listHistory).mockResolvedValue([ + { + token: 't1', + description: 'read', + reason: 'needs read', + guard: { body: '{}', slots: [] }, + queuedAt: '2026-01-01T00:00:00.000Z', + status: 'pending', + }, + ]); + + const { result } = renderHook(() => useSessionData(kernelApi)); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + await act(async () => { + result.current.openDetail(session); + await vi.advanceTimersByTimeAsync(0); + }); + + expect(result.current.detailSession).toStrictEqual(session); + expect(kernelApi.listHistory).toHaveBeenCalledWith('alice'); + }); + + it('closeDetail clears detailSession and detailHistory', async () => { + const kernelApi = makeKernelApi(); + const session = { sessionId: 'alice', ocapUrl: 'ocap://alice' }; + vi.mocked(kernelApi.listHistory).mockResolvedValue([ + { + token: 't1', + description: 'read', + reason: 'needs read', + guard: { body: '{}', slots: [] }, + queuedAt: '2026-01-01T00:00:00.000Z', + status: 'pending', + }, + ]); + + const { result } = renderHook(() => useSessionData(kernelApi)); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + await act(async () => { + result.current.openDetail(session); + await vi.advanceTimersByTimeAsync(0); + }); + + await act(async () => { + result.current.closeDetail(); + }); + + expect(result.current.detailSession).toBeNull(); + expect(result.current.detailHistory).toStrictEqual([]); + }); + + it('polls session list at POLL_INTERVAL_MS', async () => { + const kernelApi = makeKernelApi(); + renderHook(() => useSessionData(kernelApi)); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + const callsAfterMount = vi.mocked(kernelApi.listSessions).mock.calls.length; + + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + + expect(vi.mocked(kernelApi.listSessions).mock.calls.length).toBeGreaterThan( + callsAfterMount, + ); + }); + + it('does not poll detail when no detail is open', async () => { + const kernelApi = makeKernelApi(); + renderHook(() => useSessionData(kernelApi)); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + + expect(kernelApi.listHistory).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/kernel-tui/src/hooks/use-session-data.ts b/packages/kernel-tui/src/hooks/use-session-data.ts new file mode 100644 index 0000000000..b02b4a4d64 --- /dev/null +++ b/packages/kernel-tui/src/hooks/use-session-data.ts @@ -0,0 +1,149 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import type { + KernelApi, + PendingRequest, + SessionHistoryEntry, + SessionSummary, +} from '../types.ts'; + +export type SessionWithRequests = SessionSummary & { + requests: PendingRequest[]; +}; + +const POLL_INTERVAL_MS = 2000; + +export type SessionDataState = { + sessions: SessionWithRequests[]; + /** True only until the first response arrives (shows initial loading spinner). */ + loading: boolean; + error: string | null; + detailSession: SessionSummary | null; + detailHistory: SessionHistoryEntry[]; + openDetail: (session: SessionSummary) => void; + closeDetail: () => void; + /** Silently re-fetch the session list (and detail history if open). */ + refresh: () => void; + /** Call after a decision is made — refreshes both list and open detail. */ + onDecided: () => void; +}; + +/** + * Manages all session data fetching and detail-drill state. + * + * Owns two polling intervals: one for the session list, one for the open + * detail view (started/stopped automatically as detail opens/closes). + * Components consume the returned state and callbacks without knowing about + * the fetch lifecycle. + * + * @param kernelApi - Kernel API for session operations. + * @returns Session data state and action callbacks. + */ +export function useSessionData(kernelApi: KernelApi): SessionDataState { + const [sessions, setSessions] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [detailSession, setDetailSession] = useState( + null, + ); + const [detailHistory, setDetailHistory] = useState([]); + const mountedRef = useRef(true); + + const refresh = useCallback((): void => { + kernelApi + .listSessions() + .then(async (summaries) => { + const withRequests = await Promise.all( + summaries.map(async (summary) => { + const requests = await kernelApi.listRequests(summary.sessionId); + return { ...summary, requests }; + }), + ); + if (mountedRef.current) { + setSessions(withRequests); + setLoading(false); + setError(null); + } + return undefined; + }) + .catch((caught: Error) => { + if (mountedRef.current) { + setError(caught.message); + setLoading(false); + } + }); + }, [kernelApi]); + + const refreshDetail = useCallback((): void => { + if (detailSession === null) { + return; + } + kernelApi + .listHistory(detailSession.sessionId) + .then((history) => { + if (mountedRef.current) { + setDetailHistory(history); + } + return undefined; + }) + .catch(() => undefined); + }, [kernelApi, detailSession]); + + const openDetail = useCallback( + (session: SessionSummary): void => { + kernelApi + .listHistory(session.sessionId) + .then((history) => { + if (mountedRef.current) { + setDetailHistory(history); + setDetailSession(session); + } + return undefined; + }) + .catch(() => undefined); + }, + [kernelApi], + ); + + const closeDetail = useCallback((): void => { + setDetailSession(null); + setDetailHistory([]); + }, []); + + const onDecided = useCallback((): void => { + refresh(); + refreshDetail(); + }, [refresh, refreshDetail]); + + // Session list polling. + useEffect(() => { + mountedRef.current = true; + refresh(); + const interval = setInterval(refresh, POLL_INTERVAL_MS); + return () => { + mountedRef.current = false; + clearInterval(interval); + }; + }, [refresh]); + + // Detail history polling — active only while a detail is open. + useEffect(() => { + if (detailSession === null) { + return () => undefined; + } + const interval = setInterval(refreshDetail, POLL_INTERVAL_MS); + return () => clearInterval(interval); + }, [detailSession, refreshDetail]); + + return { + sessions, + loading, + error, + detailSession, + detailHistory, + openDetail, + closeDetail, + refresh, + onDecided, + }; +} diff --git a/packages/kernel-tui/src/hooks/use-terminal-size.ts b/packages/kernel-tui/src/hooks/use-terminal-size.ts new file mode 100644 index 0000000000..3f6501ce1c --- /dev/null +++ b/packages/kernel-tui/src/hooks/use-terminal-size.ts @@ -0,0 +1,28 @@ +import { useStdout } from 'ink'; +import { useEffect, useState } from 'react'; + +/** + * Returns the current terminal dimensions and re-renders whenever the terminal + * is resized. + * + * @returns An object with `columns` and `rows` reflecting the live terminal size. + */ +export function useTerminalSize(): { columns: number; rows: number } { + const { stdout } = useStdout(); + const [size, setSize] = useState({ + columns: stdout.columns, + rows: stdout.rows, + }); + + useEffect(() => { + const onResize = (): void => { + setSize({ columns: stdout.columns, rows: stdout.rows }); + }; + stdout.on('resize', onResize); + return () => { + stdout.off('resize', onResize); + }; + }, [stdout]); + + return size; +} diff --git a/packages/kernel-tui/src/modal.tsx b/packages/kernel-tui/src/modal.tsx new file mode 100644 index 0000000000..35d9381768 --- /dev/null +++ b/packages/kernel-tui/src/modal.tsx @@ -0,0 +1,293 @@ +import { + connectModalStream, + getStreamSocketPath, +} from '@metamask/kernel-node-runtime/daemon'; +import type { + Decision, + SectionNotification, +} from '@metamask/kernel-utils/session'; +import type { NodeSocketDuplexStream } from '@metamask/streams'; +import { + Box, + Text, + render as inkRender, + useApp, + useInput, + useStdout, +} from 'ink'; +import React, { useEffect, useRef, useState } from 'react'; + +type PendingDecision = SectionNotification & { + selected: 0 | 1; + feedbackMode: boolean; + feedback: string; +}; + +/** + * Return a new pending list with the first entry patched. + * + * @param prev - Current pending decisions. + * @param patch - Fields to merge into the head. + * @returns Updated list. + */ +function updateHead( + prev: PendingDecision[], + patch: Partial, +): PendingDecision[] { + const [head, ...rest] = prev; + if (head === undefined) { + return prev; + } + return [{ ...head, ...patch }, ...rest]; +} + +type ModalAppProps = { + channelUrl: string; + streamSocketPath: string; + onFatalError: (message: string) => void; +}; + +/** + * Ink component that renders the modal TUI. + * + * @param props - Component props. + * @param props.channelUrl - The OCAP URL of the channel to subscribe to. + * @param props.streamSocketPath - The stream socket path. + * @param props.onFatalError - Callback invoked with an error message on fatal stream errors. + * @returns The rendered component. + */ +function ModalApp({ + channelUrl, + streamSocketPath, + onFatalError, +}: ModalAppProps): React.JSX.Element { + const { exit } = useApp(); + const { stdout } = useStdout(); + const [pending, setPending] = useState([]); + const [error, setError] = useState(); + const streamRef = useRef | null>(null); + + useEffect(() => { + let active = true; + + const run = async (): Promise => { + let stream: NodeSocketDuplexStream; + try { + stream = await connectModalStream(streamSocketPath, channelUrl); + } catch (connectError) { + if (active) { + onFatalError(String(connectError)); + exit(); + } + return; + } + + if (!active) { + await stream.return(); + return; + } + + streamRef.current = stream; + + try { + for await (const notification of stream) { + if (!active) { + break; + } + setPending((prev) => [ + ...prev, + { + ...notification, + selected: 0, + feedbackMode: false, + feedback: '', + }, + ]); + } + } catch (streamError) { + if (active) { + onFatalError(String(streamError)); + exit(); + } + } + }; + + run().catch(() => undefined); + + return () => { + active = false; + streamRef.current?.return().catch(() => undefined); + }; + }, [channelUrl, streamSocketPath, exit]); + + const submit = (dec: PendingDecision): void => { + const verdict: Decision['verdict'] = + dec.selected === 0 ? 'accept' : 'reject'; + const decision: Decision = { + token: dec.token, + verdict, + feedback: dec.feedback, + }; + setPending((prev) => prev.filter((item) => item.token !== dec.token)); + streamRef.current?.write(decision).catch((submitError: unknown) => { + setError(String(submitError)); + }); + }; + + useInput((input, key) => { + const head = pending[0]; + if (head === undefined) { + return; + } + + if (!head.feedbackMode) { + if (key.upArrow) { + setPending((prev) => updateHead(prev, { selected: 0 })); + } else if (key.downArrow) { + setPending((prev) => updateHead(prev, { selected: 1 })); + } else if (input === '1') { + if (head.feedback) { + setPending((prev) => updateHead(prev, { selected: 0 })); + } else { + submit({ ...head, selected: 0 }); + } + } else if (input === '2') { + if (head.feedback) { + setPending((prev) => updateHead(prev, { selected: 1 })); + } else { + submit({ ...head, selected: 1 }); + } + } else if (key.tab) { + setPending((prev) => updateHead(prev, { feedbackMode: true })); + } else if (key.return) { + submit(head); + } + } else if (key.escape) { + setPending((prev) => + updateHead(prev, { feedbackMode: false, feedback: '' }), + ); + } else if (key.tab) { + setPending((prev) => updateHead(prev, { feedbackMode: false })); + } else if (key.return) { + submit(head); + } else if (key.backspace || key.delete) { + setPending((prev) => + updateHead(prev, { feedback: head.feedback.slice(0, -1) }), + ); + } else if (!key.ctrl && !key.meta && /^[\x20-\x7e]+$/u.test(input)) { + setPending((prev) => + updateHead(prev, { feedback: head.feedback + input }), + ); + } + }); + + const head = pending[0]; + + const acceptLabel = + head?.selected === 0 && head.feedbackMode + ? `Accept${head.feedback ? `, ${head.feedback}` : ''}` + : 'Accept'; + const rejectLabel = + head?.selected === 1 && head.feedbackMode + ? `Reject${head.feedback ? `, ${head.feedback}` : ''}` + : 'Reject'; + + const hint = head?.feedbackMode + ? 'Esc to cancel · Tab to finish note · Enter to submit' + : 'Esc to cancel · Tab to add note'; + + const pendingCount = pending.length; + const termHeight = stdout.rows ?? 24; + + return ( + + + + Authorization Request + + {pendingCount > 0 && ( + + {' '} + {String(pendingCount)} pending + + )} + + + {head === undefined ? ( + No requests. + ) : ( + <> + + {head.description} + + + + Do you want to proceed? + + + + {head.selected === 0 ? ( + {`> 1. ${acceptLabel}`} + ) : ( + {` 1. ${acceptLabel}`} + )} + {head.selected === 1 ? ( + {`> 2. ${rejectLabel}`} + ) : ( + {` 2. ${rejectLabel}`} + )} + + + )} + + {error !== undefined && ( + + Error: {error} + + )} + + + + {head === undefined ? 'Ctrl+C to exit' : hint} + + ); +} + +/** + * Run the interactive modal TUI connected to the given channel OCAP URL. + * + * @param channelUrl - The OCAP URL of the channel to subscribe to. + */ +export async function runModal(channelUrl: string): Promise { + const streamSocketPath = getStreamSocketPath(); + let fatalError: string | undefined; + + process.stdout.write('\x1b[?1049h'); // enter alternate screen buffer + process.stdout.write('\x1b[?25l'); // hide cursor + + const { waitUntilExit } = inkRender( + { + fatalError = message; + }} + />, + ); + + await waitUntilExit(); + + process.stdout.write('\x1b[?25h'); // restore cursor + process.stdout.write('\x1b[?1049l'); // exit alternate screen buffer + + if (fatalError !== undefined) { + process.stderr.write(`Error: ${fatalError}\n`); + // eslint-disable-next-line n/no-process-exit -- force-exit to close dangling stream socket + process.exit(1); + } + // eslint-disable-next-line n/no-process-exit -- force-exit to close dangling stream socket + process.exit(0); +} diff --git a/packages/kernel-tui/src/start-tui.ts b/packages/kernel-tui/src/start-tui.ts new file mode 100644 index 0000000000..bc9ce71be6 --- /dev/null +++ b/packages/kernel-tui/src/start-tui.ts @@ -0,0 +1,30 @@ +import type { KernelApi } from './types.ts'; + +/** + * Start the interactive TUI with the provided kernel API. + * + * @param options - Options for the TUI. + * @param options.cwd - Current working directory for file browsing. + * @param options.kernelApi - Pre-configured kernel API abstraction. + */ +export async function startTui({ + cwd, + kernelApi, +}: { + cwd: string; + kernelApi: KernelApi; +}): Promise { + // Lazy-load ink and React to sidestep SES lockdown interaction at import time. + const [{ render }, { createElement }] = await Promise.all([ + import('ink'), + import('react'), + ]); + const { Tui } = await import('./tui.tsx'); + + // Clear screen and move cursor to top-left before rendering to avoid + // artifacts from prior terminal output. + process.stdout.write('\x1B[2J\x1B[H'); + + const instance = render(createElement(Tui, { cwd, kernelApi })); + await instance.waitUntilExit(); +} diff --git a/packages/kernel-tui/src/tui.tsx b/packages/kernel-tui/src/tui.tsx new file mode 100644 index 0000000000..4f306aa195 --- /dev/null +++ b/packages/kernel-tui/src/tui.tsx @@ -0,0 +1,83 @@ +import { Box, useApp, useInput } from 'ink'; +import React, { useCallback, useState } from 'react'; + +import { FileBrowser } from './components/file-browser.tsx'; +import { InvokeView } from './components/invoke-view.tsx'; +import { LogView } from './components/log-view.tsx'; +import { ObjectRegistryView } from './components/object-registry-view.tsx'; +import { SessionsView } from './components/sessions-view.tsx'; +import { StatusBar } from './components/status-bar.tsx'; +import { useKernel } from './hooks/use-kernel.ts'; +import { useTerminalSize } from './hooks/use-terminal-size.ts'; +import type { KernelApi, ViewMode } from './types.ts'; + +const VIEWS: ViewMode[] = ['sessions', 'files', 'objects', 'invoke', 'log']; + +type TuiProps = { + cwd: string; + kernelApi: KernelApi; +}; + +/** + * Root TUI application component. + * + * @param props - Component props. + * @param props.cwd - Current working directory for file browsing. + * @param props.kernelApi - Kernel API abstraction. + * @returns The Tui component. + */ +export function Tui({ cwd, kernelApi }: TuiProps): React.ReactElement { + const { exit } = useApp(); + const { rows } = useTerminalSize(); + const { status, refreshStatus } = useKernel(kernelApi); + const [currentView, setCurrentView] = useState('sessions'); + const [logMessages, setLogMessages] = useState([]); + + const addLog = useCallback((message: string) => { + const timestamp = new Date().toLocaleTimeString(); + setLogMessages((prev) => [...prev, `[${timestamp}] ${message}`]); + }, []); + + useInput((input, key) => { + if (input === 'q' && !key.ctrl) { + exit(); + } + if (key.tab && !key.shift) { + setCurrentView((prev) => { + const idx = VIEWS.indexOf(prev); + return VIEWS[(idx + 1) % VIEWS.length] as ViewMode; + }); + } + if (key.tab && key.shift) { + setCurrentView((prev) => { + const idx = VIEWS.indexOf(prev); + return VIEWS[(idx - 1 + VIEWS.length) % VIEWS.length] as ViewMode; + }); + } + if (input === 'r' && currentView === 'objects') { + refreshStatus(); + } + }); + + return ( + + + + + {currentView === 'sessions' && } + {currentView === 'files' && ( + + )} + {currentView === 'objects' && ( + + )} + {currentView === 'invoke' && ( + + )} + {currentView === 'log' && } + + + + + ); +} diff --git a/packages/kernel-tui/src/types.ts b/packages/kernel-tui/src/types.ts new file mode 100644 index 0000000000..99b6fb63ce --- /dev/null +++ b/packages/kernel-tui/src/types.ts @@ -0,0 +1,35 @@ +import type { + SessionApi, + SessionSummary, + PendingRequest, + SessionHistoryEntry, +} from '@metamask/kernel-utils/session'; + +export type { SessionSummary, PendingRequest, SessionHistoryEntry }; + +export type KernelStatus = { + active: boolean; + vatCount: number; + subclusterCount: number; +}; + +export type RegistryEntry = { key: string; value: string }; + +export type ViewMode = 'sessions' | 'files' | 'objects' | 'invoke' | 'log'; + +export type KernelApi = SessionApi & { + listHistory: (sessionId: string) => Promise; + launchSubcluster(config: Record): Promise<{ + subclusterId: string; + bootstrapRootKref: string; + bootstrapResult?: unknown; + }>; + queueMessage( + target: string, + method: string, + args: unknown[], + ): Promise; + getStatus(): Promise; + getObjectRegistry(): Promise; + stop(): Promise; +}; diff --git a/packages/kernel-tui/tsconfig.build.json b/packages/kernel-tui/tsconfig.build.json new file mode 100644 index 0000000000..e09d24c4a5 --- /dev/null +++ b/packages/kernel-tui/tsconfig.build.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "lib": ["ES2022"], + "outDir": "./dist", + "emitDeclarationOnly": false, + "rootDir": "./src", + "types": ["node"], + "jsx": "react-jsx" + }, + "references": [ + { "path": "../kernel-utils/tsconfig.build.json" }, + { "path": "../kernel-node-runtime/tsconfig.build.json" }, + { "path": "../streams/tsconfig.build.json" } + ], + "files": [], + "include": ["./src/**/*.ts", "./src/**/*.tsx"] +} diff --git a/packages/kernel-tui/tsconfig.json b/packages/kernel-tui/tsconfig.json new file mode 100644 index 0000000000..c61ab71b21 --- /dev/null +++ b/packages/kernel-tui/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./", + "lib": ["ES2022"], + "types": ["vitest", "node"], + "jsx": "react-jsx" + }, + "references": [ + { "path": "../repo-tools" }, + { "path": "../kernel-utils" }, + { "path": "../kernel-node-runtime" }, + { "path": "../streams" } + ], + "include": [ + "../../vitest.config.ts", + "./src/**/*.ts", + "./src/**/*.tsx", + "./vite.config.ts", + "./vitest.config.ts" + ] +} diff --git a/packages/kernel-tui/typedoc.json b/packages/kernel-tui/typedoc.json new file mode 100644 index 0000000000..f8eb78ae1a --- /dev/null +++ b/packages/kernel-tui/typedoc.json @@ -0,0 +1,8 @@ +{ + "entryPoints": [], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json", + "projectDocuments": ["documents/*.md"] +} diff --git a/packages/kernel-tui/vitest.config.ts b/packages/kernel-tui/vitest.config.ts new file mode 100644 index 0000000000..2c7bdef867 --- /dev/null +++ b/packages/kernel-tui/vitest.config.ts @@ -0,0 +1,16 @@ +import { mergeConfig } from '@ocap/repo-tools/vitest-config'; +import { defineConfig, defineProject } from 'vitest/config'; + +import defaultConfig from '../../vitest.config.ts'; + +export default defineConfig((args) => { + return mergeConfig( + args, + defaultConfig, + defineProject({ + test: { + name: 'kernel-tui', + }, + }), + ); +}); diff --git a/packages/kernel-utils/CHANGELOG.md b/packages/kernel-utils/CHANGELOG.md index 063ee0e4cb..85e0a067cc 100644 --- a/packages/kernel-utils/CHANGELOG.md +++ b/packages/kernel-utils/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `./session` export path with `makeChannel`, `Channel`, and `ModalStream` session channel primitives +- Add `SessionSummary`, `PendingRequest`, and `SessionApi` transport-agnostic types to `./session` +- Add `makeSessionRegistry`, `Session`, `SessionRegistry`, and `SessionHistoryEntry` to `./session` + ## [0.5.0] ### Added diff --git a/packages/kernel-utils/package.json b/packages/kernel-utils/package.json index 9e3643ab73..acced23348 100644 --- a/packages/kernel-utils/package.json +++ b/packages/kernel-utils/package.json @@ -89,6 +89,26 @@ "default": "./dist/vite-plugins/index.cjs" } }, + "./session": { + "import": { + "types": "./dist/session/index.d.mts", + "default": "./dist/session/index.mjs" + }, + "require": { + "types": "./dist/session/index.d.cts", + "default": "./dist/session/index.cjs" + } + }, + "./session/provision": { + "import": { + "types": "./dist/session/provision-api.d.mts", + "default": "./dist/session/provision-api.mjs" + }, + "require": { + "types": "./dist/session/provision-api.d.cts", + "default": "./dist/session/provision-api.cjs" + } + }, "./package.json": "./package.json" }, "main": "./dist/index.cjs", diff --git a/packages/kernel-utils/src/session/channel.test.ts b/packages/kernel-utils/src/session/channel.test.ts new file mode 100644 index 0000000000..a24fb02962 --- /dev/null +++ b/packages/kernel-utils/src/session/channel.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { makeChannel } from './channel.ts'; +import type { Decision, SectionNotification } from './types.ts'; + +const makeNotification = (token = 't0'): SectionNotification => ({ + token, + description: 'Allow read', + reason: 'needs file', + guard: { body: '#{}', slots: [] }, +}); + +const makeDecision = ( + token = 't0', + verdict: 'accept' | 'reject' = 'accept', +): Decision => ({ token, verdict, feedback: '' }); + +const makeStream = () => { + const decisions: Decision[] = []; + const written: SectionNotification[] = []; + let resolveNext: + | ((result: IteratorResult) => void) + | undefined; + let closed = false; + + const stream = { + write: vi.fn(async (notification: SectionNotification) => { + written.push(notification); + }), + push(decision: Decision) { + decisions.push(decision); + resolveNext?.({ done: false, value: decision }); + resolveNext = undefined; + }, + close() { + closed = true; + resolveNext?.({ done: true, value: undefined }); + resolveNext = undefined; + }, + written, + [Symbol.asyncIterator]() { + return { + async next(): Promise> { + if (closed) { + return { done: true, value: undefined }; + } + const decision = decisions.shift(); + if (decision !== undefined) { + return { done: false, value: decision }; + } + return new Promise((resolve) => { + resolveNext = resolve; + }); + }, + async return(): Promise> { + return { done: true, value: undefined }; + }, + }; + }, + }; + return stream; +}; + +describe('makeChannel', () => { + it('broadcasts a notification to a subscribed stream', async () => { + const channel = makeChannel(); + const stream = makeStream(); + channel.subscribe(stream); + + const notification = makeNotification(); + channel.broadcast(notification).catch(() => undefined); + await vi.waitFor(() => expect(stream.written).toHaveLength(1)); + expect(stream.written[0]).toStrictEqual(notification); + }); + + it('broadcast resolves when subscriber sends matching decision', async () => { + const channel = makeChannel(); + const stream = makeStream(); + channel.subscribe(stream); + + const decisionPromise = channel.broadcast(makeNotification('t1')); + await vi.waitFor(() => expect(stream.written).toHaveLength(1)); + + stream.push(makeDecision('t1', 'accept')); + const decision = await decisionPromise; + expect(decision).toStrictEqual(makeDecision('t1', 'accept')); + }); + + it('ignores decisions for unknown tokens', async () => { + const channel = makeChannel(); + const stream = makeStream(); + channel.subscribe(stream); + + const decisionPromise = channel.broadcast(makeNotification('t1')); + await vi.waitFor(() => expect(stream.written).toHaveLength(1)); + + stream.push(makeDecision('unknown', 'accept')); + stream.push(makeDecision('t1', 'accept')); + const decision = await decisionPromise; + expect(decision.token).toBe('t1'); + }); + + it('replays pending notifications to a subscriber that connects late', async () => { + const channel = makeChannel(); + const notification = makeNotification('t0'); + channel.broadcast(notification).catch(() => undefined); + + const stream = makeStream(); + channel.subscribe(stream); + + await vi.waitFor(() => expect(stream.written).toHaveLength(1)); + expect(stream.written[0]).toStrictEqual(notification); + }); + + it('replays pending notifications to a new subscriber even after a previous subscriber received them', async () => { + const channel = makeChannel(); + const streamA = makeStream(); + channel.subscribe(streamA); + + const notification = makeNotification('t0'); + channel.broadcast(notification).catch(() => undefined); + await vi.waitFor(() => expect(streamA.written).toHaveLength(1)); + + // streamA received the notification but hasn't decided yet — streamB should + // also receive it via pending replay. + const streamB = makeStream(); + channel.subscribe(streamB); + await vi.waitFor(() => expect(streamB.written).toHaveLength(1)); + expect(streamB.written[0]).toStrictEqual(notification); + }); + + it('broadcasts to multiple subscribers', async () => { + const channel = makeChannel(); + const streamA = makeStream(); + const streamB = makeStream(); + channel.subscribe(streamA); + channel.subscribe(streamB); + + channel.broadcast(makeNotification('t0')).catch(() => undefined); + await vi.waitFor(() => expect(streamA.written).toHaveLength(1)); + await vi.waitFor(() => expect(streamB.written).toHaveLength(1)); + }); + + it('listPending returns notifications not yet decided', async () => { + const channel = makeChannel(); + expect(channel.listPending()).toStrictEqual([]); + + const notification = makeNotification('t0'); + channel.broadcast(notification).catch(() => undefined); + expect(channel.listPending()).toStrictEqual([notification]); + }); + + it('decide resolves the pending broadcast promise', async () => { + const channel = makeChannel(); + const notification = makeNotification('t0'); + const decisionPromise = channel.broadcast(notification); + + const decision = makeDecision('t0', 'accept'); + channel.decide(decision); + expect(await decisionPromise).toStrictEqual(decision); + expect(channel.listPending()).toStrictEqual([]); + }); + + it('decide is a no-op for unknown tokens', () => { + const channel = makeChannel(); + expect(() => channel.decide(makeDecision('unknown'))).not.toThrow(); + }); + + it('rejects pending broadcasts when the last subscriber disconnects cleanly', async () => { + const channel = makeChannel(); + const stream = makeStream(); + channel.subscribe(stream); + + const decisionPromise = channel.broadcast(makeNotification('t0')); + await vi.waitFor(() => expect(stream.written).toHaveLength(1)); + + stream.close(); + await expect(decisionPromise).rejects.toThrow( + 'All subscribers disconnected', + ); + }); + + it('does not replay a notification to new subscribers once it has been decided', async () => { + const channel = makeChannel(); + const notification = makeNotification('t0'); + const decisionPromise = channel.broadcast(notification); + + const streamA = makeStream(); + channel.subscribe(streamA); + await vi.waitFor(() => expect(streamA.written).toHaveLength(1)); + + streamA.push(makeDecision('t0', 'accept')); + await decisionPromise; + + const streamB = makeStream(); + channel.subscribe(streamB); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(streamB.written).toHaveLength(0); + }); +}); diff --git a/packages/kernel-utils/src/session/channel.ts b/packages/kernel-utils/src/session/channel.ts new file mode 100644 index 0000000000..5bb4fdf18f --- /dev/null +++ b/packages/kernel-utils/src/session/channel.ts @@ -0,0 +1,237 @@ +import { makePromiseKit } from '@endo/promise-kit'; +import type { PromiseKit } from '@endo/promise-kit'; + +import { ifDefined } from '../misc.ts'; +import type { + Decision, + Provision, + SectionNotification, + SessionHistoryEntry, +} from './types.ts'; + +/** + * Structural type for a stream that carries {@link SectionNotification} + * outbound and {@link Decision} inbound. `NodeSocketDuplexStream` from + * `@metamask/streams` satisfies this interface; we avoid importing it here to + * prevent a circular dependency (streams → kernel-utils → streams). + */ +export type ModalStream = { + write(value: SectionNotification): Promise; +} & AsyncIterable; + +/** + * A broadcast channel that fans {@link SectionNotification} messages out to + * connected modal subscribers and returns a {@link Decision} promise to the + * caller. + */ +export type Channel = { + /** + * Broadcast a notification to all connected subscribers and return a promise + * that resolves when any subscriber submits a matching decision. + * + * @param notification - The section notification to broadcast. + * @returns A promise for the subscriber's decision. + */ + broadcast(notification: SectionNotification): Promise; + + /** + * Register a modal subscriber stream. Immediately starts draining the stream + * for incoming decisions and replays all currently-pending notifications to + * the new subscriber. + * + * @param stream - The modal stream to subscribe. + */ + subscribe(stream: ModalStream): void; + + /** + * Return all notifications that have been broadcast but not yet decided. + * + * @returns Array of pending notifications, oldest first. + */ + listPending(): SectionNotification[]; + + /** + * Return all requests — both pending and decided — sorted chronologically. + * + * @returns Array of {@link SessionHistoryEntry} oldest first. + */ + listAll(): SessionHistoryEntry[]; + + /** + * Resolve or reject the pending promise for the given token, as if a + * subscriber had submitted the decision. No-op if the token is unknown. + * + * @param decision - The decision to apply. + */ + decide(decision: Decision): void; + + /** + * Record a notification as already decided by a standing provision, without + * routing it through `pending` or notifying subscribers. + * + * @param notification - The section notification to record. + * @param provision - The standing provision that approved the request. + */ + record(notification: SectionNotification, provision?: Provision): void; +}; + +type PendingEntry = { + kit: PromiseKit; + notification: SectionNotification; + queuedAt: string; +}; + +type HistoryEntry = { + notification: SectionNotification; + queuedAt: string; + verdict: 'accepted' | 'rejected' | 'provisioned'; + decidedAt: string; + provision?: Provision; +}; + +/** + * Create a broadcast channel for session authorization requests. + * + * The channel fans notifications to all connected subscribers and correlates + * responses back to the originating broadcast call via token. Any subscriber + * that connects while notifications are still pending receives a replay of + * all undecided notifications, regardless of whether earlier subscribers have + * already received them. + * + * @returns A {@link Channel}. + */ +export function makeChannel(): Channel { + const pending = new Map(); + const history: HistoryEntry[] = []; + const subscribers: ModalStream[] = []; + + /** + * Route an incoming decision to its waiting broadcast caller. + * + * @param decision - The decision from a subscriber. + */ + function routeDecision(decision: Decision): void { + const entry = pending.get(decision.token); + if (entry === undefined) { + return; + } + pending.delete(decision.token); + history.push({ + notification: entry.notification, + queuedAt: entry.queuedAt, + verdict: decision.verdict === 'accept' ? 'accepted' : 'rejected', + decidedAt: new Date().toISOString(), + ...ifDefined({ provision: decision.provision }), + }); + entry.kit.resolve(decision); + } + + /** + * Drain a subscriber stream, routing decisions to pending callers. + * On stream end or error, rejects any remaining pending entries that have no + * other subscribers left to answer them. + * + * @param stream - The subscriber stream to drain. + */ + async function drainSubscriber(stream: ModalStream): Promise { + let drainError: Error | undefined; + try { + for await (const decision of stream) { + routeDecision(decision); + } + } catch (error) { + drainError = error instanceof Error ? error : new Error(String(error)); + } finally { + const idx = subscribers.indexOf(stream); + if (idx !== -1) { + subscribers.splice(idx, 1); + } + if (subscribers.length === 0 && pending.size > 0) { + const rejectError = + drainError ?? new Error('All subscribers disconnected'); + for (const [token, entry] of pending) { + pending.delete(token); + entry.kit.reject(rejectError); + } + } + } + } + + return harden({ + async broadcast(notification: SectionNotification): Promise { + const kit = makePromiseKit(); + pending.set(notification.token, { + kit, + notification, + queuedAt: new Date().toISOString(), + }); + for (const stream of [...subscribers]) { + stream.write(notification).catch(() => undefined); + } + return kit.promise; + }, + + subscribe(stream: ModalStream): void { + subscribers.push(stream); + // Replay all undecided notifications so this subscriber sees any requests + // that arrived before it connected or while a previous subscriber held them. + for (const { notification } of pending.values()) { + stream.write(notification).catch(() => undefined); + } + drainSubscriber(stream).catch(() => undefined); + }, + + listPending(): SectionNotification[] { + return Array.from(pending.values()).map((entry) => entry.notification); + }, + + listAll(): SessionHistoryEntry[] { + const decided: SessionHistoryEntry[] = history.map((hist) => ({ + token: hist.notification.token, + description: hist.notification.description, + reason: hist.notification.reason, + guard: hist.notification.guard, + queuedAt: hist.queuedAt, + status: hist.verdict, + decidedAt: hist.decidedAt, + ...ifDefined({ invocations: hist.notification.invocations }), + ...ifDefined({ provision: hist.provision }), + })); + const stillPending: SessionHistoryEntry[] = Array.from( + pending.values(), + ).map((pend) => ({ + token: pend.notification.token, + description: pend.notification.description, + reason: pend.notification.reason, + guard: pend.notification.guard, + queuedAt: pend.queuedAt, + status: 'pending' as const, + ...ifDefined({ invocations: pend.notification.invocations }), + })); + return [...decided, ...stillPending].sort((lhs, rhs) => { + if (lhs.queuedAt < rhs.queuedAt) { + return -1; + } + if (lhs.queuedAt > rhs.queuedAt) { + return 1; + } + return 0; + }); + }, + + decide(decision: Decision): void { + routeDecision(decision); + }, + + record(notification: SectionNotification, provision?: Provision): void { + const stamp = new Date().toISOString(); + history.push({ + notification, + queuedAt: stamp, + verdict: 'provisioned', + decidedAt: stamp, + ...ifDefined({ provision }), + }); + }, + }); +} diff --git a/packages/kernel-utils/src/session/index.ts b/packages/kernel-utils/src/session/index.ts new file mode 100644 index 0000000000..ae73882dca --- /dev/null +++ b/packages/kernel-utils/src/session/index.ts @@ -0,0 +1,33 @@ +export type { + ArgPattern, + InvocationPattern, + ParsedInvocation, + Provision, + SectionRequest, + SectionNotification, + Decision, + SessionSummary, + PendingRequest, + SessionHistoryEntry, + SessionApi, +} from './types.ts'; +export { makeChannel } from './channel.ts'; +export type { Channel, ModalStream } from './channel.ts'; +export { makeSessionRegistry } from './session-registry.ts'; +export type { Session, SessionRegistry } from './session-registry.ts'; +export type { PatternOrder } from './provision.ts'; +export { + isPathArg, + pathInterval, + trivialInterval, + argInterval, + argPatternDisplay, + matchArg, + matchPattern, + matchProvision, + argPatternLe, + compareInvocationPatterns, + compareProvisions, + computeAuthority, + invocationToProvision, +} from './provision.ts'; diff --git a/packages/kernel-utils/src/session/provision-api.ts b/packages/kernel-utils/src/session/provision-api.ts new file mode 100644 index 0000000000..a8a2bd4c81 --- /dev/null +++ b/packages/kernel-utils/src/session/provision-api.ts @@ -0,0 +1,29 @@ +/** + * Provision algebra — lockdown-free subpath. + * + * Exports only types and functions from types.ts / provision.ts, which have no + * `@endo/promise-kit` dependency. Use this entry point from hook scripts and + * other non-vat processes that must not run SES lockdown. + */ +export type { + ArgPattern, + InvocationPattern, + ParsedInvocation, + Provision, +} from './types.ts'; +export type { PatternOrder } from './provision.ts'; +export { + isPathArg, + pathInterval, + trivialInterval, + argInterval, + argPatternDisplay, + matchArg, + matchPattern, + matchProvision, + argPatternLe, + compareInvocationPatterns, + compareProvisions, + computeAuthority, + invocationToProvision, +} from './provision.ts'; diff --git a/packages/kernel-utils/src/session/provision.test.ts b/packages/kernel-utils/src/session/provision.test.ts new file mode 100644 index 0000000000..45b741adba --- /dev/null +++ b/packages/kernel-utils/src/session/provision.test.ts @@ -0,0 +1,477 @@ +import { describe, expect, it } from 'vitest'; + +import { + argInterval, + argPatternDisplay, + argPatternLe, + compareInvocationPatterns, + compareProvisions, + computeAuthority, + invocationToProvision, + isPathArg, + matchArg, + matchPattern, + matchProvision, + pathInterval, + trivialInterval, +} from './provision.ts'; +import type { ArgPattern, InvocationPattern, Provision } from './types.ts'; + +// ─── helpers ────────────────────────────────────────────────────────────────── + +const exact = (value: string): ArgPattern => ({ kind: 'exact', value }); +const prefix = (pfx: string): ArgPattern => ({ kind: 'prefix', prefix: pfx }); +const wildcard: ArgPattern = { kind: 'wildcard' }; + +const pat = ( + name: string, + ...argPatterns: ArgPattern[] +): InvocationPattern => ({ + name, + argPatterns, +}); + +const provision = ( + tool: string, + ...patterns: InvocationPattern[] +): Provision => ({ + tool, + patterns, +}); + +// ─── isPathArg ──────────────────────────────────────────────────────────────── + +describe('isPathArg', () => { + it.each([ + ['/foo', true], + ['/a/b/c', true], + ['./foo', true], + ['../bar', true], + ['foo', false], + ['foo/bar', false], + ['', false], + ])('isPathArg(%s) → %s', (input, expected) => { + expect(isPathArg(input)).toBe(expected); + }); +}); + +// ─── pathInterval ───────────────────────────────────────────────────────────── + +describe('pathInterval', () => { + it('produces exact + ancestor prefixes + wildcard for an absolute path', () => { + expect(pathInterval('/a/b/c')).toStrictEqual([ + exact('/a/b/c'), + prefix('/a/b/'), + prefix('/a/'), + prefix('/'), + wildcard, + ]); + }); + + it('stops at the root for a single-segment path', () => { + expect(pathInterval('/foo')).toStrictEqual([ + exact('/foo'), + prefix('/'), + wildcard, + ]); + }); + + it('handles a root-level path', () => { + // exact('/') is more specific than prefix('/') which covers all absolute paths + expect(pathInterval('/')).toStrictEqual([ + exact('/'), + prefix('/'), + wildcard, + ]); + }); + + it('produces exact + prefix + wildcard for a relative single-segment path', () => { + expect(pathInterval('./foo')).toStrictEqual([ + exact('./foo'), + prefix('./'), + wildcard, + ]); + }); +}); + +// ─── trivialInterval ────────────────────────────────────────────────────────── + +describe('trivialInterval', () => { + it('returns [exact, wildcard]', () => { + expect(trivialInterval('hello')).toStrictEqual([exact('hello'), wildcard]); + }); +}); + +// ─── argInterval ────────────────────────────────────────────────────────────── + +describe('argInterval', () => { + it('uses pathInterval for paths', () => { + expect(argInterval('/tmp/foo')).toStrictEqual(pathInterval('/tmp/foo')); + }); + + it('uses trivialInterval for non-paths', () => { + expect(argInterval('hello')).toStrictEqual(trivialInterval('hello')); + }); +}); + +// ─── argPatternDisplay ──────────────────────────────────────────────────────── + +describe('argPatternDisplay', () => { + it.each([ + [exact('foo'), 'foo'], + [prefix('/a/b/'), '/a/b/*'], + [wildcard, '*'], + ] as [ArgPattern, string][])('display(%o) → %s', (pattern, expected) => { + expect(argPatternDisplay(pattern)).toBe(expected); + }); +}); + +// ─── matchArg ───────────────────────────────────────────────────────────────── + +describe('matchArg', () => { + describe('exact', () => { + it('matches the exact value', () => { + expect(matchArg(exact('foo'), 'foo')).toBe(true); + }); + it('does not match a different value', () => { + expect(matchArg(exact('foo'), 'bar')).toBe(false); + }); + }); + + describe('prefix', () => { + it('matches a value that starts with the prefix', () => { + expect(matchArg(prefix('/a/'), '/a/b')).toBe(true); + }); + it('does not match a value that does not start with the prefix', () => { + expect(matchArg(prefix('/a/'), '/b/c')).toBe(false); + }); + }); + + describe('wildcard', () => { + it('matches any value', () => { + expect(matchArg(wildcard, 'anything')).toBe(true); + expect(matchArg(wildcard, '')).toBe(true); + }); + }); +}); + +// ─── matchPattern ───────────────────────────────────────────────────────────── + +describe('matchPattern', () => { + it('matches exact name and args', () => { + expect(matchPattern(pat('ls', exact('/tmp')), 'ls', ['/tmp'])).toBe(true); + }); + + it('does not match wrong name', () => { + expect(matchPattern(pat('ls', exact('/tmp')), 'cat', ['/tmp'])).toBe(false); + }); + + it('truncated: pattern with fewer argPatterns matches trailing-free invocations', () => { + expect(matchPattern(pat('ls', exact('/tmp')), 'ls', ['/tmp', '-la'])).toBe( + true, + ); + }); + + it('does not match when pattern has more argPatterns than argv', () => { + expect( + matchPattern(pat('ls', exact('/tmp'), exact('-la')), 'ls', ['/tmp']), + ).toBe(false); + }); + + it('no-arg pattern matches any argv', () => { + expect(matchPattern(pat('ls'), 'ls', ['-la', '/tmp'])).toBe(true); + expect(matchPattern(pat('ls'), 'ls', [])).toBe(true); + }); + + it('uses prefix matching for prefix patterns', () => { + expect( + matchPattern(pat('cat', prefix('/home/')), 'cat', ['/home/user/file']), + ).toBe(true); + expect( + matchPattern(pat('cat', prefix('/home/')), 'cat', ['/tmp/file']), + ).toBe(false); + }); +}); + +// ─── matchProvision ─────────────────────────────────────────────────────────── + +describe('matchProvision', () => { + it('matches when tool and all patterns match', () => { + const prov = provision('Bash', pat('ls', exact('/tmp'))); + expect(matchProvision(prov, 'Bash', [{ name: 'ls', argv: ['/tmp'] }])).toBe( + true, + ); + }); + + it('does not match wrong tool', () => { + const prov = provision('Bash', pat('ls', exact('/tmp'))); + expect(matchProvision(prov, 'Read', [{ name: 'ls', argv: ['/tmp'] }])).toBe( + false, + ); + }); + + it('does not match when invocation count differs from pattern count', () => { + const prov = provision('Bash', pat('ls'), pat('cat')); + expect(matchProvision(prov, 'Bash', [{ name: 'ls', argv: [] }])).toBe( + false, + ); + }); +}); + +// ─── argPatternLe ───────────────────────────────────────────────────────────── + +describe('argPatternLe', () => { + it('exact ≤ exact(same)', () => { + expect(argPatternLe(exact('foo'), exact('foo'))).toBe(true); + }); + + it('exact ≤ wildcard', () => { + expect(argPatternLe(exact('foo'), wildcard)).toBe(true); + }); + + it('wildcard ≤ wildcard', () => { + expect(argPatternLe(wildcard, wildcard)).toBe(true); + }); + + it('wildcard is NOT ≤ exact', () => { + expect(argPatternLe(wildcard, exact('foo'))).toBe(false); + }); + + it('exact ≤ matching prefix', () => { + expect(argPatternLe(exact('/a/b'), prefix('/a/'))).toBe(true); + }); + + it('exact is NOT ≤ non-matching prefix', () => { + expect(argPatternLe(exact('/x/y'), prefix('/a/'))).toBe(false); + }); + + it('prefix ≤ broader prefix', () => { + expect(argPatternLe(prefix('/a/b/'), prefix('/a/'))).toBe(true); + }); + + it('prefix is NOT ≤ narrower prefix', () => { + expect(argPatternLe(prefix('/a/'), prefix('/a/b/'))).toBe(false); + }); + + it('prefix ≤ wildcard', () => { + expect(argPatternLe(prefix('/a/'), wildcard)).toBe(true); + }); + + it('exact is NOT ≤ different exact', () => { + expect(argPatternLe(exact('foo'), exact('bar'))).toBe(false); + }); +}); + +// ─── compareInvocationPatterns ──────────────────────────────────────────────── + +describe('compareInvocationPatterns', () => { + it('eq: same name, same argPatterns', () => { + expect( + compareInvocationPatterns( + pat('ls', exact('/tmp')), + pat('ls', exact('/tmp')), + ), + ).toBe('eq'); + }); + + it('lt: same name, a is more restricted (more argPatterns, each ≤ corresponding b)', () => { + // pat('ls', exact('/tmp')) < pat('ls', prefix('/tmp/')) — exact < prefix + expect( + compareInvocationPatterns( + pat('ls', exact('/tmp')), + pat('ls', prefix('/')), + ), + ).toBe('lt'); + }); + + it('gt: a is more permissive', () => { + expect( + compareInvocationPatterns(pat('ls', wildcard), pat('ls', exact('/tmp'))), + ).toBe('gt'); + }); + + it('incomparable: different names', () => { + expect(compareInvocationPatterns(pat('ls'), pat('cat'))).toBe( + 'incomparable', + ); + }); + + it('lt: fewer argPatterns = more permissive (gt from a perspective)', () => { + // a has 0 args (truncated, covers all), b has 1 arg constraint → a > b + expect(compareInvocationPatterns(pat('ls'), pat('ls', exact('/tmp')))).toBe( + 'gt', + ); + }); + + it('gt: a has more args = more restricted', () => { + expect(compareInvocationPatterns(pat('ls', exact('/tmp')), pat('ls'))).toBe( + 'lt', + ); + }); + + it('incomparable: same name, non-ordered argPatterns', () => { + // exact('/a') vs exact('/b') — neither ≤ the other + expect( + compareInvocationPatterns(pat('ls', exact('/a')), pat('ls', exact('/b'))), + ).toBe('incomparable'); + }); +}); + +// ─── compareProvisions ──────────────────────────────────────────────────────── + +describe('compareProvisions', () => { + it('eq: identical provisions', () => { + const prov = provision('Bash', pat('ls', exact('/tmp'))); + expect(compareProvisions(prov, prov)).toBe('eq'); + }); + + it('lt: a is strictly more restricted', () => { + const a = provision('Bash', pat('ls', exact('/tmp/foo'))); + const b = provision('Bash', pat('ls', prefix('/tmp/'))); + expect(compareProvisions(a, b)).toBe('lt'); + }); + + it('gt: a is strictly more permissive', () => { + const a = provision('Bash', pat('ls', wildcard)); + const b = provision('Bash', pat('ls', exact('/tmp'))); + expect(compareProvisions(a, b)).toBe('gt'); + }); + + it('incomparable: different tools', () => { + const a = provision('Bash', pat('ls')); + const b = provision('Read', pat('ls')); + expect(compareProvisions(a, b)).toBe('incomparable'); + }); + + it('incomparable: different pattern counts', () => { + const a = provision('Bash', pat('ls'), pat('cat')); + const b = provision('Bash', pat('ls')); + expect(compareProvisions(a, b)).toBe('incomparable'); + }); + + it('incomparable: non-ordered multi-component', () => { + // Component 0: a < b, Component 1: a > b — cosheaf collapses to incomparable + const a = provision('Bash', pat('ls', exact('/tmp')), pat('cat', wildcard)); + const b = provision( + 'Bash', + pat('ls', prefix('/')), + pat('cat', exact('/etc/hosts')), + ); + expect(compareProvisions(a, b)).toBe('incomparable'); + }); +}); + +// ─── computeAuthority ───────────────────────────────────────────────────────── + +describe('computeAuthority', () => { + it('returns 0.5 for the first provision (no existing)', () => { + const prov = provision('Bash', pat('ls')); + expect(computeAuthority(prov, [])).toBe(0.5); + }); + + it('two incomparable provisions both get 0.5', () => { + const p1 = provision('Bash', pat('ls')); + const p2 = provision('Bash', pat('cat')); + const auth1 = computeAuthority(p1, []); + const records = [{ provision: p1, authority: auth1 }]; + const auth2 = computeAuthority(p2, records); + expect(auth1).toBe(0.5); + expect(auth2).toBe(0.5); + }); + + it('more-restricted provision gets lower authority', () => { + // p_wide (wildcard) added first at 0.5 + // p_narrow (exact) is lt p_wide → authority in (0, 0.5) = 0.25 + const pWide = provision('Bash', pat('ls', wildcard)); + const pNarrow = provision('Bash', pat('ls', exact('/tmp'))); + const authWide = computeAuthority(pWide, []); + const records = [{ provision: pWide, authority: authWide }]; + const authNarrow = computeAuthority(pNarrow, records); + expect(authNarrow).toBeLessThan(authWide); + }); + + it('more-permissive provision gets higher authority', () => { + // p_exact added first at 0.5 + // p_wide (wildcard) is gt p_exact → authority in (0.5, 1) = 0.75 + const pExact = provision('Bash', pat('ls', exact('/tmp'))); + const pWide = provision('Bash', pat('ls', wildcard)); + const authExact = computeAuthority(pExact, []); + const records = [{ provision: pExact, authority: authExact }]; + const authWide = computeAuthority(pWide, records); + expect(authWide).toBeGreaterThan(authExact); + }); + + it('midpoint insertion preserves the partial order for a chain of 3', () => { + // pExact (exact) < pPrefix (prefix) < pWild (wildcard) + const pExact = provision('Bash', pat('ls', exact('/tmp/foo'))); + const pPrefix = provision('Bash', pat('ls', prefix('/tmp/'))); + const pWild = provision('Bash', pat('ls', wildcard)); + + const authExact = computeAuthority(pExact, []); + const r1 = [{ provision: pExact, authority: authExact }]; + const authPrefix = computeAuthority(pPrefix, r1); + const r2 = [...r1, { provision: pPrefix, authority: authPrefix }]; + const authWild = computeAuthority(pWild, r2); + + expect(authExact).toBeLessThan(authPrefix); + expect(authPrefix).toBeLessThan(authWild); + }); + + it('inserting a provision between two existing ones gets the midpoint', () => { + // exact('/tmp/foo') < prefix('/tmp/') < wildcard + // Add exact(0.5) and wildcard(0.75) first; prefix slots between them → 0.625 + const pExact = provision('Bash', pat('ls', exact('/tmp/foo'))); + const pWild = provision('Bash', pat('ls', wildcard)); + const pPrefix = provision('Bash', pat('ls', prefix('/tmp/'))); + + const authExact = computeAuthority(pExact, []); + const r1 = [{ provision: pExact, authority: authExact }]; + const authWild = computeAuthority(pWild, r1); + const r2 = [...r1, { provision: pWild, authority: authWild }]; + const authPrefix = computeAuthority(pPrefix, r2); + + expect(authPrefix).toBeGreaterThan(authExact); + expect(authPrefix).toBeLessThan(authWild); + expect(authPrefix).toBe((authExact + authWild) / 2); + }); +}); + +// ─── invocationToProvision ──────────────────────────────────────────────────── + +describe('invocationToProvision', () => { + it('builds an all-exact provision from an invocation', () => { + const invocations = [{ name: 'ls', argv: ['-la', '/tmp'] }]; + expect(invocationToProvision('Bash', invocations)).toStrictEqual({ + tool: 'Bash', + patterns: [ + { + name: 'ls', + argPatterns: [exact('-la'), exact('/tmp')], + }, + ], + }); + }); + + it('handles empty argv', () => { + expect( + invocationToProvision('Bash', [{ name: 'ls', argv: [] }]), + ).toStrictEqual({ + tool: 'Bash', + patterns: [{ name: 'ls', argPatterns: [] }], + }); + }); + + it('handles multiple invocations (pipeline)', () => { + const invocations = [ + { name: 'ls', argv: ['/tmp'] }, + { name: 'grep', argv: ['foo'] }, + ]; + expect(invocationToProvision('Bash', invocations)).toStrictEqual({ + tool: 'Bash', + patterns: [ + { name: 'ls', argPatterns: [exact('/tmp')] }, + { name: 'grep', argPatterns: [exact('foo')] }, + ], + }); + }); +}); diff --git a/packages/kernel-utils/src/session/provision.ts b/packages/kernel-utils/src/session/provision.ts new file mode 100644 index 0000000000..1c27fb5863 --- /dev/null +++ b/packages/kernel-utils/src/session/provision.ts @@ -0,0 +1,340 @@ +import type { + ArgPattern, + InvocationPattern, + ParsedInvocation, + Provision, +} from './types.ts'; + +/** + * Returns true if the string looks like a file-system path (absolute or relative). + * + * @param str - The string to test. + * @returns True when the string starts with `/`, `./`, or `../`. + */ +export function isPathArg(str: string): boolean { + return str.startsWith('/') || str.startsWith('./') || str.startsWith('../'); +} + +/** + * Build the ordered lattice of ArgPatterns for a path argument. + * + * Example: `/a/b/c` → + * exact('/a/b/c') · prefix('/a/b/') · prefix('/a/') · prefix('/') · wildcard + * + * @param str - A path string (absolute or relative). + * @returns The ArgPattern lattice from most- to least-specific. + */ +export function pathInterval(str: string): ArgPattern[] { + const result: ArgPattern[] = [{ kind: 'exact', value: str }]; + let path = str; + for (;;) { + const lastSlash = path.lastIndexOf('/'); + if (lastSlash < 0) { + break; + } + if (lastSlash === 0) { + result.push({ kind: 'prefix', prefix: '/' }); + break; + } + result.push({ kind: 'prefix', prefix: path.slice(0, lastSlash + 1) }); + path = path.slice(0, lastSlash); + } + result.push({ kind: 'wildcard' }); + return result; +} + +/** + * Build the two-element lattice for a non-path argument: exact or wildcard. + * + * @param str - The argument value. + * @returns `[exact(str), wildcard]`. + */ +export function trivialInterval(str: string): ArgPattern[] { + return [{ kind: 'exact', value: str }, { kind: 'wildcard' }]; +} + +/** + * Choose the appropriate interval for an argument based on whether it is a path. + * + * @param str - The argument value. + * @returns A path interval for file-system paths, trivial interval otherwise. + */ +export function argInterval(str: string): ArgPattern[] { + return isPathArg(str) ? pathInterval(str) : trivialInterval(str); +} + +/** + * Format an ArgPattern as a display string. + * + * @param pattern - The pattern to display. + * @returns A human-readable string representation. + */ +export function argPatternDisplay(pattern: ArgPattern): string { + switch (pattern.kind) { + case 'exact': + return pattern.value; + case 'prefix': + return `${pattern.prefix}*`; + case 'wildcard': + return '*'; + default: + throw new Error( + `Unknown ArgPattern kind: ${(pattern as ArgPattern).kind}`, + ); + } +} + +/** + * Returns true if `pattern` matches `value`. + * + * @param pattern - The ArgPattern to test against. + * @param value - The argument value to test. + * @returns True when the value satisfies the pattern. + */ +export function matchArg(pattern: ArgPattern, value: string): boolean { + switch (pattern.kind) { + case 'exact': + return pattern.value === value; + case 'prefix': + return value.startsWith(pattern.prefix); + case 'wildcard': + return true; + default: + throw new Error( + `Unknown ArgPattern kind: ${(pattern as ArgPattern).kind}`, + ); + } +} + +/** + * Returns true if `pattern` matches the given `(name, argv)` invocation. + * + * Uses truncated matching: the pattern need only specify argPatterns for the + * leading arguments it cares about. Trailing arguments are unconstrained. + * + * @param pattern - The InvocationPattern to test. + * @param name - The command/tool name. + * @param argv - The argument list. + * @returns True when name matches and each specified argPattern matches. + */ +export function matchPattern( + pattern: InvocationPattern, + name: string, + argv: string[], +): boolean { + if (pattern.name !== name) { + return false; + } + if (pattern.argPatterns.length > argv.length) { + return false; + } + return pattern.argPatterns.every((argPat, i) => + matchArg(argPat, argv[i] as string), + ); +} + +/** + * Returns true if `provision` covers the given `(tool, invocations)` call. + * + * The provision matches only when its tool name matches and each of its patterns + * positionally matches the corresponding component invocation (cosheaf: all must + * match). + * + * @param provision - The Provision to test. + * @param tool - The tool name from the hook payload. + * @param invocations - The parsed command components. + * @returns True when the provision covers this invocation. + */ +export function matchProvision( + provision: Provision, + tool: string, + invocations: ParsedInvocation[], +): boolean { + if (provision.tool !== tool) { + return false; + } + if (provision.patterns.length !== invocations.length) { + return false; + } + return provision.patterns.every((pattern, i) => { + const inv = invocations[i] as ParsedInvocation; + return matchPattern(pattern, inv.name, inv.argv); + }); +} + +// ─── Partial order (authority embedding) ───────────────────────────────────── + +/** + * Returns true when ArgPattern `a` covers a subset of what `b` covers — + * i.e., `a` is at least as restrictive as `b`. + * + * Partial order: exact ≤ matching-prefix ≤ broader-prefix ≤ wildcard. + * + * @param a - The candidate "more restricted" pattern. + * @param b - The candidate "more permissive" pattern. + * @returns True when a's coverage ⊆ b's coverage. + */ +export function argPatternLe(a: ArgPattern, b: ArgPattern): boolean { + if (b.kind === 'wildcard') { + return true; + } + if (a.kind === 'wildcard') { + return false; + } + if (b.kind === 'prefix') { + if (a.kind === 'exact') { + return a.value.startsWith(b.prefix); + } + return a.prefix.startsWith(b.prefix); + } + // b is exact: only equal exact matches + return a.kind === 'exact' && a.value === b.value; +} + +export type PatternOrder = 'lt' | 'eq' | 'gt' | 'incomparable'; + +/** + * Compare two InvocationPatterns in the partial order of coverage. + * + * Handles different argPattern lengths: a pattern with fewer entries uses + * truncated matching and therefore covers a superset of one with more entries + * (all else equal), so it is "above" (more permissive) in the order. + * + * @param a - First pattern. + * @param b - Second pattern. + * @returns The order relation: a < b means a is more restricted (covers less). + */ +export function compareInvocationPatterns( + a: InvocationPattern, + b: InvocationPattern, +): PatternOrder { + if (a.name !== b.name) { + return 'incomparable'; + } + // a ≤ b: a.argPatterns.length ≥ b.argPatterns.length (more constraints) AND + // each of b's patterns is at least as permissive as the corresponding a pattern. + const aLe = + a.argPatterns.length >= b.argPatterns.length && + b.argPatterns.every((bp, i) => + argPatternLe(a.argPatterns[i] as ArgPattern, bp), + ); + const bLe = + b.argPatterns.length >= a.argPatterns.length && + a.argPatterns.every((ap, i) => + argPatternLe(b.argPatterns[i] as ArgPattern, ap), + ); + if (aLe && bLe) { + return 'eq'; + } + if (aLe) { + return 'lt'; + } + if (bLe) { + return 'gt'; + } + return 'incomparable'; +} + +/** + * Compare two Provisions in the coverage partial order (cosheaf structure: + * all pipeline components must be ordered in the same direction). + * + * @param a - First provision. + * @param b - Second provision. + * @returns The order relation: a < b means a is more restricted than b. + */ +export function compareProvisions(a: Provision, b: Provision): PatternOrder { + if (a.tool !== b.tool) { + return 'incomparable'; + } + if (a.patterns.length !== b.patterns.length) { + return 'incomparable'; + } + let hasLt = false; + let hasGt = false; + for (let i = 0; i < a.patterns.length; i++) { + const cmp = compareInvocationPatterns( + a.patterns[i] as InvocationPattern, + b.patterns[i] as InvocationPattern, + ); + if (cmp === 'incomparable') { + return 'incomparable'; + } + if (cmp === 'lt') { + hasLt = true; + } + if (cmp === 'gt') { + hasGt = true; + } + if (hasLt && hasGt) { + return 'incomparable'; + } + } + if (hasLt) { + return 'lt'; + } + if (hasGt) { + return 'gt'; + } + return 'eq'; +} + +/** + * Compute the authority value for a new provision given the existing sections. + * + * Embeds the dynamically-growing partial order into (0, 1): the authority of + * a new provision is the midpoint between the supremum of authority values + * strictly below it and the infimum of authority values strictly above it. + * + * Properties: + * - a < b (a more restricted) ⟹ authority(a) < authority(b) + * - Incomparable provisions that are added simultaneously both receive 0.5 + * - The embedding is monotone and preserved under future insertions + * + * @param provision - The provision being added. + * @param existing - The current sections with their computed authority values. + * @returns An authority value in (0, 1). + */ +export function computeAuthority( + provision: Provision, + existing: readonly { provision: Provision; authority: number }[], +): number { + let limSupDown = 0; // max authority strictly below this provision + let limInfUp = 1; // min authority strictly above this provision + for (const entry of existing) { + const cmp = compareProvisions(entry.provision, provision); + if (cmp === 'lt') { + if (entry.authority > limSupDown) { + limSupDown = entry.authority; + } + } else if (cmp === 'gt') { + if (entry.authority < limInfUp) { + limInfUp = entry.authority; + } + } + } + return (limSupDown + limInfUp) / 2; +} + +// ─── Exact grant helper ─────────────────────────────────────────────────────── + +/** + * Convert an exact invocation into a Provision (all-exact argPatterns). + * Used to record a single-invocation grant as a point section in the sheaf. + * + * @param tool - The tool name. + * @param invocations - The parsed command components. + * @returns A Provision whose patterns exactly match this invocation. + */ +export function invocationToProvision( + tool: string, + invocations: ParsedInvocation[], +): Provision { + return { + tool, + patterns: invocations.map(({ name, argv }) => ({ + name, + argPatterns: argv.map((value) => ({ kind: 'exact' as const, value })), + })), + }; +} diff --git a/packages/kernel-utils/src/session/session-registry.test.ts b/packages/kernel-utils/src/session/session-registry.test.ts new file mode 100644 index 0000000000..f84458f509 --- /dev/null +++ b/packages/kernel-utils/src/session/session-registry.test.ts @@ -0,0 +1,201 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; + +import { makeChannel } from './channel.ts'; +import type { Channel } from './channel.ts'; +import { makeSessionRegistry } from './session-registry.ts'; +import type { Decision } from './types.ts'; + +// --------------------------------------------------------------------------- +// Channel bundle helper +// --------------------------------------------------------------------------- + +type ChannelFactoryBundle = { + createChannelInternal: () => Promise<{ ocapUrl: string; channel: Channel }>; + getChannelByUrl: (url: string) => Channel | undefined; +}; + +function makeChannelBundle(): ChannelFactoryBundle { + let counter = 0; + const channelMap = new Map(); + + return { + async createChannelInternal() { + const ocapUrl = `ocap:channel-${counter}@mock`; + counter += 1; + const channel = makeChannel(); + channelMap.set(ocapUrl, channel); + return { ocapUrl, channel }; + }, + getChannelByUrl(url: string): Channel | undefined { + return channelMap.get(url); + }, + }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const makeDecision = ( + token: string, + verdict: 'accept' | 'reject' = 'accept', +): Decision => ({ token, verdict, feedback: '' }); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('makeSessionRegistry', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('createSession creates a session with sessionId, ocapUrl, and startedAt', async () => { + const registry = makeSessionRegistry(makeChannelBundle()); + const session = await registry.createSession(); + + expect(typeof session.sessionId).toBe('string'); + expect(session.sessionId.length).toBeGreaterThan(0); + expect(typeof session.ocapUrl).toBe('string'); + expect(session.ocapUrl.length).toBeGreaterThan(0); + expect(typeof session.startedAt).toBe('string'); + // Verify startedAt is a valid ISO 8601 date string + expect(Number.isNaN(Date.parse(session.startedAt))).toBe(false); + }); + + it.each([ + { + label: 'stores cwd when provided', + cwd: '/home/user/project', + expected: '/home/user/project', + }, + ])('$label', async ({ cwd, expected }) => { + const registry = makeSessionRegistry(makeChannelBundle()); + const session = await registry.createSession({ cwd }); + expect(session.cwd).toBe(expected); + }); + + it('omits cwd when not provided', async () => { + const registry = makeSessionRegistry(makeChannelBundle()); + const session = await registry.createSession(); + expect(Object.prototype.hasOwnProperty.call(session, 'cwd')).toBe(false); + }); + + it('listSessions returns all created sessions', async () => { + const registry = makeSessionRegistry(makeChannelBundle()); + expect(registry.listSessions()).toStrictEqual([]); + + const sessionA = await registry.createSession(); + const sessionB = await registry.createSession(); + + const sessions = registry.listSessions(); + expect(sessions).toHaveLength(2); + expect(sessions).toContain(sessionA); + expect(sessions).toContain(sessionB); + }); + + it('getSession returns a session by id', async () => { + const registry = makeSessionRegistry(makeChannelBundle()); + const session = await registry.createSession(); + expect(registry.getSession(session.sessionId)).toBe(session); + }); + + it('getSession returns undefined for an unknown id', async () => { + const registry = makeSessionRegistry(makeChannelBundle()); + expect(registry.getSession('nonexistent')).toBeUndefined(); + }); + + it('listHistory returns empty array initially', async () => { + const registry = makeSessionRegistry(makeChannelBundle()); + const session = await registry.createSession(); + expect(session.listHistory()).toStrictEqual([]); + }); + + it.each([ + { verdict: 'accept' as const, expectedStatus: 'accepted' }, + { verdict: 'reject' as const, expectedStatus: 'rejected' }, + ])( + 'listHistory returns an entry with status $expectedStatus after queueRequest + decide', + async ({ verdict, expectedStatus }) => { + const registry = makeSessionRegistry(makeChannelBundle()); + const session = await registry.createSession(); + + const token = session.queueRequest('Read /etc/hosts', 'needs DNS'); + session.decide(makeDecision(token, verdict)); + + const history = session.listHistory(); + expect(history).toHaveLength(1); + expect(history[0]).toMatchObject({ + token, + description: 'Read /etc/hosts', + reason: 'needs DNS', + status: expectedStatus, + }); + expect(typeof history[0]?.decidedAt).toBe('string'); + }, + ); + + it('authorizeRequest resolves with the decision when decided', async () => { + const registry = makeSessionRegistry(makeChannelBundle()); + const session = await registry.createSession(); + + const authPromise = session.authorizeRequest('Write /tmp/out', { + reason: 'needs temp', + }); + + // Retrieve the token from the pending list so we can decide it + const pending = session.listPending(); + expect(pending).toHaveLength(1); + const { token } = pending[0]!; + + const decision = makeDecision(token, 'accept'); + session.decide(decision); + + const result = await authPromise; + expect(result).toStrictEqual(decision); + }); + + it('recordProvisioned adds a provisioned entry to history', async () => { + const registry = makeSessionRegistry(makeChannelBundle()); + const session = await registry.createSession(); + + session.recordProvisioned('Allow Bash({"command":"git status"})', { + invocations: [{ name: 'git', argv: ['status'] }], + }); + + const history = session.listHistory(); + expect(history).toHaveLength(1); + expect(history[0]).toMatchObject({ + description: 'Allow Bash({"command":"git status"})', + reason: 'Auto-accepted by provision', + status: 'provisioned', + invocations: [{ name: 'git', argv: ['status'] }], + }); + expect(typeof history[0]?.token).toBe('string'); + expect(typeof history[0]?.queuedAt).toBe('string'); + expect(history[0]?.queuedAt).toBe(history[0]?.decidedAt); + }); + + it('authorizeRequest rejects with timeout error after timeoutMs elapses', async () => { + vi.useFakeTimers(); + try { + const registry = makeSessionRegistry(makeChannelBundle()); + const session = await registry.createSession(); + + const authPromise = session.authorizeRequest('Execute script', { + reason: 'needs shell', + timeoutMs: 500, + }); + + // Advance past the timeout — no subscriber decides, so the race rejects + vi.advanceTimersByTime(600); + + await expect(authPromise).rejects.toMatchObject({ + message: 'No subscriber responded within timeout', + code: 'NO_SUBSCRIBER', + }); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/packages/kernel-utils/src/session/session-registry.ts b/packages/kernel-utils/src/session/session-registry.ts new file mode 100644 index 0000000000..201b0cbbbc --- /dev/null +++ b/packages/kernel-utils/src/session/session-registry.ts @@ -0,0 +1,213 @@ +import { ifDefined } from '../misc.ts'; +import type { Channel, ModalStream } from './channel.ts'; +import type { + Decision, + ParsedInvocation, + Provision, + SectionNotification, + SessionHistoryEntry, +} from './types.ts'; + +const SESSION_NAMES = [ + 'alice', + 'bob', + 'carol', + 'dave', + 'eve', + 'frank', + 'grace', + 'heidi', +]; + +export type Session = { + sessionId: string; + ocapUrl: string; + cwd?: string; + startedAt: string; + listPending(): SectionNotification[]; + listHistory(): SessionHistoryEntry[]; + decide(decision: Decision): void; + queueRequest(description: string, reason?: string): string; + authorizeRequest( + description: string, + options?: { + reason?: string; + timeoutMs?: number; + invocations?: ParsedInvocation[]; + }, + ): Promise; + recordProvisioned( + description: string, + options?: { invocations?: ParsedInvocation[]; provision?: Provision }, + ): void; + subscribe(stream: ModalStream): void; +}; + +export type SessionRegistry = { + createSession(options?: { name?: string; cwd?: string }): Promise; + getSession(sessionId: string): Session | undefined; + listSessions(): Session[]; + /** Look up any channel by its OCAP URL — covers both session-created and vat-created channels. */ + getChannelByUrl(url: string): Channel | undefined; +}; + +type ChannelFactoryBundle = { + createChannelInternal: () => Promise<{ ocapUrl: string; channel: Channel }>; + getChannelByUrl: (url: string) => Channel | undefined; +}; + +/** + * Wrap a channel as a session object. + * + * @param sessionId - The human-readable session name. + * @param ocapUrl - The OCAP URL for TUI subscribers to connect to. + * @param channel - The underlying broadcast channel. + * @param options - Optional session metadata. + * @param options.cwd - Working directory of the session creator. + * @returns A {@link Session}. + */ +function makeSession( + sessionId: string, + ocapUrl: string, + channel: Channel, + { cwd }: { cwd?: string } = {}, +): Session { + let requestCount = 0; + const startedAt = new Date().toISOString(); + + const makeNotification = ( + description: string, + reason: string, + invocations?: ParsedInvocation[], + ): SectionNotification => { + const token = `req-${requestCount}`; + requestCount += 1; + return { + token, + description, + reason, + guard: { body: '#{}', slots: [] }, + ...ifDefined({ invocations }), + }; + }; + + return harden({ + sessionId, + ocapUrl, + ...ifDefined({ cwd }), + startedAt, + + listPending(): SectionNotification[] { + return channel.listPending(); + }, + + listHistory(): SessionHistoryEntry[] { + return channel.listAll(); + }, + + decide(decision: Decision): void { + channel.decide(decision); + }, + + queueRequest(description: string, reason = 'Queued from CLI'): string { + const notification = makeNotification(description, reason); + channel.broadcast(notification).catch(() => undefined); + return notification.token; + }, + + async authorizeRequest( + description: string, + options: { + reason?: string; + timeoutMs?: number; + invocations?: ParsedInvocation[]; + } = {}, + ): Promise { + const { reason = 'Queued from CLI', timeoutMs, invocations } = options; + const notification = makeNotification(description, reason, invocations); + const decision = channel.broadcast(notification); + if (timeoutMs === undefined) { + return decision; + } + return Promise.race([ + decision, + new Promise((_resolve, reject) => { + setTimeout(() => { + reject( + Object.assign( + new Error('No subscriber responded within timeout'), + { + code: 'NO_SUBSCRIBER', + }, + ), + ); + }, timeoutMs); + }), + ]); + }, + + recordProvisioned( + description: string, + options: { invocations?: ParsedInvocation[]; provision?: Provision } = {}, + ): void { + const notification = makeNotification( + description, + 'Auto-accepted by provision', + options.invocations, + ); + channel.record(notification, options.provision); + }, + + subscribe(stream: ModalStream): void { + channel.subscribe(stream); + }, + }); +} + +/** + * Create a session registry that maps human-readable session IDs to sessions. + * + * `getChannelByUrl` covers both session-created and vat-created channels because + * all channels are stored in the factory's internal map. + * + * @param factory - The channel factory bundle (createChannelInternal + getChannelByUrl). + * @returns A {@link SessionRegistry}. + */ +export function makeSessionRegistry( + factory: ChannelFactoryBundle, +): SessionRegistry { + let nameIndex = 0; + const sessions = new Map(); + + return harden({ + async createSession( + options: { name?: string; cwd?: string } = {}, + ): Promise { + const sessionId = + options.name ?? SESSION_NAMES[nameIndex] ?? `session-${nameIndex}`; + nameIndex += 1; + + const { ocapUrl, channel } = await factory.createChannelInternal(); + const session = makeSession( + sessionId, + ocapUrl, + channel, + ifDefined({ cwd: options.cwd }), + ); + sessions.set(sessionId, session); + return session; + }, + + getSession(sessionId: string): Session | undefined { + return sessions.get(sessionId); + }, + + listSessions(): Session[] { + return Array.from(sessions.values()); + }, + + getChannelByUrl(url: string): Channel | undefined { + return factory.getChannelByUrl(url); + }, + }); +} diff --git a/packages/kernel-utils/src/session/types.ts b/packages/kernel-utils/src/session/types.ts new file mode 100644 index 0000000000..6e24148daf --- /dev/null +++ b/packages/kernel-utils/src/session/types.ts @@ -0,0 +1,134 @@ +/** + * A single parsed command-or-tool invocation: the name and its positional args. + * Used to describe what exactly was called before being converted to a Provision. + */ +export type ParsedInvocation = { name: string; argv: string[] }; + +/** + * Pattern for one positional argument in a provision. + * + * - `exact`: the argument must equal the stored value exactly. + * - `prefix`: the argument must start with the stored prefix (e.g. `/a/b/` for + * the glob `/a/b/*`). + * - `wildcard`: any value is accepted. + */ +export type ArgPattern = + | { kind: 'exact'; value: string } + | { kind: 'prefix'; prefix: string } + | { kind: 'wildcard' }; + +/** + * Pattern for one component command/tool invocation in a provision. + * + * `name` is always matched exactly; each element of `argPatterns` corresponds + * positionally to one argument of the invocation. + */ +export type InvocationPattern = { + name: string; + argPatterns: ArgPattern[]; +}; + +/** + * A standing preapproval: a neighborhood in invocation space. + * + * For Bash compound commands, `patterns` contains one entry per + * pipe/chain component (cosheaf structure: all must match). + * For other tools, `patterns` contains a single entry. + */ +export type Provision = { + tool: string; + patterns: InvocationPattern[]; +}; + +/** + * A request for a new section to be added to a session's sheaf. Produced by + * application code that has discovered a target exo and constructed a point + * guard covering the exact invocation it needs authority for. + * + * The `guard` field is an `@endo/patterns` InterfaceGuard — kept here as its + * live form; the session marshals it to CapData before broadcasting. + */ +export type SectionRequest = { + description: string; + reason: string; + schema?: unknown; + guard: unknown; // InterfaceGuard — typed as unknown to avoid @endo/patterns dep here + caveats: []; +}; + +/** + * The wire representation of a {@link SectionRequest} sent to modal subscribers. + * The guard is serialized as CapData so it can cross process boundaries as + * NDJSON and be rendered by the TUI via prettifySmallcaps. + */ +export type SectionNotification = { + token: string; + description: string; + reason: string; + schema?: unknown; + guard: { body: string; slots: string[] }; + /** Parsed invocations for the request — present when routed through the PreToolUse hook. */ + invocations?: ParsedInvocation[]; +}; + +/** + * A verdict rendered by a modal subscriber in response to a + * {@link SectionNotification}. + */ +export type Decision = { + token: string; + verdict: 'accept' | 'reject'; + feedback: string; + /** Optional guard override for accept verdicts. Absent means minimal (single-invocation) approval. */ + guard?: { body: string; slots: string[] }; + /** Optional standing preapproval. When present, simultaneously approves this request and registers the provision for future matching. */ + provision?: Provision; +}; + +/** User-facing summary of a session returned by the session list API. */ +export type SessionSummary = { + sessionId: string; + ocapUrl: string; + /** Working directory of the process that created this session. */ + cwd?: string; + /** ISO 8601 timestamp of when the session was created. */ + startedAt?: string; +}; + +/** User-facing representation of a pending authorization request. */ +export type PendingRequest = { + token: string; + description: string; + reason: string; +}; + +/** A single entry in a session's request timeline — either pending or decided. */ +export type SessionHistoryEntry = { + token: string; + description: string; + reason: string; + guard: { body: string; slots: string[] }; + queuedAt: string; + status: 'pending' | 'accepted' | 'rejected' | 'provisioned'; + decidedAt?: string; + /** Parsed invocations — present when routed through the PreToolUse hook. */ + invocations?: ParsedInvocation[]; + /** Standing provision that was granted — present when the user accepted with a provision. */ + provision?: Provision; +}; + +/** + * Transport-agnostic interface for inspecting and deciding on authorization + * requests. Shared between the TUI (Unix-socket JSON-RPC) and the browser + * extension (browser-kernel RPC). + */ +export type SessionApi = { + listSessions: () => Promise; + listRequests: (sessionId: string) => Promise; + decide: ( + sessionId: string, + token: string, + verdict: 'accept' | 'reject', + provision?: Provision, + ) => Promise; +}; diff --git a/packages/ocap-kernel/CHANGELOG.md b/packages/ocap-kernel/CHANGELOG.md index 5803ed8833..ad3091f3a8 100644 --- a/packages/ocap-kernel/CHANGELOG.md +++ b/packages/ocap-kernel/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add `fetch`, `Request`, `Headers`, and `Response` to available vat endowments ([#942](https://github.com/MetaMask/ocap-kernel/pull/942)) +- Add `create-session-channel` kernel control RPC handler - Add `VatConfig.network: { allowedHosts: string[] }`; requesting `'fetch'` without it rejects `initVat` - Integrate Snaps attenuated endowment factories into vat globals ([#937](https://github.com/MetaMask/ocap-kernel/pull/937)) - Add `setInterval`, `clearInterval`, `crypto`, `SubtleCrypto`, and `Math` (crypto-backed `Math.random`) to the default vat endowments diff --git a/packages/ocap-kernel/src/rpc/kernel-control/create-session-channel.ts b/packages/ocap-kernel/src/rpc/kernel-control/create-session-channel.ts new file mode 100644 index 0000000000..aed97c7fb0 --- /dev/null +++ b/packages/ocap-kernel/src/rpc/kernel-control/create-session-channel.ts @@ -0,0 +1,31 @@ +import type { Handler, MethodSpec } from '@metamask/kernel-rpc-methods'; +import { object, string } from '@metamask/superstruct'; + +/** + * Create a new session channel and return its OCAP URL. + */ +export const createSessionChannelSpec: MethodSpec< + 'createSessionChannel', + Record, + string +> = { + method: 'createSessionChannel', + params: object({}), + result: string(), +}; + +export type CreateSessionChannelHooks = { + channelFactory: { createChannel(): Promise }; +}; + +export const createSessionChannelHandler: Handler< + 'createSessionChannel', + Record, + Promise, + CreateSessionChannelHooks +> = { + ...createSessionChannelSpec, + hooks: { channelFactory: true }, + implementation: async ({ channelFactory }): Promise => + channelFactory.createChannel(), +}; diff --git a/packages/ocap-kernel/src/rpc/kernel-control/index.test.ts b/packages/ocap-kernel/src/rpc/kernel-control/index.test.ts index da96e2990f..a6b0910db3 100644 --- a/packages/ocap-kernel/src/rpc/kernel-control/index.test.ts +++ b/packages/ocap-kernel/src/rpc/kernel-control/index.test.ts @@ -5,6 +5,10 @@ import { collectGarbageHandler, collectGarbageSpec, } from './collect-garbage.ts'; +import { + createSessionChannelHandler, + createSessionChannelSpec, +} from './create-session-channel.ts'; import { executeDBQueryHandler, executeDBQuerySpec, @@ -44,6 +48,7 @@ describe('handlers/index', () => { it('should export all handler functions', () => { expect(rpcHandlers).toStrictEqual({ clearState: clearStateHandler, + createSessionChannel: createSessionChannelHandler, executeDBQuery: executeDBQueryHandler, getStatus: getStatusHandler, initRemoteComms: initRemoteCommsHandler, @@ -75,6 +80,7 @@ describe('handlers/index', () => { it('should export all method specs', () => { expect(rpcMethodSpecs).toStrictEqual({ clearState: clearStateSpec, + createSessionChannel: createSessionChannelSpec, executeDBQuery: executeDBQuerySpec, getStatus: getStatusSpec, initRemoteComms: initRemoteCommsSpec, diff --git a/packages/ocap-kernel/src/rpc/kernel-control/index.ts b/packages/ocap-kernel/src/rpc/kernel-control/index.ts index 2ea301692f..12d2a7ea77 100644 --- a/packages/ocap-kernel/src/rpc/kernel-control/index.ts +++ b/packages/ocap-kernel/src/rpc/kernel-control/index.ts @@ -3,6 +3,10 @@ import { collectGarbageHandler, collectGarbageSpec, } from './collect-garbage.ts'; +import { + createSessionChannelHandler, + createSessionChannelSpec, +} from './create-session-channel.ts'; import { executeDBQueryHandler, executeDBQuerySpec, @@ -42,6 +46,7 @@ import { terminateVatHandler, terminateVatSpec } from './terminate-vat.ts'; */ export const rpcHandlers = { clearState: clearStateHandler, + createSessionChannel: createSessionChannelHandler, executeDBQuery: executeDBQueryHandler, getStatus: getStatusHandler, initRemoteComms: initRemoteCommsHandler, @@ -60,6 +65,7 @@ export const rpcHandlers = { terminateSubcluster: terminateSubclusterHandler, } as { clearState: typeof clearStateHandler; + createSessionChannel: typeof createSessionChannelHandler; executeDBQuery: typeof executeDBQueryHandler; getStatus: typeof getStatusHandler; initRemoteComms: typeof initRemoteCommsHandler; @@ -83,6 +89,7 @@ export const rpcHandlers = { */ export const rpcMethodSpecs = { clearState: clearStateSpec, + createSessionChannel: createSessionChannelSpec, executeDBQuery: executeDBQuerySpec, getStatus: getStatusSpec, initRemoteComms: initRemoteCommsSpec, @@ -101,6 +108,7 @@ export const rpcMethodSpecs = { terminateSubcluster: terminateSubclusterSpec, } as { clearState: typeof clearStateSpec; + createSessionChannel: typeof createSessionChannelSpec; executeDBQuery: typeof executeDBQuerySpec; getStatus: typeof getStatusSpec; initRemoteComms: typeof initRemoteCommsSpec; diff --git a/packages/sheaves/src/compose.ts b/packages/sheaves/src/compose.ts index ef1d7fc5c7..51cb6e94ab 100644 --- a/packages/sheaves/src/compose.ts +++ b/packages/sheaves/src/compose.ts @@ -95,6 +95,33 @@ export const withRanking = (candidates, context) => inner([...candidates].sort(comparator), context); +/** + * Policy that tries candidates from most-restricted to least-restricted, using + * the numeric `authority` metadata key as the topological rank. + * + * Authority values are produced by `computeAuthority` from + * `@metamask/kernel-utils/session`, which embeds the provision partial order + * into (0, 1): lower authority ⟹ more restricted (closer to the ⊥ element). + * Candidates without an `authority` entry (e.g. when all authorities are + * identical and the key is collapsed to constraints) are treated as 0.5. + * + * @param candidates - Candidates to rank and yield. + * @param _context - The policy context (unused; present to satisfy the Policy signature). + * @yields Candidates sorted ascending by authority. + */ +export async function* leastAuthority>( + candidates: Candidate>[], + _context: PolicyContext, +): AsyncGenerator>, void, unknown[]> { + yield* noopPolicy( + [...candidates].sort((a, b) => { + const aAuth = (a.metadata as { authority?: number }).authority ?? 0.5; + const bAuth = (b.metadata as { authority?: number }).authority ?? 0.5; + return aAuth - bAuth; + }), + ); +} + /** * Try all candidates from policyA, then all candidates from policyB. * diff --git a/packages/sheaves/src/index.ts b/packages/sheaves/src/index.ts index 23d3f981de..345a1ef358 100644 --- a/packages/sheaves/src/index.ts +++ b/packages/sheaves/src/index.ts @@ -15,6 +15,7 @@ export { withFilter, withRanking, fallthrough, + leastAuthority, } from './compose.ts'; export { makeRemoteSection } from './remote.ts'; export { makeHandler } from './section.ts'; diff --git a/packages/streams/CHANGELOG.md b/packages/streams/CHANGELOG.md index b6abe03c30..93f64a628c 100644 --- a/packages/streams/CHANGELOG.md +++ b/packages/streams/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `NodeSocketDuplexStream` — a duplex stream over Node.js sockets with NDJSON framing + ## [0.6.0] ### Changed diff --git a/packages/streams/src/index.test.ts b/packages/streams/src/index.test.ts index 06e1774037..ce54b4a38c 100644 --- a/packages/streams/src/index.test.ts +++ b/packages/streams/src/index.test.ts @@ -5,6 +5,9 @@ import * as indexModule from './index.ts'; describe('index', () => { it('has the expected exports', () => { expect(Object.keys(indexModule).sort()).toStrictEqual([ + 'NodeSocketDuplexStream', + 'NodeSocketReader', + 'NodeSocketWriter', 'NodeWorkerDuplexStream', 'NodeWorkerReader', 'NodeWorkerWriter', diff --git a/packages/streams/src/index.ts b/packages/streams/src/index.ts index 65bf2c0528..6c77f7a599 100644 --- a/packages/streams/src/index.ts +++ b/packages/streams/src/index.ts @@ -5,4 +5,10 @@ export { NodeWorkerWriter, NodeWorkerDuplexStream, } from './node/NodeWorkerStream.ts'; +export { + NodeSocketReader, + NodeSocketWriter, + NodeSocketDuplexStream, +} from './node/NodeSocketStream.ts'; +export type { NetSocket } from './node/NodeSocketStream.ts'; export { split } from './split.ts'; diff --git a/packages/streams/src/node/NodeSocketStream.ts b/packages/streams/src/node/NodeSocketStream.ts new file mode 100644 index 0000000000..bdd6e5a155 --- /dev/null +++ b/packages/streams/src/node/NodeSocketStream.ts @@ -0,0 +1,192 @@ +/** + * @module Node Socket streams + */ + +import { + BaseDuplexStream, + makeDuplexStreamInputValidator, +} from '../BaseDuplexStream.ts'; +import type { + BaseReaderArgs, + BaseWriterArgs, + ValidateInput, +} from '../BaseStream.ts'; +import { BaseReader, BaseWriter } from '../BaseStream.ts'; +import { makeStreamDoneSignal, makeStreamErrorSignal } from '../utils.ts'; +import type { Dispatchable } from '../utils.ts'; + +/** + * A duck-typed subset of `net.Socket` used by the stream implementations. + * Using a structural type avoids importing from `node:net` in a package that + * targets both Node and browser environments. + */ +export type NetSocket = { + on(event: 'data', listener: (chunk: unknown) => void): unknown; + on(event: 'end', listener: () => void): unknown; + on(event: 'error', listener: (error: Error) => void): unknown; + write( + data: string, + callback?: (error: Error | null | undefined) => void, + ): unknown; + destroy(): void; +}; + +/** + * A readable stream over a {@link NetSocket}. + * + * Buffers incoming bytes, splits on newlines, and JSON-parses each line before + * forwarding it to the base reader's receive-input pipeline. + * + * @see {@link NodeSocketWriter} for the corresponding writable stream. + */ +export class NodeSocketReader extends BaseReader { + /** + * Constructs a new {@link NodeSocketReader}. + * + * @param socket - The socket to read from. + * @param options - Options bag for configuring the reader. + * @param options.validateInput - A function that validates input from the transport. + * @param options.onEnd - A function that is called when the stream ends. + */ + constructor( + socket: NetSocket, + { validateInput, onEnd }: BaseReaderArgs = {}, + ) { + super({ validateInput, onEnd: async () => await onEnd?.() }); + const receiveInput = super.getReceiveInput(); + + let buffer = ''; + + socket.on('data', (chunk: unknown) => { + buffer += String(chunk); + let idx: number; + while ((idx = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, idx); + buffer = buffer.slice(idx + 1); + if (line.length > 0) { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch (error) { + this.throw( + error instanceof Error ? error : new Error(String(error)), + ).catch(() => undefined); + return; + } + receiveInput(parsed).catch(async (error: Error) => this.throw(error)); + } + } + }); + + socket.on('end', () => { + receiveInput(makeStreamDoneSignal()).catch(() => undefined); + }); + + socket.on('error', (error: Error) => { + // eslint-disable-next-line promise/no-promise-in-callback + receiveInput(makeStreamErrorSignal(error)).catch(() => undefined); + }); + + harden(this); + } +} +harden(NodeSocketReader); + +/** + * A writable stream over a {@link NetSocket}. + * + * JSON-serializes each value and writes it as a newline-delimited line. + * + * @see {@link NodeSocketReader} for the corresponding readable stream. + */ +export class NodeSocketWriter extends BaseWriter { + /** + * Constructs a new {@link NodeSocketWriter}. + * + * @param socket - The socket to write to. + * @param options - Options bag for configuring the writer. + * @param options.name - The name of the stream, for logging purposes. + * @param options.onEnd - A function that is called when the stream ends. + */ + constructor( + socket: NetSocket, + { name, onEnd }: Omit, 'onDispatch'> = {}, + ) { + super({ + name, + onDispatch: async (value: Dispatchable) => + new Promise((resolve, reject) => { + socket.write(`${JSON.stringify(value)}\n`, (error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }), + onEnd: async () => { + await onEnd?.(); + socket.destroy(); + }, + }); + harden(this); + } +} +harden(NodeSocketWriter); + +/** + * A duplex stream over a Node socket. + */ +export class NodeSocketDuplexStream< + Read, + Write = Read, +> extends BaseDuplexStream< + Read, + NodeSocketReader, + Write, + NodeSocketWriter +> { + /** + * Constructs a new {@link NodeSocketDuplexStream}. + * + * @param socket - The socket for bidirectional communication. + * @param validateInput - A function that validates input from the transport. + */ + constructor(socket: NetSocket, validateInput?: ValidateInput) { + let writer: NodeSocketWriter; // eslint-disable-line prefer-const + const reader = new NodeSocketReader(socket, { + name: 'NodeSocketDuplexStream', + validateInput: makeDuplexStreamInputValidator(validateInput), + onEnd: async () => { + await writer.return(); + }, + }); + writer = new NodeSocketWriter(socket, { + name: 'NodeSocketDuplexStream', + onEnd: async () => { + await reader.return(); + }, + }); + super(reader, writer); + } + + /** + * Creates and synchronizes a new {@link NodeSocketDuplexStream}. + * + * @param socket - The socket for bidirectional communication. + * @param validateInput - A function that validates input from the transport. + * @returns A synchronized duplex stream. + */ + static async make( + socket: NetSocket, + validateInput?: ValidateInput, + ): Promise> { + const stream = new NodeSocketDuplexStream( + socket, + validateInput, + ); + await stream.synchronize(); + return stream; + } +} +harden(NodeSocketDuplexStream); diff --git a/tsconfig.build.json b/tsconfig.build.json index 62c8d97c5d..35a7a35b60 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -2,24 +2,26 @@ "files": [], "include": [], "references": [ - { "path": "./packages/kernel-cli/tsconfig.build.json" }, + { "path": "./packages/caprock/tsconfig.build.json" }, + { "path": "./packages/evm-wallet-experiment/tsconfig.build.json" }, { "path": "./packages/kernel-agents/tsconfig.build.json" }, { "path": "./packages/kernel-browser-runtime/tsconfig.build.json" }, + { "path": "./packages/kernel-cli/tsconfig.build.json" }, { "path": "./packages/kernel-errors/tsconfig.build.json" }, { "path": "./packages/kernel-language-model-service/tsconfig.build.json" }, + { "path": "./packages/kernel-node-runtime/tsconfig.build.json" }, { "path": "./packages/kernel-platforms/tsconfig.build.json" }, { "path": "./packages/kernel-rpc-methods/tsconfig.build.json" }, { "path": "./packages/kernel-store/tsconfig.build.json" }, + { "path": "./packages/kernel-tui/tsconfig.build.json" }, { "path": "./packages/kernel-utils/tsconfig.build.json" }, { "path": "./packages/logger/tsconfig.build.json" }, { "path": "./packages/nodejs-test-workers/tsconfig.build.json" }, - { "path": "./packages/kernel-node-runtime/tsconfig.build.json" }, { "path": "./packages/ocap-kernel/tsconfig.build.json" }, { "path": "./packages/omnium-gatherum/tsconfig.build.json" }, { "path": "./packages/remote-iterables/tsconfig.build.json" }, { "path": "./packages/sheaves/tsconfig.build.json" }, { "path": "./packages/streams/tsconfig.build.json" }, - { "path": "./packages/template-package/tsconfig.build.json" }, - { "path": "./packages/evm-wallet-experiment/tsconfig.build.json" } + { "path": "./packages/template-package/tsconfig.build.json" } ] } diff --git a/tsconfig.json b/tsconfig.json index 5f51f4fbd1..3c15597850 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,22 +13,24 @@ "files": [], "include": [], "references": [ - { "path": "./packages/kernel-cli" }, + { "path": "./packages/caprock" }, { "path": "./packages/create-package" }, { "path": "./packages/evm-wallet-experiment" }, { "path": "./packages/extension" }, { "path": "./packages/kernel-agents" }, { "path": "./packages/kernel-browser-runtime" }, + { "path": "./packages/kernel-cli" }, { "path": "./packages/kernel-errors" }, { "path": "./packages/kernel-language-model-service" }, + { "path": "./packages/kernel-node-runtime" }, { "path": "./packages/kernel-platforms" }, { "path": "./packages/kernel-rpc-methods" }, { "path": "./packages/kernel-shims" }, { "path": "./packages/kernel-store" }, + { "path": "./packages/kernel-tui" }, { "path": "./packages/kernel-ui" }, { "path": "./packages/kernel-utils" }, { "path": "./packages/logger" }, - { "path": "./packages/kernel-node-runtime" }, { "path": "./packages/nodejs-test-workers" }, { "path": "./packages/ocap-kernel" }, { "path": "./packages/omnium-gatherum" }, diff --git a/turbo.json b/turbo.json index 350f57e8f6..7434403bce 100644 --- a/turbo.json +++ b/turbo.json @@ -3,7 +3,7 @@ "tasks": { "build": { "dependsOn": ["^build"], - "outputs": ["dist/**"] + "outputs": ["dist/**", "vat/**"] }, "build:dev": { "dependsOn": ["^build"] diff --git a/yarn.config.cjs b/yarn.config.cjs index 36f4a2161f..7fe3a72186 100644 --- a/yarn.config.cjs +++ b/yarn.config.cjs @@ -29,7 +29,13 @@ const typedocExceptions = [ 'repo-tools', ]; // Packages that do not enforce the standard build script -const buildExceptions = ['create-package', 'kernel-cli', 'repo-tools']; +const buildExceptions = [ + 'caprock', + 'create-package', + 'kernel-cli', + 'kernel-tui', + 'repo-tools', +]; // Packages that do not have tests const noTests = []; // Packages that do not export a `package.json` file diff --git a/yarn.lock b/yarn.lock index 89dd227cec..7fc16a9ea5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -91,6 +91,16 @@ __metadata: languageName: node linkType: hard +"@alcalzone/ansi-tokenize@npm:^0.1.3": + version: 0.1.3 + resolution: "@alcalzone/ansi-tokenize@npm:0.1.3" + dependencies: + ansi-styles: "npm:^6.2.1" + is-fullwidth-code-point: "npm:^4.0.0" + checksum: 10/3fff28b9cd039321ab8d78f1cda26a932c801ad58a86b06d1bafa7599a8d3076c67c92bee1cbdccaa837a2b088604c976f75911da2bc03d8f8d03a042c7828a0 + languageName: node + linkType: hard + "@alloc/quick-lru@npm:^5.2.0": version: 5.2.0 resolution: "@alloc/quick-lru@npm:5.2.0" @@ -2273,6 +2283,7 @@ __metadata: "@metamask/kernel-utils": "workspace:^" "@metamask/logger": "workspace:^" "@metamask/utils": "npm:^11.9.0" + "@ocap/kernel-tui": "workspace:^" "@ocap/repo-tools": "workspace:^" "@ts-bridge/cli": "npm:^0.6.3" "@ts-bridge/shims": "npm:^0.1.1" @@ -2377,6 +2388,7 @@ __metadata: "@metamask/logger": "workspace:^" "@metamask/ocap-kernel": "workspace:^" "@metamask/streams": "workspace:^" + "@metamask/utils": "npm:^11.9.0" "@ocap/repo-tools": "workspace:^" "@ts-bridge/cli": "npm:^0.6.3" "@ts-bridge/shims": "npm:^0.1.1" @@ -3039,7 +3051,7 @@ __metadata: languageName: node linkType: hard -"@metamask/sheaves@workspace:packages/sheaves": +"@metamask/sheaves@workspace:^, @metamask/sheaves@workspace:packages/sheaves": version: 0.0.0-use.local resolution: "@metamask/sheaves@workspace:packages/sheaves" dependencies: @@ -3625,6 +3637,48 @@ __metadata: languageName: node linkType: hard +"@ocap/caprock@workspace:packages/caprock": + version: 0.0.0-use.local + resolution: "@ocap/caprock@workspace:packages/caprock" + dependencies: + "@arethetypeswrong/cli": "npm:^0.17.4" + "@endo/patterns": "npm:^1.7.0" + "@metamask/auto-changelog": "npm:^5.3.0" + "@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-cli": "workspace:^" + "@metamask/kernel-utils": "workspace:^" + "@metamask/sheaves": "workspace:^" + "@metamask/utils": "npm:^11.9.0" + "@ocap/repo-tools": "workspace:^" + "@ts-bridge/cli": "npm:^0.6.3" + "@ts-bridge/shims": "npm:^0.1.1" + "@types/node": "npm:^22.13.1" + "@typescript-eslint/eslint-plugin": "npm:^8.29.0" + "@typescript-eslint/parser": "npm:^8.29.0" + "@typescript-eslint/utils": "npm:^8.29.0" + "@vitest/eslint-plugin": "npm:^1.6.14" + depcheck: "npm:^1.4.7" + eslint: "npm:^9.23.0" + eslint-config-prettier: "npm:^10.1.1" + eslint-import-resolver-typescript: "npm:^4.3.1" + eslint-plugin-import-x: "npm:^4.10.0" + eslint-plugin-jsdoc: "npm:^50.6.9" + eslint-plugin-n: "npm:^17.17.0" + eslint-plugin-prettier: "npm:^5.2.6" + eslint-plugin-promise: "npm:^7.2.1" + prettier: "npm:^3.5.3" + rimraf: "npm:^6.0.1" + tree-sitter: "npm:^0.25.0" + tree-sitter-bash: "npm:^0.25.1" + turbo: "npm:^2.9.1" + typescript: "npm:~5.8.2" + typescript-eslint: "npm:^8.29.0" + vitest: "npm:^4.1.3" + languageName: unknown + linkType: soft + "@ocap/create-package@workspace:packages/create-package": version: 0.0.0-use.local resolution: "@ocap/create-package@workspace:packages/create-package" @@ -4002,6 +4056,62 @@ __metadata: languageName: unknown linkType: soft +"@ocap/kernel-tui@workspace:^, @ocap/kernel-tui@workspace:packages/kernel-tui": + version: 0.0.0-use.local + resolution: "@ocap/kernel-tui@workspace:packages/kernel-tui" + dependencies: + "@arethetypeswrong/cli": "npm:^0.17.4" + "@metamask/auto-changelog": "npm:^5.3.0" + "@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-node-runtime": "workspace:^" + "@metamask/kernel-shims": "workspace:^" + "@metamask/kernel-utils": "workspace:^" + "@metamask/streams": "workspace:^" + "@ocap/repo-tools": "workspace:^" + "@testing-library/react": "npm:^16.3.0" + "@ts-bridge/cli": "npm:^0.6.3" + "@ts-bridge/shims": "npm:^0.1.1" + "@types/node": "npm:^22.13.1" + "@types/react": "npm:^18.3.18" + "@types/react-dom": "npm:^18.3.5" + "@types/yargs": "npm:^17.0.33" + "@typescript-eslint/eslint-plugin": "npm:^8.29.0" + "@typescript-eslint/parser": "npm:^8.29.0" + "@typescript-eslint/utils": "npm:^8.29.0" + "@vitest/eslint-plugin": "npm:^1.6.14" + depcheck: "npm:^1.4.7" + eslint: "npm:^9.23.0" + eslint-config-prettier: "npm:^10.1.1" + eslint-import-resolver-typescript: "npm:^4.3.1" + eslint-plugin-import-x: "npm:^4.10.0" + eslint-plugin-jsdoc: "npm:^50.6.9" + eslint-plugin-n: "npm:^17.17.0" + eslint-plugin-prettier: "npm:^5.2.6" + eslint-plugin-promise: "npm:^7.2.1" + glob: "npm:^11.0.0" + ink: "npm:^5.2.1" + ink-select-input: "npm:^6.0.0" + ink-spinner: "npm:^5.0.0" + ink-text-input: "npm:^6.0.0" + jsdom: "npm:^29.0.2" + prettier: "npm:^3.5.3" + react: "npm:^18.3.1" + react-dom: "npm:^18.3.1" + rimraf: "npm:^6.0.1" + turbo: "npm:^2.9.1" + typedoc: "npm:^0.28.1" + typescript: "npm:~5.8.2" + typescript-eslint: "npm:^8.29.0" + vite: "npm:^8.0.6" + vitest: "npm:^4.1.3" + yargs: "npm:^17.7.2" + bin: + ocap-tui: ./dist/app.mjs + languageName: unknown + linkType: soft + "@ocap/monorepo@workspace:.": version: 0.0.0-use.local resolution: "@ocap/monorepo@workspace:." @@ -6872,6 +6982,13 @@ __metadata: languageName: node linkType: hard +"auto-bind@npm:^5.0.1": + version: 5.0.1 + resolution: "auto-bind@npm:5.0.1" + checksum: 10/44a6d8d040c4382e761922f8fa1b044e18ddefbc855fecee0c76ec6b4e6fc74adda21026bc86e190833e05f52b4b6615372c2a83a734858f8395b1e2a98b253a + languageName: node + linkType: hard + "autoprefixer@npm:^10.4.21": version: 10.4.21 resolution: "autoprefixer@npm:10.4.21" @@ -7405,6 +7522,22 @@ __metadata: languageName: node linkType: hard +"cli-boxes@npm:^3.0.0": + version: 3.0.0 + resolution: "cli-boxes@npm:3.0.0" + checksum: 10/637d84419d293a9eac40a1c8c96a2859e7d98b24a1a317788e13c8f441be052fc899480c6acab3acc82eaf1bccda6b7542d7cdcf5c9c3cc39227175dc098d5b2 + languageName: node + linkType: hard + +"cli-cursor@npm:^4.0.0": + version: 4.0.0 + resolution: "cli-cursor@npm:4.0.0" + dependencies: + restore-cursor: "npm:^4.0.0" + checksum: 10/ab3f3ea2076e2176a1da29f9d64f72ec3efad51c0960898b56c8a17671365c26e67b735920530eaf7328d61f8bd41c27f46b9cf6e4e10fe2fa44b5e8c0e392cc + languageName: node + linkType: hard + "cli-cursor@npm:^5.0.0": version: 5.0.0 resolution: "cli-cursor@npm:5.0.0" @@ -7430,6 +7563,13 @@ __metadata: languageName: node linkType: hard +"cli-spinners@npm:^2.7.0": + version: 2.9.2 + resolution: "cli-spinners@npm:2.9.2" + checksum: 10/a0a863f442df35ed7294424f5491fa1756bd8d2e4ff0c8736531d886cec0ece4d85e8663b77a5afaf1d296e3cbbebff92e2e99f52bbea89b667cbe789b994794 + languageName: node + linkType: hard + "cli-table3@npm:^0.6.3, cli-table3@npm:^0.6.5": version: 0.6.5 resolution: "cli-table3@npm:0.6.5" @@ -7496,6 +7636,15 @@ __metadata: languageName: node linkType: hard +"code-excerpt@npm:^4.0.0": + version: 4.0.0 + resolution: "code-excerpt@npm:4.0.0" + dependencies: + convert-to-spaces: "npm:^2.0.1" + checksum: 10/d57137d8f4825879283a828cc02a1115b56858dc54ed06c625c8f67d6685d1becd2fbaa7f0ab19ecca1f5cca03f8c97bbc1f013cab40261e4d3275032e65efe9 + languageName: node + linkType: hard + "color-convert@npm:^1.3.0": version: 1.9.3 resolution: "color-convert@npm:1.9.3" @@ -7641,6 +7790,13 @@ __metadata: languageName: node linkType: hard +"convert-to-spaces@npm:^2.0.1": + version: 2.0.1 + resolution: "convert-to-spaces@npm:2.0.1" + checksum: 10/bbb324e5916fe9866f65c0ff5f9c1ea933764d0bdb09fccaf59542e40545ed483db6b2339c6d9eb56a11965a58f1a6038f3174f0e2fb7601343c7107ca5e2751 + languageName: node + linkType: hard + "cookie-signature@npm:1.0.6": version: 1.0.6 resolution: "cookie-signature@npm:1.0.6" @@ -8523,6 +8679,18 @@ __metadata: languageName: node linkType: hard +"es-toolkit@npm:^1.22.0": + version: 1.46.1 + resolution: "es-toolkit@npm:1.46.1" + dependenciesMeta: + "@trivago/prettier-plugin-sort-imports@4.3.0": + unplugged: true + prettier-plugin-sort-re-exports@0.0.1: + unplugged: true + checksum: 10/15fa8e58848c3cf3f56b3fca6505362a7e19a6487613cd928197d11a12066010655ee47f74e5f412d949173f998df7ce7babcba9ff838bd40ce4ca79fca8f3c4 + languageName: node + linkType: hard + "esbuild@npm:~0.25.0": version: 0.25.11 resolution: "esbuild@npm:0.25.11" @@ -8626,6 +8794,13 @@ __metadata: languageName: node linkType: hard +"escape-string-regexp@npm:^2.0.0": + version: 2.0.0 + resolution: "escape-string-regexp@npm:2.0.0" + checksum: 10/9f8a2d5743677c16e85c810e3024d54f0c8dea6424fad3c79ef6666e81dd0846f7437f5e729dfcdac8981bc9e5294c39b4580814d114076b8d36318f46ae4395 + languageName: node + linkType: hard + "escape-string-regexp@npm:^4.0.0": version: 4.0.0 resolution: "escape-string-regexp@npm:4.0.0" @@ -10130,6 +10305,13 @@ __metadata: languageName: node linkType: hard +"indent-string@npm:^5.0.0": + version: 5.0.0 + resolution: "indent-string@npm:5.0.0" + checksum: 10/e466c27b6373440e6d84fbc19e750219ce25865cb82d578e41a6053d727e5520dc5725217d6eb1cc76005a1bb1696a0f106d84ce7ebda3033b963a38583fb3b3 + languageName: node + linkType: hard + "inflight@npm:^1.0.4": version: 1.0.6 resolution: "inflight@npm:1.0.6" @@ -10161,6 +10343,85 @@ __metadata: languageName: node linkType: hard +"ink-select-input@npm:^6.0.0": + version: 6.2.0 + resolution: "ink-select-input@npm:6.2.0" + dependencies: + figures: "npm:^6.1.0" + to-rotated: "npm:^1.0.0" + peerDependencies: + ink: ">=5.0.0" + react: ">=18.0.0" + checksum: 10/35c50da68d4561320e68af55fc6b70d48b045f52bdde665051a61abfd6a2da429ec86acd659795f16254c168052035a68253d655d9c975d3acbfebcf4cea4012 + languageName: node + linkType: hard + +"ink-spinner@npm:^5.0.0": + version: 5.0.0 + resolution: "ink-spinner@npm:5.0.0" + dependencies: + cli-spinners: "npm:^2.7.0" + peerDependencies: + ink: ">=4.0.0" + react: ">=18.0.0" + checksum: 10/88e547ff56ac8ee31239daef43b03ca2797eb20cc338ad25aba8e8fbe2cb322ea212494f8c545f327d345051be50542e1a27fdee3758a32a1b4a5db5308cad63 + languageName: node + linkType: hard + +"ink-text-input@npm:^6.0.0": + version: 6.0.0 + resolution: "ink-text-input@npm:6.0.0" + dependencies: + chalk: "npm:^5.3.0" + type-fest: "npm:^4.18.2" + peerDependencies: + ink: ">=5" + react: ">=18" + checksum: 10/dc2511df2bf6a93a7ee94efce03eb7fb99028d378bd5446047fa9f4c13de67e8651e9fc89331cc3d158ca84974dfdd2df465a52e241a070cdc3f85048a707931 + languageName: node + linkType: hard + +"ink@npm:^5.2.1": + version: 5.2.1 + resolution: "ink@npm:5.2.1" + dependencies: + "@alcalzone/ansi-tokenize": "npm:^0.1.3" + ansi-escapes: "npm:^7.0.0" + ansi-styles: "npm:^6.2.1" + auto-bind: "npm:^5.0.1" + chalk: "npm:^5.3.0" + cli-boxes: "npm:^3.0.0" + cli-cursor: "npm:^4.0.0" + cli-truncate: "npm:^4.0.0" + code-excerpt: "npm:^4.0.0" + es-toolkit: "npm:^1.22.0" + indent-string: "npm:^5.0.0" + is-in-ci: "npm:^1.0.0" + patch-console: "npm:^2.0.0" + react-reconciler: "npm:^0.29.0" + scheduler: "npm:^0.23.0" + signal-exit: "npm:^3.0.7" + slice-ansi: "npm:^7.1.0" + stack-utils: "npm:^2.0.6" + string-width: "npm:^7.2.0" + type-fest: "npm:^4.27.0" + widest-line: "npm:^5.0.0" + wrap-ansi: "npm:^9.0.0" + ws: "npm:^8.18.0" + yoga-layout: "npm:~3.2.1" + peerDependencies: + "@types/react": ">=18.0.0" + react: ">=18.0.0" + react-devtools-core: ^4.19.1 + peerDependenciesMeta: + "@types/react": + optional: true + react-devtools-core: + optional: true + checksum: 10/780aecdcfe4c55b5ecbd939ff21c153a9c1e3912f5217727d913d6cd05be508057037e74948e60f3b22ee0aa4ad5f089d31e97cf972f260c8926d0f509c3a3fd + languageName: node + linkType: hard + "interface-datastore@npm:^9.0.0, interface-datastore@npm:^9.0.1": version: 9.0.2 resolution: "interface-datastore@npm:9.0.2" @@ -10414,6 +10675,15 @@ __metadata: languageName: node linkType: hard +"is-in-ci@npm:^1.0.0": + version: 1.0.0 + resolution: "is-in-ci@npm:1.0.0" + bin: + is-in-ci: cli.js + checksum: 10/a2e82d04aa729008e31e4b3dda56266f02ffa44109525a9cb2f521f44a2538d2f86227a32ca4f855b0ebd24f976561c368105cacb477ca34b16acb0b766e9103 + languageName: node + linkType: hard + "is-inside-container@npm:^1.0.0": version: 1.0.0 resolution: "is-inside-container@npm:1.0.0" @@ -12144,12 +12414,12 @@ __metadata: languageName: node linkType: hard -"node-addon-api@npm:^8.3.0, node-addon-api@npm:^8.3.1": - version: 8.5.0 - resolution: "node-addon-api@npm:8.5.0" +"node-addon-api@npm:^8.2.1, node-addon-api@npm:^8.3.0, node-addon-api@npm:^8.3.1": + version: 8.7.0 + resolution: "node-addon-api@npm:8.7.0" dependencies: node-gyp: "npm:latest" - checksum: 10/9a893f4f835fbc3908e0070f7bcacf36e37fd06be8008409b104c30df4092a0d9a29927b3a74cdbc1d34338274ba4116d597a41f573e06c29538a1a70d07413f + checksum: 10/a384774d04f019fc32745b9168fab8d4b91df2d7558a57c91e6d87e00acab3c9a08292c074a3c0efb5d4f4adaf1b79cb65c002a3fbcafd8d3d5b3dc91d0ac495 languageName: node linkType: hard @@ -12189,7 +12459,7 @@ __metadata: languageName: node linkType: hard -"node-gyp-build@npm:^4.3.0, node-gyp-build@npm:^4.8.4": +"node-gyp-build@npm:^4.3.0, node-gyp-build@npm:^4.8.2, node-gyp-build@npm:^4.8.4": version: 4.8.4 resolution: "node-gyp-build@npm:4.8.4" bin: @@ -12494,7 +12764,7 @@ __metadata: languageName: node linkType: hard -"onetime@npm:^5.1.2": +"onetime@npm:^5.1.0, onetime@npm:^5.1.2": version: 5.1.2 resolution: "onetime@npm:5.1.2" dependencies: @@ -12827,6 +13097,13 @@ __metadata: languageName: node linkType: hard +"patch-console@npm:^2.0.0": + version: 2.0.0 + resolution: "patch-console@npm:2.0.0" + checksum: 10/10e7d382cc1cf930a2114a822cdc816109a1147bcbc4881ca4fa2ad0228a60cf14d53f815fce3164f25851fea71db4026ae8271e4026b42b0a6e92ddc074d4c2 + languageName: node + linkType: hard + "patch-package@npm:^8.0.0": version: 8.0.1 resolution: "patch-package@npm:8.0.1" @@ -13510,6 +13787,18 @@ __metadata: languageName: node linkType: hard +"react-reconciler@npm:^0.29.0": + version: 0.29.2 + resolution: "react-reconciler@npm:0.29.2" + dependencies: + loose-envify: "npm:^1.1.0" + scheduler: "npm:^0.23.2" + peerDependencies: + react: ^18.3.1 + checksum: 10/f9ef98a88ec07efaf520ce4508bc4f499cfbec6c929549b4b802a09c2c7cd1b7b893f197ab0505dc03398a991b4f57d7b6572ae53d2699db74496cadde541cfc + languageName: node + linkType: hard + "react-refresh@npm:^0.18.0": version: 0.18.0 resolution: "react-refresh@npm:0.18.0" @@ -13748,6 +14037,16 @@ __metadata: languageName: node linkType: hard +"restore-cursor@npm:^4.0.0": + version: 4.0.0 + resolution: "restore-cursor@npm:4.0.0" + dependencies: + onetime: "npm:^5.1.0" + signal-exit: "npm:^3.0.2" + checksum: 10/5b675c5a59763bf26e604289eab35711525f11388d77f409453904e1e69c0d37ae5889295706b2c81d23bd780165084d040f9b68fffc32cc921519031c4fa4af + languageName: node + linkType: hard + "restore-cursor@npm:^5.0.0": version: 5.1.0 resolution: "restore-cursor@npm:5.1.0" @@ -13962,7 +14261,7 @@ __metadata: languageName: node linkType: hard -"scheduler@npm:^0.23.2": +"scheduler@npm:^0.23.0, scheduler@npm:^0.23.2": version: 0.23.2 resolution: "scheduler@npm:0.23.2" dependencies: @@ -14188,7 +14487,7 @@ __metadata: languageName: node linkType: hard -"signal-exit@npm:^3.0.3": +"signal-exit@npm:^3.0.2, signal-exit@npm:^3.0.3, signal-exit@npm:^3.0.7": version: 3.0.7 resolution: "signal-exit@npm:3.0.7" checksum: 10/a2f098f247adc367dffc27845853e9959b9e88b01cb301658cfe4194352d8d2bb32e18467c786a7fe15f1d44b233ea35633d076d5e737870b7139949d1ab6318 @@ -14448,6 +14747,15 @@ __metadata: languageName: node linkType: hard +"stack-utils@npm:^2.0.6": + version: 2.0.6 + resolution: "stack-utils@npm:2.0.6" + dependencies: + escape-string-regexp: "npm:^2.0.0" + checksum: 10/cdc988acbc99075b4b036ac6014e5f1e9afa7e564482b687da6384eee6a1909d7eaffde85b0a17ffbe186c5247faf6c2b7544e802109f63b72c7be69b13151bb + languageName: node + linkType: hard + "stackback@npm:0.0.2": version: 0.0.2 resolution: "stackback@npm:0.0.2" @@ -14498,7 +14806,7 @@ __metadata: languageName: node linkType: hard -"string-width@npm:^7.0.0": +"string-width@npm:^7.0.0, string-width@npm:^7.2.0": version: 7.2.0 resolution: "string-width@npm:7.2.0" dependencies: @@ -14950,6 +15258,13 @@ __metadata: languageName: node linkType: hard +"to-rotated@npm:^1.0.0": + version: 1.0.0 + resolution: "to-rotated@npm:1.0.0" + checksum: 10/235d08e8b61b7ec648652ca8ca4f3a35975adf1826255ceb99493331b9e76e93bbd9a11db856e7ad773daf16f1a3f21f372190de60bad9ed3227de4734a61f63 + languageName: node + linkType: hard + "toidentifier@npm:1.0.1": version: 1.0.1 resolution: "toidentifier@npm:1.0.1" @@ -14989,6 +15304,22 @@ __metadata: languageName: node linkType: hard +"tree-sitter-bash@npm:^0.25.1": + version: 0.25.1 + resolution: "tree-sitter-bash@npm:0.25.1" + dependencies: + node-addon-api: "npm:^8.2.1" + node-gyp: "npm:latest" + node-gyp-build: "npm:^4.8.2" + peerDependencies: + tree-sitter: ^0.25.0 + peerDependenciesMeta: + tree-sitter: + optional: true + checksum: 10/a603520c5bbfc34e237895fef5b9c47afe48f79333cddb63b3fee1c169c157d0cb6e1f15b726962735ddd717e5985e1660ed492981afb45c152b78f8423b9856 + languageName: node + linkType: hard + "tree-sitter-javascript@npm:^0.25.0": version: 0.25.0 resolution: "tree-sitter-javascript@npm:0.25.0" @@ -15143,6 +15474,13 @@ __metadata: languageName: node linkType: hard +"type-fest@npm:^4.18.2, type-fest@npm:^4.27.0": + version: 4.41.0 + resolution: "type-fest@npm:4.41.0" + checksum: 10/617ace794ac0893c2986912d28b3065ad1afb484cad59297835a0807dc63286c39e8675d65f7de08fafa339afcb8fe06a36e9a188b9857756ae1e92ee8bda212 + languageName: node + linkType: hard + "type-is@npm:~1.6.18": version: 1.6.18 resolution: "type-is@npm:1.6.18" @@ -16159,6 +16497,15 @@ __metadata: languageName: node linkType: hard +"widest-line@npm:^5.0.0": + version: 5.0.0 + resolution: "widest-line@npm:5.0.0" + dependencies: + string-width: "npm:^7.0.0" + checksum: 10/07f6527b961b88d40ac250596c06fada00cbe049080c6cc8ef4d7bc4f4ab03d7eb1a1c2e5585dd0d8b6ec99ba6f168d5b236edd8ba9221aeb8d914451f0235f9 + languageName: node + linkType: hard + "word-wrap@npm:^1.2.5": version: 1.2.5 resolution: "word-wrap@npm:1.2.5" @@ -16231,9 +16578,9 @@ __metadata: languageName: node linkType: hard -"ws@npm:^8.18.3, ws@npm:^8.19.0": - version: 8.20.0 - resolution: "ws@npm:8.20.0" +"ws@npm:^8.18.0, ws@npm:^8.18.3, ws@npm:^8.19.0": + version: 8.20.1 + resolution: "ws@npm:8.20.1" peerDependencies: bufferutil: ^4.0.1 utf-8-validate: ">=5.0.2" @@ -16242,7 +16589,7 @@ __metadata: optional: true utf-8-validate: optional: true - checksum: 10/b7ab934b21ffdea9f25a5af5097e8c1ec7625db553bca026c5a23e35b7c236f3fb89782f2b57fab9da553864512f9aa7d245827ef998d26ffa1b2187a19a6d10 + checksum: 10/8c4d2b06dc65381b6bfab1f2e584275dabd30a99a5ce058b4dc76f3d03fad1921cef3a21d8f53127d30a808cfd1864aa2fe6890a5d43359f682457315baec873 languageName: node linkType: hard @@ -16375,3 +16722,10 @@ __metadata: checksum: 10/563fbec88bce9716d1044bc98c96c329e1d7a7c503e6f1af68f1ff914adc3ba55ce953c871395e2efecad329f85f1632f51a99c362032940321ff80c42a6f74d languageName: node linkType: hard + +"yoga-layout@npm:~3.2.1": + version: 3.2.1 + resolution: "yoga-layout@npm:3.2.1" + checksum: 10/60fdd6cbcf7abf0ed9ed5a2391543eabcdf9054ee2f2212a79d624564d3545710b1f2a2acde092d7270dd35b6230a5bd1596521c0a270d995fec9542a7fb6737 + languageName: node + linkType: hard