From 872f87c12051ab6e8d9e590a5d2f51c275cf707d Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 13 May 2026 18:11:08 -0400 Subject: [PATCH 01/34] feat(channels): add NodeSocketDuplexStream and session channel primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two new capabilities needed by the modal authorization flow: NodeSocketDuplexStream (@metamask/streams): A duplex stream over a Node net.Socket. Reads NDJSON lines inbound, writes NDJSON lines outbound. Reader/writer cross-terminate on end. Exported via the streams package barrel. Session channel (kernel-utils/session): makeChannel() — a broadcast channel that fans SectionNotification messages to all connected ModalStream subscribers and resolves a Decision promise back to the broadcaster. New subscribers receive a replay of all currently-pending (undecided) notifications. SectionRequest / SectionNotification / Decision wire types. Co-Authored-By: Claude Sonnet 4.6 --- packages/kernel-utils/package.json | 10 + .../kernel-utils/src/session/channel.test.ts | 200 ++++++++++++++++++ packages/kernel-utils/src/session/channel.ts | 149 +++++++++++++ packages/kernel-utils/src/session/index.ts | 3 + packages/kernel-utils/src/session/types.ts | 40 ++++ packages/streams/src/index.test.ts | 3 + packages/streams/src/index.ts | 6 + packages/streams/src/node/NodeSocketStream.ts | 192 +++++++++++++++++ 8 files changed, 603 insertions(+) create mode 100644 packages/kernel-utils/src/session/channel.test.ts create mode 100644 packages/kernel-utils/src/session/channel.ts create mode 100644 packages/kernel-utils/src/session/index.ts create mode 100644 packages/kernel-utils/src/session/types.ts create mode 100644 packages/streams/src/node/NodeSocketStream.ts diff --git a/packages/kernel-utils/package.json b/packages/kernel-utils/package.json index 9e3643ab73..1d9c54077b 100644 --- a/packages/kernel-utils/package.json +++ b/packages/kernel-utils/package.json @@ -89,6 +89,16 @@ "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" + } + }, "./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..e19fc273e1 --- /dev/null +++ b/packages/kernel-utils/src/session/channel.ts @@ -0,0 +1,149 @@ +import { makePromiseKit } from '@endo/promise-kit'; +import type { PromiseKit } from '@endo/promise-kit'; + +import type { Decision, SectionNotification } 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[]; + + /** + * 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; +}; + +type PendingEntry = { + kit: PromiseKit; + notification: SectionNotification; +}; + +/** + * 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 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); + 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 }); + 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); + }, + + decide(decision: Decision): void { + routeDecision(decision); + }, + }); +} diff --git a/packages/kernel-utils/src/session/index.ts b/packages/kernel-utils/src/session/index.ts new file mode 100644 index 0000000000..e085991de8 --- /dev/null +++ b/packages/kernel-utils/src/session/index.ts @@ -0,0 +1,3 @@ +export type { SectionRequest, SectionNotification, Decision } from './types.ts'; +export { makeChannel } from './channel.ts'; +export type { Channel, ModalStream } from './channel.ts'; diff --git a/packages/kernel-utils/src/session/types.ts b/packages/kernel-utils/src/session/types.ts new file mode 100644 index 0000000000..c079cb1c34 --- /dev/null +++ b/packages/kernel-utils/src/session/types.ts @@ -0,0 +1,40 @@ +/** + * 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[] }; +}; + +/** + * 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[] }; +}; 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); From 9526d74679b6597d10098d10d6ce8ca46eb0bfa6 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 18 May 2026 11:17:42 -0400 Subject: [PATCH 02/34] chore(changelog): add changelog entries for channels PR --- packages/kernel-utils/CHANGELOG.md | 4 ++++ packages/streams/CHANGELOG.md | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/packages/kernel-utils/CHANGELOG.md b/packages/kernel-utils/CHANGELOG.md index 063ee0e4cb..8fca630f27 100644 --- a/packages/kernel-utils/CHANGELOG.md +++ b/packages/kernel-utils/CHANGELOG.md @@ -7,6 +7,10 @@ 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 + ## [0.5.0] ### Added 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 From 9e1ba56a4e38d3146aa39ad2405e4bea215134e8 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Thu, 14 May 2026 11:54:16 -0400 Subject: [PATCH 03/34] feat(session-types): add shared SessionSummary, PendingRequest, SessionApi types Transport-agnostic user-facing types used by both the TUI and the browser extension Authorization panel. Placing them in kernel-utils/session makes them available to any package without a node-runtime dependency. Co-Authored-By: Claude Sonnet 4.6 --- packages/kernel-utils/src/session/index.ts | 9 ++++++- packages/kernel-utils/src/session/types.ts | 28 ++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/kernel-utils/src/session/index.ts b/packages/kernel-utils/src/session/index.ts index e085991de8..a929a25abe 100644 --- a/packages/kernel-utils/src/session/index.ts +++ b/packages/kernel-utils/src/session/index.ts @@ -1,3 +1,10 @@ -export type { SectionRequest, SectionNotification, Decision } from './types.ts'; +export type { + SectionRequest, + SectionNotification, + Decision, + SessionSummary, + PendingRequest, + SessionApi, +} from './types.ts'; export { makeChannel } from './channel.ts'; export type { Channel, ModalStream } from './channel.ts'; diff --git a/packages/kernel-utils/src/session/types.ts b/packages/kernel-utils/src/session/types.ts index c079cb1c34..2f0b838793 100644 --- a/packages/kernel-utils/src/session/types.ts +++ b/packages/kernel-utils/src/session/types.ts @@ -38,3 +38,31 @@ export type Decision = { /** Optional guard override for accept verdicts. Absent means minimal (single-invocation) approval. */ guard?: { body: string; slots: string[] }; }; + +/** User-facing summary of a session returned by the session list API. */ +export type SessionSummary = { + sessionId: string; + ocapUrl: string; +}; + +/** User-facing representation of a pending authorization request. */ +export type PendingRequest = { + token: string; + description: string; + reason: string; +}; + +/** + * 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', + ) => Promise; +}; From 705ca7e1fea32955844ed7d8e4737cad347a120b Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 13 May 2026 18:14:00 -0400 Subject: [PATCH 04/34] feat(sessions): add session registry, channel factory, and stream socket server Adds ChannelFactory exo (kernel service), SessionRegistry, StreamSocketServer, and DaemonClient to support CLI-driven authorization session management. The daemon now exposes session RPC methods and a persistent stream socket for TUI subscriber connections. Co-Authored-By: Claude Sonnet 4.6 --- .../kernel-cli/src/commands/daemon-entry.ts | 16 +- packages/kernel-node-runtime/package.json | 1 + .../src/daemon/daemon-client.ts | 141 +++++++++++++++ .../kernel-node-runtime/src/daemon/index.ts | 11 ++ .../src/daemon/rpc-socket-server.ts | 170 +++++++++++++++++- .../src/daemon/session-registry.ts | 2 + .../src/daemon/start-daemon.test.ts | 67 ++++--- .../src/daemon/start-daemon.ts | 42 ++++- .../src/daemon/stream-socket-server.ts | 113 ++++++++++++ packages/kernel-node-runtime/src/index.ts | 1 + .../src/modal/channel-factory.test.ts | 51 ++++++ .../src/modal/channel-factory.ts | 67 +++++++ .../kernel-node-runtime/src/modal/index.ts | 5 + packages/kernel-utils/src/session/index.ts | 2 + .../src/session/session-registry.ts | 123 +++++++++++++ .../kernel-control/create-session-channel.ts | 31 ++++ .../src/rpc/kernel-control/index.test.ts | 6 + .../src/rpc/kernel-control/index.ts | 8 + 18 files changed, 818 insertions(+), 39 deletions(-) create mode 100644 packages/kernel-node-runtime/src/daemon/daemon-client.ts create mode 100644 packages/kernel-node-runtime/src/daemon/session-registry.ts create mode 100644 packages/kernel-node-runtime/src/daemon/stream-socket-server.ts create mode 100644 packages/kernel-node-runtime/src/modal/channel-factory.test.ts create mode 100644 packages/kernel-node-runtime/src/modal/channel-factory.ts create mode 100644 packages/kernel-node-runtime/src/modal/index.ts create mode 100644 packages/kernel-utils/src/session/session-registry.ts create mode 100644 packages/ocap-kernel/src/rpc/kernel-control/create-session-channel.ts 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-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.ts b/packages/kernel-node-runtime/src/daemon/rpc-socket-server.ts index 11168f6f90..08c2ce6760 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,15 @@ import { RpcService } from '@metamask/kernel-rpc-methods'; import type { KernelDatabase } from '@metamask/kernel-store'; +import { ifDefined } from '@metamask/kernel-utils'; 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 +30,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 +80,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 +116,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 +137,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 +152,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 +171,139 @@ 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 session = await sessionRegistry.createSession(name); + return ok({ sessionId: session.sessionId, ocapUrl: session.ocapUrl }); + } + + case 'session.list': { + return ok( + sessionRegistry + .listSessions() + .map(({ sessionId, ocapUrl }) => ({ sessionId, ocapUrl })), + ); + } + + case 'session.get': { + const session = requireSession(args.sessionId); + return ok({ sessionId: session.sessionId, ocapUrl: session.ocapUrl }); + } + + case 'session.requests': { + const session = requireSession(args.sessionId); + return ok(session.listPending()); + } + + 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.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; + + 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 }) }); + 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-utils/src/session/index.ts b/packages/kernel-utils/src/session/index.ts index a929a25abe..5a999bb4e8 100644 --- a/packages/kernel-utils/src/session/index.ts +++ b/packages/kernel-utils/src/session/index.ts @@ -8,3 +8,5 @@ export type { } 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'; 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..5ea9965874 --- /dev/null +++ b/packages/kernel-utils/src/session/session-registry.ts @@ -0,0 +1,123 @@ +import type { Channel, ModalStream } from './channel.ts'; +import type { Decision, SectionNotification } from './types.ts'; + +const SESSION_NAMES = [ + 'alice', + 'bob', + 'carol', + 'dave', + 'eve', + 'frank', + 'grace', + 'heidi', +]; + +export type Session = { + sessionId: string; + ocapUrl: string; + listPending(): SectionNotification[]; + decide(decision: Decision): void; + queueRequest(description: string, reason?: string): string; + subscribe(stream: ModalStream): void; +}; + +export type SessionRegistry = { + createSession(name?: 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. + * @returns A {@link Session}. + */ +function makeSession( + sessionId: string, + ocapUrl: string, + channel: Channel, +): Session { + let requestCount = 0; + + return harden({ + sessionId, + ocapUrl, + + listPending(): SectionNotification[] { + return channel.listPending(); + }, + + decide(decision: Decision): void { + channel.decide(decision); + }, + + queueRequest(description: string, reason = 'Queued from CLI'): string { + const token = `req-${requestCount}`; + requestCount += 1; + channel + .broadcast({ + token, + description, + reason, + guard: { body: '#{}', slots: [] }, + }) + .catch(() => undefined); + return token; + }, + + 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(name?: string): Promise { + const sessionId = + name ?? SESSION_NAMES[nameIndex] ?? `session-${nameIndex}`; + nameIndex += 1; + + const { ocapUrl, channel } = await factory.createChannelInternal(); + const session = makeSession(sessionId, ocapUrl, channel); + 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/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; From eb3e56a39f8bbfb2d1330243ca6313f35bb6baf7 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 18 May 2026 11:14:09 -0400 Subject: [PATCH 05/34] feat(sessions): add history tracking and session.authorize - channel: track queuedAt timestamp, record history log with listAll() - session-registry: add startedAt/cwd, add listHistory() and authorizeRequest() - rpc-socket-server: add session.history and session.authorize RPC methods; include cwd/startedAt in session.create/list/get responses Co-Authored-By: Claude Sonnet 4.6 --- .../src/daemon/rpc-socket-server.ts | 52 +++++++++-- packages/kernel-utils/src/session/channel.ts | 65 +++++++++++++- packages/kernel-utils/src/session/index.ts | 1 + .../src/session/session-registry.ts | 90 +++++++++++++++---- packages/kernel-utils/src/session/types.ts | 15 ++++ 5 files changed, 199 insertions(+), 24 deletions(-) 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 08c2ce6760..18ff933664 100644 --- a/packages/kernel-node-runtime/src/daemon/rpc-socket-server.ts +++ b/packages/kernel-node-runtime/src/daemon/rpc-socket-server.ts @@ -235,21 +235,38 @@ async function handleSessionRequest( switch (method) { case 'session.create': { const name = typeof args.name === 'string' ? args.name : undefined; - const session = await sessionRegistry.createSession(name); - return ok({ sessionId: session.sessionId, ocapUrl: session.ocapUrl }); + 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(({ sessionId, ocapUrl }) => ({ sessionId, ocapUrl })), + 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 }); + return ok({ + sessionId: session.sessionId, + ocapUrl: session.ocapUrl, + ...ifDefined({ cwd: session.cwd }), + startedAt: session.startedAt, + }); } case 'session.requests': { @@ -257,6 +274,11 @@ async function handleSessionRequest( 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 = @@ -269,6 +291,24 @@ async function handleSessionRequest( 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 decision = await session.authorizeRequest( + description, + reason, + timeoutMs, + ); + return ok(decision); + } + case 'session.decide': { const session = requireSession(args.sessionId); const { token } = args; diff --git a/packages/kernel-utils/src/session/channel.ts b/packages/kernel-utils/src/session/channel.ts index e19fc273e1..4be1ebb985 100644 --- a/packages/kernel-utils/src/session/channel.ts +++ b/packages/kernel-utils/src/session/channel.ts @@ -1,7 +1,11 @@ import { makePromiseKit } from '@endo/promise-kit'; import type { PromiseKit } from '@endo/promise-kit'; -import type { Decision, SectionNotification } from './types.ts'; +import type { + Decision, + SectionNotification, + SessionHistoryEntry, +} from './types.ts'; /** * Structural type for a stream that carries {@link SectionNotification} @@ -44,6 +48,13 @@ export type Channel = { */ 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. @@ -56,6 +67,14 @@ export type Channel = { type PendingEntry = { kit: PromiseKit; notification: SectionNotification; + queuedAt: string; +}; + +type HistoryEntry = { + notification: SectionNotification; + queuedAt: string; + verdict: 'accepted' | 'rejected'; + decidedAt: string; }; /** @@ -71,6 +90,7 @@ type PendingEntry = { */ export function makeChannel(): Channel { const pending = new Map(); + const history: HistoryEntry[] = []; const subscribers: ModalStream[] = []; /** @@ -84,6 +104,12 @@ export function makeChannel(): Channel { return; } pending.delete(decision.token); + history.push({ + notification: entry.notification, + queuedAt: entry.queuedAt, + verdict: decision.verdict === 'accept' ? 'accepted' : 'rejected', + decidedAt: new Date().toISOString(), + }); entry.kit.resolve(decision); } @@ -121,7 +147,11 @@ export function makeChannel(): Channel { return harden({ async broadcast(notification: SectionNotification): Promise { const kit = makePromiseKit(); - pending.set(notification.token, { kit, notification }); + pending.set(notification.token, { + kit, + notification, + queuedAt: new Date().toISOString(), + }); for (const stream of [...subscribers]) { stream.write(notification).catch(() => undefined); } @@ -142,6 +172,37 @@ export function makeChannel(): Channel { 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, + })); + 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, + })); + 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); }, diff --git a/packages/kernel-utils/src/session/index.ts b/packages/kernel-utils/src/session/index.ts index 5a999bb4e8..87830ec7e0 100644 --- a/packages/kernel-utils/src/session/index.ts +++ b/packages/kernel-utils/src/session/index.ts @@ -4,6 +4,7 @@ export type { Decision, SessionSummary, PendingRequest, + SessionHistoryEntry, SessionApi, } from './types.ts'; export { makeChannel } from './channel.ts'; diff --git a/packages/kernel-utils/src/session/session-registry.ts b/packages/kernel-utils/src/session/session-registry.ts index 5ea9965874..56014ecece 100644 --- a/packages/kernel-utils/src/session/session-registry.ts +++ b/packages/kernel-utils/src/session/session-registry.ts @@ -1,5 +1,10 @@ +import { ifDefined } from '../misc.ts'; import type { Channel, ModalStream } from './channel.ts'; -import type { Decision, SectionNotification } from './types.ts'; +import type { + Decision, + SectionNotification, + SessionHistoryEntry, +} from './types.ts'; const SESSION_NAMES = [ 'alice', @@ -15,14 +20,22 @@ const SESSION_NAMES = [ 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, + reason?: string, + timeoutMs?: number, + ): Promise; subscribe(stream: ModalStream): void; }; export type SessionRegistry = { - createSession(name?: string): Promise; + 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. */ @@ -40,39 +53,77 @@ type ChannelFactoryBundle = { * @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, + ): SectionNotification => { + const token = `req-${requestCount}`; + requestCount += 1; + return { token, description, reason, guard: { body: '#{}', slots: [] } }; + }; 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 token = `req-${requestCount}`; - requestCount += 1; - channel - .broadcast({ - token, - description, - reason, - guard: { body: '#{}', slots: [] }, - }) - .catch(() => undefined); - return token; + const notification = makeNotification(description, reason); + channel.broadcast(notification).catch(() => undefined); + return notification.token; + }, + + async authorizeRequest( + description: string, + reason = 'Queued from CLI', + timeoutMs?: number, + ): Promise { + const notification = makeNotification(description, reason); + 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); + }), + ]); }, subscribe(stream: ModalStream): void { @@ -97,13 +148,20 @@ export function makeSessionRegistry( const sessions = new Map(); return harden({ - async createSession(name?: string): Promise { + async createSession( + options: { name?: string; cwd?: string } = {}, + ): Promise { const sessionId = - name ?? SESSION_NAMES[nameIndex] ?? `session-${nameIndex}`; + options.name ?? SESSION_NAMES[nameIndex] ?? `session-${nameIndex}`; nameIndex += 1; const { ocapUrl, channel } = await factory.createChannelInternal(); - const session = makeSession(sessionId, ocapUrl, channel); + const session = makeSession( + sessionId, + ocapUrl, + channel, + ifDefined({ cwd: options.cwd }), + ); sessions.set(sessionId, session); return session; }, diff --git a/packages/kernel-utils/src/session/types.ts b/packages/kernel-utils/src/session/types.ts index 2f0b838793..5e7115ef72 100644 --- a/packages/kernel-utils/src/session/types.ts +++ b/packages/kernel-utils/src/session/types.ts @@ -43,6 +43,10 @@ export type Decision = { 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. */ @@ -52,6 +56,17 @@ export type PendingRequest = { 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'; + decidedAt?: string; +}; + /** * Transport-agnostic interface for inspecting and deciding on authorization * requests. Shared between the TUI (Unix-socket JSON-RPC) and the browser From 1c0dbc94edb2b2caf4ae4a303f46fb03548d6689 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 18 May 2026 11:18:13 -0400 Subject: [PATCH 06/34] chore(changelog): add changelog entries for sessions PR --- packages/kernel-node-runtime/CHANGELOG.md | 6 ++++++ packages/kernel-utils/CHANGELOG.md | 2 ++ packages/ocap-kernel/CHANGELOG.md | 1 + 3 files changed, 9 insertions(+) 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-utils/CHANGELOG.md b/packages/kernel-utils/CHANGELOG.md index 8fca630f27..85e0a067cc 100644 --- a/packages/kernel-utils/CHANGELOG.md +++ b/packages/kernel-utils/CHANGELOG.md @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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] 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 From de519e039ba2d00c31ce8415d46f934004bc0717 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 13 May 2026 18:14:55 -0400 Subject: [PATCH 07/34] feat(session-cli): add session subcommands to kernel-cli Adds ocap session create/list/requests/queue/approve/reject subcommands. Refactors daemon-client to delegate socket/RPC helpers to kernel-node-runtime, and extracts session command builder into session.ts. Co-Authored-By: Claude Sonnet 4.6 --- packages/kernel-cli/src/app.ts | 10 +- .../kernel-cli/src/commands/daemon-client.ts | 107 +----- packages/kernel-cli/src/commands/session.ts | 358 ++++++++++++++++++ 3 files changed, 372 insertions(+), 103 deletions(-) create mode 100644 packages/kernel-cli/src/commands/session.ts diff --git a/packages/kernel-cli/src/app.ts b/packages/kernel-cli/src/app.ts index cee2127d50..da12f45f08 100755 --- a/packages/kernel-cli/src/app.ts +++ b/packages/kernel-cli/src/app.ts @@ -22,6 +22,7 @@ import { stopRelay, } from './commands/relay.ts'; import { getServer } from './commands/serve.ts'; +import { buildSessionCommands } from './commands/session.ts'; import { watchDir } from './commands/watch.ts'; import { defaultConfig } from './config.ts'; import type { Config } from './config.ts'; @@ -426,6 +427,13 @@ const yargsInstance = yargs(hideBin(process.argv)) () => { // Handled by subcommands. }, + ) + .command( + 'session', + 'Manage authorization sessions', + (_yargs) => buildSessionCommands(_yargs), + () => { + // Handled by subcommands. + }, ); - 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/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 }), + ); + }, + ); +} From 7a7d7476ec4e422fb48508599e13ff415aed8dcc Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 13 May 2026 18:15:15 -0400 Subject: [PATCH 08/34] feat(modal-tui): add kernel-tui package and modal command to kernel-cli Adds @ocap/kernel-tui with an Ink/React terminal UI for interactive session authorization. Adds the `ocap modal ` command to kernel-cli that spawns the TUI binary with the resolved channel URL. Co-Authored-By: Claude Sonnet 4.6 --- packages/kernel-cli/package.json | 1 + packages/kernel-cli/src/app.ts | 61 ++++- packages/kernel-tui/CHANGELOG.md | 10 + packages/kernel-tui/README.md | 15 ++ packages/kernel-tui/package.json | 89 +++++++ packages/kernel-tui/src/app.ts | 26 +++ packages/kernel-tui/src/modal.tsx | 293 ++++++++++++++++++++++++ packages/kernel-tui/tsconfig.build.json | 19 ++ packages/kernel-tui/tsconfig.json | 22 ++ packages/kernel-tui/typedoc.json | 8 + packages/kernel-tui/vitest.config.ts | 16 ++ tsconfig.build.json | 9 +- tsconfig.json | 5 +- yarn.config.cjs | 7 +- yarn.lock | 251 +++++++++++++++++++- 15 files changed, 816 insertions(+), 16 deletions(-) create mode 100644 packages/kernel-tui/CHANGELOG.md create mode 100644 packages/kernel-tui/README.md create mode 100644 packages/kernel-tui/package.json create mode 100644 packages/kernel-tui/src/app.ts create mode 100644 packages/kernel-tui/src/modal.tsx create mode 100644 packages/kernel-tui/tsconfig.build.json create mode 100644 packages/kernel-tui/tsconfig.json create mode 100644 packages/kernel-tui/typedoc.json create mode 100644 packages/kernel-tui/vitest.config.ts 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 da12f45f08..ee68bec787 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,7 +25,7 @@ import { stopRelay, } from './commands/relay.ts'; import { getServer } from './commands/serve.ts'; -import { buildSessionCommands } from './commands/session.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'; @@ -44,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]') @@ -435,5 +457,42 @@ const yargsInstance = yargs(hideBin(process.argv)) () => { // 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); + }); + }, ); await yargsInstance.help('help').parse(); diff --git a/packages/kernel-tui/CHANGELOG.md b/packages/kernel-tui/CHANGELOG.md new file mode 100644 index 0000000000..0c82cb1ed6 --- /dev/null +++ b/packages/kernel-tui/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/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..79a1b303ab --- /dev/null +++ b/packages/kernel-tui/package.json @@ -0,0 +1,89 @@ +{ + "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:^", + "@ts-bridge/cli": "^0.6.3", + "@ts-bridge/shims": "^0.1.1", + "@types/node": "^22.13.1", + "@types/react": "^18.3.18", + "@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", + "prettier": "^3.5.3", + "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:^", + "@metamask/utils": "^11.9.0", + "ink": "^5.2.1", + "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..fdd33d1495 --- /dev/null +++ b/packages/kernel-tui/src/app.ts @@ -0,0 +1,26 @@ +import '@metamask/kernel-shims/endoify-node'; +import yargs from 'yargs'; +import { hideBin } from 'yargs/helpers'; + +import { runModal } from './modal.tsx'; + +const yargsInstance = yargs(hideBin(process.argv)) + .scriptName('ocap-tui') + .usage('$0 [options]') + .demandCommand(1) + .strict() + .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/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/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/tsconfig.build.json b/tsconfig.build.json index 62c8d97c5d..bbf6a8cdf9 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -2,24 +2,25 @@ "files": [], "include": [], "references": [ - { "path": "./packages/kernel-cli/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..2d233f5abd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,22 +13,23 @@ "files": [], "include": [], "references": [ - { "path": "./packages/kernel-cli" }, { "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/yarn.config.cjs b/yarn.config.cjs index 36f4a2161f..89813eed92 100644 --- a/yarn.config.cjs +++ b/yarn.config.cjs @@ -29,7 +29,12 @@ const typedocExceptions = [ 'repo-tools', ]; // Packages that do not enforce the standard build script -const buildExceptions = ['create-package', 'kernel-cli', 'repo-tools']; +const buildExceptions = [ + '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..a9f34a20c8 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" @@ -4002,6 +4014,53 @@ __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/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" + "@types/react": "npm:^18.3.18" + "@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" + ink: "npm:^5.2.1" + prettier: "npm:^3.5.3" + react: "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 +6931,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 +7471,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" @@ -7496,6 +7578,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 +7732,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 +8621,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 +8736,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 +10247,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 +10285,47 @@ __metadata: 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 +10579,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" @@ -12494,7 +12668,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 +13001,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 +13691,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 +13941,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 +14165,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 +14391,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 +14651,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 +14710,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: @@ -15143,6 +15355,13 @@ __metadata: languageName: node linkType: hard +"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 +16378,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 +16459,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 +16470,7 @@ __metadata: optional: true utf-8-validate: optional: true - checksum: 10/b7ab934b21ffdea9f25a5af5097e8c1ec7625db553bca026c5a23e35b7c236f3fb89782f2b57fab9da553864512f9aa7d245827ef998d26ffa1b2187a19a6d10 + checksum: 10/8c4d2b06dc65381b6bfab1f2e584275dabd30a99a5ce058b4dc76f3d03fad1921cef3a21d8f53127d30a808cfd1864aa2fe6890a5d43359f682457315baec873 languageName: node linkType: hard @@ -16375,3 +16603,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 From 821c09e6ffec90205ba31bd32975aea2fbca4845 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 13 May 2026 18:34:39 -0400 Subject: [PATCH 09/34] feat(kernel-tui): add full multi-view TUI with sessions view Ports the old ephemeral kernel-tui components (files, objects, invoke, log) to work against the daemon via makeDaemonKernelApi, which calls kernel RPC methods over the UNIX socket. Adds a sessions view that polls all sessions and their pending authorization requests with keyboard approve/reject. Adds `ocap-tui tui` command alongside the existing `modal` command. Co-Authored-By: Claude Sonnet 4.6 --- packages/kernel-tui/package.json | 4 + packages/kernel-tui/src/app.ts | 17 ++ .../src/components/file-browser.tsx | 106 ++++++++++ .../kernel-tui/src/components/invoke-view.tsx | 143 ++++++++++++++ .../kernel-tui/src/components/log-view.tsx | 44 +++++ .../src/components/object-registry-view.tsx | 125 ++++++++++++ .../src/components/sessions-view.tsx | 187 ++++++++++++++++++ .../kernel-tui/src/components/status-bar.tsx | 51 +++++ packages/kernel-tui/src/hooks/use-kernel.ts | 126 ++++++++++++ packages/kernel-tui/src/start-tui.ts | 26 +++ packages/kernel-tui/src/tui.tsx | 81 ++++++++ packages/kernel-tui/src/types.ts | 40 ++++ yarn.lock | 60 +++++- 13 files changed, 1009 insertions(+), 1 deletion(-) create mode 100644 packages/kernel-tui/src/components/file-browser.tsx create mode 100644 packages/kernel-tui/src/components/invoke-view.tsx create mode 100644 packages/kernel-tui/src/components/log-view.tsx create mode 100644 packages/kernel-tui/src/components/object-registry-view.tsx create mode 100644 packages/kernel-tui/src/components/sessions-view.tsx create mode 100644 packages/kernel-tui/src/components/status-bar.tsx create mode 100644 packages/kernel-tui/src/hooks/use-kernel.ts create mode 100644 packages/kernel-tui/src/start-tui.ts create mode 100644 packages/kernel-tui/src/tui.tsx create mode 100644 packages/kernel-tui/src/types.ts diff --git a/packages/kernel-tui/package.json b/packages/kernel-tui/package.json index 79a1b303ab..f1e1fd3aaa 100644 --- a/packages/kernel-tui/package.json +++ b/packages/kernel-tui/package.json @@ -82,7 +82,11 @@ "@metamask/kernel-utils": "workspace:^", "@metamask/streams": "workspace:^", "@metamask/utils": "^11.9.0", + "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 index fdd33d1495..d15b3c06c2 100644 --- a/packages/kernel-tui/src/app.ts +++ b/packages/kernel-tui/src/app.ts @@ -1,14 +1,31 @@ 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', 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..0c6dbe16b5 --- /dev/null +++ b/packages/kernel-tui/src/components/object-registry-view.tsx @@ -0,0 +1,125 @@ +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.map((entry) => ( + + {' '} + {entry.key} = {entry.value} + + ))} + + )} + + {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/sessions-view.tsx b/packages/kernel-tui/src/components/sessions-view.tsx new file mode 100644 index 0000000000..c441066602 --- /dev/null +++ b/packages/kernel-tui/src/components/sessions-view.tsx @@ -0,0 +1,187 @@ +import { Box, Text, useInput } from 'ink'; +import Spinner from 'ink-spinner'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; + +import type { KernelApi, PendingRequest, SessionSummary } from '../types.ts'; + +type SessionWithRequests = SessionSummary & { requests: PendingRequest[] }; + +type FlatRequest = { sessionId: string; request: PendingRequest }; + +type SessionsViewProps = { + kernelApi: KernelApi; +}; + +const POLL_INTERVAL_MS = 2000; + +/** + * View showing all sessions and their pending authorization requests. + * Navigation: ↑/↓ to move between requests, a=accept, r=reject, R=refresh. + * + * @param props - Component props. + * @param props.kernelApi - Kernel API for session operations. + * @returns The SessionsView component. + */ +export function SessionsView({ + kernelApi, +}: SessionsViewProps): React.ReactElement { + const [sessions, setSessions] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [cursor, setCursor] = useState(0); + const [deciding, setDeciding] = useState(false); + const mountedRef = useRef(true); + + const allRequests: FlatRequest[] = sessions.flatMap((session) => + session.requests.map((request) => ({ + sessionId: session.sessionId, + request, + })), + ); + + 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]); + + useEffect(() => { + mountedRef.current = true; + refresh(); + const interval = setInterval(refresh, POLL_INTERVAL_MS); + return () => { + mountedRef.current = false; + clearInterval(interval); + }; + }, [refresh]); + + useInput((input, key) => { + if (key.upArrow) { + setCursor((prev) => Math.max(0, prev - 1)); + } else if (key.downArrow) { + setCursor((prev) => Math.min(allRequests.length - 1, prev + 1)); + } else if (input === 'R') { + setLoading(true); + refresh(); + } else if ((input === 'a' || input === 'r') && !deciding) { + const focused = allRequests[cursor]; + if (focused === undefined) { + return; + } + const verdict = input === 'a' ? 'accept' : 'reject'; + setDeciding(true); + kernelApi + .decide(focused.sessionId, focused.request.token, verdict) + .then(() => { + if (mountedRef.current) { + setCursor((prev) => Math.max(0, prev - 1)); + refresh(); + } + return undefined; + }) + .catch((caught: Error) => { + if (mountedRef.current) { + setError(caught.message); + } + }) + .finally(() => { + if (mountedRef.current) { + setDeciding(false); + } + }); + } + }); + + if (loading && sessions.length === 0) { + return ( + + + Loading sessions... + + + ); + } + + if (error) { + return ( + + Error: {error} + + ); + } + + if (sessions.length === 0) { + return ( + + + No sessions — use `ocap session create` to start one + + + ); + } + + let requestIndex = 0; + + return ( + + {deciding && ( + + Submitting decision... + + )} + {sessions.map((session) => ( + + + {session.sessionId} + + {session.requests.length === 0 ? ( + {' '}(no pending requests) + ) : ( + session.requests.map((req) => { + const isFocused = requestIndex === cursor; + const idx = requestIndex; + requestIndex += 1; + return ( + + + {isFocused ? '► ' : ' '}[{idx}] {req.description} + + + {' '} + {req.reason} + + + ); + }) + )} + + ))} + + ); +} 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..2a834f1de9 --- /dev/null +++ b/packages/kernel-tui/src/components/status-bar.tsx @@ -0,0 +1,51 @@ +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 | a: accept | r: reject | 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..0dd4fbeccf --- /dev/null +++ b/packages/kernel-tui/src/hooks/use-kernel.ts @@ -0,0 +1,126 @@ +import { + getSocketPath, + sendCommand, +} from '@metamask/kernel-node-runtime/daemon'; +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 }[]>('session.list'); + }, + + async listRequests(sessionId) { + return send<{ token: string; description: string; reason: string }[]>( + 'session.requests', + { sessionId }, + ); + }, + + async decide(sessionId, token, verdict) { + await send('session.decide', { sessionId, token, verdict, feedback: '' }); + }, + }; +} + +/** + * 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/start-tui.ts b/packages/kernel-tui/src/start-tui.ts new file mode 100644 index 0000000000..aca06fda4f --- /dev/null +++ b/packages/kernel-tui/src/start-tui.ts @@ -0,0 +1,26 @@ +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'); + + 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..6a0d0afd79 --- /dev/null +++ b/packages/kernel-tui/src/tui.tsx @@ -0,0 +1,81 @@ +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 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 { 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..25672acd8c --- /dev/null +++ b/packages/kernel-tui/src/types.ts @@ -0,0 +1,40 @@ +export type KernelStatus = { + active: boolean; + vatCount: number; + subclusterCount: number; +}; + +export type RegistryEntry = { key: string; value: string }; + +export type SessionSummary = { sessionId: string; ocapUrl: string }; + +export type PendingRequest = { + token: string; + description: string; + reason: string; +}; + +export type ViewMode = 'sessions' | 'files' | 'objects' | 'invoke' | 'log'; + +export type KernelApi = { + launchSubcluster(config: Record): Promise<{ + subclusterId: string; + bootstrapRootKref: string; + bootstrapResult?: unknown; + }>; + queueMessage( + target: string, + method: string, + args: unknown[], + ): Promise; + getStatus(): Promise; + getObjectRegistry(): Promise; + stop(): Promise; + listSessions(): Promise; + listRequests(sessionId: string): Promise; + decide( + sessionId: string, + token: string, + verdict: 'accept' | 'reject', + ): Promise; +}; diff --git a/yarn.lock b/yarn.lock index a9f34a20c8..aa96589a57 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4025,6 +4025,8 @@ __metadata: "@metamask/eslint-config-typescript": "npm:^15.0.0" "@metamask/kernel-node-runtime": "workspace:^" "@metamask/kernel-shims": "workspace:^" + "@metamask/kernel-utils": "workspace:^" + "@metamask/streams": "workspace:^" "@metamask/utils": "npm:^11.9.0" "@ocap/repo-tools": "workspace:^" "@ts-bridge/cli": "npm:^0.6.3" @@ -4045,7 +4047,11 @@ __metadata: 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" prettier: "npm:^3.5.3" react: "npm:^18.3.1" rimraf: "npm:^6.0.1" @@ -7512,6 +7518,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" @@ -10285,6 +10298,44 @@ __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" @@ -15162,6 +15213,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" @@ -15355,7 +15413,7 @@ __metadata: languageName: node linkType: hard -"type-fest@npm:^4.27.0": +"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 From 38ad282f966f1d46563807e213b64c3a2afffab3 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 13 May 2026 18:39:18 -0400 Subject: [PATCH 10/34] fix(kernel-tui): omit optional Text props instead of passing undefined exactOptionalPropertyTypes rejects color={undefined} on ink's Text component. Use spread to conditionally apply focused styles. Co-Authored-By: Claude Sonnet 4.6 --- packages/kernel-tui/src/components/sessions-view.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/kernel-tui/src/components/sessions-view.tsx b/packages/kernel-tui/src/components/sessions-view.tsx index c441066602..3741445754 100644 --- a/packages/kernel-tui/src/components/sessions-view.tsx +++ b/packages/kernel-tui/src/components/sessions-view.tsx @@ -167,8 +167,7 @@ export function SessionsView({ paddingLeft={2} > {isFocused ? '► ' : ' '}[{idx}] {req.description} From 0875c10130e09b2158b1fff6e220ef330ad8b61a Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 13 May 2026 18:54:37 -0400 Subject: [PATCH 11/34] feat(kernel-tui): add ocap tui command and improve session error message Adds `ocap tui` to kernel-cli for convenient TUI launch (mirrors `ocap modal`). Sessions view now shows a helpful hint when the daemon lacks session RPCs (i.e. is running from main rather than the sessions branch). Co-Authored-By: Claude Sonnet 4.6 --- packages/kernel-cli/src/app.ts | 28 +++++++++++++++++++ .../src/components/sessions-view.tsx | 14 ++++++++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/packages/kernel-cli/src/app.ts b/packages/kernel-cli/src/app.ts index ee68bec787..c487697ad8 100755 --- a/packages/kernel-cli/src/app.ts +++ b/packages/kernel-cli/src/app.ts @@ -494,5 +494,33 @@ const yargsInstance = yargs(hideBin(process.argv)) 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-tui/src/components/sessions-view.tsx b/packages/kernel-tui/src/components/sessions-view.tsx index 3741445754..1ad3bd6bb9 100644 --- a/packages/kernel-tui/src/components/sessions-view.tsx +++ b/packages/kernel-tui/src/components/sessions-view.tsx @@ -122,9 +122,17 @@ export function SessionsView({ } 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. + + )} ); } @@ -167,7 +175,9 @@ export function SessionsView({ paddingLeft={2} > {isFocused ? '► ' : ' '}[{idx}] {req.description} From 46a3245b82e78ff39bc4c07fca52b00ec4be2097 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Thu, 14 May 2026 11:55:03 -0400 Subject: [PATCH 12/34] refactor(kernel-tui): use shared SessionApi/SessionSummary/PendingRequest from kernel-utils Removes locally-defined SessionSummary, PendingRequest, and the explicit session method declarations from KernelApi, replacing them with the shared types from @metamask/kernel-utils/session. Drops the unused @metamask/utils runtime dependency. Co-Authored-By: Claude Sonnet 4.6 --- packages/kernel-tui/package.json | 1 - packages/kernel-tui/src/types.ts | 25 +++++++++---------------- 2 files changed, 9 insertions(+), 17 deletions(-) diff --git a/packages/kernel-tui/package.json b/packages/kernel-tui/package.json index f1e1fd3aaa..0ced6f452e 100644 --- a/packages/kernel-tui/package.json +++ b/packages/kernel-tui/package.json @@ -81,7 +81,6 @@ "@metamask/kernel-shims": "workspace:^", "@metamask/kernel-utils": "workspace:^", "@metamask/streams": "workspace:^", - "@metamask/utils": "^11.9.0", "glob": "^11.0.0", "ink": "^5.2.1", "ink-select-input": "^6.0.0", diff --git a/packages/kernel-tui/src/types.ts b/packages/kernel-tui/src/types.ts index 25672acd8c..3ddedcff81 100644 --- a/packages/kernel-tui/src/types.ts +++ b/packages/kernel-tui/src/types.ts @@ -1,3 +1,11 @@ +import type { + SessionApi, + SessionSummary, + PendingRequest, +} from '@metamask/kernel-utils/session'; + +export type { SessionSummary, PendingRequest }; + export type KernelStatus = { active: boolean; vatCount: number; @@ -6,17 +14,9 @@ export type KernelStatus = { export type RegistryEntry = { key: string; value: string }; -export type SessionSummary = { sessionId: string; ocapUrl: string }; - -export type PendingRequest = { - token: string; - description: string; - reason: string; -}; - export type ViewMode = 'sessions' | 'files' | 'objects' | 'invoke' | 'log'; -export type KernelApi = { +export type KernelApi = SessionApi & { launchSubcluster(config: Record): Promise<{ subclusterId: string; bootstrapRootKref: string; @@ -30,11 +30,4 @@ export type KernelApi = { getStatus(): Promise; getObjectRegistry(): Promise; stop(): Promise; - listSessions(): Promise; - listRequests(sessionId: string): Promise; - decide( - sessionId: string, - token: string, - verdict: 'accept' | 'reject', - ): Promise; }; From a0d39a4ffd3cc678d750c609251badb8f1eab50a Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Fri, 15 May 2026 08:46:02 -0400 Subject: [PATCH 13/34] fix(kernel-tui): fix overflow, duplicate titles, and add Sessions header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add overflow=hidden to content box and cap object registry promises at 20 rows to prevent the view from overflowing the terminal - Remove VIEW_LABELS title box from tui.tsx root — FileBrowser, InvokeView, and ObjectRegistryView already render their own headers, so the extra box was causing duplicate titles - Add Sessions title to SessionsView to match the other views - Clear screen before rendering and constrain root height to terminal rows via useTerminalSize to prevent rendering artifacts Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/object-registry-view.tsx | 7 ++++- .../src/components/sessions-view.tsx | 1 + .../kernel-tui/src/hooks/use-terminal-size.ts | 28 +++++++++++++++++++ packages/kernel-tui/src/start-tui.ts | 4 +++ packages/kernel-tui/src/tui.tsx | 6 ++-- yarn.lock | 1 - 6 files changed, 43 insertions(+), 4 deletions(-) create mode 100644 packages/kernel-tui/src/hooks/use-terminal-size.ts diff --git a/packages/kernel-tui/src/components/object-registry-view.tsx b/packages/kernel-tui/src/components/object-registry-view.tsx index 0c6dbe16b5..c9f42ce54b 100644 --- a/packages/kernel-tui/src/components/object-registry-view.tsx +++ b/packages/kernel-tui/src/components/object-registry-view.tsx @@ -91,12 +91,17 @@ export function ObjectRegistryView({ Promises ({promises.length}) - {promises.map((entry) => ( + {promises.slice(0, 20).map((entry) => ( {' '} {entry.key} = {entry.value} ))} + {promises.length > 20 && ( + + {' '}... and {promises.length - 20} more + + )} )} diff --git a/packages/kernel-tui/src/components/sessions-view.tsx b/packages/kernel-tui/src/components/sessions-view.tsx index 1ad3bd6bb9..8032c0fcbb 100644 --- a/packages/kernel-tui/src/components/sessions-view.tsx +++ b/packages/kernel-tui/src/components/sessions-view.tsx @@ -151,6 +151,7 @@ export function SessionsView({ return ( + Sessions {deciding && ( Submitting decision... 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/start-tui.ts b/packages/kernel-tui/src/start-tui.ts index aca06fda4f..bc9ce71be6 100644 --- a/packages/kernel-tui/src/start-tui.ts +++ b/packages/kernel-tui/src/start-tui.ts @@ -21,6 +21,10 @@ export async function startTui({ ]); 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 index 6a0d0afd79..4f306aa195 100644 --- a/packages/kernel-tui/src/tui.tsx +++ b/packages/kernel-tui/src/tui.tsx @@ -8,6 +8,7 @@ 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']; @@ -27,6 +28,7 @@ type TuiProps = { */ 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([]); @@ -58,10 +60,10 @@ export function Tui({ cwd, kernelApi }: TuiProps): React.ReactElement { }); return ( - + - + {currentView === 'sessions' && } {currentView === 'files' && ( diff --git a/yarn.lock b/yarn.lock index aa96589a57..2106002afd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4027,7 +4027,6 @@ __metadata: "@metamask/kernel-shims": "workspace:^" "@metamask/kernel-utils": "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" From ec8a84e2208ceebda70b60045d57b834f72672b4 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 18 May 2026 11:14:59 -0400 Subject: [PATCH 14/34] feat(kernel-tui): add session detail view and history - sessions-view: replace flat request list with per-session cursor; right arrow drills into a session's detail view; left arrow returns - session-detail-view: new component showing session history entries with authorization status, timestamps, and descriptions - use-kernel: add listHistory to KernelApi, expose SessionHistoryEntry - types: expand listSessions return type to include cwd/startedAt Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/session-detail-view.tsx | 162 +++++++++++++ .../src/components/sessions-view.tsx | 217 +++++++++++------- packages/kernel-tui/src/hooks/use-kernel.ts | 23 +- packages/kernel-tui/src/types.ts | 4 +- 4 files changed, 323 insertions(+), 83 deletions(-) create mode 100644 packages/kernel-tui/src/components/session-detail-view.tsx 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..127f734237 --- /dev/null +++ b/packages/kernel-tui/src/components/session-detail-view.tsx @@ -0,0 +1,162 @@ +import { Box, Text, useInput } from 'ink'; +import React, { 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: '✗', +}; + +const STATUS_COLOR: Record< + SessionHistoryEntry['status'], + 'yellow' | 'green' | 'red' +> = { + pending: 'yellow', + accepted: 'green', + rejected: 'red', +}; + +/** + * 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(':'); +} + +/** + * Detail view for a single session showing a chronological timeline of + * authorization requests. Each entry can be expanded with the right arrow key + * and collapsed with the left arrow key. Left arrow on a collapsed first entry + * navigates back to the session list. + * + * Keybindings: ↑/↓ navigate, → expand, ← collapse/back, a accept, r reject. + * + * @param props - Component props. + * @param props.session - The session being viewed. + * @param props.entries - Chronological history entries. + * @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 [cursor, setCursor] = useState(0); + const [expanded, setExpanded] = useState>(new Set()); + const [deciding, setDeciding] = useState(false); + const [error, setError] = useState(null); + + const safeCursor = Math.min(cursor, Math.max(0, entries.length - 1)); + const focused = entries[safeCursor]; + + useInput((input, key) => { + if (key.upArrow) { + setCursor((prev) => Math.max(0, prev - 1)); + } else if (key.downArrow) { + setCursor((prev) => Math.min(entries.length - 1, prev + 1)); + } else if (key.rightArrow && focused !== undefined) { + setExpanded((prev) => new Set([...prev, focused.token])); + } else if (key.leftArrow) { + if (focused !== undefined && expanded.has(focused.token)) { + setExpanded((prev) => { + const next = new Set(prev); + next.delete(focused.token); + return next; + }); + } else { + onBack(); + } + } else if ((input === 'a' || input === 'r') && !deciding) { + if (focused === undefined || focused.status !== 'pending') { + return; + } + const verdict = input === 'a' ? 'accept' : 'reject'; + setDeciding(true); + kernelApi + .decide(session.sessionId, focused.token, verdict) + .then(() => { + onDecided(); + return undefined; + }) + .catch((caught: Error) => { + setError(caught.message); + }) + .finally(() => { + setDeciding(false); + }); + } + }); + + return ( + + + + + {session.sessionId} + + {deciding && (submitting…)} + + {error !== null && {error}} + + {entries.length === 0 ? ( + No requests yet. + ) : ( + entries.map((entry, idx) => { + const isFocused = idx === safeCursor; + const isExpanded = expanded.has(entry.token); + const icon = STATUS_ICON[entry.status]; + const color = STATUS_COLOR[entry.status]; + + return ( + + + {isFocused ? '►' : ' '} + {icon} + + {formatTime(entry.queuedAt)} + + {entry.description} + + {isExpanded && ( + + {entry.reason} + {entry.decidedAt !== undefined && ( + decided {formatTime(entry.decidedAt)} + )} + {entry.guard.body !== '#{}' && ( + guard: {entry.guard.body} + )} + + )} + + ); + }) + )} + + ); +} diff --git a/packages/kernel-tui/src/components/sessions-view.tsx b/packages/kernel-tui/src/components/sessions-view.tsx index 8032c0fcbb..bc57ac90da 100644 --- a/packages/kernel-tui/src/components/sessions-view.tsx +++ b/packages/kernel-tui/src/components/sessions-view.tsx @@ -1,22 +1,58 @@ import { Box, Text, useInput } from 'ink'; import Spinner from 'ink-spinner'; +import { homedir } from 'node:os'; import React, { useCallback, useEffect, useRef, useState } from 'react'; -import type { KernelApi, PendingRequest, SessionSummary } from '../types.ts'; +import type { + KernelApi, + PendingRequest, + SessionHistoryEntry, + SessionSummary, +} from '../types.ts'; +import { SessionDetailView } from './session-detail-view.tsx'; type SessionWithRequests = SessionSummary & { requests: PendingRequest[] }; -type FlatRequest = { sessionId: string; request: PendingRequest }; - type SessionsViewProps = { kernelApi: KernelApi; }; const POLL_INTERVAL_MS = 2000; +/** + * 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}`; +} + /** * View showing all sessions and their pending authorization requests. - * Navigation: ↑/↓ to move between requests, a=accept, r=reject, R=refresh. + * + * Top-level navigation: ↑/↓ between sessions, → to drill into a session. + * Session detail navigation: ↑/↓ between timeline entries, → expand, ← collapse/back. * * @param props - Component props. * @param props.kernelApi - Kernel API for session operations. @@ -29,15 +65,11 @@ export function SessionsView({ const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [cursor, setCursor] = useState(0); - const [deciding, setDeciding] = useState(false); - const mountedRef = useRef(true); - - const allRequests: FlatRequest[] = sessions.flatMap((session) => - session.requests.map((request) => ({ - sessionId: session.sessionId, - request, - })), + const [detailSession, setDetailSession] = useState( + null, ); + const [detailHistory, setDetailHistory] = useState([]); + const mountedRef = useRef(true); const refresh = useCallback((): void => { kernelApi @@ -64,6 +96,37 @@ export function SessionsView({ }); }, [kernelApi]); + 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 refreshDetail = useCallback((): void => { + if (detailSession === null) { + return; + } + kernelApi + .listHistory(detailSession.sessionId) + .then((history) => { + if (mountedRef.current) { + setDetailHistory(history); + } + return undefined; + }) + .catch(() => undefined); + }, [kernelApi, detailSession]); + useEffect(() => { mountedRef.current = true; refresh(); @@ -75,42 +138,42 @@ export function SessionsView({ }, [refresh]); 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(allRequests.length - 1, prev + 1)); + 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 === 'R') { setLoading(true); refresh(); - } else if ((input === 'a' || input === 'r') && !deciding) { - const focused = allRequests[cursor]; - if (focused === undefined) { - return; - } - const verdict = input === 'a' ? 'accept' : 'reject'; - setDeciding(true); - kernelApi - .decide(focused.sessionId, focused.request.token, verdict) - .then(() => { - if (mountedRef.current) { - setCursor((prev) => Math.max(0, prev - 1)); - refresh(); - } - return undefined; - }) - .catch((caught: Error) => { - if (mountedRef.current) { - setError(caught.message); - } - }) - .finally(() => { - if (mountedRef.current) { - setDeciding(false); - } - }); } }); + if (detailSession !== null) { + return ( + { + setDetailSession(null); + setDetailHistory([]); + }} + onDecided={() => { + refresh(); + refreshDetail(); + }} + /> + ); + } + if (loading && sessions.length === 0) { return ( @@ -147,51 +210,43 @@ export function SessionsView({ ); } - let requestIndex = 0; - return ( Sessions - {deciding && ( - - Submitting decision... - - )} - {sessions.map((session) => ( - - - {session.sessionId} - - {session.requests.length === 0 ? ( - {' '}(no pending requests) - ) : ( - session.requests.map((req) => { - const isFocused = requestIndex === cursor; - const idx = requestIndex; - requestIndex += 1; - return ( - - - {isFocused ? '► ' : ' '}[{idx}] {req.description} - - - {' '} - {req.reason} - - - ); - }) - )} - - ))} + {sessions.map((session, idx) => { + const isFocused = idx === cursor; + const pendingCount = session.requests.length; + const meta: string[] = []; + if (session.cwd !== undefined) { + meta.push(tildify(session.cwd)); + } + if (session.startedAt !== undefined) { + meta.push(formatStartedAt(session.startedAt)); + } + const metaSuffix = meta.length > 0 ? ` (${meta.join(' ')})` : ''; + + return ( + + + {isFocused ? '►' : ' '} + + {session.sessionId} + + {metaSuffix.length > 0 && {metaSuffix}} + + + {pendingCount === 0 ? ( + (no pending requests) + ) : ( + + {pendingCount} pending + {isFocused ? ' — → to inspect' : ''} + + )} + + + ); + })} ); } diff --git a/packages/kernel-tui/src/hooks/use-kernel.ts b/packages/kernel-tui/src/hooks/use-kernel.ts index 0dd4fbeccf..cf9468edcb 100644 --- a/packages/kernel-tui/src/hooks/use-kernel.ts +++ b/packages/kernel-tui/src/hooks/use-kernel.ts @@ -67,7 +67,14 @@ export function makeDaemonKernelApi( }, async listSessions() { - return send<{ sessionId: string; ocapUrl: string }[]>('session.list'); + return send< + { + sessionId: string; + ocapUrl: string; + cwd?: string; + startedAt?: string; + }[] + >('session.list'); }, async listRequests(sessionId) { @@ -77,6 +84,20 @@ export function makeDaemonKernelApi( ); }, + async listHistory(sessionId) { + return send< + { + token: string; + description: string; + reason: string; + guard: { body: string; slots: string[] }; + queuedAt: string; + status: 'pending' | 'accepted' | 'rejected'; + decidedAt?: string; + }[] + >('session.history', { sessionId }); + }, + async decide(sessionId, token, verdict) { await send('session.decide', { sessionId, token, verdict, feedback: '' }); }, diff --git a/packages/kernel-tui/src/types.ts b/packages/kernel-tui/src/types.ts index 3ddedcff81..99b6fb63ce 100644 --- a/packages/kernel-tui/src/types.ts +++ b/packages/kernel-tui/src/types.ts @@ -2,9 +2,10 @@ import type { SessionApi, SessionSummary, PendingRequest, + SessionHistoryEntry, } from '@metamask/kernel-utils/session'; -export type { SessionSummary, PendingRequest }; +export type { SessionSummary, PendingRequest, SessionHistoryEntry }; export type KernelStatus = { active: boolean; @@ -17,6 +18,7 @@ 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; From 0bbfbac6f943cd1d03d1e8d67865785c98946c07 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 18 May 2026 08:07:48 -0400 Subject: [PATCH 15/34] fix(kernel-tui): poll session history while detail view is open New requests arriving while inspecting a session were invisible until the user navigated away and back. Add a POLL_INTERVAL_MS interval tied to detailSession so refreshDetail runs automatically while the detail view is active. Co-Authored-By: Claude Sonnet 4.6 --- packages/kernel-tui/src/components/sessions-view.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/kernel-tui/src/components/sessions-view.tsx b/packages/kernel-tui/src/components/sessions-view.tsx index bc57ac90da..304a321991 100644 --- a/packages/kernel-tui/src/components/sessions-view.tsx +++ b/packages/kernel-tui/src/components/sessions-view.tsx @@ -137,6 +137,12 @@ export function SessionsView({ }; }, [refresh]); + useEffect(() => { + if (detailSession === null) return; + const interval = setInterval(refreshDetail, POLL_INTERVAL_MS); + return () => clearInterval(interval); + }, [detailSession, refreshDetail]); + useInput((input, key) => { if (detailSession !== null) { return; From 961261df61d0c4f92679ee070248844b01fe1fc3 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 18 May 2026 08:51:48 -0400 Subject: [PATCH 16/34] refactor(kernel-tui): extract session data fetching into useSessionData hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SessionsView previously owned polling intervals, all fetch callbacks, loading/ error state, and detail-open state alongside its render logic. Move all of that into a useSessionData hook so the component owns only cursor position. - hooks/use-session-data.ts: new hook — owns refresh/refreshDetail intervals, openDetail/closeDetail, onDecided, loading, error, sessions, detailHistory - sessions-view.tsx: consumes useSessionData; only local state is cursor; extract sessionMetaSuffix helper to keep render logic readable Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/sessions-view.tsx | 145 ++++------------- .../kernel-tui/src/hooks/use-session-data.ts | 149 ++++++++++++++++++ 2 files changed, 183 insertions(+), 111 deletions(-) create mode 100644 packages/kernel-tui/src/hooks/use-session-data.ts diff --git a/packages/kernel-tui/src/components/sessions-view.tsx b/packages/kernel-tui/src/components/sessions-view.tsx index 304a321991..478cbb326b 100644 --- a/packages/kernel-tui/src/components/sessions-view.tsx +++ b/packages/kernel-tui/src/components/sessions-view.tsx @@ -1,24 +1,16 @@ import { Box, Text, useInput } from 'ink'; import Spinner from 'ink-spinner'; import { homedir } from 'node:os'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useState } from 'react'; -import type { - KernelApi, - PendingRequest, - SessionHistoryEntry, - SessionSummary, -} from '../types.ts'; +import type { KernelApi, SessionSummary } from '../types.ts'; +import { useSessionData } from '../hooks/use-session-data.ts'; import { SessionDetailView } from './session-detail-view.tsx'; -type SessionWithRequests = SessionSummary & { requests: PendingRequest[] }; - type SessionsViewProps = { kernelApi: KernelApi; }; -const POLL_INTERVAL_MS = 2000; - /** * Tildefy an absolute path by replacing the home directory prefix with `~`. * @@ -48,12 +40,28 @@ function formatStartedAt(iso: string): string { 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. @@ -61,92 +69,21 @@ function formatStartedAt(iso: string): string { export function SessionsView({ kernelApi, }: SessionsViewProps): React.ReactElement { - const [sessions, setSessions] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); const [cursor, setCursor] = useState(0); - 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 openDetail = useCallback( - (session: SessionSummary): void => { - kernelApi - .listHistory(session.sessionId) - .then((history) => { - if (mountedRef.current) { - setDetailHistory(history); - setDetailSession(session); - } - return undefined; - }) - .catch(() => undefined); - }, - [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]); - - useEffect(() => { - mountedRef.current = true; - refresh(); - const interval = setInterval(refresh, POLL_INTERVAL_MS); - return () => { - mountedRef.current = false; - clearInterval(interval); - }; - }, [refresh]); - - useEffect(() => { - if (detailSession === null) return; - const interval = setInterval(refreshDetail, POLL_INTERVAL_MS); - return () => clearInterval(interval); - }, [detailSession, refreshDetail]); + const { + sessions, + loading, + error, + detailSession, + detailHistory, + openDetail, + closeDetail, + refresh, + onDecided, + } = useSessionData(kernelApi); useInput((input, key) => { - if (detailSession !== null) { - return; - } + if (detailSession !== null) return; if (key.upArrow) { setCursor((prev) => Math.max(0, prev - 1)); } else if (key.downArrow) { @@ -157,7 +94,6 @@ export function SessionsView({ openDetail(focused); } } else if (input === 'R') { - setLoading(true); refresh(); } }); @@ -168,14 +104,8 @@ export function SessionsView({ session={detailSession} entries={detailHistory} kernelApi={kernelApi} - onBack={() => { - setDetailSession(null); - setDetailHistory([]); - }} - onDecided={() => { - refresh(); - refreshDetail(); - }} + onBack={closeDetail} + onDecided={onDecided} /> ); } @@ -222,14 +152,7 @@ export function SessionsView({ {sessions.map((session, idx) => { const isFocused = idx === cursor; const pendingCount = session.requests.length; - const meta: string[] = []; - if (session.cwd !== undefined) { - meta.push(tildify(session.cwd)); - } - if (session.startedAt !== undefined) { - meta.push(formatStartedAt(session.startedAt)); - } - const metaSuffix = meta.length > 0 ? ` (${meta.join(' ')})` : ''; + const metaSuffix = sessionMetaSuffix(session); return ( 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, + }; +} From c93ba9a8b2bc3c7d6153dc1a93d5977d127a3335 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 18 May 2026 11:18:54 -0400 Subject: [PATCH 17/34] chore(changelog): add changelog entries for kernel-tui PR --- packages/kernel-cli/CHANGELOG.md | 5 +++++ packages/kernel-tui/CHANGELOG.md | 7 +++++++ 2 files changed, 12 insertions(+) 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-tui/CHANGELOG.md b/packages/kernel-tui/CHANGELOG.md index 0c82cb1ed6..739468200f 100644 --- a/packages/kernel-tui/CHANGELOG.md +++ b/packages/kernel-tui/CHANGELOG.md @@ -7,4 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [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/ From 739f24b924e37ef82b20146501adb828f815a2fb Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 18 May 2026 11:33:14 -0400 Subject: [PATCH 18/34] test: add coverage for session registry, RPC session methods, and useSessionData hook - session-registry.test.ts: 11 tests for listHistory(), authorizeRequest() (including timeout), createSession() with cwd/startedAt - rpc-socket-server.test.ts: 9 integration tests for session.history, session.authorize, and updated session.create/list/get responses - use-session-data.test.ts: 9 hook tests covering loading state, session fetch, error handling, openDetail, closeDetail, and polling Co-Authored-By: Claude Sonnet 4.6 --- .../src/daemon/rpc-socket-server.test.ts | 383 ++++++++++++++++++ packages/kernel-tui/package.json | 4 + .../src/hooks/use-session-data.test.ts | 190 +++++++++ .../src/session/session-registry.test.ts | 182 +++++++++ yarn.lock | 4 + 5 files changed, 763 insertions(+) create mode 100644 packages/kernel-node-runtime/src/daemon/rpc-socket-server.test.ts create mode 100644 packages/kernel-tui/src/hooks/use-session-data.test.ts create mode 100644 packages/kernel-utils/src/session/session-registry.test.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..8f0c00f1c6 --- /dev/null +++ b/packages/kernel-node-runtime/src/daemon/rpc-socket-server.test.ts @@ -0,0 +1,383 @@ +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: '', + }), + 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.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', + 'Needed for operation', + undefined, + ); + }); +}); diff --git a/packages/kernel-tui/package.json b/packages/kernel-tui/package.json index 0ced6f452e..7122d963ba 100644 --- a/packages/kernel-tui/package.json +++ b/packages/kernel-tui/package.json @@ -46,10 +46,12 @@ "@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", @@ -64,7 +66,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", 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-utils/src/session/session-registry.test.ts b/packages/kernel-utils/src/session/session-registry.test.ts new file mode 100644 index 0000000000..290d51953a --- /dev/null +++ b/packages/kernel-utils/src/session/session-registry.test.ts @@ -0,0 +1,182 @@ +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', + '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('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', + 'needs shell', + 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/yarn.lock b/yarn.lock index 2106002afd..651b2a4952 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4028,10 +4028,12 @@ __metadata: "@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" @@ -4051,8 +4053,10 @@ __metadata: 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" From 9b51a119e863e8a63769d894ad2675feb0ad2f56 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 18 May 2026 12:17:54 -0400 Subject: [PATCH 19/34] feat(kernel-tui): reverse entry order, prettify expanded JSON, split Bash commands - Show most-recent history entries at the top of the detail view - Expand entries by rendering prettified JSON with description(params) format - Split compound shell commands on &&, |, ; into list segments for readability Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/session-detail-view.tsx | 216 ++++++++++++++++-- 1 file changed, 196 insertions(+), 20 deletions(-) diff --git a/packages/kernel-tui/src/components/session-detail-view.tsx b/packages/kernel-tui/src/components/session-detail-view.tsx index 127f734237..cea077863a 100644 --- a/packages/kernel-tui/src/components/session-detail-view.tsx +++ b/packages/kernel-tui/src/components/session-detail-view.tsx @@ -44,16 +44,178 @@ function formatTime(iso: string): string { } /** - * Detail view for a single session showing a chronological timeline of - * authorization requests. Each entry can be expanded with the right arrow key - * and collapsed with the left arrow key. Left arrow on a collapsed first entry - * navigates back to the session list. + * 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; +}; + +/** + * Split an `entry.description` of the form `Label({...json...})` into a short + * label and a params object. Returns the full description as the label when the + * format is not recognised or the inner content is not a JSON object. + * + * @param description - Raw entry description string. + * @returns Parsed label and params. + */ +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; +} + +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 }; +} + +/** + * Format the expanded content for a history entry. + * + * Parses `entry.description` as `Label({...params...})` and returns a + * prettified `Label({\n ...\n})` string. For Bash entries, compound shell + * operators in the `command` field are pre-split into an array. Falls back to + * the raw description when the format is not recognised. + * + * @param entry - The history entry to format. + * @returns Formatted string for display. + */ +const MAX_STRING_LENGTH = 200; + +function truncateParams( + params: Record, +): Record { + return Object.fromEntries( + Object.entries(params).map(([k, v]) => [ + k, + typeof v === 'string' && v.length > MAX_STRING_LENGTH + ? `${v.slice(0, MAX_STRING_LENGTH)}…` + : v, + ]), + ); +} + +function formatExpandedContent(entry: SessionHistoryEntry): string { + const { label, params } = parseDescription(entry.description); + if (params === null) { + return entry.description; + } + + let displayParams = truncateParams(params); + if (label.includes('Bash') && typeof displayParams.command === 'string') { + const segments = splitShellCommand(displayParams.command); + if (segments.length > 1) { + displayParams = { ...displayParams, command: segments }; + } + } + + return `${label}(${JSON.stringify(displayParams, null, 2)})`; +} + +/** + * 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, a accept, r reject. * * @param props - Component props. * @param props.session - The session being viewed. - * @param props.entries - Chronological history entries. + * @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. @@ -71,14 +233,17 @@ export function SessionDetailView({ const [deciding, setDeciding] = useState(false); const [error, setError] = useState(null); - const safeCursor = Math.min(cursor, Math.max(0, entries.length - 1)); - const focused = entries[safeCursor]; + // Reverse so newest (including all pending) appears at the top. + const displayEntries = [...entries].reverse(); + + const safeCursor = Math.min(cursor, Math.max(0, displayEntries.length - 1)); + const focused = displayEntries[safeCursor]; useInput((input, key) => { if (key.upArrow) { setCursor((prev) => Math.max(0, prev - 1)); } else if (key.downArrow) { - setCursor((prev) => Math.min(entries.length - 1, prev + 1)); + setCursor((prev) => Math.min(displayEntries.length - 1, prev + 1)); } else if (key.rightArrow && focused !== undefined) { setExpanded((prev) => new Set([...prev, focused.token])); } else if (key.leftArrow) { @@ -123,15 +288,21 @@ export function SessionDetailView({ {error !== null && {error}} - {entries.length === 0 ? ( + {displayEntries.length === 0 ? ( No requests yet. ) : ( - entries.map((entry, idx) => { + displayEntries.map((entry, idx) => { const isFocused = idx === safeCursor; const isExpanded = expanded.has(entry.token); const icon = STATUS_ICON[entry.status]; const color = STATUS_COLOR[entry.status]; + const { label } = parseDescription(entry.description); + + const expandedLines = isExpanded + ? formatExpandedContent(entry).split('\n') + : []; + return ( @@ -140,17 +311,22 @@ export function SessionDetailView({ {formatTime(entry.queuedAt)} - {entry.description} + {label} - {isExpanded && ( - - {entry.reason} - {entry.decidedAt !== undefined && ( - decided {formatTime(entry.decidedAt)} - )} - {entry.guard.body !== '#{}' && ( - guard: {entry.guard.body} - )} + {expandedLines.map((line, lineIdx) => ( + // eslint-disable-next-line react/no-array-index-key + + {line} + + ))} + {isExpanded && entry.decidedAt !== undefined && ( + + decided {formatTime(entry.decidedAt)} + + )} + {isExpanded && entry.guard.body !== '#{}' && ( + + guard: {entry.guard.body} )} From 2386bba4c7a41750899c003fc075186a751e577c Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 20 May 2026 10:05:28 -0400 Subject: [PATCH 20/34] feat(kernel-tui): token-stable cursor, top-level decisions, and accept/reject keybinds - Replace numeric cursor index with token-based focus in SessionDetailView so new requests arriving above the cursor no longer shift what's highlighted - Show oldest pending request expanded at the session list level; 1/3 decide without drilling into the detail view - Change accept/reject keybinds from a/r to 1/3 throughout (status bar updated) - Export formatExpandedContent and parseDescription for reuse across views Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/session-detail-view.tsx | 420 +++++++++++++++--- .../src/components/sessions-view.tsx | 91 +++- .../kernel-tui/src/components/status-bar.tsx | 2 +- 3 files changed, 435 insertions(+), 78 deletions(-) diff --git a/packages/kernel-tui/src/components/session-detail-view.tsx b/packages/kernel-tui/src/components/session-detail-view.tsx index cea077863a..c5dca1c623 100644 --- a/packages/kernel-tui/src/components/session-detail-view.tsx +++ b/packages/kernel-tui/src/components/session-detail-view.tsx @@ -1,5 +1,5 @@ -import { Box, Text, useInput } from 'ink'; -import React, { useState } from 'react'; +import { Box, Text, useInput, useStdout } from 'ink'; +import React, { useEffect, useMemo, useState } from 'react'; import type { KernelApi, @@ -72,17 +72,19 @@ type ParsedDescription = { }; /** - * Split an `entry.description` of the form `Label({...json...})` into a short - * label and a params object. Returns the full description as the label when the - * format is not recognised or the inner content is not a JSON object. + * Attempt to parse a string as a JSON object (not an array or primitive). * - * @param description - Raw entry description string. - * @returns Parsed label and params. + * @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)) { + if ( + typeof parsed === 'object' && + parsed !== null && + !Array.isArray(parsed) + ) { return parsed as Record; } } catch { @@ -125,10 +127,15 @@ function escapeControlCharsInStrings(str: string): string { 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')}`; + 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; } @@ -139,7 +146,14 @@ function escapeControlCharsInStrings(str: string): string { return out; } -function parseDescription(description: string): ParsedDescription { +/** + * 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 }; @@ -162,47 +176,237 @@ function parseDescription(description: string): ParsedDescription { return { label, params: fallback }; } +const MAX_STRING_LENGTH = 200; + /** - * Format the expanded content for a history entry. + * 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. * - * Parses `entry.description` as `Label({...params...})` and returns a - * prettified `Label({\n ...\n})` string. For Bash entries, compound shell - * operators in the `command` field are pre-split into an array. Falls back to - * the raw description when the format is not recognised. + * @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. * - * @param entry - The history entry to format. - * @returns Formatted string for 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. */ -const MAX_STRING_LENGTH = 200; +export function formatExpandedContent(description: string): string { + const { label, params } = parseDescription(description); -function truncateParams( - params: Record, -): Record { - return Object.fromEntries( - Object.entries(params).map(([k, v]) => [ - k, - typeof v === 'string' && v.length > MAX_STRING_LENGTH - ? `${v.slice(0, MAX_STRING_LENGTH)}…` - : v, - ]), - ); + // 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'); } -function formatExpandedContent(entry: SessionHistoryEntry): string { - const { label, params } = parseDescription(entry.description); - if (params === null) { - return entry.description; +/** + * 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 extras = + (entry.decidedAt === undefined ? 0 : 1) + + (entry.guard.body === '#{}' ? 0 : 1); + return 1 + contentRows + extras; +} - let displayParams = truncateParams(params); - if (label.includes('Bash') && typeof displayParams.command === 'string') { - const segments = splitShellCommand(displayParams.command); - if (segments.length > 1) { - displayParams = { ...displayParams, command: segments }; +/** + * 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; +} - return `${label}(${JSON.stringify(displayParams, null, 2)})`; +/** + * 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; } /** @@ -211,7 +415,7 @@ function formatExpandedContent(entry: SessionHistoryEntry): string { * 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, a accept, r reject. + * Keybindings: ↑/↓ navigate, → expand, ← collapse/back, 1 accept, 3 reject. * * @param props - Component props. * @param props.session - The session being viewed. @@ -228,39 +432,123 @@ export function SessionDetailView({ onBack, onDecided, }: SessionDetailViewProps): React.ReactElement { - const [cursor, setCursor] = useState(0); - const [expanded, setExpanded] = useState>(new Set()); + 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 { 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 = [...entries].reverse(); + 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]); - const safeCursor = Math.min(cursor, Math.max(0, displayEntries.length - 1)); - const focused = displayEntries[safeCursor]; + // 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 (key.upArrow) { - setCursor((prev) => Math.max(0, prev - 1)); + 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) { - setCursor((prev) => Math.min(displayEntries.length - 1, prev + 1)); + 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) { - setExpanded((prev) => new Set([...prev, focused.token])); + 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)) { - setExpanded((prev) => { - const next = new Set(prev); - next.delete(focused.token); - return next; - }); + 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 === 'a' || input === 'r') && !deciding) { + } else if ((input === '1' || input === '3') && !deciding) { if (focused === undefined || focused.status !== 'pending') { return; } - const verdict = input === 'a' ? 'accept' : 'reject'; + const verdict = input === '1' ? 'accept' : 'reject'; setDeciding(true); kernelApi .decide(session.sessionId, focused.token, verdict) @@ -288,11 +576,14 @@ export function SessionDetailView({ {error !== null && {error}} + {countAbove > 0 && ↑ {countAbove} more} + {displayEntries.length === 0 ? ( No requests yet. ) : ( - displayEntries.map((entry, idx) => { - const isFocused = idx === safeCursor; + visibleEntries.map((entry) => { + const idx = displayEntries.indexOf(entry); + const isFocused = idx === cursorIdx; const isExpanded = expanded.has(entry.token); const icon = STATUS_ICON[entry.status]; const color = STATUS_COLOR[entry.status]; @@ -300,7 +591,7 @@ export function SessionDetailView({ const { label } = parseDescription(entry.description); const expandedLines = isExpanded - ? formatExpandedContent(entry).split('\n') + ? formatExpandedContent(entry.description).split('\n') : []; return ( @@ -314,9 +605,10 @@ export function SessionDetailView({ {label} {expandedLines.map((line, lineIdx) => ( - // eslint-disable-next-line react/no-array-index-key - {line} + + {line} + ))} {isExpanded && entry.decidedAt !== undefined && ( @@ -333,6 +625,8 @@ export function SessionDetailView({ ); }) )} + + {countBelow > 0 && ↓ {countBelow} more} ); } diff --git a/packages/kernel-tui/src/components/sessions-view.tsx b/packages/kernel-tui/src/components/sessions-view.tsx index 478cbb326b..a884eb8146 100644 --- a/packages/kernel-tui/src/components/sessions-view.tsx +++ b/packages/kernel-tui/src/components/sessions-view.tsx @@ -3,9 +3,13 @@ import Spinner from 'ink-spinner'; import { homedir } from 'node:os'; import React, { useState } from 'react'; -import type { KernelApi, SessionSummary } from '../types.ts'; import { useSessionData } from '../hooks/use-session-data.ts'; -import { SessionDetailView } from './session-detail-view.tsx'; +import type { KernelApi, SessionSummary } from '../types.ts'; +import { + formatExpandedContent, + parseDescription, + SessionDetailView, +} from './session-detail-view.tsx'; type SessionsViewProps = { kernelApi: KernelApi; @@ -48,8 +52,12 @@ function formatStartedAt(iso: string): string { */ 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)); + if (session.cwd !== undefined) { + meta.push(tildify(session.cwd)); + } + if (session.startedAt !== undefined) { + meta.push(formatStartedAt(session.startedAt)); + } return meta.length > 0 ? ` (${meta.join(' ')})` : ''; } @@ -70,6 +78,8 @@ 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, @@ -83,7 +93,9 @@ export function SessionsView({ } = useSessionData(kernelApi); useInput((input, key) => { - if (detailSession !== null) return; + if (detailSession !== null) { + return; + } if (key.upArrow) { setCursor((prev) => Math.max(0, prev - 1)); } else if (key.downArrow) { @@ -93,6 +105,27 @@ export function SessionsView({ 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(); } @@ -149,10 +182,47 @@ export function SessionsView({ 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 ( @@ -163,15 +233,8 @@ export function SessionsView({ {metaSuffix.length > 0 && {metaSuffix}} - - {pendingCount === 0 ? ( - (no pending requests) - ) : ( - - {pendingCount} pending - {isFocused ? ' — → to inspect' : ''} - - )} + + {pendingSection} ); diff --git a/packages/kernel-tui/src/components/status-bar.tsx b/packages/kernel-tui/src/components/status-bar.tsx index 2a834f1de9..5954f68aee 100644 --- a/packages/kernel-tui/src/components/status-bar.tsx +++ b/packages/kernel-tui/src/components/status-bar.tsx @@ -9,7 +9,7 @@ type StatusBarProps = { }; const VIEW_HINTS: Record = { - sessions: '↑/↓: navigate | a: accept | r: reject | R: refresh', + sessions: '↑/↓: navigate | 1: accept | 3: reject | R: refresh', files: 'Select a bundle to launch', objects: 'r: refresh', invoke: 'Tab: next field | Enter on args: send', From bdba1e666152f3608757cb884afa450fb2ec3992 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 20 May 2026 11:48:31 -0400 Subject: [PATCH 21/34] feat(caprock): add caprock Claude Code plugin package Migrates the standalone `pola` plugin into the monorepo as `@ocap/caprock`. The plugin routes every Claude Code tool invocation through an ocap-kernel permission vat (POLA enforcement), blocking for a TUI decision on first use of any (tool, input-hash) pair not yet in the allow-set. Key factoring decisions made visible by co-location: - `sendCommand`/`getSocketPath` imported from `@metamask/kernel-node-runtime` - `Decision` type imported from `@metamask/kernel-utils/session` - `getOcapHome` imported from `@metamask/kernel-utils/nodejs` - Vat bundle built at `build` time via `ocap bundle` (no committed artifact) Includes 62 unit tests covering `decompose()` (bash parser security logic) and `session.ts` persistence round-trips. Co-Authored-By: Claude Sonnet 4.6 --- packages/caprock/.claude-plugin/plugin.json | 8 + packages/caprock/CHANGELOG.md | 10 + packages/caprock/README.md | 15 + packages/caprock/bin/hook.ts | 760 ++++++++++++++++++++ packages/caprock/bin/setup.ts | 174 +++++ packages/caprock/bin/status.ts | 221 ++++++ packages/caprock/hooks/hooks.json | 77 ++ packages/caprock/package.json | 92 +++ packages/caprock/scripts/setup.sh | 3 + packages/caprock/scripts/status.sh | 3 + packages/caprock/src/bash.test.ts | 255 +++++++ packages/caprock/src/bash.ts | 531 ++++++++++++++ packages/caprock/src/index.test.ts | 17 + packages/caprock/src/index.ts | 3 + packages/caprock/src/paths/ocap-kernel.ts | 32 + packages/caprock/src/paths/plugin.ts | 62 ++ packages/caprock/src/paths/user.ts | 29 + packages/caprock/src/rpc.ts | 114 +++ packages/caprock/src/session.test.ts | 176 +++++ packages/caprock/src/session.ts | 128 ++++ packages/caprock/src/transcript.ts | 81 +++ packages/caprock/src/types.ts | 96 +++ packages/caprock/tsconfig.build.json | 16 + packages/caprock/tsconfig.json | 20 + packages/caprock/typedoc.json | 8 + packages/caprock/vat/permission-tracker.ts | 61 ++ packages/caprock/vitest.config.ts | 22 + tsconfig.build.json | 1 + tsconfig.json | 1 + turbo.json | 2 +- yarn.config.cjs | 1 + yarn.lock | 67 +- 32 files changed, 3080 insertions(+), 6 deletions(-) create mode 100644 packages/caprock/.claude-plugin/plugin.json create mode 100644 packages/caprock/CHANGELOG.md create mode 100644 packages/caprock/README.md create mode 100644 packages/caprock/bin/hook.ts create mode 100644 packages/caprock/bin/setup.ts create mode 100644 packages/caprock/bin/status.ts create mode 100644 packages/caprock/hooks/hooks.json create mode 100644 packages/caprock/package.json create mode 100755 packages/caprock/scripts/setup.sh create mode 100755 packages/caprock/scripts/status.sh create mode 100644 packages/caprock/src/bash.test.ts create mode 100644 packages/caprock/src/bash.ts create mode 100644 packages/caprock/src/index.test.ts create mode 100644 packages/caprock/src/index.ts create mode 100644 packages/caprock/src/paths/ocap-kernel.ts create mode 100644 packages/caprock/src/paths/plugin.ts create mode 100644 packages/caprock/src/paths/user.ts create mode 100644 packages/caprock/src/rpc.ts create mode 100644 packages/caprock/src/session.test.ts create mode 100644 packages/caprock/src/session.ts create mode 100644 packages/caprock/src/transcript.ts create mode 100644 packages/caprock/src/types.ts create mode 100644 packages/caprock/tsconfig.build.json create mode 100644 packages/caprock/tsconfig.json create mode 100644 packages/caprock/typedoc.json create mode 100644 packages/caprock/vat/permission-tracker.ts create mode 100644 packages/caprock/vitest.config.ts 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/hook.ts b/packages/caprock/bin/hook.ts new file mode 100644 index 0000000000..82a78af5f7 --- /dev/null +++ b/packages/caprock/bin/hook.ts @@ -0,0 +1,760 @@ +/* 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 { 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 { + 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, + createKernelSession, + authorizeRequest, +} 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 }; +} + +/** + * Query the permission vat: returns 'allow' if the (toolName, sha) pair was granted. + * + * @param rootKref - The vat's root kref. + * @param toolName - The tool name. + * @param sha - The input SHA. + * @returns 'allow' or 'ask'. + */ +async function vatCheck( + rootKref: string, + toolName: string, + sha: string, +): Promise { + const response = await sendCommand({ + socketPath: SOCKET_PATH, + method: 'queueMessage', + params: [rootKref, 'check', [toolName, sha]], + }); + if (isJsonRpcFailure(response)) { + throw new Error(`vatCheck: ${response.error.message}`); + } + return decodeCapData(response.result as CapData) as string; +} + +/** + * Grant authority in the permission vat for (toolName, sha). Idempotent. + * + * @param rootKref - The vat's root kref. + * @param toolName - The tool name. + * @param sha - The input SHA. + */ +async function vatGrant( + rootKref: string, + toolName: string, + sha: string, +): Promise { + await sendCommand({ + socketPath: SOCKET_PATH, + method: 'queueMessage', + params: [rootKref, 'grant', [toolName, sha]], + }); +} + +/** + * 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; +} + +// ─── 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 state = await getOrInitSession(payload); + if (!state) { + process.stdout.write(JSON.stringify({ continue: true })); + return; + } + + let vatResponse = 'unknown'; + try { + vatResponse = await vatCheck(state.rootKref, tool_name, sha); + } catch (error) { + process.stderr.write(`[caprock] vatCheck failed: ${String(error)}\n`); + } + + await appendEvent(session_id, { + t: now(), + event: 'check', + sessionId: session_id, + toolName: tool_name, + inputSha: sha, + vatResponse, + }); + + if (vatResponse === 'allow') { + 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, + ); + } 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); + state.kernelSessionId = ks.sessionId; + 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') { + await vatGrant(state.rootKref, tool_name, sha).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; + } + + try { + await vatGrant(state.rootKref, tool_name, sha); + } catch (error) { + process.stderr.write(`[caprock] vatGrant 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 && sha) { + try { + const vatResponse = await vatCheck(state.rootKref, tool_name, sha); + 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..4774bd46dd --- /dev/null +++ b/packages/caprock/hooks/hooks.json @@ -0,0 +1,77 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/hook.js\"" + } + ] + } + ], + "PreToolUse": [ + { + "timeout_ms": 300000, + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/hook.js\"" + } + ] + } + ], + "PostToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/hook.js\"" + } + ] + } + ], + "PermissionRequest": [ + { + "timeout_ms": 300000, + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/hook.js\"" + } + ] + } + ], + "PermissionDenied": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/hook.js\"" + } + ] + } + ], + "FileChanged": [ + { + "matcher": ".claude/settings.json|.claude/settings.local.json", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/hook.js\"" + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/hook.js\"" + } + ] + } + ] + } +} diff --git a/packages/caprock/package.json b/packages/caprock/package.json new file mode 100644 index 0000000000..bf75079441 --- /dev/null +++ b/packages/caprock/package.json @@ -0,0 +1,92 @@ +{ + "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": { + "@metamask/kernel-node-runtime": "workspace:^", + "@metamask/kernel-utils": "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..bf3dc775e0 --- /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/setup.js" "$@" diff --git a/packages/caprock/scripts/status.sh b/packages/caprock/scripts/status.sh new file mode 100755 index 0000000000..6b47a23d85 --- /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/status.js" "$@" 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..7fdbc8475b --- /dev/null +++ b/packages/caprock/src/paths/ocap-kernel.ts @@ -0,0 +1,32 @@ +/* eslint-disable n/no-process-env */ + +import { getSocketPath } from '@metamask/kernel-node-runtime/daemon'; +import { getOcapHome } from '@metamask/kernel-utils/nodejs'; +import { join } from 'node:path'; + +import { getPluginDataDir } from './plugin.ts'; + +export { getOcapHome, getSocketPath }; + +/** + * 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..545c52e370 --- /dev/null +++ b/packages/caprock/src/rpc.ts @@ -0,0 +1,114 @@ +import { sendCommand } from '@metamask/kernel-node-runtime/daemon'; +import { isJsonRpcFailure } from '@metamask/utils'; + +import type { CapData, Decision } from './types.ts'; + +export { sendCommand }; + +/** + * 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 reason and client-side timeout. + * @param options.reason - Optional reason for the request. + * @param options.timeoutMs - Optional client-side timeout in milliseconds. + * @returns The TUI's decision. + */ +export async function authorizeRequest( + socketPath: string, + kernelSessionId: string, + description: string, + options?: { reason?: string; timeoutMs?: number }, +): Promise { + const params: Record = { + sessionId: kernelSessionId, + description, + }; + if (options?.reason !== undefined) { + params.reason = options.reason; + } + if (options?.timeoutMs !== undefined) { + params.timeoutMs = options.timeoutMs; + } + 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; +} + +/** + * 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)}`); +} 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..a4bd47c90d --- /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": "../kernel-node-runtime/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..53ffbfa791 --- /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": "../kernel-node-runtime" } + ], + "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.ts b/packages/caprock/vat/permission-tracker.ts new file mode 100644 index 0000000000..7ca9ec701d --- /dev/null +++ b/packages/caprock/vat/permission-tracker.ts @@ -0,0 +1,61 @@ +/** + * 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 with an empty allow-list; + * the ToFU rule means authority only grows, never shrinks. + * + * Sheaf model: + * - allowedKeys: Set of ":" strings + * - check(toolName, inputSha) → 'allow' | 'ask' + * - grant(toolName, inputSha) → void (idempotent) + * + * Build: run `yarn workspace @ocap/caprock build:vat` to produce + * `vat/permission-tracker.bundle`, which is committed alongside this source. + */ + +import { makeDefaultExo } from '@metamask/kernel-utils/exo'; + +/** + * Build the root object for the permission-tracker vat. + * + * @returns The exo capability object exposed as the vat's bootstrap. + */ +export function buildRootObject(): ReturnType { + const allowedKeys = new Set(); + + return makeDefaultExo('permission-tracker', { + // eslint-disable-next-line no-empty-function + bootstrap(): void {}, + + /** + * Returns 'allow' if this (toolName, inputSha) pair was previously granted. + * + * @param toolName - The tool name. + * @param inputSha - The input SHA. + * @returns 'allow' or 'ask'. + */ + check(toolName: string, inputSha: string): string { + return allowedKeys.has(`${toolName}:${inputSha}`) ? 'allow' : 'ask'; + }, + + /** + * Add an allow entry for (toolName, inputSha). Idempotent. + * + * @param toolName - The tool name. + * @param inputSha - The input SHA. + */ + grant(toolName: string, inputSha: string): void { + allowedKeys.add(`${toolName}:${inputSha}`); + }, + + /** + * Return the current allow-set size (for session_end stats). + * + * @returns The number of granted (toolName, inputSha) pairs. + */ + size(): number { + return allowedKeys.size; + }, + }); +} 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/tsconfig.build.json b/tsconfig.build.json index bbf6a8cdf9..35a7a35b60 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -2,6 +2,7 @@ "files": [], "include": [], "references": [ + { "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" }, diff --git a/tsconfig.json b/tsconfig.json index 2d233f5abd..3c15597850 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,7 @@ "files": [], "include": [], "references": [ + { "path": "./packages/caprock" }, { "path": "./packages/create-package" }, { "path": "./packages/evm-wallet-experiment" }, { "path": "./packages/extension" }, 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 89813eed92..7fe3a72186 100644 --- a/yarn.config.cjs +++ b/yarn.config.cjs @@ -30,6 +30,7 @@ const typedocExceptions = [ ]; // Packages that do not enforce the standard build script const buildExceptions = [ + 'caprock', 'create-package', 'kernel-cli', 'kernel-tui', diff --git a/yarn.lock b/yarn.lock index 651b2a4952..13bcab8977 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3637,6 +3637,47 @@ __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" + "@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-node-runtime": "workspace:^" + "@metamask/kernel-utils": "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" @@ -12372,12 +12413,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 @@ -12417,7 +12458,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: @@ -15262,6 +15303,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" From 02c121917b5242d01c2061bcced0cf3aaa456bcb Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Thu, 21 May 2026 12:06:17 -0400 Subject: [PATCH 22/34] feat(sheaves): add leastAuthority policy Sorts candidates ascending by numeric `authority` metadata, so the most- restricted matching section is tried first. Candidates without an authority entry default to 0.5 (midpoint). Co-Authored-By: Claude Sonnet 4.6 --- packages/sheaves/src/compose.ts | 28 ++++++++++++++++++++++++++++ packages/sheaves/src/index.ts | 1 + 2 files changed, 29 insertions(+) diff --git a/packages/sheaves/src/compose.ts b/packages/sheaves/src/compose.ts index ef1d7fc5c7..7e3b4cdcc6 100644 --- a/packages/sheaves/src/compose.ts +++ b/packages/sheaves/src/compose.ts @@ -95,6 +95,34 @@ 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 (passed through to noopPolicy). + * @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; + }), + context, + ); +} + /** * 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'; From a87ec61678ff67fbfd920cbc12aa08d18dbab66a Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Thu, 21 May 2026 12:09:24 -0400 Subject: [PATCH 23/34] feat(kernel-utils): provision types and partial-order algebra Adds ArgPattern / InvocationPattern / Provision types and the full algebra: interval builders (pathInterval, trivialInterval, argInterval), matchArg / matchPattern / matchProvision, argPatternLe, compareInvocationPatterns / compareProvisions, computeAuthority (midpoint-embedding of the partial order into (0,1)), and invocationToProvision. Adds provision? to Decision and SessionApi.decide so approved provisions can flow from the TUI through the RPC layer to the hook. Co-Authored-By: Claude Sonnet 4.6 --- packages/kernel-utils/src/session/index.ts | 19 + .../src/session/provision.test.ts | 477 ++++++++++++++++++ .../kernel-utils/src/session/provision.ts | 337 +++++++++++++ packages/kernel-utils/src/session/types.ts | 39 ++ 4 files changed, 872 insertions(+) create mode 100644 packages/kernel-utils/src/session/provision.test.ts create mode 100644 packages/kernel-utils/src/session/provision.ts diff --git a/packages/kernel-utils/src/session/index.ts b/packages/kernel-utils/src/session/index.ts index 87830ec7e0..0adbbb4d74 100644 --- a/packages/kernel-utils/src/session/index.ts +++ b/packages/kernel-utils/src/session/index.ts @@ -1,4 +1,7 @@ export type { + ArgPattern, + InvocationPattern, + Provision, SectionRequest, SectionNotification, Decision, @@ -11,3 +14,19 @@ 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 { ParsedInvocation, 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..080084f025 --- /dev/null +++ b/packages/kernel-utils/src/session/provision.ts @@ -0,0 +1,337 @@ +import type { ArgPattern, InvocationPattern, Provision } from './types.ts'; + +export type ParsedInvocation = { name: string; argv: string[] }; + +/** + * 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/types.ts b/packages/kernel-utils/src/session/types.ts index 5e7115ef72..7549a37c94 100644 --- a/packages/kernel-utils/src/session/types.ts +++ b/packages/kernel-utils/src/session/types.ts @@ -1,3 +1,39 @@ +/** + * 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 @@ -37,6 +73,8 @@ export type Decision = { 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. */ @@ -79,5 +117,6 @@ export type SessionApi = { sessionId: string, token: string, verdict: 'accept' | 'reject', + provision?: Provision, ) => Promise; }; From ce92fd96ead6e9a3e9f77eedc890be1e510185bd Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Thu, 21 May 2026 12:10:39 -0400 Subject: [PATCH 24/34] feat(caprock): sheaf-based permission-tracker vat Rewrites the permission-tracker vat to use the real sheaf machinery: each Provision becomes a Provider<{authority,idx}> with an M.eq guard for the tool name and an identity handler that throws on pattern mismatch. leastAuthority drives the dispatch so the most-restricted matching section wins. The idx field in metadata prevents collapseEquivalent from merging distinct providers that share the same authority value (incomparable provisions). Co-Authored-By: Claude Sonnet 4.6 --- packages/caprock/package.json | 2 + packages/caprock/tsconfig.build.json | 3 +- packages/caprock/tsconfig.json | 3 +- .../caprock/vat/permission-tracker.test.ts | 257 ++++++++++++++++++ packages/caprock/vat/permission-tracker.ts | 151 ++++++++-- yarn.lock | 4 +- 6 files changed, 398 insertions(+), 22 deletions(-) create mode 100644 packages/caprock/vat/permission-tracker.test.ts diff --git a/packages/caprock/package.json b/packages/caprock/package.json index bf75079441..3243be60c7 100644 --- a/packages/caprock/package.json +++ b/packages/caprock/package.json @@ -49,8 +49,10 @@ "test:dev:quiet": "yarn test:dev --reporter @ocap/repo-tools/vitest-reporters/silent" }, "dependencies": { + "@endo/patterns": "^1.7.0", "@metamask/kernel-node-runtime": "workspace:^", "@metamask/kernel-utils": "workspace:^", + "@metamask/sheaves": "workspace:^", "@metamask/utils": "^11.9.0", "tree-sitter": "^0.25.0", "tree-sitter-bash": "^0.25.1" diff --git a/packages/caprock/tsconfig.build.json b/packages/caprock/tsconfig.build.json index a4bd47c90d..457c14c836 100644 --- a/packages/caprock/tsconfig.build.json +++ b/packages/caprock/tsconfig.build.json @@ -9,7 +9,8 @@ }, "references": [ { "path": "../kernel-utils/tsconfig.build.json" }, - { "path": "../kernel-node-runtime/tsconfig.build.json" } + { "path": "../kernel-node-runtime/tsconfig.build.json" }, + { "path": "../sheaves/tsconfig.build.json" } ], "files": [], "include": ["./src", "./bin"] diff --git a/packages/caprock/tsconfig.json b/packages/caprock/tsconfig.json index 53ffbfa791..28e41a5476 100644 --- a/packages/caprock/tsconfig.json +++ b/packages/caprock/tsconfig.json @@ -8,7 +8,8 @@ "references": [ { "path": "../repo-tools" }, { "path": "../kernel-utils" }, - { "path": "../kernel-node-runtime" } + { "path": "../kernel-node-runtime" }, + { "path": "../sheaves" } ], "include": [ "../../vitest.config.ts", 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 index 7ca9ec701d..8690ee1535 100644 --- a/packages/caprock/vat/permission-tracker.ts +++ b/packages/caprock/vat/permission-tracker.ts @@ -2,19 +2,97 @@ * 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 with an empty allow-list; - * the ToFU rule means authority only grows, never shrinks. + * Claude Code session. Launched fresh per-session; authority only grows. * * Sheaf model: - * - allowedKeys: Set of ":" strings - * - check(toolName, inputSha) → 'allow' | 'ask' - * - grant(toolName, inputSha) → void (idempotent) + * - 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 } 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. @@ -22,40 +100,75 @@ import { makeDefaultExo } from '@metamask/kernel-utils/exo'; * @returns The exo capability object exposed as the vat's bootstrap. */ export function buildRootObject(): ReturnType { - const allowedKeys = new Set(); + 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 {}, /** - * Returns 'allow' if this (toolName, inputSha) pair was previously granted. + * 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 toolName - The tool name. - * @param inputSha - The input SHA. + * @param tool - The tool name. + * @param invocations - The parsed command components. * @returns 'allow' or 'ask'. */ - check(toolName: string, inputSha: string): string { - return allowedKeys.has(`${toolName}:${inputSha}`) ? 'allow' : '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 an allow entry for (toolName, inputSha). Idempotent. + * Add a section to the sheaf. Computes the authority value by embedding + * the provision's position in the partial order into (0, 1). * - * @param toolName - The tool name. - * @param inputSha - The input SHA. + * @param provision - The Provision to add. */ - grant(toolName: string, inputSha: string): void { - allowedKeys.add(`${toolName}:${inputSha}`); + 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 current allow-set size (for session_end stats). + * Return the current section count (for session_end stats). * - * @returns The number of granted (toolName, inputSha) pairs. + * @returns The number of sections in the sheaf. */ size(): number { - return allowedKeys.size; + return providers.length; }, }); } diff --git a/yarn.lock b/yarn.lock index 13bcab8977..c72b51c4c7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3051,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: @@ -3642,6 +3642,7 @@ __metadata: 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" @@ -3649,6 +3650,7 @@ __metadata: "@metamask/kernel-cli": "workspace:^" "@metamask/kernel-node-runtime": "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" From 1a62c4aaa3198e76fc251d935f3c630664dd5c0f Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Thu, 21 May 2026 12:11:14 -0400 Subject: [PATCH 25/34] feat(caprock): rewrite hook to use invocation-based provision routing Replaces the old vatCheck/vatGrant SHA-based API with invocation-aware routing. buildInvocations parses Bash via decompose() and falls back to [{name: tool, argv: stringFields}] for other tools. On a pre-tool-use accept the hook records the approved provision (or constructs an exact one via invocationToProvision). On post-tool-use it always records an exact provision for the completed invocation. Co-Authored-By: Claude Sonnet 4.6 --- packages/caprock/bin/hook.ts | 122 ++++++++++++++++++++++++++--------- 1 file changed, 93 insertions(+), 29 deletions(-) diff --git a/packages/caprock/bin/hook.ts b/packages/caprock/bin/hook.ts index 82a78af5f7..298bfc8489 100644 --- a/packages/caprock/bin/hook.ts +++ b/packages/caprock/bin/hook.ts @@ -7,12 +7,18 @@ * to the appropriate handler, writes control JSON to stdout if needed. */ +import type { + ParsedInvocation, + Provision, +} from '@metamask/kernel-utils/session'; +import { invocationToProvision } from '@metamask/kernel-utils/session'; 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, @@ -212,45 +218,45 @@ async function launchPermissionVat(): Promise<{ } /** - * Query the permission vat: returns 'allow' if the (toolName, sha) pair was granted. + * Dispatch the permission sheaf: returns 'allow' if any section covers this + * invocation, 'ask' otherwise. * * @param rootKref - The vat's root kref. - * @param toolName - The tool name. - * @param sha - The input SHA. + * @param tool - The tool name. + * @param invocations - The parsed command components. * @returns 'allow' or 'ask'. */ -async function vatCheck( +async function vatRoute( rootKref: string, - toolName: string, - sha: string, + tool: string, + invocations: ParsedInvocation[], ): Promise { const response = await sendCommand({ socketPath: SOCKET_PATH, method: 'queueMessage', - params: [rootKref, 'check', [toolName, sha]], + params: [rootKref, 'route', [tool, invocations]], }); if (isJsonRpcFailure(response)) { - throw new Error(`vatCheck: ${response.error.message}`); + throw new Error(`vatRoute: ${response.error.message}`); } return decodeCapData(response.result as CapData) as string; } /** - * Grant authority in the permission vat for (toolName, sha). Idempotent. + * 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 toolName - The tool name. - * @param sha - The input SHA. + * @param provision - The Provision to add as a new section. */ -async function vatGrant( +async function vatAddSection( rootKref: string, - toolName: string, - sha: string, + provision: Provision, ): Promise { await sendCommand({ socketPath: SOCKET_PATH, method: 'queueMessage', - params: [rootKref, 'grant', [toolName, sha]], + params: [rootKref, 'addSection', [provision]], }); } @@ -272,6 +278,37 @@ async function vatSize(rootKref: string): Promise { 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 ────────────────────────────────────────────────── /** @@ -451,6 +488,7 @@ async function onSessionStart(payload: SessionStartPayload): Promise { 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) { @@ -460,9 +498,11 @@ async function onPreToolUse(payload: PreToolUsePayload): Promise { let vatResponse = 'unknown'; try { - vatResponse = await vatCheck(state.rootKref, tool_name, sha); + if (invocations !== null) { + vatResponse = await vatRoute(state.rootKref, tool_name, invocations); + } } catch (error) { - process.stderr.write(`[caprock] vatCheck failed: ${String(error)}\n`); + process.stderr.write(`[caprock] vatRoute failed: ${String(error)}\n`); } await appendEvent(session_id, { @@ -521,7 +561,16 @@ async function onPreToolUse(payload: PreToolUsePayload): Promise { } if (decision.verdict === 'accept') { - await vatGrant(state.rootKref, tool_name, sha).catch(() => undefined); + 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', @@ -560,10 +609,18 @@ async function onPostToolUse(payload: PostToolUsePayload): Promise { return; } - try { - await vatGrant(state.rootKref, tool_name, sha); - } catch (error) { - process.stderr.write(`[caprock] vatGrant failed: ${String(error)}\n`); + 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, { @@ -600,14 +657,21 @@ async function onPermissionRequest( return; } - if (tool_name && sha) { - try { - const vatResponse = await vatCheck(state.rootKref, tool_name, sha); - if (vatResponse === 'allow') { - process.stdout.write(`${permissionAllow()}\n`); + 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 */ } - } catch { - /* vat error — defer to Claude Code native dialog */ } } } From 919be7a6a62965d14759bc4238f57e1c9f821111 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Thu, 21 May 2026 12:11:40 -0400 Subject: [PATCH 26/34] feat(kernel): thread provision through session.decide Passes the optional provision from the TUI's decide call through the RPC socket layer to the kernel session, so the permission-tracker vat can record whatever provision the user approved. Co-Authored-By: Claude Sonnet 4.6 --- .../src/daemon/rpc-socket-server.ts | 13 ++++++++++++- packages/kernel-tui/src/hooks/use-kernel.ts | 10 ++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) 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 18ff933664..62b3f4f38d 100644 --- a/packages/kernel-node-runtime/src/daemon/rpc-socket-server.ts +++ b/packages/kernel-node-runtime/src/daemon/rpc-socket-server.ts @@ -1,6 +1,7 @@ import { RpcService } from '@metamask/kernel-rpc-methods'; import type { KernelDatabase } from '@metamask/kernel-store'; import { ifDefined } from '@metamask/kernel-utils'; +import type { 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'; @@ -318,6 +319,10 @@ async function handleSessionRequest( 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' || @@ -328,7 +333,13 @@ async function handleSessionRequest( 'session.decide requires string token and verdict ("accept"|"reject")', ); } - session.decide({ token, verdict, feedback, ...ifDefined({ guard }) }); + session.decide({ + token, + verdict, + feedback, + ...ifDefined({ guard }), + ...ifDefined({ provision }), + }); return ok(null); } diff --git a/packages/kernel-tui/src/hooks/use-kernel.ts b/packages/kernel-tui/src/hooks/use-kernel.ts index cf9468edcb..2292de8fce 100644 --- a/packages/kernel-tui/src/hooks/use-kernel.ts +++ b/packages/kernel-tui/src/hooks/use-kernel.ts @@ -98,8 +98,14 @@ export function makeDaemonKernelApi( >('session.history', { sessionId }); }, - async decide(sessionId, token, verdict) { - await send('session.decide', { sessionId, token, verdict, feedback: '' }); + async decide(sessionId, token, verdict, provision) { + await send('session.decide', { + sessionId, + token, + verdict, + feedback: '', + ...(provision === undefined ? {} : { provision }), + }); }, }; } From ebd507487265d206410ed0398838261bc30b2b1f Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Thu, 21 May 2026 12:14:29 -0400 Subject: [PATCH 27/34] fix(sheaves): drop extra context arg from noopPolicy call in leastAuthority noopPolicy takes one argument; passing context caused a TS2554 build error. Co-Authored-By: Claude Sonnet 4.6 --- packages/sheaves/src/compose.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/sheaves/src/compose.ts b/packages/sheaves/src/compose.ts index 7e3b4cdcc6..51cb6e94ab 100644 --- a/packages/sheaves/src/compose.ts +++ b/packages/sheaves/src/compose.ts @@ -106,12 +106,12 @@ export const withRanking = * 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 (passed through to noopPolicy). + * @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, + _context: PolicyContext, ): AsyncGenerator>, void, unknown[]> { yield* noopPolicy( [...candidates].sort((a, b) => { @@ -119,7 +119,6 @@ export async function* leastAuthority>( const bAuth = (b.metadata as { authority?: number }).authority ?? 0.5; return aAuth - bAuth; }), - context, ); } From d0ae972a7f501bb79c3e10264e7ba73a98fe318b Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Sat, 23 May 2026 16:02:12 -0400 Subject: [PATCH 28/34] fix(caprock): break @endo dependency chain in hook process The hook process must not run SES lockdown because full lockdown freezes native prototypes and breaks tree-sitter's C++ bindings. @endo modules call harden() and assert() at module-evaluation time, so any transitive import of @endo/* would crash the hook with 'harden is not defined' or 'Cannot initialize @endo/errors'. - Add harden-shim.ts: installs a no-op identity harden before any @endo import can evaluate (ESM depth-first import order guarantees this runs first) - Inline sendCommand/readLine/writeLine/connectSocket into rpc.ts using only node:crypto and node:net, removing the import of @metamask/kernel-node-runtime/daemon which transitively pulled in @endo/promise-kit and @endo/errors - Add kernel-utils/session/provision lockdown-free subpath that re-exports only from types.ts and provision.ts (no channel.ts, no @endo/promise-kit); hook.ts imports from this subpath - Remove @metamask/kernel-node-runtime from caprock dependencies and tsconfig references - Fix hooks.json and scripts to use dist/bin/hook.mjs (ts-bridge with rootDir:. outputs bin/ under dist/bin/) Co-Authored-By: Claude Sonnet 4.6 --- packages/caprock/bin/harden-shim.ts | 12 ++ packages/caprock/bin/hook.ts | 7 +- packages/caprock/hooks/hooks.json | 14 +- packages/caprock/package.json | 1 - packages/caprock/scripts/setup.sh | 2 +- packages/caprock/scripts/status.sh | 2 +- packages/caprock/src/paths/ocap-kernel.ts | 12 +- packages/caprock/src/rpc.ts | 180 +++++++++++++++++- packages/caprock/tsconfig.build.json | 1 - packages/caprock/tsconfig.json | 1 - packages/kernel-utils/package.json | 10 + .../kernel-utils/src/session/provision-api.ts | 29 +++ yarn.lock | 1 - 13 files changed, 250 insertions(+), 22 deletions(-) create mode 100644 packages/caprock/bin/harden-shim.ts create mode 100644 packages/kernel-utils/src/session/provision-api.ts 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.ts b/packages/caprock/bin/hook.ts index 298bfc8489..a623965775 100644 --- a/packages/caprock/bin/hook.ts +++ b/packages/caprock/bin/hook.ts @@ -7,11 +7,13 @@ * to the appropriate handler, writes control JSON to stdout if needed. */ +import './harden-shim.ts'; + import type { ParsedInvocation, Provision, -} from '@metamask/kernel-utils/session'; -import { invocationToProvision } from '@metamask/kernel-utils/session'; +} 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'; @@ -532,6 +534,7 @@ async function onPreToolUse(payload: PreToolUsePayload): Promise { SOCKET_PATH, state.kernelSessionId, description, + invocations === null ? undefined : { invocations }, ); } catch (error) { const errorStr = String(error); diff --git a/packages/caprock/hooks/hooks.json b/packages/caprock/hooks/hooks.json index 4774bd46dd..a237cdae79 100644 --- a/packages/caprock/hooks/hooks.json +++ b/packages/caprock/hooks/hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/hook.js\"" + "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/bin/hook.mjs\"" } ] } @@ -16,7 +16,7 @@ "hooks": [ { "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/hook.js\"" + "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/bin/hook.mjs\"" } ] } @@ -26,7 +26,7 @@ "hooks": [ { "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/hook.js\"" + "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/bin/hook.mjs\"" } ] } @@ -37,7 +37,7 @@ "hooks": [ { "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/hook.js\"" + "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/bin/hook.mjs\"" } ] } @@ -47,7 +47,7 @@ "hooks": [ { "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/hook.js\"" + "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/bin/hook.mjs\"" } ] } @@ -58,7 +58,7 @@ "hooks": [ { "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/hook.js\"" + "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/bin/hook.mjs\"" } ] } @@ -68,7 +68,7 @@ "hooks": [ { "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/hook.js\"" + "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/bin/hook.mjs\"" } ] } diff --git a/packages/caprock/package.json b/packages/caprock/package.json index 3243be60c7..222577a178 100644 --- a/packages/caprock/package.json +++ b/packages/caprock/package.json @@ -50,7 +50,6 @@ }, "dependencies": { "@endo/patterns": "^1.7.0", - "@metamask/kernel-node-runtime": "workspace:^", "@metamask/kernel-utils": "workspace:^", "@metamask/sheaves": "workspace:^", "@metamask/utils": "^11.9.0", diff --git a/packages/caprock/scripts/setup.sh b/packages/caprock/scripts/setup.sh index bf3dc775e0..a5146454bc 100755 --- a/packages/caprock/scripts/setup.sh +++ b/packages/caprock/scripts/setup.sh @@ -1,3 +1,3 @@ #!/usr/bin/env bash PLUGIN_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -node "${PLUGIN_ROOT}/dist/setup.js" "$@" +node "${PLUGIN_ROOT}/dist/bin/setup.mjs" "$@" diff --git a/packages/caprock/scripts/status.sh b/packages/caprock/scripts/status.sh index 6b47a23d85..2374edfd4f 100755 --- a/packages/caprock/scripts/status.sh +++ b/packages/caprock/scripts/status.sh @@ -1,3 +1,3 @@ #!/usr/bin/env bash PLUGIN_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -node "${PLUGIN_ROOT}/dist/status.js" "$@" +node "${PLUGIN_ROOT}/dist/bin/status.mjs" "$@" diff --git a/packages/caprock/src/paths/ocap-kernel.ts b/packages/caprock/src/paths/ocap-kernel.ts index 7fdbc8475b..6522aaf31e 100644 --- a/packages/caprock/src/paths/ocap-kernel.ts +++ b/packages/caprock/src/paths/ocap-kernel.ts @@ -1,12 +1,20 @@ /* eslint-disable n/no-process-env */ -import { getSocketPath } from '@metamask/kernel-node-runtime/daemon'; import { getOcapHome } from '@metamask/kernel-utils/nodejs'; import { join } from 'node:path'; import { getPluginDataDir } from './plugin.ts'; -export { getOcapHome, getSocketPath }; +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. diff --git a/packages/caprock/src/rpc.ts b/packages/caprock/src/rpc.ts index 545c52e370..7581eb78a3 100644 --- a/packages/caprock/src/rpc.ts +++ b/packages/caprock/src/rpc.ts @@ -1,9 +1,171 @@ -import { sendCommand } from '@metamask/kernel-node-runtime/daemon'; -import { isJsonRpcFailure } from '@metamask/utils'; +import type { ParsedInvocation } 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'; -export { sendCommand }; +// ─── 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. @@ -56,16 +218,21 @@ export async function createKernelSession( * @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 reason and client-side timeout. + * @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 }, + options?: { + reason?: string; + timeoutMs?: number; + invocations?: ParsedInvocation[]; + }, ): Promise { const params: Record = { sessionId: kernelSessionId, @@ -77,6 +244,9 @@ export async function authorizeRequest( if (options?.timeoutMs !== undefined) { params.timeoutMs = options.timeoutMs; } + if (options?.invocations !== undefined) { + params.invocations = options.invocations; + } const response = await sendCommand({ socketPath, method: 'session.authorize', diff --git a/packages/caprock/tsconfig.build.json b/packages/caprock/tsconfig.build.json index 457c14c836..0c02f89975 100644 --- a/packages/caprock/tsconfig.build.json +++ b/packages/caprock/tsconfig.build.json @@ -9,7 +9,6 @@ }, "references": [ { "path": "../kernel-utils/tsconfig.build.json" }, - { "path": "../kernel-node-runtime/tsconfig.build.json" }, { "path": "../sheaves/tsconfig.build.json" } ], "files": [], diff --git a/packages/caprock/tsconfig.json b/packages/caprock/tsconfig.json index 28e41a5476..2c969d2f0c 100644 --- a/packages/caprock/tsconfig.json +++ b/packages/caprock/tsconfig.json @@ -8,7 +8,6 @@ "references": [ { "path": "../repo-tools" }, { "path": "../kernel-utils" }, - { "path": "../kernel-node-runtime" }, { "path": "../sheaves" } ], "include": [ diff --git a/packages/kernel-utils/package.json b/packages/kernel-utils/package.json index 1d9c54077b..acced23348 100644 --- a/packages/kernel-utils/package.json +++ b/packages/kernel-utils/package.json @@ -99,6 +99,16 @@ "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/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/yarn.lock b/yarn.lock index c72b51c4c7..7fc16a9ea5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3648,7 +3648,6 @@ __metadata: "@metamask/eslint-config-nodejs": "npm:^15.0.0" "@metamask/eslint-config-typescript": "npm:^15.0.0" "@metamask/kernel-cli": "workspace:^" - "@metamask/kernel-node-runtime": "workspace:^" "@metamask/kernel-utils": "workspace:^" "@metamask/sheaves": "workspace:^" "@metamask/utils": "npm:^11.9.0" From bb5e686a028a2f4aa0ed8f5617ca4eff5ebc58d0 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Sat, 23 May 2026 16:02:25 -0400 Subject: [PATCH 29/34] feat(session): thread ParsedInvocation through authorization pipeline Carries parsed command invocations from the hook call-site through the session registry and channel to TUI history entries, enabling the provision editor to show per-arg pattern controls. - Move ParsedInvocation type to session/types.ts to avoid circular deps - Add invocations? to SectionNotification and SessionHistoryEntry - Change authorizeRequest signature to an options bag { reason?, timeoutMs?, invocations? } for extensibility - channel.listAll() includes invocations via ifDefined in both the pending and decided entry paths - rpc-socket-server session.authorize handler extracts invocations from JSON-RPC params and forwards to session.authorizeRequest Co-Authored-By: Claude Sonnet 4.6 --- .../src/daemon/rpc-socket-server.test.ts | 3 +-- .../src/daemon/rpc-socket-server.ts | 18 ++++++++----- packages/kernel-utils/src/session/channel.ts | 3 +++ packages/kernel-utils/src/session/index.ts | 3 ++- .../kernel-utils/src/session/provision.ts | 9 ++++--- .../src/session/session-registry.test.ts | 16 +++++------ .../src/session/session-registry.ts | 27 ++++++++++++++----- packages/kernel-utils/src/session/types.ts | 10 +++++++ 8 files changed, 62 insertions(+), 27 deletions(-) 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 index 8f0c00f1c6..fa29db4b29 100644 --- a/packages/kernel-node-runtime/src/daemon/rpc-socket-server.test.ts +++ b/packages/kernel-node-runtime/src/daemon/rpc-socket-server.test.ts @@ -376,8 +376,7 @@ describe('startRpcSocketServer — session.* methods', () => { expect(response.result).toStrictEqual(decision); expect(existing.authorizeRequest).toHaveBeenCalledWith( 'Allow read access', - 'Needed for operation', - undefined, + { 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 62b3f4f38d..dce6748de3 100644 --- a/packages/kernel-node-runtime/src/daemon/rpc-socket-server.ts +++ b/packages/kernel-node-runtime/src/daemon/rpc-socket-server.ts @@ -1,7 +1,10 @@ import { RpcService } from '@metamask/kernel-rpc-methods'; import type { KernelDatabase } from '@metamask/kernel-store'; import { ifDefined } from '@metamask/kernel-utils'; -import type { Provision } from '@metamask/kernel-utils/session'; +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'; @@ -302,11 +305,14 @@ async function handleSessionRequest( typeof args.reason === 'string' ? args.reason : undefined; const timeoutMs = typeof args.timeoutMs === 'number' ? args.timeoutMs : undefined; - const decision = await session.authorizeRequest( - description, - reason, - timeoutMs, - ); + 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); } diff --git a/packages/kernel-utils/src/session/channel.ts b/packages/kernel-utils/src/session/channel.ts index 4be1ebb985..158e058482 100644 --- a/packages/kernel-utils/src/session/channel.ts +++ b/packages/kernel-utils/src/session/channel.ts @@ -1,6 +1,7 @@ import { makePromiseKit } from '@endo/promise-kit'; import type { PromiseKit } from '@endo/promise-kit'; +import { ifDefined } from '../misc.ts'; import type { Decision, SectionNotification, @@ -181,6 +182,7 @@ export function makeChannel(): Channel { queuedAt: hist.queuedAt, status: hist.verdict, decidedAt: hist.decidedAt, + ...ifDefined({ invocations: hist.notification.invocations }), })); const stillPending: SessionHistoryEntry[] = Array.from( pending.values(), @@ -191,6 +193,7 @@ export function makeChannel(): Channel { 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) { diff --git a/packages/kernel-utils/src/session/index.ts b/packages/kernel-utils/src/session/index.ts index 0adbbb4d74..ae73882dca 100644 --- a/packages/kernel-utils/src/session/index.ts +++ b/packages/kernel-utils/src/session/index.ts @@ -1,6 +1,7 @@ export type { ArgPattern, InvocationPattern, + ParsedInvocation, Provision, SectionRequest, SectionNotification, @@ -14,7 +15,7 @@ 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 { ParsedInvocation, PatternOrder } from './provision.ts'; +export type { PatternOrder } from './provision.ts'; export { isPathArg, pathInterval, diff --git a/packages/kernel-utils/src/session/provision.ts b/packages/kernel-utils/src/session/provision.ts index 080084f025..1c27fb5863 100644 --- a/packages/kernel-utils/src/session/provision.ts +++ b/packages/kernel-utils/src/session/provision.ts @@ -1,6 +1,9 @@ -import type { ArgPattern, InvocationPattern, Provision } from './types.ts'; - -export type ParsedInvocation = { name: string; argv: string[] }; +import type { + ArgPattern, + InvocationPattern, + ParsedInvocation, + Provision, +} from './types.ts'; /** * Returns true if the string looks like a file-system path (absolute or relative). diff --git a/packages/kernel-utils/src/session/session-registry.test.ts b/packages/kernel-utils/src/session/session-registry.test.ts index 290d51953a..c0d0f993b2 100644 --- a/packages/kernel-utils/src/session/session-registry.test.ts +++ b/packages/kernel-utils/src/session/session-registry.test.ts @@ -139,10 +139,9 @@ describe('makeSessionRegistry', () => { const registry = makeSessionRegistry(makeChannelBundle()); const session = await registry.createSession(); - const authPromise = session.authorizeRequest( - 'Write /tmp/out', - 'needs temp', - ); + 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(); @@ -162,11 +161,10 @@ describe('makeSessionRegistry', () => { const registry = makeSessionRegistry(makeChannelBundle()); const session = await registry.createSession(); - const authPromise = session.authorizeRequest( - 'Execute script', - 'needs shell', - 500, - ); + 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); diff --git a/packages/kernel-utils/src/session/session-registry.ts b/packages/kernel-utils/src/session/session-registry.ts index 56014ecece..bd7b76562f 100644 --- a/packages/kernel-utils/src/session/session-registry.ts +++ b/packages/kernel-utils/src/session/session-registry.ts @@ -2,6 +2,7 @@ import { ifDefined } from '../misc.ts'; import type { Channel, ModalStream } from './channel.ts'; import type { Decision, + ParsedInvocation, SectionNotification, SessionHistoryEntry, } from './types.ts'; @@ -28,8 +29,11 @@ export type Session = { queueRequest(description: string, reason?: string): string; authorizeRequest( description: string, - reason?: string, - timeoutMs?: number, + options?: { + reason?: string; + timeoutMs?: number; + invocations?: ParsedInvocation[]; + }, ): Promise; subscribe(stream: ModalStream): void; }; @@ -69,10 +73,17 @@ function makeSession( const makeNotification = ( description: string, reason: string, + invocations?: ParsedInvocation[], ): SectionNotification => { const token = `req-${requestCount}`; requestCount += 1; - return { token, description, reason, guard: { body: '#{}', slots: [] } }; + return { + token, + description, + reason, + guard: { body: '#{}', slots: [] }, + ...ifDefined({ invocations }), + }; }; return harden({ @@ -101,10 +112,14 @@ function makeSession( async authorizeRequest( description: string, - reason = 'Queued from CLI', - timeoutMs?: number, + options: { + reason?: string; + timeoutMs?: number; + invocations?: ParsedInvocation[]; + } = {}, ): Promise { - const notification = makeNotification(description, reason); + const { reason = 'Queued from CLI', timeoutMs, invocations } = options; + const notification = makeNotification(description, reason, invocations); const decision = channel.broadcast(notification); if (timeoutMs === undefined) { return decision; diff --git a/packages/kernel-utils/src/session/types.ts b/packages/kernel-utils/src/session/types.ts index 7549a37c94..9e32eec6ad 100644 --- a/packages/kernel-utils/src/session/types.ts +++ b/packages/kernel-utils/src/session/types.ts @@ -1,3 +1,9 @@ +/** + * 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. * @@ -61,6 +67,8 @@ export type SectionNotification = { reason: string; schema?: unknown; guard: { body: string; slots: string[] }; + /** Parsed invocations for the request — present when routed through the PreToolUse hook. */ + invocations?: ParsedInvocation[]; }; /** @@ -103,6 +111,8 @@ export type SessionHistoryEntry = { queuedAt: string; status: 'pending' | 'accepted' | 'rejected'; decidedAt?: string; + /** Parsed invocations — present when routed through the PreToolUse hook. */ + invocations?: ParsedInvocation[]; }; /** From d07e5fcbcd21d6f58fc293846cfbc9ef590cd3da Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Sat, 23 May 2026 16:02:37 -0400 Subject: [PATCH 30/34] feat(kernel-tui): provision editor with per-arg pattern tuning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a '2' keybind on pending session-detail entries that opens an interactive provision editor before granting a standing provision. The editor flattens all invocation args into a navigable list. Each arg cycles through its pattern interval (exact → prefix levels → wildcard) via ↑/↓; ←/→ moves between args. Enter submits the shaped Provision; Esc cancels back to the normal view. Falls back to a tool-level wildcard provision when invocations data is unavailable (unparseable command or old daemon). Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/session-detail-view.tsx | 277 +++++++++++++++++- .../kernel-tui/src/components/status-bar.tsx | 2 +- packages/kernel-tui/src/hooks/use-kernel.ts | 2 + 3 files changed, 269 insertions(+), 12 deletions(-) diff --git a/packages/kernel-tui/src/components/session-detail-view.tsx b/packages/kernel-tui/src/components/session-detail-view.tsx index c5dca1c623..38484c97fd 100644 --- a/packages/kernel-tui/src/components/session-detail-view.tsx +++ b/packages/kernel-tui/src/components/session-detail-view.tsx @@ -1,3 +1,13 @@ +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'; @@ -409,13 +419,206 @@ function clampScroll( 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); + }), + })), + }; +} + /** * 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, 3 reject. + * Keybindings: ↑/↓ navigate, → expand, ← collapse/back, 1 accept, 2 grant with provision, 3 reject. * * @param props - Component props. * @param props.session - The session being viewed. @@ -444,6 +647,7 @@ export function SessionDetailView({ const [scrollOffset, setScrollOffset] = useState(0); const [deciding, setDeciding] = useState(false); const [error, setError] = useState(null); + const [editingProvision, setEditingProvision] = useState(false); const { stdout } = useStdout(); const columns = stdout.columns ?? 80; @@ -515,6 +719,9 @@ export function SessionDetailView({ const countBelow = displayEntries.length - visEnd; useInput((input, key) => { + if (editingProvision) { + return; // ProvisionEditor handles its own input + } if (key.upArrow) { const nextIdx = Math.max(0, cursorIdx - 1); setFocusedToken(displayEntries[nextIdx]?.token ?? null); @@ -544,6 +751,11 @@ export function SessionDetailView({ } 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; @@ -565,6 +777,26 @@ export function SessionDetailView({ } }); + 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 ( @@ -585,14 +817,22 @@ export function SessionDetailView({ const idx = displayEntries.indexOf(entry); const isFocused = idx === cursorIdx; const isExpanded = expanded.has(entry.token); + const isEditingThis = + editingProvision && isFocused && entry.status === 'pending'; const icon = STATUS_ICON[entry.status]; const color = STATUS_COLOR[entry.status]; const { label } = parseDescription(entry.description); - const expandedLines = isExpanded - ? formatExpandedContent(entry.description).split('\n') - : []; + 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 ( @@ -602,8 +842,21 @@ export function SessionDetailView({ {formatTime(entry.queuedAt)} - {label} + + {label} + {isEditingThis && ( + (grant with provision…) + )} + + {isEditingThis && ( + setEditingProvision(false)} + /> + )} {expandedLines.map((line, lineIdx) => ( @@ -611,12 +864,14 @@ export function SessionDetailView({ ))} - {isExpanded && entry.decidedAt !== undefined && ( - - decided {formatTime(entry.decidedAt)} - - )} - {isExpanded && entry.guard.body !== '#{}' && ( + {isExpanded && + !isEditingThis && + entry.decidedAt !== undefined && ( + + decided {formatTime(entry.decidedAt)} + + )} + {isExpanded && !isEditingThis && entry.guard.body !== '#{}' && ( guard: {entry.guard.body} diff --git a/packages/kernel-tui/src/components/status-bar.tsx b/packages/kernel-tui/src/components/status-bar.tsx index 5954f68aee..4c960e967c 100644 --- a/packages/kernel-tui/src/components/status-bar.tsx +++ b/packages/kernel-tui/src/components/status-bar.tsx @@ -9,7 +9,7 @@ type StatusBarProps = { }; const VIEW_HINTS: Record = { - sessions: '↑/↓: navigate | 1: accept | 3: reject | R: refresh', + sessions: '↑/↓: navigate | 1: accept | 2: provision | 3: reject | R: refresh', files: 'Select a bundle to launch', objects: 'r: refresh', invoke: 'Tab: next field | Enter on args: send', diff --git a/packages/kernel-tui/src/hooks/use-kernel.ts b/packages/kernel-tui/src/hooks/use-kernel.ts index 2292de8fce..c3d12591d6 100644 --- a/packages/kernel-tui/src/hooks/use-kernel.ts +++ b/packages/kernel-tui/src/hooks/use-kernel.ts @@ -2,6 +2,7 @@ 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'; @@ -94,6 +95,7 @@ export function makeDaemonKernelApi( queuedAt: string; status: 'pending' | 'accepted' | 'rejected'; decidedAt?: string; + invocations?: ParsedInvocation[]; }[] >('session.history', { sessionId }); }, From b0e78db72e640546323d40e54deb2317a3b0daaa Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Sat, 23 May 2026 16:02:58 -0400 Subject: [PATCH 31/34] test(caprock): integration test for hook binary startup Spawns dist/bin/hook.mjs as a child process and asserts it exits cleanly without @endo/SES missing-globals errors. Builds the binary in beforeAll so the test is always self-contained and never silently skips due to a stale or missing dist/. Co-Authored-By: Claude Sonnet 4.6 --- packages/caprock/bin/hook.test.ts | 115 ++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 packages/caprock/bin/hook.test.ts 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); +}); From 27571dbec92b9c79376b7474e741c767ba29f990 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Sat, 23 May 2026 16:41:34 -0400 Subject: [PATCH 32/34] feat(session): surface provisioned requests in TUI timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a 'provisioned' status for auto-accepted requests and expose the standing provision that was set by the user when accepting with keybind 2. - `SessionHistoryEntry` gains status 'provisioned' and optional `provision` field - `Channel.record()` inserts a pre-decided entry without blocking subscribers - `Session.recordProvisioned()` records an auto-accepted request synchronously - `session.record` RPC dispatches to `recordProvisioned` in the daemon - hook fires-and-forgets `recordProvisioned` when the vat returns 'allow' - TUI: ◆ for user-accepted-with-provision, → for auto-provisioned - TUI: expanded view shows provision patterns; provisioned entries show '→ standing provision' instead of decided time Co-Authored-By: Claude Sonnet 4.6 --- packages/caprock/bin/hook.ts | 10 +++++ packages/caprock/src/rpc.ts | 22 ++++++++++ .../src/daemon/rpc-socket-server.test.ts | 33 ++++++++++++++ .../src/daemon/rpc-socket-server.ts | 16 +++++++ .../src/components/session-detail-view.tsx | 44 +++++++++++++++++-- packages/kernel-utils/src/session/channel.ts | 24 +++++++++- .../src/session/session-registry.test.ts | 21 +++++++++ .../src/session/session-registry.ts | 16 +++++++ packages/kernel-utils/src/session/types.ts | 4 +- 9 files changed, 184 insertions(+), 6 deletions(-) diff --git a/packages/caprock/bin/hook.ts b/packages/caprock/bin/hook.ts index a623965775..2858ba0400 100644 --- a/packages/caprock/bin/hook.ts +++ b/packages/caprock/bin/hook.ts @@ -38,6 +38,7 @@ import { decodeCapData, createKernelSession, authorizeRequest, + recordProvisioned, } from '../src/rpc.ts'; import { loadSessionState, @@ -517,6 +518,15 @@ async function onPreToolUse(payload: PreToolUsePayload): Promise { }); if (vatResponse === 'allow') { + if (state.kernelSessionId) { + const autoDescription = `Allow ${tool_name}(${JSON.stringify(tool_input)})`; + recordProvisioned( + SOCKET_PATH, + state.kernelSessionId, + autoDescription, + invocations === null ? undefined : { invocations }, + ).catch(() => undefined); + } process.stdout.write(JSON.stringify({ continue: true })); return; } diff --git a/packages/caprock/src/rpc.ts b/packages/caprock/src/rpc.ts index 7581eb78a3..fefb60f493 100644 --- a/packages/caprock/src/rpc.ts +++ b/packages/caprock/src/rpc.ts @@ -265,6 +265,28 @@ export async function authorizeRequest( 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. + */ +export async function recordProvisioned( + socketPath: string, + sessionId: string, + description: string, + options?: { invocations?: ParsedInvocation[] }, +): Promise { + const params: Record = { sessionId, description }; + if (options?.invocations !== undefined) { + params.invocations = options.invocations; + } + await sendCommand({ socketPath, method: 'session.record', params }); +} + /** * Decode a CapData body to a JavaScript value. * 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 index fa29db4b29..61fdd1aefc 100644 --- a/packages/kernel-node-runtime/src/daemon/rpc-socket-server.test.ts +++ b/packages/kernel-node-runtime/src/daemon/rpc-socket-server.test.ts @@ -73,6 +73,7 @@ function makeTestSession(overrides: Partial = {}): Session { verdict: 'accept' as const, feedback: '', }), + recordProvisioned: vi.fn(), subscribe: vi.fn(), ...overrides, }; @@ -343,6 +344,38 @@ describe('startRpcSocketServer — session.* methods', () => { }); }); + 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(); 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 dce6748de3..977ab01b07 100644 --- a/packages/kernel-node-runtime/src/daemon/rpc-socket-server.ts +++ b/packages/kernel-node-runtime/src/daemon/rpc-socket-server.ts @@ -316,6 +316,22 @@ async function handleSessionRequest( 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; + session.recordProvisioned( + description, + invocations === undefined ? undefined : { invocations }, + ); + return ok(null); + } + case 'session.decide': { const session = requireSession(args.sessionId); const { token } = args; diff --git a/packages/kernel-tui/src/components/session-detail-view.tsx b/packages/kernel-tui/src/components/session-detail-view.tsx index 38484c97fd..1b0028ebf4 100644 --- a/packages/kernel-tui/src/components/session-detail-view.tsx +++ b/packages/kernel-tui/src/components/session-detail-view.tsx @@ -29,6 +29,7 @@ const STATUS_ICON: Record = { pending: '…', accepted: '✓', rejected: '✗', + provisioned: '→', }; const STATUS_COLOR: Record< @@ -38,6 +39,7 @@ const STATUS_COLOR: Record< pending: 'yellow', accepted: 'green', rejected: 'red', + provisioned: 'green', }; /** @@ -339,9 +341,12 @@ function entryRowCount( 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); + (entry.guard.body === '#{}' ? 0 : 1) + + provisionRows; return 1 + contentRows + extras; } @@ -819,8 +824,12 @@ export function SessionDetailView({ const isExpanded = expanded.has(entry.token); const isEditingThis = editingProvision && isFocused && entry.status === 'pending'; - const icon = STATUS_ICON[entry.status]; + 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); @@ -838,7 +847,9 @@ export function SessionDetailView({ {isFocused ? '►' : ' '} - {icon} + + {icon} + {formatTime(entry.queuedAt)} @@ -866,7 +877,32 @@ export function SessionDetailView({ ))} {isExpanded && !isEditingThis && - entry.decidedAt !== undefined && ( + entry.provision !== undefined && ( + + provision: + {entry.provision.patterns.map((pattern, patIdx) => ( + + {pattern.name} + {pattern.argPatterns.map((argPat, argIdx) => ( + + {argPatternDisplay(argPat)} + + ))} + + ))} + + )} + {isExpanded && + !isEditingThis && + entry.status === 'provisioned' && ( + + → standing provision + + )} + {isExpanded && + !isEditingThis && + entry.decidedAt !== undefined && + entry.status !== 'provisioned' && ( decided {formatTime(entry.decidedAt)} diff --git a/packages/kernel-utils/src/session/channel.ts b/packages/kernel-utils/src/session/channel.ts index 158e058482..735c50de83 100644 --- a/packages/kernel-utils/src/session/channel.ts +++ b/packages/kernel-utils/src/session/channel.ts @@ -4,6 +4,7 @@ import type { PromiseKit } from '@endo/promise-kit'; import { ifDefined } from '../misc.ts'; import type { Decision, + Provision, SectionNotification, SessionHistoryEntry, } from './types.ts'; @@ -63,6 +64,14 @@ export type Channel = { * @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. + */ + record(notification: SectionNotification): void; }; type PendingEntry = { @@ -74,8 +83,9 @@ type PendingEntry = { type HistoryEntry = { notification: SectionNotification; queuedAt: string; - verdict: 'accepted' | 'rejected'; + verdict: 'accepted' | 'rejected' | 'provisioned'; decidedAt: string; + provision?: Provision; }; /** @@ -110,6 +120,7 @@ export function makeChannel(): Channel { queuedAt: entry.queuedAt, verdict: decision.verdict === 'accept' ? 'accepted' : 'rejected', decidedAt: new Date().toISOString(), + ...ifDefined({ provision: decision.provision }), }); entry.kit.resolve(decision); } @@ -183,6 +194,7 @@ export function makeChannel(): Channel { status: hist.verdict, decidedAt: hist.decidedAt, ...ifDefined({ invocations: hist.notification.invocations }), + ...ifDefined({ provision: hist.provision }), })); const stillPending: SessionHistoryEntry[] = Array.from( pending.values(), @@ -209,5 +221,15 @@ export function makeChannel(): Channel { decide(decision: Decision): void { routeDecision(decision); }, + + record(notification: SectionNotification): void { + const stamp = new Date().toISOString(); + history.push({ + notification, + queuedAt: stamp, + verdict: 'provisioned', + decidedAt: stamp, + }); + }, }); } diff --git a/packages/kernel-utils/src/session/session-registry.test.ts b/packages/kernel-utils/src/session/session-registry.test.ts index c0d0f993b2..f84458f509 100644 --- a/packages/kernel-utils/src/session/session-registry.test.ts +++ b/packages/kernel-utils/src/session/session-registry.test.ts @@ -155,6 +155,27 @@ describe('makeSessionRegistry', () => { 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 { diff --git a/packages/kernel-utils/src/session/session-registry.ts b/packages/kernel-utils/src/session/session-registry.ts index bd7b76562f..f563b6117b 100644 --- a/packages/kernel-utils/src/session/session-registry.ts +++ b/packages/kernel-utils/src/session/session-registry.ts @@ -35,6 +35,10 @@ export type Session = { invocations?: ParsedInvocation[]; }, ): Promise; + recordProvisioned( + description: string, + options?: { invocations?: ParsedInvocation[] }, + ): void; subscribe(stream: ModalStream): void; }; @@ -141,6 +145,18 @@ function makeSession( ]); }, + recordProvisioned( + description: string, + options: { invocations?: ParsedInvocation[] } = {}, + ): void { + const notification = makeNotification( + description, + 'Auto-accepted by provision', + options.invocations, + ); + channel.record(notification); + }, + subscribe(stream: ModalStream): void { channel.subscribe(stream); }, diff --git a/packages/kernel-utils/src/session/types.ts b/packages/kernel-utils/src/session/types.ts index 9e32eec6ad..6e24148daf 100644 --- a/packages/kernel-utils/src/session/types.ts +++ b/packages/kernel-utils/src/session/types.ts @@ -109,10 +109,12 @@ export type SessionHistoryEntry = { reason: string; guard: { body: string; slots: string[] }; queuedAt: string; - status: 'pending' | 'accepted' | 'rejected'; + 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; }; /** From bbfb3713bc8c7f87c3b4d9f4249eedaa7dd4ff29 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Sat, 23 May 2026 17:29:34 -0400 Subject: [PATCH 33/34] feat(session): identify matched provision and add active-provisions panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tweak 1 — show which provision auto-accepted a request: - Add findMatch(tool, invocations) and listProvisions() to permission-tracker vat - Hook calls vatFindMatch after vatRoute returns 'allow' and passes the result to recordProvisioned, threading it through rpc.ts → session.record RPC → channel.record() → HistoryEntry.provision - TUI: provisioned entries show '→ by provision: git log * | head *' (compact one-liner) instead of '→ standing provision'; detailed pattern block reserved for user-accepted-with-provision (◆) entries Tweak 2 — active-provisions panel (P keybind): - ProvisionsPanel component derives unique active provisions from session history - Press P in session detail view to open; Esc to close - Lists each provision as '◆ tool name arg1 arg2 | name2 arg1' - Status bar hint updated Co-Authored-By: Claude Sonnet 4.6 --- packages/caprock/bin/hook.ts | 48 ++- packages/caprock/src/rpc.ts | 11 +- packages/caprock/vat/permission-tracker.ts | 32 +- .../src/daemon/rpc-socket-server.ts | 12 +- .../src/components/session-detail-view.tsx | 314 ++++++++++++------ .../kernel-tui/src/components/status-bar.tsx | 3 +- packages/kernel-utils/src/session/channel.ts | 6 +- .../src/session/session-registry.ts | 7 +- 8 files changed, 315 insertions(+), 118 deletions(-) diff --git a/packages/caprock/bin/hook.ts b/packages/caprock/bin/hook.ts index 2858ba0400..5189a927ba 100644 --- a/packages/caprock/bin/hook.ts +++ b/packages/caprock/bin/hook.ts @@ -263,6 +263,31 @@ async function vatAddSection( }); } +/** + * 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; + } + return decodeCapData(response.result as CapData) as Provision | null; +} + /** * Return the number of entries in the permission vat's allow set. * @@ -518,14 +543,21 @@ async function onPreToolUse(payload: PreToolUsePayload): Promise { }); if (vatResponse === 'allow') { - if (state.kernelSessionId) { + if (state.kernelSessionId && invocations !== null) { const autoDescription = `Allow ${tool_name}(${JSON.stringify(tool_input)})`; - recordProvisioned( - SOCKET_PATH, - state.kernelSessionId, - autoDescription, - invocations === null ? undefined : { invocations }, - ).catch(() => undefined); + 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; @@ -556,7 +588,9 @@ async function onPreToolUse(payload: PreToolUsePayload): Promise { 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; diff --git a/packages/caprock/src/rpc.ts b/packages/caprock/src/rpc.ts index fefb60f493..856ca49f3a 100644 --- a/packages/caprock/src/rpc.ts +++ b/packages/caprock/src/rpc.ts @@ -1,4 +1,7 @@ -import type { ParsedInvocation } from '@metamask/kernel-utils/session/provision'; +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'; @@ -273,17 +276,21 @@ export async function authorizeRequest( * @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[] }, + 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 }); } diff --git a/packages/caprock/vat/permission-tracker.ts b/packages/caprock/vat/permission-tracker.ts index 8690ee1535..2345e5626a 100644 --- a/packages/caprock/vat/permission-tracker.ts +++ b/packages/caprock/vat/permission-tracker.ts @@ -22,7 +22,11 @@ import type { Provision, ParsedInvocation, } from '@metamask/kernel-utils/session'; -import { computeAuthority, matchPattern } from '@metamask/kernel-utils/session'; +import { + computeAuthority, + matchPattern, + matchProvision, +} from '@metamask/kernel-utils/session'; import { constant, leastAuthority, @@ -162,6 +166,32 @@ export function buildRootObject(): ReturnType { 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). * 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 977ab01b07..462a4997a9 100644 --- a/packages/kernel-node-runtime/src/daemon/rpc-socket-server.ts +++ b/packages/kernel-node-runtime/src/daemon/rpc-socket-server.ts @@ -325,10 +325,14 @@ async function handleSessionRequest( const invocations = Array.isArray(args.invocations) ? (args.invocations as ParsedInvocation[]) : undefined; - session.recordProvisioned( - description, - invocations === undefined ? undefined : { invocations }, - ); + const provision = + typeof args.provision === 'object' && args.provision !== null + ? (args.provision as Provision) + : undefined; + session.recordProvisioned(description, { + ...ifDefined({ invocations }), + ...ifDefined({ provision }), + }); return ok(null); } diff --git a/packages/kernel-tui/src/components/session-detail-view.tsx b/packages/kernel-tui/src/components/session-detail-view.tsx index 1b0028ebf4..87cdfa5e32 100644 --- a/packages/kernel-tui/src/components/session-detail-view.tsx +++ b/packages/kernel-tui/src/components/session-detail-view.tsx @@ -188,6 +188,20 @@ export function parseDescription(description: string): ParsedDescription { 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; /** @@ -617,6 +631,74 @@ function buildProvision( }; } +/** + * 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 @@ -653,6 +735,12 @@ export function SessionDetailView({ 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; @@ -727,6 +815,13 @@ export function SessionDetailView({ 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); @@ -813,111 +908,134 @@ export function SessionDetailView({ {error !== null && {error}} - {countAbove > 0 && ↑ {countAbove} more} - - {displayEntries.length === 0 ? ( - No requests yet. + {showProvisions ? ( + setShowProvisions(false)} + /> ) : ( - 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} + <> + {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 && ( - (grant with provision…) + setEditingProvision(false)} + /> )} - - - {isEditingThis && ( - setEditingProvision(false)} - /> - )} - {expandedLines.map((line, lineIdx) => ( - - - {line} - - - ))} - {isExpanded && - !isEditingThis && - entry.provision !== undefined && ( - - provision: - {entry.provision.patterns.map((pattern, patIdx) => ( - - {pattern.name} - {pattern.argPatterns.map((argPat, argIdx) => ( - - {argPatternDisplay(argPat)} - + {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' && ( - - → standing provision - - )} - {isExpanded && - !isEditingThis && - entry.decidedAt !== undefined && - entry.status !== 'provisioned' && ( - - decided {formatTime(entry.decidedAt)} - - )} - {isExpanded && !isEditingThis && entry.guard.body !== '#{}' && ( - - guard: {entry.guard.body} + )} + {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} + {countBelow > 0 && ↓ {countBelow} more} + + )} ); } diff --git a/packages/kernel-tui/src/components/status-bar.tsx b/packages/kernel-tui/src/components/status-bar.tsx index 4c960e967c..3c4aa2ed57 100644 --- a/packages/kernel-tui/src/components/status-bar.tsx +++ b/packages/kernel-tui/src/components/status-bar.tsx @@ -9,7 +9,8 @@ type StatusBarProps = { }; const VIEW_HINTS: Record = { - sessions: '↑/↓: navigate | 1: accept | 2: provision | 3: reject | R: refresh', + 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', diff --git a/packages/kernel-utils/src/session/channel.ts b/packages/kernel-utils/src/session/channel.ts index 735c50de83..5bb4fdf18f 100644 --- a/packages/kernel-utils/src/session/channel.ts +++ b/packages/kernel-utils/src/session/channel.ts @@ -70,8 +70,9 @@ export type Channel = { * 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): void; + record(notification: SectionNotification, provision?: Provision): void; }; type PendingEntry = { @@ -222,13 +223,14 @@ export function makeChannel(): Channel { routeDecision(decision); }, - record(notification: SectionNotification): void { + 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/session-registry.ts b/packages/kernel-utils/src/session/session-registry.ts index f563b6117b..201b0cbbbc 100644 --- a/packages/kernel-utils/src/session/session-registry.ts +++ b/packages/kernel-utils/src/session/session-registry.ts @@ -3,6 +3,7 @@ import type { Channel, ModalStream } from './channel.ts'; import type { Decision, ParsedInvocation, + Provision, SectionNotification, SessionHistoryEntry, } from './types.ts'; @@ -37,7 +38,7 @@ export type Session = { ): Promise; recordProvisioned( description: string, - options?: { invocations?: ParsedInvocation[] }, + options?: { invocations?: ParsedInvocation[]; provision?: Provision }, ): void; subscribe(stream: ModalStream): void; }; @@ -147,14 +148,14 @@ function makeSession( recordProvisioned( description: string, - options: { invocations?: ParsedInvocation[] } = {}, + options: { invocations?: ParsedInvocation[]; provision?: Provision } = {}, ): void { const notification = makeNotification( description, 'Auto-accepted by provision', options.invocations, ); - channel.record(notification); + channel.record(notification, options.provision); }, subscribe(stream: ModalStream): void { From 5f267b1377bc7363f22a6c5479f60beb2979f923 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Sat, 23 May 2026 17:59:24 -0400 Subject: [PATCH 34/34] fix(caprock): decode smallcaps ! escape in vat provision responses @endo/marshal smallcaps encoding prefixes strings starting with sigil characters (like `-` for negative floats) with `!`. Shell flags such as `--oneline` or `-l` were rendered as `!--oneline` and `!-l` in the TUI because vatFindMatch decoded only the outer CapData wrapper, leaving nested strings un-unescaped. Co-Authored-By: Claude Sonnet 4.6 --- packages/caprock/bin/hook.ts | 4 +++- packages/caprock/src/rpc.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/caprock/bin/hook.ts b/packages/caprock/bin/hook.ts index 5189a927ba..3bd85d0076 100644 --- a/packages/caprock/bin/hook.ts +++ b/packages/caprock/bin/hook.ts @@ -36,6 +36,7 @@ import { pingDaemon, sendCommand, decodeCapData, + decodeSmallcapsStrings, createKernelSession, authorizeRequest, recordProvisioned, @@ -285,7 +286,8 @@ async function vatFindMatch( if (isJsonRpcFailure(response)) { return null; } - return decodeCapData(response.result as CapData) as Provision | null; + const raw = decodeCapData(response.result as CapData); + return decodeSmallcapsStrings(raw) as Provision | null; } /** diff --git a/packages/caprock/src/rpc.ts b/packages/caprock/src/rpc.ts index 856ca49f3a..97c3eb0d04 100644 --- a/packages/caprock/src/rpc.ts +++ b/packages/caprock/src/rpc.ts @@ -311,3 +311,31 @@ export function decodeCapData(capData: CapData): unknown { } 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; +}