From 83487089a6d49f42bc78ea5544a2a7333a06111a Mon Sep 17 00:00:00 2001 From: vastsa Date: Fri, 18 Sep 2026 03:41:50 +0800 Subject: [PATCH 01/13] refactor(remote): extract headless runtime service Rollout R1 made the Agent Host module Electron-free, but everything under it still lived in Electron main: the host-core and sidecar stdio transports, the restart supervisor, the durable turn lifecycle, transcript persistence, and approved-plan dispatch. The R2 pi-host bundle must run that layer on a machine without Electron, and re-implementing the turn lifecycle there would create a second copy of the abort-lock, stale terminal event, and queue-release invariants the desktop already fixed. Move the layer into packages/host-runtime (ADR 0282, D445). HostProcess and AgentSidecar take their launch as options instead of resolving Electron resource paths and forcing ELECTRON_RUN_AS_NODE; the restart policy of process-model section 4 becomes RuntimeSupervisor; RuntimeService implements the module's RuntimePort with the same claim-before-await finalization; a headless launch resolver reads host-core's own registries. Electron main keeps thin subclasses that add binary locations, redacted stderr, and the schema/glibc diagnoses, and the Agent Host bridge reuses the shared host-core ports. The local prompt path, IPC, sidecar RPC, host-core RPC, and persisted data are unchanged; TurnStartRequest gains an optional userMessageId. Source-contract tests that pinned the moved transports now read the package sources; the supervisor, turn lifecycle, persistence, ports, and launch resolver have unit tests that run without Electron. --- CLAUDE.md | 2 + .../electron/main/agent-host-bridge.ts | 169 +---- apps/desktop/electron/main/agent-sidecar.ts | 665 ++---------------- .../electron/main/bootstrap/shutdown.ts | 2 +- apps/desktop/electron/main/host-process.ts | 427 ++--------- apps/desktop/electron/main/index.ts | 4 +- apps/desktop/electron/main/ipc/agent-ipc.ts | 3 +- .../main/runtime/event-persistence.ts | 2 +- .../electron/main/runtime/lifecycle.ts | 174 +++-- apps/desktop/electron/main/runtime/plans.ts | 2 +- apps/desktop/electron/main/runtime/sidecar.ts | 2 +- apps/desktop/package.json | 1 + .../test/browser-preview-tool.test.mjs | 2 +- .../test/host-boot-diagnostics.test.mjs | 26 +- .../desktop/test/inflight-checkpoint.test.mjs | 2 +- apps/desktop/test/network-proxy.test.mjs | 2 +- .../test/rpc-lifecycle-contract.test.mjs | 4 +- .../test/session-message-input.test.mjs | 2 +- apps/desktop/test/subagent-wiring.test.mjs | 2 +- docs/adr/0282-headless-runtime-boundary.md | 118 ++++ docs/adr/README.md | 1 + .../spec/02-architecture/03-repo-structure.md | 2 + docs/spec/03-runtime/07-process-model.md | 4 +- .../06-delivery/07-remote-control-rollout.md | 8 +- docs/spec/08-meta/decisions-log.md | 1 + .../spec/02-architecture/03-repo-structure.md | 2 + .../zh-CN/spec/03-runtime/07-process-model.md | 2 +- .../06-delivery/07-remote-control-rollout.md | 2 +- docs/zh-CN/spec/08-meta/decisions-log.md | 1 + packages/README.md | 2 + packages/agent-host/src/ports.ts | 2 + packages/host-runtime/package.json | 30 + packages/host-runtime/src/agent-sidecar.ts | 647 +++++++++++++++++ packages/host-runtime/src/host-ports.test.ts | 102 +++ packages/host-runtime/src/host-ports.ts | 182 +++++ packages/host-runtime/src/host-process.ts | 387 ++++++++++ packages/host-runtime/src/index.ts | 12 + .../host-runtime/src}/inflight-checkpoint.ts | 0 .../host-runtime/src/launch-resolver.test.ts | 105 +++ packages/host-runtime/src/launch-resolver.ts | 409 +++++++++++ packages/host-runtime/src/plan-dispatch.ts | 208 ++++++ .../host-runtime/src}/plan-execution.ts | 0 .../host-runtime/src/runtime-service.test.ts | 319 +++++++++ packages/host-runtime/src/runtime-service.ts | 646 +++++++++++++++++ .../src/runtime-supervisor.test.ts | 111 +++ .../host-runtime/src/runtime-supervisor.ts | 134 ++++ .../src}/session-message-input.ts | 8 +- packages/host-runtime/src/turn-events.ts | 330 +++++++++ .../host-runtime/src/turn-persistence.test.ts | 69 ++ packages/host-runtime/src/turn-persistence.ts | 119 ++++ packages/host-runtime/tsconfig.json | 17 + packages/host-runtime/vitest.config.ts | 7 + pnpm-lock.yaml | 25 + 53 files changed, 4243 insertions(+), 1262 deletions(-) create mode 100644 docs/adr/0282-headless-runtime-boundary.md create mode 100644 packages/host-runtime/package.json create mode 100644 packages/host-runtime/src/agent-sidecar.ts create mode 100644 packages/host-runtime/src/host-ports.test.ts create mode 100644 packages/host-runtime/src/host-ports.ts create mode 100644 packages/host-runtime/src/host-process.ts create mode 100644 packages/host-runtime/src/index.ts rename {apps/desktop/electron/main => packages/host-runtime/src}/inflight-checkpoint.ts (100%) create mode 100644 packages/host-runtime/src/launch-resolver.test.ts create mode 100644 packages/host-runtime/src/launch-resolver.ts create mode 100644 packages/host-runtime/src/plan-dispatch.ts rename {apps/desktop/electron/main => packages/host-runtime/src}/plan-execution.ts (100%) create mode 100644 packages/host-runtime/src/runtime-service.test.ts create mode 100644 packages/host-runtime/src/runtime-service.ts create mode 100644 packages/host-runtime/src/runtime-supervisor.test.ts create mode 100644 packages/host-runtime/src/runtime-supervisor.ts rename {apps/desktop/electron/main => packages/host-runtime/src}/session-message-input.ts (88%) create mode 100644 packages/host-runtime/src/turn-events.ts create mode 100644 packages/host-runtime/src/turn-persistence.test.ts create mode 100644 packages/host-runtime/src/turn-persistence.ts create mode 100644 packages/host-runtime/tsconfig.json create mode 100644 packages/host-runtime/vitest.config.ts diff --git a/CLAUDE.md b/CLAUDE.md index 27b4a94774..9cb3515774 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -231,6 +231,8 @@ packages/ shared/ IPC/protocol contracts, error codes i18n/ UI catalogs agent-runtime/ pi sidecar wrapper + agent-host/ headless Agent Host module (admission, queue, approvals, events) + host-runtime/ Electron-independent runtime (transports, supervisor, turn lifecycle) plugin-sdk/ plugin author types/validators plugin-devkit/ pi-plugin CLI examples/plugins/ sample plugins diff --git a/apps/desktop/electron/main/agent-host-bridge.ts b/apps/desktop/electron/main/agent-host-bridge.ts index a92b75bc0c..9b99580edb 100644 --- a/apps/desktop/electron/main/agent-host-bridge.ts +++ b/apps/desktop/electron/main/agent-host-bridge.ts @@ -1,20 +1,18 @@ -import { basename } from "node:path"; - import { AgentHost, RacpError, type ApprovalPort, - type PendingToolRequest, type Principal, type QueueEntryView, - type QueueStore, - type QueuedTurnRecord, type RuntimePort, - type SessionPort, - type SessionSummary, type TurnStartRequest, type TurnSteerRequest, } from "@pi-desktop/agent-host"; +import { + createHostQueueStore, + createHostSessionPort, + listPendingToolRequests, +} from "@pi-desktop/host-runtime"; import type { AgentEventEnvelope, AgentQueueChangedEvent, @@ -22,9 +20,7 @@ import type { AskToolResolution, QueuedTurnSummary, RacpApprovalResult, - RacpItemSummary, RacpPermissionMode, - UiMessage, } from "@pi-desktop/shared"; import { IPC, isGlobalPermissionMode } from "@pi-desktop/shared"; @@ -53,19 +49,6 @@ export const DESKTOP_PRINCIPAL: Principal = { pairedDevice: true, }; -type HostSessionRecord = { - id: string; - title?: string; - projectId?: string; - projectPath?: string; - mode?: string; - permissionMode?: string; - planningState?: string; - createdAt?: string; - updatedAt?: string; - messages?: UiMessage[]; -}; - /** * Electron Main's adapter around the headless Agent Host module (rollout R1, * D374/D375). The module wraps the existing registered IPC handlers through @@ -209,77 +192,13 @@ export function createAgentHostBridge(options: AgentHostBridgeOptions) { resolvingViaModule.delete(input.proposalId); } }, - async listPendingTools(sessionId) { - const host = options.getHost(); - if (!host) return []; - const result = await host.call<{ requests?: PendingToolRequest[] }>("permissions.pending", { - ...(sessionId ? { sessionId } : {}), - }); - return result.requests ?? []; - }, - }; - - const sessions: SessionPort = { - async get(sessionId) { - const record = await fetchSession(sessionId); - return record ? toSummary(record) : null; - }, - async history(sessionId, { limit, beforeItemId }) { - const record = await fetchSession(sessionId); - const messages = record?.messages ?? []; - const end = beforeItemId ? messages.findIndex((message) => message.id === beforeItemId) : messages.length; - const cut = end === -1 ? messages.length : end; - const start = Math.max(0, cut - limit); - return { - items: messages.slice(start, cut).map(toItem), - hasMore: start > 0, - }; - }, + listPendingTools: (sessionId) => listPendingToolRequests(options.getHost, sessionId), }; - async function fetchSession(sessionId: string): Promise { - const host = options.getHost(); - if (!host) return null; - const result = await host.call<{ session?: HostSessionRecord | null }>("session.get", { id: sessionId }); - return result.session ?? null; - } - - /** The Host-owned turn queue persisted by host-core (schema v15, ADR 0213). */ - const queueStore: QueueStore = { - async listAll() { - const host = options.getHost(); - if (!host) return []; - const result = await host.call<{ entries?: HostQueueEntry[] }>("session.queueList", {}); - return (result.entries ?? []).map(fromHostQueueEntry); - }, - async push(record) { - await requireHost().call("session.queuePush", { - id: record.id, - sessionId: record.sessionId, - principal: record.principalSubject, - ...(record.idempotencyKey ? { idempotencyKey: record.idempotencyKey } : {}), - inputHash: record.inputHash, - content: record.content, - ...(record.sessionMessageId ? { sessionMessageId: record.sessionMessageId } : {}), - ...(record.attachments ? { attachments: record.attachments } : {}), - permissionMode: record.effectivePermissionMode, - }); - }, - async remove(id) { - const result = await requireHost().call<{ removed?: boolean }>("session.queueRemove", { id }); - return result.removed === true; - }, - async prioritize(id) { - await requireHost().call("session.queuePrioritize", { id }); - }, - async reorder(id, direction) { - const result = await requireHost().call<{ moved?: boolean }>("session.queueReorder", { - id, - direction, - }); - return result.moved === true; - }, - }; + // Session reads and the persisted turn queue (schema v15, ADR 0213) go + // straight to host-core; the same ports serve the headless Host. + const sessions = createHostSessionPort(options.getHost); + const queueStore = createHostQueueStore(options.getHost); const agentHost = new AgentHost({ runtime, @@ -450,71 +369,3 @@ function toQueueSummary(entry: QueueEntryView): QueuedTurnSummary { createdAt: entry.turn.startedAt ?? new Date().toISOString(), }; } - -type HostQueueEntry = { - id: string; - sessionId: string; - principal: string; - idempotencyKey?: string; - inputHash: string; - content: string; - sessionMessageId?: string; - attachments?: unknown; - permissionMode: string; - position: number; - priority?: number; - createdAt: string; -}; - -function fromHostQueueEntry(entry: HostQueueEntry): QueuedTurnRecord { - const permissionMode: RacpPermissionMode = - entry.permissionMode === "accept-edits" || entry.permissionMode === "auto" ? entry.permissionMode : "ask"; - return { - id: entry.id, - sessionId: entry.sessionId, - principalSubject: entry.principal, - content: entry.content, - ...(entry.sessionMessageId ? { sessionMessageId: entry.sessionMessageId } : {}), - ...(Array.isArray(entry.attachments) ? { attachments: entry.attachments as QueuedTurnRecord["attachments"] } : {}), - effectivePermissionMode: permissionMode, - ...(entry.idempotencyKey ? { idempotencyKey: entry.idempotencyKey } : {}), - inputHash: entry.inputHash, - ...(entry.priority !== undefined ? { priority: entry.priority } : {}), - createdAt: Date.parse(entry.createdAt) || 0, - }; -} - -function toSummary(record: HostSessionRecord): SessionSummary { - const mode = record.mode === "plan" || record.mode === "goal" ? record.mode : "agent"; - const permissionMode: RacpPermissionMode = - record.permissionMode === "accept-edits" || record.permissionMode === "auto" ? record.permissionMode : "ask"; - const planningState = - record.planningState === "planning" || record.planningState === "awaiting_approval" - ? record.planningState - : "inactive"; - return { - id: record.id, - title: record.title ?? "", - ...(record.projectId ? { projectId: record.projectId } : {}), - ...(record.projectPath ? { workspaceLabel: basename(record.projectPath) } : {}), - mode, - permissionMode, - planningState, - createdAt: record.createdAt ?? new Date(0).toISOString(), - updatedAt: record.updatedAt ?? record.createdAt ?? new Date(0).toISOString(), - }; -} - -function toItem(message: UiMessage): RacpItemSummary { - const turnId = (message as { turnId?: string }).turnId ?? ""; - return { - id: message.id, - turnId, - itemType: "message", - status: message.status === "streaming" ? "streaming" : "completed", - createdAt: message.createdAt, - ...(message.parentToolCallId ? { parentToolCallId: message.parentToolCallId } : {}), - ...(message.agentName ? { agentName: message.agentName } : {}), - content: message, - }; -} diff --git a/apps/desktop/electron/main/agent-sidecar.ts b/apps/desktop/electron/main/agent-sidecar.ts index bb486b7d1e..3b0daadae8 100644 --- a/apps/desktop/electron/main/agent-sidecar.ts +++ b/apps/desktop/electron/main/agent-sidecar.ts @@ -1,95 +1,19 @@ -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; -import { randomUUID } from "node:crypto"; -import { join } from "node:path"; import { existsSync } from "node:fs"; -import type { HostProcess, ProcessExitHandler, StderrHandler } from "./host-process"; +import { join } from "node:path"; +import { + AgentSidecar as RuntimeAgentSidecar, + type StderrHandler, +} from "@pi-desktop/host-runtime"; import { redactValue } from "./logger"; -import { DEFAULT_RPC_TIMEOUT_MS, readNdjsonLines, rpcTimeoutMs } from "@pi-desktop/shared"; - -// stderr lines kept per sidecar so an unexpected exit can be reported with the -// process's last words instead of a bare "agent sidecar exited". -const SIDECAR_STDERR_TAIL_LINES = 40; - -export type SidecarNotificationHandler = (method: string, params: unknown) => void; - -/** Result shape the sidecar's tool executor expects from tools.execute. */ -export type LocalToolResult = { - ok: boolean; - content: unknown; - isError?: boolean; - errorCode?: string; -}; - -export type LocalToolHandler = (input: { - sessionId: string; - toolCallId: string; - args: unknown; -}) => Promise; - -export type ProjectInstructionResolver = (input: { - sessionId: string; - path: string; - /** Project root registered by Electron main for this session. */ - projectPath?: string; -}) => Promise; - -/** - * Resolve request auth for a vendor account. Answered by main against the - * signed-in pi-ai collection; the sidecar names a provider row, never a vendor - * or a credential, and gets back only a short-lived `ModelAuth`. - */ -export type VendorAuthResolver = (input: { - sessionId: string; - providerId: string; -}) => Promise; -// The sidecar runs model-directed code paths; it must not be able to pull -// secrets or mutate configuration through the parent proxy. Tight allowlist -// of host methods the agent loop legitimately needs. -// -// `provider.resolveAuth` is answered by main itself (never forwarded to -// host-core) and only for a provider row main bound to that same session, so -// it cannot reach a credential the session was not launched with. -const HOST_PROXY_ALLOWED = new Set([ - "tools.execute", - "tools.abort", - "tools.list", - "session.get", - "session.appendMessage", - "session.appendCompaction", - "session.replaceMessages", - "workspace.get", - "plans.enter", - "plans.submit", - "plans.pending", - "plans.abort", - "project.instructions.resolve", - "provider.resolveAuth", - "provider.resolveSubagentModel", - "app.health", - // Trusted extensions (D387): answered by main, plus the session methods - // the ExtensionAPI reaches (spec 16 §10.1). - "extensions.commands.publish", - "extensions.ui.request", - "extensions.diagnostics.publish", - "extensions.model.configure", - "session.rename", - "session.create", - "session.fork", - "session.queuePush", - "session.queuePrioritize", -]); - -/** Main-side answers for the `extensions.*` proxy methods. */ -export type TrustedExtensionSidecarBridge = { - publishCommands: (params: Record) => void; - publishDiagnostics: (params: Record) => void; - requestUi: (params: Record) => Promise; - configureModel: (params: Record) => Promise; - /** `sendUserMessage`: the Host-owned queue drains it (D386); host-core alone would only store it. */ - queuePush: (params: Record) => Promise; - queuePrioritize: (params: Record) => Promise; -}; +export type { + LocalToolHandler, + LocalToolResult, + ProjectInstructionResolver, + SidecarNotificationHandler, + TrustedExtensionSidecarBridge, + VendorAuthResolver, +} from "@pi-desktop/host-runtime"; function resolveSidecarEntry(): string { const candidates = [ @@ -103,543 +27,38 @@ function resolveSidecarEntry(): string { return join(__dirname, "../../../../packages/agent-runtime/dist/sidecar.js"); } -export class AgentSidecar { - private child: ChildProcessWithoutNullStreams; - private pending = new Map< - string, - { - resolve: (v: any) => void; - reject: (e: Error) => void; - timer?: ReturnType; - } - >(); - private handlers = new Set(); - private exitHandlers = new Set(); - private disposed = false; - private closed = false; - private exitNotified = false; - private stderrTail: string[] = []; - private host: HostProcess | null = null; - private unsubscribeHost: (() => void) | null = null; - private unsubscribeHostExit: (() => void) | null = null; - private stdoutReader?: ReturnType; - // Tools served by Electron main itself (e.g. BrowserPreview drives the - // work panel's WebContentsView) — host-core never sees these. - private localTools = new Map(); - private localToolTimers = new Set>(); - private projectInstructionResolver: ProjectInstructionResolver | null = null; - // The sidecar may request a path, but it never chooses the project root. - // Electron main registers this binding from the host-owned session record - // immediately before starting a runtime turn. - private projectInstructionRoots = new Map(); - private vendorAuthResolver: VendorAuthResolver | null = null; - private trustedExtensionBridge: TrustedExtensionSidecarBridge | null = null; - // Vendor-account rows this session was launched with. The sidecar can only - // ask for auth it is already using, and a session that never bound an OAuth - // row can ask for nothing at all. - private vendorAuthBindings = new Map>(); +function fallbackStderrLogger(text: string): void { + console.error( + `[agent/runtime] ${JSON.stringify({ + ts: new Date().toISOString(), + level: "info", + channel: "agent", + category: "runtime", + event: "child.process.stderr", + message: "child process stderr", + data: { output: redactValue(text.trimEnd()) }, + })}`, + ); +} +/** + * The desktop's agent sidecar: the shared stdio transport from + * `@pi-desktop/host-runtime`, launched the only way Electron can run Node + * code out of process — its own executable with `ELECTRON_RUN_AS_NODE` — on + * the sidecar bundle this build ships. + */ +export class AgentSidecar extends RuntimeAgentSidecar { constructor(onStderr?: StderrHandler) { - const entry = resolveSidecarEntry(); - this.child = spawn(process.execPath, [entry], { - stdio: ["pipe", "pipe", "pipe"], - env: { - ...process.env, - ELECTRON_RUN_AS_NODE: "1", + super({ + launch: { + command: process.execPath, + args: [resolveSidecarEntry()], + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: "1", + }, }, - }); - - this.child.stderr.setEncoding("utf8"); - this.child.stderr.on("data", (text: string) => { - if (!text) return; - this.recordStderr(text); - if (onStderr) onStderr(text); - else { - console.error( - `[agent/runtime] ${JSON.stringify({ - ts: new Date().toISOString(), - level: "info", - channel: "agent", - category: "runtime", - event: "child.process.stderr", - message: "child process stderr", - data: { output: redactValue(text.trimEnd()) }, - })}`, - ); - } - }); - - this.child.on("exit", (code, signal) => { - this.closeTransport(new Error("agent sidecar exited")); - this.notifyExit({ code, signal, intentional: this.disposed }); - }); - this.child.on("error", (error) => { - this.closeTransport(error instanceof Error ? error : new Error(String(error))); - this.notifyExit({ code: null, signal: null, intentional: this.disposed }); - }); - - this.stdoutReader = readNdjsonLines(this.child.stdout, (line) => - void this.onLine(line), - ); - } - - private recordStderr(text: string) { - for (const line of text.split(/\r?\n/)) { - if (!line.trim()) continue; - this.stderrTail.push(line); - if (this.stderrTail.length > SIDECAR_STDERR_TAIL_LINES) { - this.stderrTail.shift(); - } - } - } - - private closeTransport(error: Error) { - if (this.closed) return; - this.closed = true; - this.unsubscribeHost?.(); - this.unsubscribeHost = null; - this.unsubscribeHostExit?.(); - this.unsubscribeHostExit = null; - this.host = null; - for (const [, p] of this.pending) { - if (p.timer) clearTimeout(p.timer); - p.reject(error); - } - this.pending.clear(); - for (const timer of this.localToolTimers) clearTimeout(timer); - this.localToolTimers.clear(); - this.handlers.clear(); - this.stdoutReader?.close(); - this.stdoutReader = undefined; - this.child.removeAllListeners("exit"); - this.child.removeAllListeners("error"); - this.child.stderr.removeAllListeners("data"); - } - - private notifyExit(info: { - code: number | null; - signal: NodeJS.Signals | null; - intentional: boolean; - }) { - if (this.exitNotified) return; - this.exitNotified = true; - // Snapshot the sidecar's last stderr lines: the dying process emits no - // stdout, so they are the only context a crash report gets. - const stderrTail = this.stderrTail.slice(); - for (const h of this.exitHandlers) h({ ...info, stderrTail }); - this.exitHandlers.clear(); - } - - private writeToChild(payload: string): boolean { - if (this.closed || this.disposed) return false; - if (this.child.stdin.destroyed || !this.child.stdin.writable) { - const failure = new Error("agent sidecar stdin is unavailable"); - this.closeTransport(failure); - this.notifyExit({ code: null, signal: null, intentional: this.disposed }); - return false; - } - try { - this.child.stdin.write(payload, (error) => { - if (error) { - this.closeTransport(error); - this.notifyExit({ code: null, signal: null, intentional: this.disposed }); - } - }); - return true; - } catch (error) { - const failure = error instanceof Error ? error : new Error(String(error)); - this.closeTransport(failure); - this.notifyExit({ code: null, signal: null, intentional: this.disposed }); - return false; - } - } - - private async runLocalTool( - handler: LocalToolHandler, - input: Parameters[0], - ): Promise { - let timer: ReturnType | undefined; - try { - return await Promise.race([ - handler(input), - new Promise((_, reject) => { - timer = setTimeout(() => { - reject(new Error("main-local tool timeout")); - }, DEFAULT_RPC_TIMEOUT_MS); - this.localToolTimers.add(timer); - }), - ]); - } finally { - if (timer) { - clearTimeout(timer); - this.localToolTimers.delete(timer); - } - } - } - - onExit(handler: ProcessExitHandler): () => void { - if (this.closed) return () => undefined; - this.exitHandlers.add(handler); - return () => this.exitHandlers.delete(handler); - } - - /** Register a tool the sidecar can call that main handles locally. */ - setLocalTool(name: string, handler: LocalToolHandler): void { - this.localTools.set(name, handler); - } - - setProjectInstructionResolver(resolver: ProjectInstructionResolver): void { - this.projectInstructionResolver = resolver; - } - - setProjectInstructionRoot(sessionId: string, projectPath?: string): void { - const id = sessionId.trim(); - if (!id) return; - const root = projectPath?.trim(); - if (root) this.projectInstructionRoots.set(id, root); - else this.projectInstructionRoots.delete(id); - } - - clearProjectInstructionRoot(sessionId: string): void { - this.projectInstructionRoots.delete(sessionId.trim()); - } - - setVendorAuthResolver(resolver: VendorAuthResolver): void { - this.vendorAuthResolver = resolver; - } - - setTrustedExtensionBridge(bridge: TrustedExtensionSidecarBridge): void { - this.trustedExtensionBridge = bridge; - } - - /** - * Bind the vendor-account rows a turn may sign requests with. Replaces the - * session's previous set, so a row dropped from the launch payload — a model - * switch or account removal — stops being resolvable on the next turn. - */ - setVendorAuthBindings( - sessionId: string, - bindings: ReadonlyArray<{ providerId: string }>, - ): void { - const id = sessionId.trim(); - if (!id) return; - const providerIds = new Set(); - for (const binding of bindings) { - const providerId = binding.providerId?.trim(); - if (providerId) providerIds.add(providerId); - } - if (providerIds.size > 0) this.vendorAuthBindings.set(id, providerIds); - else this.vendorAuthBindings.delete(id); - } - - clearVendorAuthBindings(sessionId: string): void { - this.vendorAuthBindings.delete(sessionId.trim()); - } - - setHost(host: HostProcess) { - if (this.closed) return; - this.host = host; - this.unsubscribeHost?.(); - this.unsubscribeHostExit?.(); - this.unsubscribeHost = host.onNotification((method, params) => { - // Forward host notifications to sidecar. permissions.request stays out: - // the renderer already gets it straight from wireHost, and bouncing it - // through the sidecar delivered the dialog twice with the full args - // payload re-serialized across two extra stdio hops. - if (method === "permissions.request") return; - const payload = - JSON.stringify({ - jsonrpc: "2.0", - method: "host.notification", - params: { method, params }, - }) + "\n"; - this.writeToChild(payload); - }); - this.unsubscribeHostExit = host.onExit(() => { - this.unsubscribeHost?.(); - this.unsubscribeHost = null; - this.unsubscribeHostExit = null; - this.host = null; - }); - } - - /** - * Answer one `provider.resolveAuth` request. Refuses anything the session was - * not launched with: the provider row has to be in this session's bindings, - * which main rewrites on every turn. - */ - private async resolveVendorAuth( - params: Record, - ): Promise { - if (!this.vendorAuthResolver) { - throw new Error("vendor account auth resolver unavailable"); - } - const sessionId = String(params.sessionId ?? "").trim(); - const providerId = String(params.providerId ?? "").trim(); - const bound = this.vendorAuthBindings.get(sessionId)?.has(providerId); - if (!bound) { - throw Object.assign( - new Error("provider is not bound to this session"), - { code: -32000, data: { errorCode: "PROVIDER_NOT_BOUND" } }, - ); - } - return this.vendorAuthResolver({ sessionId, providerId }); - } - - /** - * On-demand subagent model resolution. The runtime calls this when the - * parent agent passes a `model` override on Task that was not statically - * pinned by any definition. Main resolves it against the user's configured - * providers and the models.dev catalog, respecting the - * `availableForSubagents` gate on each model binding. - */ - private subagentModelResolver: - | ((key: string) => Promise) - | null = null; - - setSubagentModelResolver( - resolver: (key: string) => Promise, - ): void { - this.subagentModelResolver = resolver; - } - - private async resolveSubagentModel( - params: Record, - ): Promise { - if (!this.subagentModelResolver) { - throw new Error("subagent model resolver unavailable"); - } - const key = String(params.key ?? "").trim(); - if (!key || !key.includes("/")) { - throw Object.assign( - new Error("invalid model key; expected 'provider/model'"), - { code: -32602 }, - ); - } - return this.subagentModelResolver(key); - } - - private async onLine(line: string) { - if (!line.trim()) return; - let msg: any; - try { - msg = JSON.parse(line); - } catch { - console.warn( - `[RPC] Invalid agent-sidecar NDJSON frame (${Buffer.byteLength(line, "utf8")} bytes)`, - ); - return; - } - - // Reverse RPC from sidecar → host proxy - if (msg.method === "host.proxy" && msg.id !== undefined) { - try { - const method = String(msg.params?.method || ""); - if (!HOST_PROXY_ALLOWED.has(method)) { - throw Object.assign( - new Error(`host method not allowed from sidecar: ${method}`), - { code: -32601 }, - ); - } - const params = (msg.params?.params ?? {}) as Record; - const requestedToolName = String(params.toolName ?? ""); - const planLocalTool = - requestedToolName === "Skill" || - requestedToolName === "PluginCheck" || - requestedToolName === "PluginScaffold" || - requestedToolName === "PluginPack" || - requestedToolName.startsWith("plugin_"); - if ( - method === "tools.execute" && - params.mode === "plan" && - planLocalTool && - requestedToolName !== "BrowserPreview" - ) { - throw Object.assign( - new Error(`${requestedToolName} is unavailable in Plan mode`), - { code: -32000, data: { errorCode: "TOOL_DISABLED_IN_PLAN" } }, - ); - } - if (method === "project.instructions.resolve") { - if (!this.projectInstructionResolver) { - throw new Error("project instruction resolver unavailable"); - } - const sessionId = String(params.sessionId ?? ""); - const result = await this.projectInstructionResolver({ - sessionId, - path: String(params.path ?? ""), - projectPath: this.projectInstructionRoots.get(sessionId), - }); - this.writeToChild( - JSON.stringify({ jsonrpc: "2.0", id: msg.id, result }) + "\n", - ); - return; - } - if (method === "provider.resolveAuth") { - const result = await this.resolveVendorAuth(params); - this.writeToChild( - JSON.stringify({ jsonrpc: "2.0", id: msg.id, result }) + "\n", - ); - return; - } - if (method === "provider.resolveSubagentModel") { - const result = await this.resolveSubagentModel(params); - this.writeToChild( - JSON.stringify({ jsonrpc: "2.0", id: msg.id, result }) + "\n", - ); - return; - } - if (method.startsWith("extensions.") || method === "session.queuePush" || method === "session.queuePrioritize") { - const bridge = this.trustedExtensionBridge; - if (!bridge) throw new Error("trusted extension bridge unavailable"); - let result: unknown = { ok: true }; - if (method === "extensions.commands.publish") bridge.publishCommands(params); - else if (method === "extensions.diagnostics.publish") bridge.publishDiagnostics(params); - else if (method === "extensions.model.configure") result = await bridge.configureModel(params); - else if (method === "session.queuePush") result = await bridge.queuePush(params); - else if (method === "session.queuePrioritize") result = await bridge.queuePrioritize(params); - else result = await bridge.requestUi(params); - this.writeToChild( - JSON.stringify({ jsonrpc: "2.0", id: msg.id, result }) + "\n", - ); - return; - } - // Main-local tools short-circuit before host-core (which doesn't - // know them); everything else proxies through unchanged. - const localTool = - method === "tools.execute" - ? this.localTools.get(requestedToolName) - : undefined; - if (localTool) { - const toolName = requestedToolName; - // Local tools can bypass host-core's permission boundary. Plan mode - // therefore permits only the read-only BrowserPreview bridge; every - // other main-local tool fails closed even if a stale runtime asks for - // it directly. - const result = - params.mode === "plan" && toolName !== "BrowserPreview" - ? { - ok: false, - isError: true, - errorCode: "TOOL_DISABLED_IN_PLAN", - content: `${toolName} is unavailable in Plan mode.`, - } - : await this.runLocalTool(localTool, { - sessionId: String(params.sessionId ?? ""), - toolCallId: String(params.toolCallId ?? ""), - args: params.args, - }); - this.writeToChild( - JSON.stringify({ jsonrpc: "2.0", id: msg.id, result }) + "\n", - ); - return; - } - if (!this.host) throw new Error("host unavailable"); - const result = await this.host.call(method, params); - this.writeToChild( - JSON.stringify({ jsonrpc: "2.0", id: msg.id, result }) + "\n", - ); - } catch (e: any) { - this.writeToChild( - JSON.stringify({ - jsonrpc: "2.0", - id: msg.id, - error: { - code: e?.code ?? -32000, - message: e instanceof Error ? e.message : String(e), - data: e?.data, - }, - }) + "\n", - ); - } - return; - } - - if (msg.id !== undefined && msg.id !== null && msg.method === undefined) { - const pending = this.pending.get(String(msg.id)); - if (pending) { - this.pending.delete(String(msg.id)); - if (pending.timer) clearTimeout(pending.timer); - if (msg.error) { - const err = new Error(msg.error.message) as Error & { - code?: number; - data?: unknown; - }; - err.code = msg.error.code; - err.data = msg.error.data; - pending.reject(err); - } else { - pending.resolve(msg.result); - } - } - return; - } - - if (msg.method) { - for (const h of this.handlers) h(msg.method, msg.params); - } - } - - onNotification(handler: SidecarNotificationHandler): () => void { - if (this.closed) return () => undefined; - this.handlers.add(handler); - return () => this.handlers.delete(handler); - } - - async call(method: string, params: unknown = {}): Promise { - if (this.closed) throw new Error("agent sidecar is unavailable"); - const id = randomUUID(); - const timeoutMs = rpcTimeoutMs(method, params); - const payload = JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n"; - return new Promise((resolve, reject) => { - this.pending.set(id, { - resolve: resolve as (v: any) => void, - reject, - }); - const settle = (settleWith: (pending: { - resolve: (v: any) => void; - reject: (e: Error) => void; - timer?: ReturnType; - }) => void) => { - const pending = this.pending.get(id); - if (!pending) return; - this.pending.delete(id); - if (pending.timer) clearTimeout(pending.timer); - settleWith(pending); - }; - if (!this.writeToChild(payload)) { - settle((pending) => pending.reject(new Error("agent sidecar stdin is unavailable"))); - } - if (timeoutMs !== undefined) { - const timer = setTimeout(() => { - settle((pending) => pending.reject(new Error(`sidecar RPC timeout: ${method}`))); - }, timeoutMs); - const pending = this.pending.get(id); - if (pending) pending.timer = timer; - } - }); - } - - async dispose(): Promise { - this.disposed = true; - this.projectInstructionRoots.clear(); - this.vendorAuthBindings.clear(); - this.closeTransport(new Error("agent sidecar disposed")); - this.exitHandlers.clear(); - if (this.child.exitCode !== null || this.child.signalCode !== null) return; - // Wait for the process to actually leave so quit's settle step is real - // rather than returning while the sidecar is still tearing down. - await new Promise((resolve) => { - const timer = setTimeout(resolve, SIDECAR_DISPOSE_GRACE_MS); - timer.unref?.(); - this.child.once("exit", () => { - clearTimeout(timer); - resolve(); - }); - this.child.kill(); + onStderr: onStderr ?? fallbackStderrLogger, }); } } - -/** Upper bound on how long `dispose()` waits for the killed sidecar to exit. */ -const SIDECAR_DISPOSE_GRACE_MS = 2_000; diff --git a/apps/desktop/electron/main/bootstrap/shutdown.ts b/apps/desktop/electron/main/bootstrap/shutdown.ts index 14e7bb944c..761d5d24fa 100644 --- a/apps/desktop/electron/main/bootstrap/shutdown.ts +++ b/apps/desktop/electron/main/bootstrap/shutdown.ts @@ -3,7 +3,7 @@ import type { CloseBehavior } from "@pi-desktop/shared"; import type { AgentSidecar } from "../agent-sidecar"; import type { BrowserPane } from "../browser-view"; import type { HostProcess } from "../host-process"; -import type { InflightCheckpointer } from "../inflight-checkpoint"; +import type { InflightCheckpointer } from "@pi-desktop/host-runtime"; import type { Logger } from "../logger"; import type { PersistenceOutbox } from "../persistence-outbox"; import type { PluginPanelHost } from "../plugin-panel-host"; diff --git a/apps/desktop/electron/main/host-process.ts b/apps/desktop/electron/main/host-process.ts index 8541252833..5b5a5f1384 100644 --- a/apps/desktop/electron/main/host-process.ts +++ b/apps/desktop/electron/main/host-process.ts @@ -1,15 +1,11 @@ -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; -import { randomUUID } from "node:crypto"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { - ErrorCodes, - MAX_HOST_STDIN_LINE_BYTES, - PROTOCOL_VERSION, - readNdjsonLines, - rpcTimeoutMs, - stripProxyEnv, -} from "@pi-desktop/shared"; + HostProcess as RuntimeHostProcess, + type DiagnosedHostFailure, + type StderrHandler, +} from "@pi-desktop/host-runtime"; +import { ErrorCodes } from "@pi-desktop/shared"; import { GlibcUnsupportedError, glibcMissingSymbol, @@ -17,30 +13,11 @@ import { import { DbSchemaTooNewError, parseSchemaTooNew } from "./host-boot-diagnostics"; import { redactValue } from "./logger"; -const HOST_DISPOSE_GRACE_MS = 3_000; -const HOST_FORCE_KILL_GRACE_MS = 1_000; -const HOST_OVERLOAD_RETRY_DELAYS_MS = [50, 100, 200, 400] as const; - -export type HostNotificationHandler = (method: string, params: unknown) => void; -export type ProcessExitHandler = (info: { - code: number | null; - signal: NodeJS.Signals | null; - intentional: boolean; - /** Last child stderr lines before exit; only the agent sidecar fills it. */ - stderrTail?: string[]; -}) => void; -export type StderrHandler = (text: string) => void; - -function isHostOverloaded(error: unknown): boolean { - const candidate = error as { - errorCode?: unknown; - data?: { errorCode?: unknown }; - } | null; - return ( - candidate?.errorCode === ErrorCodes.HOST_OVERLOADED || - candidate?.data?.errorCode === ErrorCodes.HOST_OVERLOADED - ); -} +export type { + HostNotificationHandler, + ProcessExitHandler, + StderrHandler, +} from "@pi-desktop/host-runtime"; function resolveHostBinary(): string { if (process.env.PI_DESKTOP_HOST_BIN && existsSync(process.env.PI_DESKTOP_HOST_BIN)) { @@ -81,345 +58,65 @@ function resolveBuiltinPluginsDir(): string | null { return null; } -export class HostProcess { - private child: ChildProcessWithoutNullStreams; - private pending = new Map< - string, - { - resolve: (v: any) => void; - reject: (e: Error) => void; - timer?: ReturnType; - } - >(); - private handlers = new Set(); - private exitHandlers = new Set(); - private disposed = false; - private available = true; - private closed = false; - private exitNotified = false; - private exitObserved = false; - private exitPromise: Promise; - private resolveExit!: () => void; - private disposePromise?: Promise; - private stdoutReader?: ReturnType; - private lastStderr = ""; - readonly binaryPath: string; - readonly generation = randomUUID(); - - constructor(dataDir: string, onStderr?: StderrHandler) { - this.exitPromise = new Promise((resolve) => { - this.resolveExit = resolve; - }); - this.binaryPath = resolveHostBinary(); - const builtinPlugins = resolveBuiltinPluginsDir(); - this.child = spawn(this.binaryPath, [], { - stdio: ["pipe", "pipe", "pipe"], - env: { - ...stripProxyEnv(process.env), - PI_DESKTOP_DATA_DIR: dataDir, - // Only Electron knows whether this build runs from `resources/` or a - // source checkout, so it resolves the bundled-plugin directory and - // host-core simply reconciles its registry against it (ADR 0105). - ...(builtinPlugins ? { PI_DESKTOP_BUILTIN_PLUGINS_DIR: builtinPlugins } : {}), - }, - }); - - this.child.stderr.setEncoding("utf8"); - this.child.stderr.on("data", (text: string) => { - if (!text) return; - this.lastStderr = `${this.lastStderr}${text}`.slice(-4_000); - if (onStderr) onStderr(text); - else { - console.error( - `[host/runtime] ${JSON.stringify({ - ts: new Date().toISOString(), - level: "info", - channel: "host", - category: "runtime", - event: "child.process.stderr", - message: "child process stderr", - data: { output: redactValue(text.trimEnd()) }, - })}`, - ); - } - }); - - this.child.on("exit", (code, signal) => { - this.available = false; - this.closeTransport(this.unavailableError("host-core exited")); - this.exitObserved = true; - this.resolveExit(); - this.notifyExit({ code, signal, intentional: this.disposed }); - this.cleanupProcessListeners(); - }); - this.child.on("error", (error) => { - this.available = false; - const failure = this.unavailableError( - `host-core process error: ${error.message}`, - ); - this.closeTransport(failure); - // A spawn failure never produces an `exit` event, so without settling the - // exit promise here `dispose()` would wait the full grace period, send a - // SIGKILL to a process that never started, and wait again. - if (this.child.pid === undefined || this.child.exitCode !== null) { - this.exitObserved = true; - this.resolveExit(); - } - this.notifyExit({ code: null, signal: null, intentional: this.disposed }); - if (this.exitObserved) this.cleanupProcessListeners(); - }); - - this.stdoutReader = readNdjsonLines(this.child.stdout, (line) => - this.onLine(line), - ); - } - - private closeTransport(error: Error) { - if (this.closed) return; - this.available = false; - this.closed = true; - for (const [, p] of this.pending) { - if (p.timer) clearTimeout(p.timer); - p.reject(error); - } - this.pending.clear(); - this.handlers.clear(); - this.stdoutReader?.close(); - this.stdoutReader = undefined; - } - - private cleanupProcessListeners() { - this.child.removeAllListeners("exit"); - this.child.removeAllListeners("error"); - this.child.stderr.removeAllListeners("data"); - } - - private waitForExit(timeoutMs: number): Promise { - if (this.exitObserved) return Promise.resolve(true); - return new Promise((resolve) => { - let settled = false; - const finish = (observed: boolean) => { - if (settled) return; - settled = true; - if (timer) clearTimeout(timer); - resolve(observed); - }; - const timer = setTimeout(() => finish(false), timeoutMs); - timer.unref?.(); - this.exitPromise.then(() => finish(true)); +/** + * Name the two boot refusals the desktop phrases for the user (D380): a data + * directory newer than this build, and a glibc below the packaged floor. The + * schema refusal is checked first because its stderr line is the more specific + * one; anything else stays the generic `HOST_UNAVAILABLE` transport error. + */ +export function diagnoseHostFailure({ + lastStderr, + message, +}: { + lastStderr: string; + message: string; +}): DiagnosedHostFailure | null { + const schema = parseSchemaTooNew(lastStderr) ?? parseSchemaTooNew(message); + if (schema) { + return Object.assign(new DbSchemaTooNewError(schema), { + errorCode: ErrorCodes.HOST_UNAVAILABLE, }); } - - private notifyExit(info: { - code: number | null; - signal: NodeJS.Signals | null; - intentional: boolean; - }) { - if (this.exitNotified) return; - this.exitNotified = true; - for (const h of this.exitHandlers) h(info); - this.exitHandlers.clear(); - } - - /** - * Every rejection that only means "the transport is gone" is built here, so a - * caller can tell routine teardown from a real failure by the error code - * rather than by matching message text. - */ - private unavailableError(message: string): Error & { errorCode: string } { - const schema = parseSchemaTooNew(this.lastStderr) ?? parseSchemaTooNew(message); - if (schema) { - return Object.assign(new DbSchemaTooNewError(schema), { - errorCode: ErrorCodes.HOST_UNAVAILABLE, - }); - } - if (glibcMissingSymbol(this.lastStderr) || glibcMissingSymbol(message)) { - return Object.assign(new GlibcUnsupportedError(), { - errorCode: ErrorCodes.HOST_UNAVAILABLE, - }); - } - return Object.assign(new Error(message), { + if (glibcMissingSymbol(lastStderr) || glibcMissingSymbol(message)) { + return Object.assign(new GlibcUnsupportedError(), { errorCode: ErrorCodes.HOST_UNAVAILABLE, }); } + return null; +} - isAvailable(): boolean { - return ( - this.available && - !this.closed && - this.child.exitCode === null && - !this.child.killed - ); - } - - onExit(handler: ProcessExitHandler): () => void { - if (this.closed) return () => undefined; - this.exitHandlers.add(handler); - return () => this.exitHandlers.delete(handler); - } - - private onLine(line: string) { - if (!line.trim()) return; - let msg: any; - try { - msg = JSON.parse(line); - } catch { - console.warn( - `[RPC] Invalid host-process NDJSON frame (${Buffer.byteLength(line, "utf8")} bytes)`, - ); - return; - } - if (msg.id !== undefined && msg.id !== null) { - const pending = this.pending.get(String(msg.id)); - if (pending) { - this.pending.delete(String(msg.id)); - if (pending.timer) clearTimeout(pending.timer); - if (msg.error) { - const err = new Error(msg.error.message) as Error & { - code?: number; - data?: unknown; - errorCode?: string; - }; - err.code = msg.error.code; - err.data = msg.error.data; - const errorCode = - msg.error.data && typeof msg.error.data.errorCode === "string" - ? msg.error.data.errorCode - : undefined; - if (errorCode) err.errorCode = errorCode; - pending.reject(err); - } else { - pending.resolve(msg.result); - } - } - return; - } - if (msg.method) { - for (const h of this.handlers) h(msg.method, msg.params); - } - } - - onNotification(handler: HostNotificationHandler): () => void { - if (this.closed) return () => undefined; - this.handlers.add(handler); - return () => this.handlers.delete(handler); - } - - async call( - method: string, - params: unknown = {}, - timeoutOverrideMs?: number, - ): Promise { - for (const delayMs of [0, ...HOST_OVERLOAD_RETRY_DELAYS_MS]) { - if (delayMs > 0) { - await new Promise((resolve) => setTimeout(resolve, delayMs)); - } - try { - return await this.callOnce(method, params, timeoutOverrideMs); - } catch (error) { - if ( - !isHostOverloaded(error) || - delayMs === HOST_OVERLOAD_RETRY_DELAYS_MS.at(-1) - ) { - throw error; - } - } - } - throw new Error(`host RPC retry exhausted: ${method}`); - } +function fallbackStderrLogger(text: string): void { + console.error( + `[host/runtime] ${JSON.stringify({ + ts: new Date().toISOString(), + level: "info", + channel: "host", + category: "runtime", + event: "child.process.stderr", + message: "child process stderr", + data: { output: redactValue(text.trimEnd()) }, + })}`, + ); +} - private async callOnce( - method: string, - params: unknown, - timeoutOverrideMs?: number, - ): Promise { - if (this.closed) throw this.unavailableError("host-core is unavailable"); - if (!this.isAvailable()) { - throw this.unavailableError(`host RPC unavailable: ${method}`); - } - const id = randomUUID(); - const timeoutMs = timeoutOverrideMs ?? rpcTimeoutMs(method, params); - const payload = JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n"; - if (Buffer.byteLength(payload, "utf8") > MAX_HOST_STDIN_LINE_BYTES) { - throw Object.assign(new Error("request line exceeds 64 MiB"), { - errorCode: ErrorCodes.LIMIT_EXCEEDED, - code: 1002, - }); - } - return new Promise((resolve, reject) => { - let timer: ReturnType | undefined; - let settled = false; - const settle = (finish: () => void) => { - if (settled) return; - settled = true; - this.pending.delete(id); - if (timer) clearTimeout(timer); - finish(); - }; - this.pending.set(id, { - resolve: (value) => settle(() => resolve(value)), - reject: (error) => settle(() => reject(error)), - }); - if (!this.isAvailable()) { - settle(() => reject(this.unavailableError(`host RPC unavailable: ${method}`))); - return; - } - if (timeoutMs !== undefined) { - timer = setTimeout( - () => settle(() => reject(new Error(`host RPC timeout: ${method}`))), - timeoutMs, - ); - } - try { - this.child.stdin.write(payload, (error) => { - if (!error) return; - const failure = this.unavailableError( - `host RPC write failed: ${error.message}`, - ); - settle(() => reject(failure)); - this.closeTransport(failure); - this.notifyExit({ code: null, signal: null, intentional: this.disposed }); - }); - } catch (error) { - const failure = this.unavailableError( - `host RPC write failed: ${error instanceof Error ? error.message : String(error)}`, - ); - settle(() => reject(failure)); - this.closeTransport(failure); - this.notifyExit({ code: null, signal: null, intentional: this.disposed }); - } +/** + * The desktop's host-core child: the shared stdio transport from + * `@pi-desktop/host-runtime` plus the two things only Electron knows — where + * this build keeps the binary and its bundled plugins, and how to name the + * boot refusals the renderer phrases. + */ +export class HostProcess extends RuntimeHostProcess { + constructor(dataDir: string, onStderr?: StderrHandler) { + const builtinPlugins = resolveBuiltinPluginsDir(); + super({ + binaryPath: resolveHostBinary(), + dataDir, + // Only Electron knows whether this build runs from `resources/` or a + // source checkout, so it resolves the bundled-plugin directory and + // host-core simply reconciles its registry against it (ADR 0105). + env: builtinPlugins ? { PI_DESKTOP_BUILTIN_PLUGINS_DIR: builtinPlugins } : {}, + onStderr: onStderr ?? fallbackStderrLogger, + diagnoseFailure: diagnoseHostFailure, }); } - - async handshake(): Promise { - await this.call("app.handshake", { protocolVersion: PROTOCOL_VERSION }); - } - - async dispose(): Promise { - if (this.disposePromise) return this.disposePromise; - this.disposePromise = this.disposeInternal(); - return this.disposePromise; - } - - private async disposeInternal(): Promise { - this.disposed = true; - this.available = false; - // EOF is the graceful host-core shutdown signal. Send it before rejecting - // transport callers so the runner can clean up active tools first. - if (!this.child.stdin.destroyed && !this.child.stdin.writableEnded) { - this.child.stdin.end(); - } - this.closeTransport(this.unavailableError("host-core disposed")); - if (this.exitObserved) return; - - const exited = await this.waitForExit(HOST_DISPOSE_GRACE_MS); - if (exited || this.exitObserved) return; - - try { - this.child.kill("SIGKILL"); - } catch { - // The child may have exited between the grace check and kill fallback. - } - await this.waitForExit(HOST_FORCE_KILL_GRACE_MS); - } } diff --git a/apps/desktop/electron/main/index.ts b/apps/desktop/electron/main/index.ts index 5646421884..480fbeec7c 100644 --- a/apps/desktop/electron/main/index.ts +++ b/apps/desktop/electron/main/index.ts @@ -50,7 +50,6 @@ import { shouldShowNativeNotification, } from "./notification-policy"; import { PersistenceOutbox } from "./persistence-outbox"; -import { InflightCheckpointer } from "./inflight-checkpoint"; import { AgentSidecar } from "./agent-sidecar"; import { Logger, ignoreBrokenStdio } from "./logger"; import { installMainProcessErrorHandlers } from "./main-process-errors"; @@ -89,10 +88,11 @@ import { type PreparedPromptAttachment, } from "./prompt-attachments"; import { + InflightCheckpointer, executionFromResponse, executionListFromResponse, planExecutionFromUnknown, -} from "./plan-execution"; +} from "@pi-desktop/host-runtime"; import { readWindowState, writeWindowState, diff --git a/apps/desktop/electron/main/ipc/agent-ipc.ts b/apps/desktop/electron/main/ipc/agent-ipc.ts index 5a3fd3d182..f83b69c1fe 100644 --- a/apps/desktop/electron/main/ipc/agent-ipc.ts +++ b/apps/desktop/electron/main/ipc/agent-ipc.ts @@ -3,8 +3,7 @@ import type { FinishTurn } from "../runtime/plans"; import { expandSlashInvocation, enhancePromptDraft, summarizeSessionTitle, visionFromModelConfig, type ComposerTemplate, type RuntimeProviderConfig } from "@pi-desktop/agent-runtime"; import { OAUTH_AUTH_KIND, type VendorOAuth } from "../oauth"; import { appendPromptFallbackPaths, durableUserMessageId, preparePromptAttachments, type PreparedPromptAttachment } from "../prompt-attachments"; -import { executionFromResponse } from "../plan-execution"; -import { resolveSessionMessageInput } from "../session-message-input"; +import { executionFromResponse, resolveSessionMessageInput } from "@pi-desktop/host-runtime"; import type { AgentExtensionBridge } from "../agent-extensions"; import type { AgentHostBridge } from "../agent-host-bridge"; import type { AgentSidecar } from "../agent-sidecar"; diff --git a/apps/desktop/electron/main/runtime/event-persistence.ts b/apps/desktop/electron/main/runtime/event-persistence.ts index 75810d3c8a..972dc9fc01 100644 --- a/apps/desktop/electron/main/runtime/event-persistence.ts +++ b/apps/desktop/electron/main/runtime/event-persistence.ts @@ -1,7 +1,7 @@ import { applyMessageUpdate, IPC, type AgentEventEnvelope, type UiMessage } from "@pi-desktop/shared"; import type { FinishTurn } from "./plans"; import type { RuntimeState } from "./context"; -import type { InflightCheckpointer } from "../inflight-checkpoint"; +import type { InflightCheckpointer } from "@pi-desktop/host-runtime"; import type { Logger } from "../logger"; import type { PersistenceOutbox } from "../persistence-outbox"; diff --git a/apps/desktop/electron/main/runtime/lifecycle.ts b/apps/desktop/electron/main/runtime/lifecycle.ts index a693b34f24..d2afdd4cc3 100644 --- a/apps/desktop/electron/main/runtime/lifecycle.ts +++ b/apps/desktop/electron/main/runtime/lifecycle.ts @@ -19,6 +19,7 @@ import type { Logger } from "../logger"; import type { PluginRuntime } from "../plugin-runtime"; import type { RuntimeState } from "./context"; import { syncPluginDisplayLocale } from "../plugin-display-locale"; +import { RuntimeSupervisor } from "@pi-desktop/host-runtime"; type RestartKind = "host" | "sidecar"; export type RuntimeLifecycleDependencies = { @@ -66,111 +67,108 @@ export function createRuntimeLifecycle({ runtimeArch: () => ReturnType; bootBackends: () => Promise; } { - const restartState = { - host: { count: 0, windowStart: 0 }, - sidecar: { count: 0, windowStart: 0 }, - }; - const restartInFlight: Record | null> = { - host: null, - sidecar: null, - }; let runtimeArchCache: ReturnType | null = null; - const superviseRestart = (kind: RestartKind): Promise => { - const existing = restartInFlight[kind]; - if (existing) return existing; - const run = superviseRestartLoop(kind).finally(() => { - if (restartInFlight[kind] === run) restartInFlight[kind] = null; - }); - restartInFlight[kind] = run; - return run; + /** + * Fatal boot refusals a restart can never fix (D380): the schema check comes + * first because its stderr line is the more specific one. + */ + const fatalStatusFor = (kind: RestartKind, error: unknown): HostStatusEvent | null => { + const schema = schemaTooNewOf(error); + if (schema) { + logger.app("runtime", "error", "local data schema is newer than this build", { + code: ErrorCodes.HOST_UNAVAILABLE, + data: schema, + }); + return { + ok: false, + component: kind, + fatal: true, + message: DB_SCHEMA_TOO_NEW_STATUS, + schema, + }; + } + if (isGlibcUnsupportedError(error)) { + logger.app("runtime", "error", "linux glibc is below the packaged host floor", { + code: ErrorCodes.HOST_UNAVAILABLE, + data: String(error), + }); + return { + ok: false, + component: kind, + fatal: true, + message: GLIBC_UNSUPPORTED_STATUS, + }; + } + return null; }; - async function superviseRestartLoop(kind: RestartKind): Promise { - const state = restartState[kind]; - while (!isQuitting()) { - const now = Date.now(); - if (now - state.windowStart > 120_000) { - state.windowStart = now; - state.count = 0; - } - state.count += 1; - if (state.count > 3) { - logger.app( - "runtime", - "error", - kind + " restart limit reached; giving up", - { code: ErrorCodes.HOST_UNAVAILABLE }, - ); - sendToRenderer(IPC.event.hostStatus, { - ok: false, - component: kind, - fatal: true, - }); - return; - } - const delay = Math.min(500 * 2 ** (state.count - 1), 4000); - await new Promise((resolve) => setTimeout(resolve, delay)); - if (isQuitting()) return; - try { - if (kind === "host") { - await startHost(); - const sidecar = runtimeState.sidecar; - const host = runtimeState.host; - if (sidecar && host) sidecar.setHost(host); - // A fresh host process starts in English, so the app language and - // the language-dependent rows it draws have to be restored here: - // nothing else re-applies settings after a crash. - await syncPluginDisplayLocale(host, getDisplayLocale()); - } else { - await startSidecar(); - } - await drainApprovedPlanExecutions(); - logger.app("runtime", "warn", kind + " restarted after crash"); - sendToRenderer(IPC.event.hostStatus, { - ok: true, - component: kind, - restarted: true, - }); - // The plugin rows were drawn from the dead process: re-read them so a - // restart is invisible in the Extensions page and the launcher. - sendToRenderer(IPC.event.pluginChanged, { reason: "hostRestart" }); - return; - } catch (error) { - const schema = schemaTooNewOf(error); - if (schema) { - logger.app("runtime", "error", "local data schema is newer than this build", { - code: ErrorCodes.HOST_UNAVAILABLE, - data: schema, - }); + // The restart policy itself (backoff, window budget, single flight) is the + // shared supervisor; everything renderer- or plugin-facing stays here. + const supervisor = new RuntimeSupervisor({ + start: { + host: async () => { + await startHost(); + const sidecar = runtimeState.sidecar; + const host = runtimeState.host; + if (sidecar && host) sidecar.setHost(host); + // A fresh host process starts in English, so the app language and + // the language-dependent rows it draws have to be restored here: + // nothing else re-applies settings after a crash. + await syncPluginDisplayLocale(host, getDisplayLocale()); + }, + sidecar: startSidecar, + }, + afterRestart: () => drainApprovedPlanExecutions(), + isUnrecoverable: (error) => Boolean(schemaTooNewOf(error)) || isGlibcUnsupportedError(error), + isShuttingDown: isQuitting, + onEvent: (event) => { + const { kind } = event; + switch (event.phase) { + case "fatal": { + if (event.reason === "unrecoverable") { + const status = fatalStatusFor(kind, event.error); + if (status) sendToRenderer(IPC.event.hostStatus, status); + return; + } + logger.app( + "runtime", + "error", + kind + " restart limit reached; giving up", + { code: ErrorCodes.HOST_UNAVAILABLE }, + ); sendToRenderer(IPC.event.hostStatus, { ok: false, component: kind, fatal: true, - message: DB_SCHEMA_TOO_NEW_STATUS, - schema, }); return; } - if (isGlibcUnsupportedError(error)) { - logger.app("runtime", "error", "linux glibc is below the packaged host floor", { - code: ErrorCodes.HOST_UNAVAILABLE, - data: String(error), - }); + case "restarted": { + logger.app("runtime", "warn", kind + " restarted after crash"); sendToRenderer(IPC.event.hostStatus, { - ok: false, + ok: true, component: kind, - fatal: true, - message: GLIBC_UNSUPPORTED_STATUS, + restarted: true, }); + // The plugin rows were drawn from the dead process: re-read them so a + // restart is invisible in the Extensions page and the launcher. + sendToRenderer(IPC.event.pluginChanged, { reason: "hostRestart" }); return; } - logger.app("runtime", "error", kind + " restart failed", { - data: String(error), - }); + case "restart_failed": + logger.app("runtime", "error", kind + " restart failed", { + data: String(event.error), + }); + return; + case "restarting": + return; } - } - } + }, + }); + + const superviseRestart = (kind: RestartKind): Promise => + supervisor.superviseRestart(kind); /** * Boot outcome pushed once the renderer has mounted. Known unrecoverable diff --git a/apps/desktop/electron/main/runtime/plans.ts b/apps/desktop/electron/main/runtime/plans.ts index be3d59647a..5f53b32af7 100644 --- a/apps/desktop/electron/main/runtime/plans.ts +++ b/apps/desktop/electron/main/runtime/plans.ts @@ -1,5 +1,5 @@ import { ErrorCodes, IPC, type AgentEventEnvelope, type AppNotification, type PlanExecution, type PlanExecutionFinishStatus, type UiMessage } from "@pi-desktop/shared"; -import { executionFromResponse, executionListFromResponse, planExecutionFromUnknown } from "../plan-execution"; +import { executionFromResponse, executionListFromResponse, planExecutionFromUnknown } from "@pi-desktop/host-runtime"; import type { RuntimeState } from "./context"; import type { SessionCoordination, diff --git a/apps/desktop/electron/main/runtime/sidecar.ts b/apps/desktop/electron/main/runtime/sidecar.ts index 1d2522db9b..685734af07 100644 --- a/apps/desktop/electron/main/runtime/sidecar.ts +++ b/apps/desktop/electron/main/runtime/sidecar.ts @@ -14,7 +14,7 @@ import { AgentSidecar } from "../agent-sidecar"; import { OAUTH_AUTH_KIND, type VendorOAuth } from "../oauth"; import type { AgentExtensionBridge } from "../agent-extensions"; import type { BrowserHost } from "../browser-host"; -import type { InflightCheckpointer } from "../inflight-checkpoint"; +import type { InflightCheckpointer } from "@pi-desktop/host-runtime"; import { summarizeToolResult, type Logger } from "../logger"; import type { ModelsDevCatalog } from "../models-dev-catalog"; import type { PluginRuntime } from "../plugin-runtime"; diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 72358616d9..e22f8bf46c 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -37,6 +37,7 @@ "@earendil-works/pi-ai": "0.85.1", "@pi-desktop/agent-host": "workspace:*", "@pi-desktop/agent-runtime": "workspace:*", + "@pi-desktop/host-runtime": "workspace:*", "@pi-desktop/i18n": "workspace:*", "@pi-desktop/plugin-devkit": "workspace:*", "@pi-desktop/plugin-sdk": "workspace:*", diff --git a/apps/desktop/test/browser-preview-tool.test.mjs b/apps/desktop/test/browser-preview-tool.test.mjs index b42c2f14f7..2e4308fb6f 100644 --- a/apps/desktop/test/browser-preview-tool.test.mjs +++ b/apps/desktop/test/browser-preview-tool.test.mjs @@ -4,7 +4,7 @@ import { readFile } from "node:fs/promises"; import test from "node:test"; const sidecarSource = await readFile( - new URL("../electron/main/agent-sidecar.ts", import.meta.url), + new URL("../../../packages/host-runtime/src/agent-sidecar.ts", import.meta.url), "utf8", ); const mainSource = await readMainSource(); diff --git a/apps/desktop/test/host-boot-diagnostics.test.mjs b/apps/desktop/test/host-boot-diagnostics.test.mjs index 62440bb503..017c5e3a7f 100644 --- a/apps/desktop/test/host-boot-diagnostics.test.mjs +++ b/apps/desktop/test/host-boot-diagnostics.test.mjs @@ -108,20 +108,32 @@ test("an Intel build under Rosetta is a mismatch; native builds are not", () => }); test("host-process turns the refusal into a typed error before glibc matching", () => { - const body = hostSrc.slice(hostSrc.indexOf("private unavailableError(")); - const schemaAt = body.indexOf("parseSchemaTooNew(this.lastStderr)"); - const glibcAt = body.indexOf("glibcMissingSymbol(this.lastStderr)"); + // The transport lives in @pi-desktop/host-runtime; the desktop only names + // the refusals it can phrase, through the injected failure diagnosis. + const body = hostSrc.slice(hostSrc.indexOf("export function diagnoseHostFailure(")); + const schemaAt = body.indexOf("parseSchemaTooNew(lastStderr)"); + const glibcAt = body.indexOf("glibcMissingSymbol(lastStderr)"); assert.ok(schemaAt > 0 && glibcAt > 0); assert.ok(schemaAt < glibcAt, "schema refusal must be checked first"); assert.match(body, /new DbSchemaTooNewError\(schema\)/); + assert.match(hostSrc, /diagnoseFailure: diagnoseHostFailure,/); }); test("main stops restarting on a schema refusal and pushes a named status", () => { - const loop = lifecycleSrc.slice(lifecycleSrc.indexOf("async function superviseRestartLoop(")); - const schemaAt = loop.indexOf("schemaTooNewOf(error)"); - const glibcAt = loop.indexOf("isGlibcUnsupportedError(error)"); + // The restart policy lives in the shared RuntimeSupervisor; main only names + // the two unrecoverable refusals and phrases their status for the renderer. + const fatal = lifecycleSrc.slice( + lifecycleSrc.indexOf("const fatalStatusFor ="), + lifecycleSrc.indexOf("const supervisor = new RuntimeSupervisor("), + ); + const schemaAt = fatal.indexOf("schemaTooNewOf(error)"); + const glibcAt = fatal.indexOf("isGlibcUnsupportedError(error)"); assert.ok(schemaAt > 0 && schemaAt < glibcAt); - assert.match(loop.slice(schemaAt, glibcAt), /message: DB_SCHEMA_TOO_NEW_STATUS,\s*schema,\s*\}\);\s*return;/); + assert.match(fatal.slice(schemaAt, glibcAt), /message: DB_SCHEMA_TOO_NEW_STATUS,\s*schema,\s*\};/); + assert.match( + lifecycleSrc, + /isUnrecoverable: \(error\) => Boolean\(schemaTooNewOf\(error\)\) \|\| isGlibcUnsupportedError\(error\)/, + ); const boot = lifecycleSrc.slice(lifecycleSrc.indexOf("const bootHostStatus =")); assert.match(boot, /status\.message = DB_SCHEMA_TOO_NEW_STATUS;\s*status\.schema = schema;/); assert.match(boot, /status\.archMismatch = \{/); diff --git a/apps/desktop/test/inflight-checkpoint.test.mjs b/apps/desktop/test/inflight-checkpoint.test.mjs index e3bb428ea6..c8ced52541 100644 --- a/apps/desktop/test/inflight-checkpoint.test.mjs +++ b/apps/desktop/test/inflight-checkpoint.test.mjs @@ -4,7 +4,7 @@ import test from "node:test"; import { InflightCheckpointer, isCheckpointableMessage, -} from "../electron/main/inflight-checkpoint.ts"; +} from "../../../packages/host-runtime/src/inflight-checkpoint.ts"; const assistant = (id, content, thinking) => ({ id, diff --git a/apps/desktop/test/network-proxy.test.mjs b/apps/desktop/test/network-proxy.test.mjs index 9a9dd6af22..6ee0cb5c39 100644 --- a/apps/desktop/test/network-proxy.test.mjs +++ b/apps/desktop/test/network-proxy.test.mjs @@ -15,7 +15,7 @@ const nodeProxy = await readFile( "utf8", ); const hostProcess = await readFile( - new URL("../electron/main/host-process.ts", import.meta.url), + new URL("../../../packages/host-runtime/src/host-process.ts", import.meta.url), "utf8", ); const hostProxy = await readFile( diff --git a/apps/desktop/test/rpc-lifecycle-contract.test.mjs b/apps/desktop/test/rpc-lifecycle-contract.test.mjs index 3a191b7fbb..40c1078240 100644 --- a/apps/desktop/test/rpc-lifecycle-contract.test.mjs +++ b/apps/desktop/test/rpc-lifecycle-contract.test.mjs @@ -4,7 +4,7 @@ import { readFile } from "node:fs/promises"; import test from "node:test"; const sidecarSource = await readFile( - new URL("../electron/main/agent-sidecar.ts", import.meta.url), + new URL("../../../packages/host-runtime/src/agent-sidecar.ts", import.meta.url), "utf8", ); const runtimeSidecarSource = await readFile( @@ -12,7 +12,7 @@ const runtimeSidecarSource = await readFile( "utf8", ); const hostSource = await readFile( - new URL("../electron/main/host-process.ts", import.meta.url), + new URL("../../../packages/host-runtime/src/host-process.ts", import.meta.url), "utf8", ); const mainSource = await readMainSource(); diff --git a/apps/desktop/test/session-message-input.test.mjs b/apps/desktop/test/session-message-input.test.mjs index 05050f6c99..5ce7adfff9 100644 --- a/apps/desktop/test/session-message-input.test.mjs +++ b/apps/desktop/test/session-message-input.test.mjs @@ -4,7 +4,7 @@ import test from "node:test"; import { IPC } from "@pi-desktop/shared"; register(new URL("./helpers/ts-import-hooks.mjs", import.meta.url)); -const { resolveSessionMessageInput } = await import("../electron/main/session-message-input.ts"); +const { resolveSessionMessageInput } = await import("../../../packages/host-runtime/src/session-message-input.ts"); const { registerAgentIpc } = await import("../electron/main/ipc/agent-ipc.ts"); const message = { diff --git a/apps/desktop/test/subagent-wiring.test.mjs b/apps/desktop/test/subagent-wiring.test.mjs index b575b7fb1b..6e7085042b 100644 --- a/apps/desktop/test/subagent-wiring.test.mjs +++ b/apps/desktop/test/subagent-wiring.test.mjs @@ -25,7 +25,7 @@ const hostCollectionSource = await readFile( "utf8", ); const hostProcessSource = await readFile( - new URL("../electron/main/host-process.ts", import.meta.url), + new URL("../../../packages/host-runtime/src/host-process.ts", import.meta.url), "utf8", ); diff --git a/docs/adr/0282-headless-runtime-boundary.md b/docs/adr/0282-headless-runtime-boundary.md new file mode 100644 index 0000000000..1ffc08750c --- /dev/null +++ b/docs/adr/0282-headless-runtime-boundary.md @@ -0,0 +1,118 @@ +# ADR 0282: Headless runtime boundary in `packages/host-runtime` + +- Status: Accepted for implementation +- Date: 2026-09-18 +- Decision: D445 +- Related: ADR 0205 (D373 / D374 / D375), ADR 0213 (D386), + `02-architecture/05-remote-agent-control.md` §4 and §11, + `03-runtime/07-process-model.md` §4, + `06-delivery/07-remote-control-rollout.md` R2 + +## Context + +Rollout R1 delivered the headless Agent Host module (`packages/agent-host`): +admission, the turn queue, the approval broker, the event log, and the +snapshot builder run without Electron. Everything underneath it did not. The +stdio transports to host-core and the agent sidecar, the restart supervisor, +the durable turn lifecycle (`session.beginTurn` → prompt → `session.endTurn`), +transcript persistence, the in-flight reply checkpoint, and approved +Plan/Goal execution all lived in `apps/desktop/electron/main`, and the +Electron `AgentHost` bridge reached them by invoking the desktop's own IPC +handlers. `HostProcess` resolved the binary from `process.resourcesPath`, and +`AgentSidecar` could only start the sidecar as +`process.execPath` + `ELECTRON_RUN_AS_NODE`. + +R2 needs the same runtime on a machine that has no Electron: the `pi-host` +bundle (`02-architecture/05-remote-agent-control.md` §5.2) must run the Agent +Host module, the sidecar, and host-core behind a RACP server. Re-implementing +the turn lifecycle in a second place would create a second source of truth +for exactly the invariants the desktop spent a year fixing (abort locks, +stale terminal events, single-flight finalization, queue release after +durable settlement). + +## Decision + +1. **A new workspace package, `packages/host-runtime`, owns the + Electron-independent runtime layer.** It depends on `shared`, + `agent-host`, and `agent-runtime` only, and never on `electron` or on + `apps/desktop`. Its modules are: + - `HostProcess` and `AgentSidecar`: the stdio NDJSON JSON-RPC transports, + moved from Electron main unchanged in behavior. Both take their launch + as options — `binaryPath` / `dataDir` / extra `env` for host-core, + `{ command, args, env }` for the sidecar — and an `onStderr` sink. The + sidecar's `host.proxy` allowlist, local-tool short circuit, Plan-mode + gate, vendor-auth binding check, and trusted-extension bridge are + unchanged. `HostProcess` accepts an optional `diagnoseFailure` hook so + an embedding host can name a boot refusal it can phrase. + - `RuntimeSupervisor`: the restart policy of process-model §4 (0.5s → 1s + → 2s capped at 4s, three restarts per two-minute window, single flight + per child, never during shutdown, stop on the first unrecoverable + failure). The embedding host supplies `start`, `afterRestart`, + `isUnrecoverable`, and an event sink. + - `RuntimeService`: `RuntimePort` for the Agent Host module on top of the + two transports — prompt admission with the durable turn row and the + user message appended before the sidecar starts, steer, stop, abort + with the abort lock, asktool answers, manual compaction, and + `finishTurn` with the same claim-before-await rule, stale-terminal + guard, and settlement announcement as Electron main. + - `TurnEventPipeline` and `TurnPersistence`: the per-event pass + (tool-call tracking, D299 checkpoints, terminal events, completed rows) + and an in-memory ordered append queue with a bounded retry while + host-core restarts. A `pi-host` process ends only with its supervisor, + so the desktop's file-backed outbox is not duplicated. + - `createHeadlessLaunchResolver`: launch resolution from host-core's own + registries (providers and secrets, the effective command shell, project + instructions and memory, user skills, subagent definitions). It refuses + vendor OAuth accounts and plugin agents, which need the desktop. + - `PlanExecutionDispatcher`: approved Plan/Goal execution (D189) with the + same claim, no-replay, and finalization rules. + - Host-core adapters for the module's `SessionPort`, `QueueStore` + (schema v15), and `permissions.pending`, plus the session-message + ledger lookup, the inflight checkpointer, and the plan-execution + decoders, moved out of Electron main. +2. **Electron main keeps thin adapters.** `electron/main/host-process.ts` + and `agent-sidecar.ts` subclass the package classes and add only what + Electron knows: the packaged binary and bundled-plugin locations, the + `ELECTRON_RUN_AS_NODE` launch, redacted stderr logging, and the + schema-too-new / glibc diagnoses. `runtime/lifecycle.ts` drives the shared + supervisor and keeps the renderer status pushes, plugin locale resync, + and approved-plan drain. The Agent Host bridge uses the shared host-core + ports instead of its own copies. The local prompt path (`agent-ipc.ts`), + with its attachments, slash expansion, plugin tools, MCP relay, vendor + accounts, and notifications, is unchanged and still owned by the desktop. +3. **`TurnStartRequest` gains an optional `userMessageId`** so a client can + keep its optimistic user row id through the headless runtime, exactly as + the renderer does through IPC (D288). Additive; no caller is required to + send it. +4. **No wire contract changes.** Electron IPC, the sidecar JSON-RPC, host-core + RPC, and the Plugin SDK are untouched; the desktop's observable behavior, + defaults, and persisted data are unchanged. + +## Consequences + +- `pi-host` (R2) can compose `HostProcess + AgentSidecar + RuntimeService + + AgentHost + RuntimeSupervisor` in plain Node; the RACP server binds to the + module, never to Electron IPC. +- The restart policy, the turn lifecycle, and the transports now have unit + tests that run without Electron (`packages/host-runtime/src/*.test.ts`). +- The desktop's source-contract tests that pinned `host-process.ts` and + `agent-sidecar.ts` now read the package sources; the renderer-facing + status and diagnosis assertions still read the Electron adapters. +- Local Electron main still runs its own prompt path through IPC. Moving the + desktop onto `RuntimeService` for local sessions is a later, + behavior-preserving step; it is not required for R2 and is not done here. +- The headless resolver deliberately supports less than the desktop: + no prompt attachments, no plugin tools, no desktop MCP relay, no vendor + OAuth. Those return typed errors rather than degrading silently. + +## Alternatives considered + +- **Extract only the transports and re-implement the turn lifecycle in + `pi-host`.** Rejected: the lifecycle invariants are the hard part, and a + second copy would drift from the desktop's. +- **Keep the modules in `apps/desktop/electron/main` and import them from + `pi-host`.** Rejected: `packages/*` must not depend on desktop + implementation code, and the modules would keep growing Electron imports. +- **Put the runtime into `packages/agent-host`.** Rejected: the module is the + transport-free semantic core with no process or filesystem knowledge, and + the RACP server and integrations depend on it staying that way. diff --git a/docs/adr/README.md b/docs/adr/README.md index 3777f7f90b..797c64526a 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -308,4 +308,5 @@ Each ADR includes: | 0279 | [Resumable subagent delegations](0279-resumable-subagent-delegations.md) | Accepted for implementation (amends ADR 0062; ADR 0089; issue #513) | | 0280 | [Plugin-owned UI localizes from the host locale](0280-plugin-owned-ui-localizes-from-host-locale.md) | Accepted (amends ADR 0267; ADR 0159) | | 0281 | [Host speech capability](0281-host-speech-capability.md) | Accepted for implementation (amends ADR 0257) | +| 0282 | [Headless runtime boundary in `packages/host-runtime`](0282-headless-runtime-boundary.md) | Accepted for implementation (D445; ADR 0205 R2 prerequisite) | | turn-process-and-thinking-display | [Turn process and thinking presentation](turn-process-and-thinking-display.md) | Accepted | diff --git a/docs/spec/02-architecture/03-repo-structure.md b/docs/spec/02-architecture/03-repo-structure.md index 5453067158..f3e9f3d8ef 100644 --- a/docs/spec/02-architecture/03-repo-structure.md +++ b/docs/spec/02-architecture/03-repo-structure.md @@ -36,6 +36,8 @@ PI-Desktop/ │ ├── shared/ # IPC/protocol contracts, error codes, changelog │ ├── i18n/ # shipped UI catalogs plus locale helpers │ ├── agent-runtime/ # pi sidecar and runtime wrapper (bundled into the app) +│ ├── agent-host/ # headless Agent Host module: admission, queue, approvals, event log +│ ├── host-runtime/ # Electron-independent runtime: stdio transports, supervisor, turn lifecycle │ ├── plugin-sdk/ # plugin author types and validators │ └── plugin-devkit/ # pi-plugin CLI: scaffold, check, pack, publish ├── examples/ diff --git a/docs/spec/03-runtime/07-process-model.md b/docs/spec/03-runtime/07-process-model.md index d3ae914f6b..9066cbd50f 100644 --- a/docs/spec/03-runtime/07-process-model.md +++ b/docs/spec/03-runtime/07-process-model.md @@ -123,7 +123,9 @@ start its local service. Windows 11 ARM64 systems run this x64 package through the operating system's x64 emulation; native Windows ARM64 artifacts are not currently published. -Supervision parameters (implemented in Electron main): +Supervision parameters (the transports, restart policy, and turn lifecycle are +`packages/host-runtime`, ADR 0282; Electron main adapts them and owns the +renderer-facing status): - Child exit rejects all in-flight RPCs for that child immediately (no 130s timeout wait). - An NDJSON request line over 64 MiB is drained and answered with `LIMIT_EXCEEDED`; it does not end the stdin reader (ADR 0216). Electron rejects the same size before writing stdin (ADR 0217). diff --git a/docs/spec/06-delivery/07-remote-control-rollout.md b/docs/spec/06-delivery/07-remote-control-rollout.md index 63e5d0ca56..6cb33ea59a 100644 --- a/docs/spec/06-delivery/07-remote-control-rollout.md +++ b/docs/spec/06-delivery/07-remote-control-rollout.md @@ -402,7 +402,13 @@ Recorded on the `feat/remote-agent-host` branch, 2026-09-10: graceful stop. - R1 open: a runtime-level per-turn permission ceiling (a capped turn currently fails closed in the bridge). -- R2 and later: not started. +- R2 started (2026-09-18, D445 / ADR 0282): `packages/host-runtime` holds the + Electron-independent runtime layer — the host-core and sidecar stdio + transports, the restart supervisor, `RuntimeService` (the module's + `RuntimePort` with the durable turn lifecycle), transcript persistence, a + headless launch resolver, and approved Plan/Goal dispatch — and Electron + main runs on it through thin adapters. The `pi-host` bundle, the RACP-WS + transport, the SSH bootstrap, and the desktop adapter are not started. ## 8. Amendment history diff --git a/docs/spec/08-meta/decisions-log.md b/docs/spec/08-meta/decisions-log.md index 6c22ec812f..8c4edf7d7b 100644 --- a/docs/spec/08-meta/decisions-log.md +++ b/docs/spec/08-meta/decisions-log.md @@ -22,6 +22,7 @@ This log freezes previously open questions into concrete decisions. | D010 | First release platform | **macOS arm64 only** | Focus acceptance and packaging | | D373 | Remote Agent Control host boundary | *(amended by D374 and D375)* **Remote control (post-MVP) runs through a logical Agent Host that owns Sessions, Turns, event cursors, approvals, attachments, workspace policy, and crash recovery; a production Gateway owns identity, routing, rate limits, revocation, and audit, and the Agent Host connects outbound. The existing Electron IPC, `host.proxy`, and host-core stdio boundaries are never exposed.** | A remote surface needs its own boundary rather than a tunnel into local IPC or the host-core stdio contract (ADR 0205, E2E-221 through E2E-230) | | D374 | Remote Agent Control v1 target amendments | **Amend D373 / ADR 0205: `RACP-WS` is the only normative v1 binding (`RACP-HTTP` is the browser profile, `RACP-GRPC` is reserved); typebox in `packages/shared` is the single contract source; the headless `packages/agent-host` module is the first deliverable and is shared by desktop IPC, local MCP, and RACP; cursors are `{ epoch, sequence }` with ephemeral deltas; the turn queue moves into the Host; host-core exposes `permissions.pending`; remote approvals carry the full local decision vocabulary under a default `ask` ceiling; browser clients use a cookie profile; the first deployment is single-tenant.** | Reviewing the D373 draft against the shipped desktop found the remote approval vocabulary narrower than the local contracts, pending requests held as connection state, per-token deltas exhausting the replay window, and more bindings than v1 can carry (ADR 0205) | +| D445 | Headless runtime boundary | **Amend ADR 0205 rollout R2 prerequisites (ADR 0282): the Electron-independent runtime layer under the Agent Host module lives in `packages/host-runtime` — the host-core and sidecar stdio transports, the restart supervisor (process-model §4 policy), the durable turn lifecycle (`RuntimeService` as the module's `RuntimePort`: `session.beginTurn` → user row → prompt → `session.endTurn`, abort lock, stale-terminal guard, single-flight finalization), transcript persistence with the D299 checkpoint, a headless launch resolver over host-core's own registries, approved Plan/Goal dispatch (D189), and the host-core adapters for `SessionPort` / `QueueStore` / `permissions.pending`. Electron main keeps thin adapters (binary and bundle locations, `ELECTRON_RUN_AS_NODE`, redacted stderr, schema/glibc diagnoses, renderer status pushes) and its own local prompt path. `TurnStartRequest` gains an optional `userMessageId`. No IPC, sidecar, host-core, Plugin SDK, default, or persisted-data change.** | `pi-host` must run the same lifecycle invariants as the desktop without Electron; a second implementation of abort locks, stale terminal events, and queue release after durable settlement would drift from the one the desktop already fixed | ## B. Secondary implementation defaults diff --git a/docs/zh-CN/spec/02-architecture/03-repo-structure.md b/docs/zh-CN/spec/02-architecture/03-repo-structure.md index 237bd1a971..9e9c56fbcb 100644 --- a/docs/zh-CN/spec/02-architecture/03-repo-structure.md +++ b/docs/zh-CN/spec/02-architecture/03-repo-structure.md @@ -37,6 +37,8 @@ PI-Desktop/ │ ├── shared/ # IPC/协议契约、错误码、更新日志 │ ├── i18n/ # en 与 zh-CN 目录及 locale 辅助函数 │ ├── agent-runtime/ # pi sidecar 与运行时包装(打包进应用) +│ ├── agent-host/ # 无头 Agent Host 模块:准入、队列、审批、事件日志 +│ ├── host-runtime/ # 与 Electron 无关的运行时:stdio 传输、监督器、回合生命周期 │ ├── plugin-sdk/ # 插件作者类型与校验器 │ └── plugin-devkit/ # pi-plugin CLI:scaffold、check、pack、publish ├── examples/ diff --git a/docs/zh-CN/spec/03-runtime/07-process-model.md b/docs/zh-CN/spec/03-runtime/07-process-model.md index 80444a43d8..7745a3531e 100644 --- a/docs/zh-CN/spec/03-runtime/07-process-model.md +++ b/docs/zh-CN/spec/03-runtime/07-process-model.md @@ -97,7 +97,7 @@ Windows 安装包目标为 x64。Windows host-core 使用 系统通过操作系统的 x64 模拟运行该 x64 安装包;目前不发布原生 Windows ARM64 工件。 -监管参数(在Electron main中实现): +监管参数(传输、重启策略与回合生命周期位于 `packages/host-runtime`,ADR 0282;Electron main 适配它们并负责面向渲染层的状态): - 子进程退出立即拒绝该子进程的所有正在进行的 RPC(无 130 秒超时等待)。 - 超过 64 MiB 的 NDJSON 请求行以 `LIMIT_EXCEEDED` 应答,不结束 stdin 读取器(ADR 0216)。Electron 在写入 stdin 前拒绝同样大小的载荷(ADR 0217)。 diff --git a/docs/zh-CN/spec/06-delivery/07-remote-control-rollout.md b/docs/zh-CN/spec/06-delivery/07-remote-control-rollout.md index 198cc67645..2dc8a3d7cb 100644 --- a/docs/zh-CN/spec/06-delivery/07-remote-control-rollout.md +++ b/docs/zh-CN/spec/06-delivery/07-remote-control-rollout.md @@ -138,7 +138,7 @@ runbook 写明 feature flag、配对撤销路径、远端机器上的数据保 - R1 已交付:renderer 的内存 prompt 队列已退役;composer 经 `agent/queue/push` 推入, 镜像 `agent/event/queueChanged`,“立即发送”即 `turn/prioritize` 加优雅停止。 - R1 未完成:运行时级别的逐回合权限上限(当前被限制的回合在桥接层直接拒绝)。 -- R2 及之后:尚未开始。 +- R2 已开始(2026-09-18,D445 / ADR 0282):`packages/host-runtime` 承载与 Electron 无关的运行时层 —— host-core 与 sidecar 的 stdio 传输、重启监督器、`RuntimeService`(模块的 `RuntimePort`,含持久回合生命周期)、转录持久化、无头启动解析器与已批准 Plan/Goal 的派发 —— Electron main 通过薄适配层运行其上。`pi-host` 包、RACP-WS 传输、SSH 引导与桌面适配器尚未开始。 ## 8. 修订记录 diff --git a/docs/zh-CN/spec/08-meta/decisions-log.md b/docs/zh-CN/spec/08-meta/decisions-log.md index 5f8c145c6c..f4e29596f4 100644 --- a/docs/zh-CN/spec/08-meta/decisions-log.md +++ b/docs/zh-CN/spec/08-meta/decisions-log.md @@ -25,6 +25,7 @@ | D010 | 首个发布平台 | **仅限 macOS arm64** | 重点验收及包装 | | D373 | 远程 Agent 控制的 Host 边界 | *(由 D374 与 D375 修订)* **远程控制(MVP 之后)经由一个逻辑 Agent Host 运行,它拥有 Sessions、Turns、事件游标、审批、附件、工作区策略与崩溃恢复;生产 Gateway 拥有身份、路由、限流、吊销与审计,Agent Host 向外连接。现有 Electron IPC、`host.proxy` 与 host-core stdio 边界永不暴露。** | 远程表面需要自己的边界,而不是通往本地 IPC 或 host-core stdio 契约的隧道(ADR 0205,E2E-221 至 E2E-230) | | D374 | 远程 Agent 控制 v1 目标修订 | **修订 D373 / ADR 0205:`RACP-WS` 是 v1 唯一规范绑定(`RACP-HTTP` 为浏览器 profile,`RACP-GRPC` 保留);`packages/shared` 中的 typebox 是唯一契约来源;无头的 `packages/agent-host` 模块是首个交付物,桌面 IPC、本地 MCP 与 RACP 共同调用;游标为 `{ epoch, sequence }` 且增量为临时数据;回合队列移入 Host;host-core 暴露 `permissions.pending`;远程审批携带完整本地决策词汇并默认 `ask` 上限;浏览器客户端使用 cookie profile;首个部署为单租户。** | 对照已交付桌面审查 D373 草案发现远程审批词汇比本地契约窄、待处理请求被当作连接状态、逐 token 增量会耗尽重放窗口、绑定数量超过 v1 承载能力(ADR 0205) | +| D445 | 无头运行时边界 | **修订 ADR 0205 里程碑 R2 的前置条件(ADR 0282):Agent Host 模块之下与 Electron 无关的运行时层放入 `packages/host-runtime` —— host-core 与 sidecar 的 stdio 传输、重启监督器(进程模型 §4 的策略)、持久回合生命周期(`RuntimeService` 作为模块的 `RuntimePort`:`session.beginTurn` → 用户行 → prompt → `session.endTurn`、中止锁、过期终态事件守卫、单飞终结)、带 D299 检查点的转录持久化、基于 host-core 自身注册表的无头启动解析器、已批准 Plan/Goal 的派发(D189),以及 `SessionPort` / `QueueStore` / `permissions.pending` 的 host-core 适配器。Electron main 只保留薄适配层(二进制与资源位置、`ELECTRON_RUN_AS_NODE`、脱敏 stderr、schema/glibc 诊断、渲染层状态推送)以及自己的本地 prompt 路径。`TurnStartRequest` 新增可选 `userMessageId`。不改 IPC、sidecar、host-core、插件 SDK、默认值或持久化数据。** | `pi-host` 必须在没有 Electron 的情况下运行与桌面相同的生命周期不变量;第二份中止锁、过期终态事件与持久落库后释放队列的实现会与桌面已修好的那份漂移 | ## B. 辅助实现默认值 diff --git a/packages/README.md b/packages/README.md index 1627210fc7..5307d4634b 100644 --- a/packages/README.md +++ b/packages/README.md @@ -4,3 +4,5 @@ - `i18n` — English source catalog - `plugin-sdk` — plugin author types/helpers - `agent-runtime` — pi sidecar + runtime wrapper +- `agent-host` — headless Agent Host module (admission, turn queue, approvals, event log) +- `host-runtime` — Electron-independent runtime layer (stdio transports, restart supervisor, turn lifecycle) diff --git a/packages/agent-host/src/ports.ts b/packages/agent-host/src/ports.ts index 864a20c22a..0bd4fe7db1 100644 --- a/packages/agent-host/src/ports.ts +++ b/packages/agent-host/src/ports.ts @@ -35,6 +35,8 @@ export type TurnStartRequest = { sessionId: string; content: string; sessionMessageId?: string; + /** Client-chosen id for the durable user row (D288); the runtime mints one otherwise. */ + userMessageId?: string; attachments?: AgentPromptAttachment[]; effectivePermissionMode: RacpPermissionMode; idempotencyKey?: string; diff --git a/packages/host-runtime/package.json b/packages/host-runtime/package.json new file mode 100644 index 0000000000..f7c2a37372 --- /dev/null +++ b/packages/host-runtime/package.json @@ -0,0 +1,30 @@ +{ + "name": "@pi-desktop/host-runtime", + "version": "0.15.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run", + "clean": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\"" + }, + "dependencies": { + "@pi-desktop/agent-host": "workspace:*", + "@pi-desktop/agent-runtime": "workspace:*", + "@pi-desktop/shared": "workspace:*" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "typescript": "^5.9.3", + "vitest": "^4.1.10" + } +} diff --git a/packages/host-runtime/src/agent-sidecar.ts b/packages/host-runtime/src/agent-sidecar.ts new file mode 100644 index 0000000000..b89741ae86 --- /dev/null +++ b/packages/host-runtime/src/agent-sidecar.ts @@ -0,0 +1,647 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { DEFAULT_RPC_TIMEOUT_MS, readNdjsonLines, rpcTimeoutMs } from "@pi-desktop/shared"; +import type { ProcessExitHandler, StderrHandler } from "./host-process.js"; + +// stderr lines kept per sidecar so an unexpected exit can be reported with the +// process's last words instead of a bare "agent sidecar exited". +const SIDECAR_STDERR_TAIL_LINES = 40; + +export type SidecarNotificationHandler = (method: string, params: unknown) => void; + +/** Result shape the sidecar's tool executor expects from tools.execute. */ +export type LocalToolResult = { + ok: boolean; + content: unknown; + isError?: boolean; + errorCode?: string; +}; + +export type LocalToolHandler = (input: { + sessionId: string; + toolCallId: string; + args: unknown; +}) => Promise; + +export type ProjectInstructionResolver = (input: { + sessionId: string; + path: string; + /** Project root registered by the embedding host for this session. */ + projectPath?: string; +}) => Promise; + +/** + * Resolve request auth for a vendor account. Answered by the embedding host + * against the signed-in pi-ai collection; the sidecar names a provider row, + * never a vendor or a credential, and gets back only a short-lived `ModelAuth`. + */ +export type VendorAuthResolver = (input: { + sessionId: string; + providerId: string; +}) => Promise; + +// The sidecar runs model-directed code paths; it must not be able to pull +// secrets or mutate configuration through the parent proxy. Tight allowlist +// of host methods the agent loop legitimately needs. +// +// `provider.resolveAuth` is answered by the embedding host itself (never +// forwarded to host-core) and only for a provider row bound to that same +// session, so it cannot reach a credential the session was not launched with. +const HOST_PROXY_ALLOWED = new Set([ + "tools.execute", + "tools.abort", + "tools.list", + "session.get", + "session.appendMessage", + "session.appendCompaction", + "session.replaceMessages", + "workspace.get", + "plans.enter", + "plans.submit", + "plans.pending", + "plans.abort", + "project.instructions.resolve", + "provider.resolveAuth", + "provider.resolveSubagentModel", + "app.health", + // Trusted extensions (D387): answered by the embedding host, plus the + // session methods the ExtensionAPI reaches (spec 16 §10.1). + "extensions.commands.publish", + "extensions.ui.request", + "extensions.diagnostics.publish", + "extensions.model.configure", + "session.rename", + "session.create", + "session.fork", + "session.queuePush", + "session.queuePrioritize", +]); + +/** Host-side answers for the `extensions.*` proxy methods. */ +export type TrustedExtensionSidecarBridge = { + publishCommands: (params: Record) => void; + publishDiagnostics: (params: Record) => void; + requestUi: (params: Record) => Promise; + configureModel: (params: Record) => Promise; + /** `sendUserMessage`: the Host-owned queue drains it (D386); host-core alone would only store it. */ + queuePush: (params: Record) => Promise; + queuePrioritize: (params: Record) => Promise; +}; + +/** The host-core transport as the sidecar proxy sees it. `HostProcess` satisfies it. */ +export interface SidecarHostLink { + call(method: string, params?: unknown): Promise; + onNotification(handler: (method: string, params: unknown) => void): () => void; + onExit(handler: ProcessExitHandler): () => void; +} + +/** + * How to start the sidecar process. Electron runs its own executable as Node + * (`ELECTRON_RUN_AS_NODE=1`); the headless host runs a plain Node binary. The + * transport does not care which, so the choice is made by the caller. + */ +export type SidecarLaunch = { + command: string; + args: string[]; + env?: NodeJS.ProcessEnv; + cwd?: string; +}; + +export type AgentSidecarOptions = { + launch: SidecarLaunch; + /** Receives raw stderr text as it arrives; the embedding host owns redaction and logging. */ + onStderr: StderrHandler; +}; + +/** + * The Node pi agent sidecar over stdio NDJSON JSON-RPC. Besides plain calls it + * answers the sidecar's reverse `host.proxy` requests: an allowlisted subset is + * forwarded to host-core, and the rest is served by handlers the embedding + * host registers (local tools, project instructions, vendor auth, trusted + * extensions). + */ +export class AgentSidecar { + private child: ChildProcessWithoutNullStreams; + private pending = new Map< + string, + { + resolve: (v: any) => void; + reject: (e: Error) => void; + timer?: ReturnType; + } + >(); + private handlers = new Set(); + private exitHandlers = new Set(); + private disposed = false; + private closed = false; + private exitNotified = false; + private stderrTail: string[] = []; + private host: SidecarHostLink | null = null; + private unsubscribeHost: (() => void) | null = null; + private unsubscribeHostExit: (() => void) | null = null; + private stdoutReader?: ReturnType; + // Tools served by the embedding host itself (e.g. BrowserPreview drives the + // work panel's WebContentsView) — host-core never sees these. + private localTools = new Map(); + private localToolTimers = new Set>(); + private projectInstructionResolver: ProjectInstructionResolver | null = null; + // The sidecar may request a path, but it never chooses the project root. + // The embedding host registers this binding from the host-owned session + // record immediately before starting a runtime turn. + private projectInstructionRoots = new Map(); + private vendorAuthResolver: VendorAuthResolver | null = null; + private trustedExtensionBridge: TrustedExtensionSidecarBridge | null = null; + // Vendor-account rows this session was launched with. The sidecar can only + // ask for auth it is already using, and a session that never bound an OAuth + // row can ask for nothing at all. + private vendorAuthBindings = new Map>(); + + constructor(options: AgentSidecarOptions) { + const { launch, onStderr } = options; + this.child = spawn(launch.command, launch.args, { + stdio: ["pipe", "pipe", "pipe"], + env: launch.env ?? process.env, + ...(launch.cwd ? { cwd: launch.cwd } : {}), + }); + + this.child.stderr.setEncoding("utf8"); + this.child.stderr.on("data", (text: string) => { + if (!text) return; + this.recordStderr(text); + onStderr(text); + }); + + this.child.on("exit", (code, signal) => { + this.closeTransport(new Error("agent sidecar exited")); + this.notifyExit({ code, signal, intentional: this.disposed }); + }); + this.child.on("error", (error) => { + this.closeTransport(error instanceof Error ? error : new Error(String(error))); + this.notifyExit({ code: null, signal: null, intentional: this.disposed }); + }); + + this.stdoutReader = readNdjsonLines(this.child.stdout, (line) => + void this.onLine(line), + ); + } + + private recordStderr(text: string) { + for (const line of text.split(/\r?\n/)) { + if (!line.trim()) continue; + this.stderrTail.push(line); + if (this.stderrTail.length > SIDECAR_STDERR_TAIL_LINES) { + this.stderrTail.shift(); + } + } + } + + private closeTransport(error: Error) { + if (this.closed) return; + this.closed = true; + this.unsubscribeHost?.(); + this.unsubscribeHost = null; + this.unsubscribeHostExit?.(); + this.unsubscribeHostExit = null; + this.host = null; + for (const [, p] of this.pending) { + if (p.timer) clearTimeout(p.timer); + p.reject(error); + } + this.pending.clear(); + for (const timer of this.localToolTimers) clearTimeout(timer); + this.localToolTimers.clear(); + this.handlers.clear(); + this.stdoutReader?.close(); + this.stdoutReader = undefined; + this.child.removeAllListeners("exit"); + this.child.removeAllListeners("error"); + this.child.stderr.removeAllListeners("data"); + } + + private notifyExit(info: { + code: number | null; + signal: NodeJS.Signals | null; + intentional: boolean; + }) { + if (this.exitNotified) return; + this.exitNotified = true; + // Snapshot the sidecar's last stderr lines: the dying process emits no + // stdout, so they are the only context a crash report gets. + const stderrTail = this.stderrTail.slice(); + for (const h of this.exitHandlers) h({ ...info, stderrTail }); + this.exitHandlers.clear(); + } + + private writeToChild(payload: string): boolean { + if (this.closed || this.disposed) return false; + if (this.child.stdin.destroyed || !this.child.stdin.writable) { + const failure = new Error("agent sidecar stdin is unavailable"); + this.closeTransport(failure); + this.notifyExit({ code: null, signal: null, intentional: this.disposed }); + return false; + } + try { + this.child.stdin.write(payload, (error) => { + if (error) { + this.closeTransport(error); + this.notifyExit({ code: null, signal: null, intentional: this.disposed }); + } + }); + return true; + } catch (error) { + const failure = error instanceof Error ? error : new Error(String(error)); + this.closeTransport(failure); + this.notifyExit({ code: null, signal: null, intentional: this.disposed }); + return false; + } + } + + private async runLocalTool( + handler: LocalToolHandler, + input: Parameters[0], + ): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + handler(input), + new Promise((_, reject) => { + timer = setTimeout(() => { + reject(new Error("host-local tool timeout")); + }, DEFAULT_RPC_TIMEOUT_MS); + this.localToolTimers.add(timer); + }), + ]); + } finally { + if (timer) { + clearTimeout(timer); + this.localToolTimers.delete(timer); + } + } + } + + onExit(handler: ProcessExitHandler): () => void { + if (this.closed) return () => undefined; + this.exitHandlers.add(handler); + return () => this.exitHandlers.delete(handler); + } + + /** Register a tool the sidecar can call that the embedding host handles locally. */ + setLocalTool(name: string, handler: LocalToolHandler): void { + this.localTools.set(name, handler); + } + + setProjectInstructionResolver(resolver: ProjectInstructionResolver): void { + this.projectInstructionResolver = resolver; + } + + setProjectInstructionRoot(sessionId: string, projectPath?: string): void { + const id = sessionId.trim(); + if (!id) return; + const root = projectPath?.trim(); + if (root) this.projectInstructionRoots.set(id, root); + else this.projectInstructionRoots.delete(id); + } + + clearProjectInstructionRoot(sessionId: string): void { + this.projectInstructionRoots.delete(sessionId.trim()); + } + + setVendorAuthResolver(resolver: VendorAuthResolver): void { + this.vendorAuthResolver = resolver; + } + + setTrustedExtensionBridge(bridge: TrustedExtensionSidecarBridge): void { + this.trustedExtensionBridge = bridge; + } + + /** + * Bind the vendor-account rows a turn may sign requests with. Replaces the + * session's previous set, so a row dropped from the launch payload — a model + * switch or account removal — stops being resolvable on the next turn. + */ + setVendorAuthBindings( + sessionId: string, + bindings: ReadonlyArray<{ providerId: string }>, + ): void { + const id = sessionId.trim(); + if (!id) return; + const providerIds = new Set(); + for (const binding of bindings) { + const providerId = binding.providerId?.trim(); + if (providerId) providerIds.add(providerId); + } + if (providerIds.size > 0) this.vendorAuthBindings.set(id, providerIds); + else this.vendorAuthBindings.delete(id); + } + + clearVendorAuthBindings(sessionId: string): void { + this.vendorAuthBindings.delete(sessionId.trim()); + } + + setHost(host: SidecarHostLink) { + if (this.closed) return; + this.host = host; + this.unsubscribeHost?.(); + this.unsubscribeHostExit?.(); + this.unsubscribeHost = host.onNotification((method, params) => { + // Forward host notifications to sidecar. permissions.request stays out: + // the embedding host already delivers it to its own UI, and bouncing it + // through the sidecar delivered the dialog twice with the full args + // payload re-serialized across two extra stdio hops. + if (method === "permissions.request") return; + const payload = + JSON.stringify({ + jsonrpc: "2.0", + method: "host.notification", + params: { method, params }, + }) + "\n"; + this.writeToChild(payload); + }); + this.unsubscribeHostExit = host.onExit(() => { + this.unsubscribeHost?.(); + this.unsubscribeHost = null; + this.unsubscribeHostExit = null; + this.host = null; + }); + } + + /** + * Answer one `provider.resolveAuth` request. Refuses anything the session was + * not launched with: the provider row has to be in this session's bindings, + * which the embedding host rewrites on every turn. + */ + private async resolveVendorAuth( + params: Record, + ): Promise { + if (!this.vendorAuthResolver) { + throw new Error("vendor account auth resolver unavailable"); + } + const sessionId = String(params.sessionId ?? "").trim(); + const providerId = String(params.providerId ?? "").trim(); + const bound = this.vendorAuthBindings.get(sessionId)?.has(providerId); + if (!bound) { + throw Object.assign( + new Error("provider is not bound to this session"), + { code: -32000, data: { errorCode: "PROVIDER_NOT_BOUND" } }, + ); + } + return this.vendorAuthResolver({ sessionId, providerId }); + } + + /** + * On-demand subagent model resolution. The runtime calls this when the + * parent agent passes a `model` override on Task that was not statically + * pinned by any definition. The embedding host resolves it against the + * user's configured providers and the model catalog, respecting the + * `availableForSubagents` gate on each model binding. + */ + private subagentModelResolver: + | ((key: string) => Promise) + | null = null; + + setSubagentModelResolver( + resolver: (key: string) => Promise, + ): void { + this.subagentModelResolver = resolver; + } + + private async resolveSubagentModel( + params: Record, + ): Promise { + if (!this.subagentModelResolver) { + throw new Error("subagent model resolver unavailable"); + } + const key = String(params.key ?? "").trim(); + if (!key || !key.includes("/")) { + throw Object.assign( + new Error("invalid model key; expected 'provider/model'"), + { code: -32602 }, + ); + } + return this.subagentModelResolver(key); + } + + private async onLine(line: string) { + if (!line.trim()) return; + let msg: any; + try { + msg = JSON.parse(line); + } catch { + console.warn( + `[RPC] Invalid agent-sidecar NDJSON frame (${Buffer.byteLength(line, "utf8")} bytes)`, + ); + return; + } + + // Reverse RPC from sidecar → host proxy + if (msg.method === "host.proxy" && msg.id !== undefined) { + try { + const method = String(msg.params?.method || ""); + if (!HOST_PROXY_ALLOWED.has(method)) { + throw Object.assign( + new Error(`host method not allowed from sidecar: ${method}`), + { code: -32601 }, + ); + } + const params = (msg.params?.params ?? {}) as Record; + const requestedToolName = String(params.toolName ?? ""); + const planLocalTool = + requestedToolName === "Skill" || + requestedToolName === "PluginCheck" || + requestedToolName === "PluginScaffold" || + requestedToolName === "PluginPack" || + requestedToolName.startsWith("plugin_"); + if ( + method === "tools.execute" && + params.mode === "plan" && + planLocalTool && + requestedToolName !== "BrowserPreview" + ) { + throw Object.assign( + new Error(`${requestedToolName} is unavailable in Plan mode`), + { code: -32000, data: { errorCode: "TOOL_DISABLED_IN_PLAN" } }, + ); + } + if (method === "project.instructions.resolve") { + if (!this.projectInstructionResolver) { + throw new Error("project instruction resolver unavailable"); + } + const sessionId = String(params.sessionId ?? ""); + const result = await this.projectInstructionResolver({ + sessionId, + path: String(params.path ?? ""), + projectPath: this.projectInstructionRoots.get(sessionId), + }); + this.writeToChild( + JSON.stringify({ jsonrpc: "2.0", id: msg.id, result }) + "\n", + ); + return; + } + if (method === "provider.resolveAuth") { + const result = await this.resolveVendorAuth(params); + this.writeToChild( + JSON.stringify({ jsonrpc: "2.0", id: msg.id, result }) + "\n", + ); + return; + } + if (method === "provider.resolveSubagentModel") { + const result = await this.resolveSubagentModel(params); + this.writeToChild( + JSON.stringify({ jsonrpc: "2.0", id: msg.id, result }) + "\n", + ); + return; + } + if (method.startsWith("extensions.") || method === "session.queuePush" || method === "session.queuePrioritize") { + const bridge = this.trustedExtensionBridge; + if (!bridge) throw new Error("trusted extension bridge unavailable"); + let result: unknown = { ok: true }; + if (method === "extensions.commands.publish") bridge.publishCommands(params); + else if (method === "extensions.diagnostics.publish") bridge.publishDiagnostics(params); + else if (method === "extensions.model.configure") result = await bridge.configureModel(params); + else if (method === "session.queuePush") result = await bridge.queuePush(params); + else if (method === "session.queuePrioritize") result = await bridge.queuePrioritize(params); + else result = await bridge.requestUi(params); + this.writeToChild( + JSON.stringify({ jsonrpc: "2.0", id: msg.id, result }) + "\n", + ); + return; + } + // Host-local tools short-circuit before host-core (which doesn't + // know them); everything else proxies through unchanged. + const localTool = + method === "tools.execute" + ? this.localTools.get(requestedToolName) + : undefined; + if (localTool) { + const toolName = requestedToolName; + // Local tools can bypass host-core's permission boundary. Plan mode + // therefore permits only the read-only BrowserPreview bridge; every + // other host-local tool fails closed even if a stale runtime asks for + // it directly. + const result = + params.mode === "plan" && toolName !== "BrowserPreview" + ? { + ok: false, + isError: true, + errorCode: "TOOL_DISABLED_IN_PLAN", + content: `${toolName} is unavailable in Plan mode.`, + } + : await this.runLocalTool(localTool, { + sessionId: String(params.sessionId ?? ""), + toolCallId: String(params.toolCallId ?? ""), + args: params.args, + }); + this.writeToChild( + JSON.stringify({ jsonrpc: "2.0", id: msg.id, result }) + "\n", + ); + return; + } + if (!this.host) throw new Error("host unavailable"); + const result = await this.host.call(method, params); + this.writeToChild( + JSON.stringify({ jsonrpc: "2.0", id: msg.id, result }) + "\n", + ); + } catch (e: any) { + this.writeToChild( + JSON.stringify({ + jsonrpc: "2.0", + id: msg.id, + error: { + code: e?.code ?? -32000, + message: e instanceof Error ? e.message : String(e), + data: e?.data, + }, + }) + "\n", + ); + } + return; + } + + if (msg.id !== undefined && msg.id !== null && msg.method === undefined) { + const pending = this.pending.get(String(msg.id)); + if (pending) { + this.pending.delete(String(msg.id)); + if (pending.timer) clearTimeout(pending.timer); + if (msg.error) { + const err = new Error(msg.error.message) as Error & { + code?: number; + data?: unknown; + }; + err.code = msg.error.code; + err.data = msg.error.data; + pending.reject(err); + } else { + pending.resolve(msg.result); + } + } + return; + } + + if (msg.method) { + for (const h of this.handlers) h(msg.method, msg.params); + } + } + + onNotification(handler: SidecarNotificationHandler): () => void { + if (this.closed) return () => undefined; + this.handlers.add(handler); + return () => this.handlers.delete(handler); + } + + async call(method: string, params: unknown = {}): Promise { + if (this.closed) throw new Error("agent sidecar is unavailable"); + const id = randomUUID(); + const timeoutMs = rpcTimeoutMs(method, params); + const payload = JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n"; + return new Promise((resolve, reject) => { + this.pending.set(id, { + resolve: resolve as (v: any) => void, + reject, + }); + const settle = (settleWith: (pending: { + resolve: (v: any) => void; + reject: (e: Error) => void; + timer?: ReturnType; + }) => void) => { + const pending = this.pending.get(id); + if (!pending) return; + this.pending.delete(id); + if (pending.timer) clearTimeout(pending.timer); + settleWith(pending); + }; + if (!this.writeToChild(payload)) { + settle((pending) => pending.reject(new Error("agent sidecar stdin is unavailable"))); + } + if (timeoutMs !== undefined) { + const timer = setTimeout(() => { + settle((pending) => pending.reject(new Error(`sidecar RPC timeout: ${method}`))); + }, timeoutMs); + const pending = this.pending.get(id); + if (pending) pending.timer = timer; + } + }); + } + + async dispose(): Promise { + this.disposed = true; + this.projectInstructionRoots.clear(); + this.vendorAuthBindings.clear(); + this.closeTransport(new Error("agent sidecar disposed")); + this.exitHandlers.clear(); + if (this.child.exitCode !== null || this.child.signalCode !== null) return; + // Wait for the process to actually leave so quit's settle step is real + // rather than returning while the sidecar is still tearing down. + await new Promise((resolve) => { + const timer = setTimeout(resolve, SIDECAR_DISPOSE_GRACE_MS); + timer.unref?.(); + this.child.once("exit", () => { + clearTimeout(timer); + resolve(); + }); + this.child.kill(); + }); + } +} + +/** Upper bound on how long `dispose()` waits for the killed sidecar to exit. */ +const SIDECAR_DISPOSE_GRACE_MS = 2_000; diff --git a/packages/host-runtime/src/host-ports.test.ts b/packages/host-runtime/src/host-ports.test.ts new file mode 100644 index 0000000000..7ef2d5c84c --- /dev/null +++ b/packages/host-runtime/src/host-ports.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; + +import { createHostQueueStore, createHostSessionPort, fromHostQueueEntry, toSessionSummary } from "./host-ports.js"; + +describe("host ports", () => { + it("maps a host session record to the Agent Host summary without leaking the absolute path", () => { + const summary = toSessionSummary({ + id: "s1", + title: "Fix build", + projectId: "proj", + projectPath: "/home/user/work/pi", + mode: "plan", + permissionMode: "accept-edits", + planningState: "awaiting_approval", + createdAt: "2026-09-18T00:00:00.000Z", + }); + expect(summary).toEqual({ + id: "s1", + title: "Fix build", + projectId: "proj", + workspaceLabel: "pi", + mode: "plan", + permissionMode: "accept-edits", + planningState: "awaiting_approval", + createdAt: "2026-09-18T00:00:00.000Z", + updatedAt: "2026-09-18T00:00:00.000Z", + }); + expect(toSessionSummary({ id: "x", mode: "weird", permissionMode: "yolo", planningState: "?" })).toMatchObject({ + mode: "agent", + permissionMode: "ask", + planningState: "inactive", + }); + }); + + it("pages history backwards from an item id", async () => { + const messages = ["m1", "m2", "m3", "m4"].map((id) => ({ + id, + role: "assistant" as const, + content: id, + createdAt: "2026-09-18T00:00:00.000Z", + status: "complete" as const, + })); + const port = createHostSessionPort(() => ({ + async call() { + return { session: { id: "s1", messages } } as T; + }, + })); + const page = await port.history("s1", { limit: 2, beforeItemId: "m4" }); + expect(page.items.map((item) => item.id)).toEqual(["m2", "m3"]); + expect(page.hasMore).toBe(true); + const tail = await port.history("s1", { limit: 10 }); + expect(tail.items.map((item) => item.id)).toEqual(["m1", "m2", "m3", "m4"]); + expect(tail.hasMore).toBe(false); + expect(await createHostSessionPort(() => null).get("s1")).toBeNull(); + }); + + it("round-trips queue records through the host-core RPC shape", async () => { + const calls: Array<{ method: string; params: Record }> = []; + const store = createHostQueueStore(() => ({ + async call(method: string, params: Record = {}) { + calls.push({ method, params }); + if (method === "session.queueList") { + return { + entries: [ + { + id: "q1", + sessionId: "s1", + principal: "phone", + inputHash: "h", + content: "later", + permissionMode: "auto", + position: 1, + priority: 2, + createdAt: "2026-09-18T00:00:00.000Z", + }, + ], + } as T; + } + if (method === "session.queueRemove") return { removed: true } as T; + if (method === "session.queueReorder") return { moved: false } as T; + return {} as T; + }, + })); + const [record] = await store.listAll(); + expect(record).toMatchObject({ id: "q1", principalSubject: "phone", effectivePermissionMode: "auto", priority: 2 }); + expect(record?.createdAt).toBe(Date.parse("2026-09-18T00:00:00.000Z")); + await store.push({ ...record!, attachments: [{ path: "/x", name: "x", kind: "file" }] }); + expect(calls.at(-1)?.params).toMatchObject({ id: "q1", principal: "phone", permissionMode: "auto" }); + expect(await store.remove("q1")).toBe(true); + expect(await store.reorder!("q1", "up")).toBe(false); + expect(fromHostQueueEntry({ id: "q", sessionId: "s", principal: "p", inputHash: "h", content: "c", permissionMode: "nope", position: 1, createdAt: "bad" })).toMatchObject({ + effectivePermissionMode: "ask", + createdAt: 0, + }); + }); + + it("fails closed when the host is gone", async () => { + const store = createHostQueueStore(() => null); + expect(await store.listAll()).toEqual([]); + await expect(store.remove("q1")).rejects.toMatchObject({ code: "AGENT_UNAVAILABLE" }); + }); +}); diff --git a/packages/host-runtime/src/host-ports.ts b/packages/host-runtime/src/host-ports.ts new file mode 100644 index 0000000000..8390846fd7 --- /dev/null +++ b/packages/host-runtime/src/host-ports.ts @@ -0,0 +1,182 @@ +import { basename } from "node:path"; + +import type { + PendingToolRequest, + QueueStore, + QueuedTurnRecord, + SessionPort, + SessionSummary, +} from "@pi-desktop/agent-host"; +import { RacpError } from "@pi-desktop/agent-host"; +import type { RacpItemSummary, RacpPermissionMode, UiMessage } from "@pi-desktop/shared"; + +/** Rust host-core over stdio JSON-RPC, as the ports below need it. */ +export type HostRpc = { + call(method: string, params?: Record): Promise; +}; + +export type HostSessionRecord = { + id: string; + title?: string; + projectId?: string; + projectPath?: string; + mode?: string; + permissionMode?: string; + planningState?: string; + createdAt?: string; + updatedAt?: string; + messages?: UiMessage[]; +}; + +export function requireHostRpc(getHost: () => HostRpc | null): HostRpc { + const host = getHost(); + if (!host) throw new RacpError("AGENT_UNAVAILABLE", "host is not running", { retriable: true }); + return host; +} + +export function toSessionSummary(record: HostSessionRecord): SessionSummary { + const mode = record.mode === "plan" || record.mode === "goal" ? record.mode : "agent"; + const permissionMode: RacpPermissionMode = + record.permissionMode === "accept-edits" || record.permissionMode === "auto" ? record.permissionMode : "ask"; + const planningState = + record.planningState === "planning" || record.planningState === "awaiting_approval" + ? record.planningState + : "inactive"; + return { + id: record.id, + title: record.title ?? "", + ...(record.projectId ? { projectId: record.projectId } : {}), + ...(record.projectPath ? { workspaceLabel: basename(record.projectPath) } : {}), + mode, + permissionMode, + planningState, + createdAt: record.createdAt ?? new Date(0).toISOString(), + updatedAt: record.updatedAt ?? record.createdAt ?? new Date(0).toISOString(), + }; +} + +export function toRacpItem(message: UiMessage): RacpItemSummary { + const turnId = (message as { turnId?: string }).turnId ?? ""; + return { + id: message.id, + turnId, + itemType: "message", + status: message.status === "streaming" ? "streaming" : "completed", + createdAt: message.createdAt, + ...(message.parentToolCallId ? { parentToolCallId: message.parentToolCallId } : {}), + ...(message.agentName ? { agentName: message.agentName } : {}), + content: message, + }; +} + +/** Session metadata and transcript pages straight from host `session.get`. */ +export function createHostSessionPort(getHost: () => HostRpc | null): SessionPort { + async function fetchSession(sessionId: string): Promise { + const host = getHost(); + if (!host) return null; + const result = await host.call<{ session?: HostSessionRecord | null }>("session.get", { id: sessionId }); + return result.session ?? null; + } + return { + async get(sessionId) { + const record = await fetchSession(sessionId); + return record ? toSessionSummary(record) : null; + }, + async history(sessionId, { limit, beforeItemId }) { + const record = await fetchSession(sessionId); + const messages = record?.messages ?? []; + const end = beforeItemId ? messages.findIndex((message) => message.id === beforeItemId) : messages.length; + const cut = end === -1 ? messages.length : end; + const start = Math.max(0, cut - limit); + return { + items: messages.slice(start, cut).map(toRacpItem), + hasMore: start > 0, + }; + }, + }; +} + +export type HostQueueEntry = { + id: string; + sessionId: string; + principal: string; + idempotencyKey?: string; + inputHash: string; + content: string; + sessionMessageId?: string; + attachments?: unknown; + permissionMode: string; + position: number; + priority?: number; + createdAt: string; +}; + +export function fromHostQueueEntry(entry: HostQueueEntry): QueuedTurnRecord { + const permissionMode: RacpPermissionMode = + entry.permissionMode === "accept-edits" || entry.permissionMode === "auto" ? entry.permissionMode : "ask"; + return { + id: entry.id, + sessionId: entry.sessionId, + principalSubject: entry.principal, + content: entry.content, + ...(entry.sessionMessageId ? { sessionMessageId: entry.sessionMessageId } : {}), + ...(Array.isArray(entry.attachments) ? { attachments: entry.attachments as QueuedTurnRecord["attachments"] } : {}), + effectivePermissionMode: permissionMode, + ...(entry.idempotencyKey ? { idempotencyKey: entry.idempotencyKey } : {}), + inputHash: entry.inputHash, + ...(entry.priority !== undefined ? { priority: entry.priority } : {}), + createdAt: Date.parse(entry.createdAt) || 0, + }; +} + +/** The Host-owned turn queue persisted by host-core (schema v15, ADR 0213). */ +export function createHostQueueStore(getHost: () => HostRpc | null): QueueStore { + return { + async listAll() { + const host = getHost(); + if (!host) return []; + const result = await host.call<{ entries?: HostQueueEntry[] }>("session.queueList", {}); + return (result.entries ?? []).map(fromHostQueueEntry); + }, + async push(record) { + await requireHostRpc(getHost).call("session.queuePush", { + id: record.id, + sessionId: record.sessionId, + principal: record.principalSubject, + ...(record.idempotencyKey ? { idempotencyKey: record.idempotencyKey } : {}), + inputHash: record.inputHash, + content: record.content, + ...(record.sessionMessageId ? { sessionMessageId: record.sessionMessageId } : {}), + ...(record.attachments ? { attachments: record.attachments } : {}), + permissionMode: record.effectivePermissionMode, + }); + }, + async remove(id) { + const result = await requireHostRpc(getHost).call<{ removed?: boolean }>("session.queueRemove", { id }); + return result.removed === true; + }, + async prioritize(id) { + await requireHostRpc(getHost).call("session.queuePrioritize", { id }); + }, + async reorder(id, direction) { + const result = await requireHostRpc(getHost).call<{ moved?: boolean }>("session.queueReorder", { + id, + direction, + }); + return result.moved === true; + }, + }; +} + +/** Open tool requests as Host state (`permissions.pending`). */ +export async function listPendingToolRequests( + getHost: () => HostRpc | null, + sessionId?: string, +): Promise { + const host = getHost(); + if (!host) return []; + const result = await host.call<{ requests?: PendingToolRequest[] }>("permissions.pending", { + ...(sessionId ? { sessionId } : {}), + }); + return result.requests ?? []; +} diff --git a/packages/host-runtime/src/host-process.ts b/packages/host-runtime/src/host-process.ts new file mode 100644 index 0000000000..8729e526b6 --- /dev/null +++ b/packages/host-runtime/src/host-process.ts @@ -0,0 +1,387 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { + ErrorCodes, + MAX_HOST_STDIN_LINE_BYTES, + PROTOCOL_VERSION, + readNdjsonLines, + rpcTimeoutMs, + stripProxyEnv, +} from "@pi-desktop/shared"; + +const HOST_DISPOSE_GRACE_MS = 3_000; +const HOST_FORCE_KILL_GRACE_MS = 1_000; +const HOST_OVERLOAD_RETRY_DELAYS_MS = [50, 100, 200, 400] as const; + +export type HostNotificationHandler = (method: string, params: unknown) => void; +export type ProcessExitHandler = (info: { + code: number | null; + signal: NodeJS.Signals | null; + intentional: boolean; + /** Last child stderr lines before exit; only the agent sidecar fills it. */ + stderrTail?: string[]; +}) => void; +export type StderrHandler = (text: string) => void; + +/** A transport failure the embedding host recognised, e.g. a schema refusal. */ +export type DiagnosedHostFailure = Error & { errorCode: string }; + +export type HostProcessOptions = { + /** Absolute path of the `pi-desktop-host-core` binary to spawn. */ + binaryPath: string; + /** Data directory handed to host-core as `PI_DESKTOP_DATA_DIR`. */ + dataDir: string; + /** + * Extra environment for the child. The inherited environment is copied with + * proxy variables stripped first, so host-core never sees proxy credentials + * (D340); entries here are applied on top. + */ + env?: Record; + /** Receives raw stderr text as it arrives; the embedding host owns redaction and logging. */ + onStderr: StderrHandler; + /** + * Classify a gone transport from the child's last stderr lines. Returns a + * typed error when the failure is one the embedding host names (a data + * directory newer than the build, an unsupported glibc), otherwise `null` + * and the generic `HOST_UNAVAILABLE` error is used. + */ + diagnoseFailure?: (context: { lastStderr: string; message: string }) => DiagnosedHostFailure | null; +}; + +function isHostOverloaded(error: unknown): boolean { + const candidate = error as { + errorCode?: unknown; + data?: { errorCode?: unknown }; + } | null; + return ( + candidate?.errorCode === ErrorCodes.HOST_OVERLOADED || + candidate?.data?.errorCode === ErrorCodes.HOST_OVERLOADED + ); +} + +/** + * The Rust host-core child over stdio NDJSON JSON-RPC. This class knows how to + * spawn, call, observe, and dispose the process; it does not know where the + * binary lives or which host embeds it, so Electron Main and the headless + * `pi-host` drive the same transport. + */ +export class HostProcess { + private child: ChildProcessWithoutNullStreams; + private pending = new Map< + string, + { + resolve: (v: any) => void; + reject: (e: Error) => void; + timer?: ReturnType; + } + >(); + private handlers = new Set(); + private exitHandlers = new Set(); + private disposed = false; + private available = true; + private closed = false; + private exitNotified = false; + private exitObserved = false; + private exitPromise: Promise; + private resolveExit!: () => void; + private disposePromise?: Promise; + private stdoutReader?: ReturnType; + private lastStderr = ""; + private readonly diagnoseFailure?: HostProcessOptions["diagnoseFailure"]; + readonly binaryPath: string; + readonly generation = randomUUID(); + + constructor(options: HostProcessOptions) { + this.exitPromise = new Promise((resolve) => { + this.resolveExit = resolve; + }); + this.binaryPath = options.binaryPath; + this.diagnoseFailure = options.diagnoseFailure; + const onStderr = options.onStderr; + this.child = spawn(this.binaryPath, [], { + stdio: ["pipe", "pipe", "pipe"], + env: { + ...stripProxyEnv(process.env), + PI_DESKTOP_DATA_DIR: options.dataDir, + ...(options.env ?? {}), + }, + }); + + this.child.stderr.setEncoding("utf8"); + this.child.stderr.on("data", (text: string) => { + if (!text) return; + this.lastStderr = `${this.lastStderr}${text}`.slice(-4_000); + onStderr(text); + }); + + this.child.on("exit", (code, signal) => { + this.available = false; + this.closeTransport(this.unavailableError("host-core exited")); + this.exitObserved = true; + this.resolveExit(); + this.notifyExit({ code, signal, intentional: this.disposed }); + this.cleanupProcessListeners(); + }); + this.child.on("error", (error) => { + this.available = false; + const failure = this.unavailableError( + `host-core process error: ${error.message}`, + ); + this.closeTransport(failure); + // A spawn failure never produces an `exit` event, so without settling the + // exit promise here `dispose()` would wait the full grace period, send a + // SIGKILL to a process that never started, and wait again. + if (this.child.pid === undefined || this.child.exitCode !== null) { + this.exitObserved = true; + this.resolveExit(); + } + this.notifyExit({ code: null, signal: null, intentional: this.disposed }); + if (this.exitObserved) this.cleanupProcessListeners(); + }); + + this.stdoutReader = readNdjsonLines(this.child.stdout, (line) => + this.onLine(line), + ); + } + + private closeTransport(error: Error) { + if (this.closed) return; + this.available = false; + this.closed = true; + for (const [, p] of this.pending) { + if (p.timer) clearTimeout(p.timer); + p.reject(error); + } + this.pending.clear(); + this.handlers.clear(); + this.stdoutReader?.close(); + this.stdoutReader = undefined; + } + + private cleanupProcessListeners() { + this.child.removeAllListeners("exit"); + this.child.removeAllListeners("error"); + this.child.stderr.removeAllListeners("data"); + } + + private waitForExit(timeoutMs: number): Promise { + if (this.exitObserved) return Promise.resolve(true); + return new Promise((resolve) => { + let settled = false; + const finish = (observed: boolean) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + resolve(observed); + }; + const timer = setTimeout(() => finish(false), timeoutMs); + timer.unref?.(); + this.exitPromise.then(() => finish(true)); + }); + } + + private notifyExit(info: { + code: number | null; + signal: NodeJS.Signals | null; + intentional: boolean; + }) { + if (this.exitNotified) return; + this.exitNotified = true; + for (const h of this.exitHandlers) h(info); + this.exitHandlers.clear(); + } + + /** + * Every rejection that only means "the transport is gone" is built here, so a + * caller can tell routine teardown from a real failure by the error code + * rather than by matching message text. The embedding host may recognise the + * failure from the last stderr lines and return a more specific typed error. + */ + private unavailableError(message: string): Error & { errorCode: string } { + const diagnosed = this.diagnoseFailure?.({ lastStderr: this.lastStderr, message }); + if (diagnosed) return diagnosed; + return Object.assign(new Error(message), { + errorCode: ErrorCodes.HOST_UNAVAILABLE, + }); + } + + isAvailable(): boolean { + return ( + this.available && + !this.closed && + this.child.exitCode === null && + !this.child.killed + ); + } + + onExit(handler: ProcessExitHandler): () => void { + if (this.closed) return () => undefined; + this.exitHandlers.add(handler); + return () => this.exitHandlers.delete(handler); + } + + private onLine(line: string) { + if (!line.trim()) return; + let msg: any; + try { + msg = JSON.parse(line); + } catch { + console.warn( + `[RPC] Invalid host-process NDJSON frame (${Buffer.byteLength(line, "utf8")} bytes)`, + ); + return; + } + if (msg.id !== undefined && msg.id !== null) { + const pending = this.pending.get(String(msg.id)); + if (pending) { + this.pending.delete(String(msg.id)); + if (pending.timer) clearTimeout(pending.timer); + if (msg.error) { + const err = new Error(msg.error.message) as Error & { + code?: number; + data?: unknown; + errorCode?: string; + }; + err.code = msg.error.code; + err.data = msg.error.data; + const errorCode = + msg.error.data && typeof msg.error.data.errorCode === "string" + ? msg.error.data.errorCode + : undefined; + if (errorCode) err.errorCode = errorCode; + pending.reject(err); + } else { + pending.resolve(msg.result); + } + } + return; + } + if (msg.method) { + for (const h of this.handlers) h(msg.method, msg.params); + } + } + + onNotification(handler: HostNotificationHandler): () => void { + if (this.closed) return () => undefined; + this.handlers.add(handler); + return () => this.handlers.delete(handler); + } + + async call( + method: string, + params: unknown = {}, + timeoutOverrideMs?: number, + ): Promise { + for (const delayMs of [0, ...HOST_OVERLOAD_RETRY_DELAYS_MS]) { + if (delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + try { + return await this.callOnce(method, params, timeoutOverrideMs); + } catch (error) { + if ( + !isHostOverloaded(error) || + delayMs === HOST_OVERLOAD_RETRY_DELAYS_MS.at(-1) + ) { + throw error; + } + } + } + throw new Error(`host RPC retry exhausted: ${method}`); + } + + private async callOnce( + method: string, + params: unknown, + timeoutOverrideMs?: number, + ): Promise { + if (this.closed) throw this.unavailableError("host-core is unavailable"); + if (!this.isAvailable()) { + throw this.unavailableError(`host RPC unavailable: ${method}`); + } + const id = randomUUID(); + const timeoutMs = timeoutOverrideMs ?? rpcTimeoutMs(method, params); + const payload = JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n"; + if (Buffer.byteLength(payload, "utf8") > MAX_HOST_STDIN_LINE_BYTES) { + throw Object.assign(new Error("request line exceeds 64 MiB"), { + errorCode: ErrorCodes.LIMIT_EXCEEDED, + code: 1002, + }); + } + return new Promise((resolve, reject) => { + let timer: ReturnType | undefined; + let settled = false; + const settle = (finish: () => void) => { + if (settled) return; + settled = true; + this.pending.delete(id); + if (timer) clearTimeout(timer); + finish(); + }; + this.pending.set(id, { + resolve: (value) => settle(() => resolve(value)), + reject: (error) => settle(() => reject(error)), + }); + if (!this.isAvailable()) { + settle(() => reject(this.unavailableError(`host RPC unavailable: ${method}`))); + return; + } + if (timeoutMs !== undefined) { + timer = setTimeout( + () => settle(() => reject(new Error(`host RPC timeout: ${method}`))), + timeoutMs, + ); + } + try { + this.child.stdin.write(payload, (error) => { + if (!error) return; + const failure = this.unavailableError( + `host RPC write failed: ${error.message}`, + ); + settle(() => reject(failure)); + this.closeTransport(failure); + this.notifyExit({ code: null, signal: null, intentional: this.disposed }); + }); + } catch (error) { + const failure = this.unavailableError( + `host RPC write failed: ${error instanceof Error ? error.message : String(error)}`, + ); + settle(() => reject(failure)); + this.closeTransport(failure); + this.notifyExit({ code: null, signal: null, intentional: this.disposed }); + } + }); + } + + async handshake(): Promise { + await this.call("app.handshake", { protocolVersion: PROTOCOL_VERSION }); + } + + async dispose(): Promise { + if (this.disposePromise) return this.disposePromise; + this.disposePromise = this.disposeInternal(); + return this.disposePromise; + } + + private async disposeInternal(): Promise { + this.disposed = true; + this.available = false; + // EOF is the graceful host-core shutdown signal. Send it before rejecting + // transport callers so the runner can clean up active tools first. + if (!this.child.stdin.destroyed && !this.child.stdin.writableEnded) { + this.child.stdin.end(); + } + this.closeTransport(this.unavailableError("host-core disposed")); + if (this.exitObserved) return; + + const exited = await this.waitForExit(HOST_DISPOSE_GRACE_MS); + if (exited || this.exitObserved) return; + + try { + this.child.kill("SIGKILL"); + } catch { + // The child may have exited between the grace check and kill fallback. + } + await this.waitForExit(HOST_FORCE_KILL_GRACE_MS); + } +} diff --git a/packages/host-runtime/src/index.ts b/packages/host-runtime/src/index.ts new file mode 100644 index 0000000000..55720460b5 --- /dev/null +++ b/packages/host-runtime/src/index.ts @@ -0,0 +1,12 @@ +export * from "./host-process.js"; +export * from "./agent-sidecar.js"; +export * from "./runtime-supervisor.js"; +export * from "./host-ports.js"; +export * from "./launch-resolver.js"; +export * from "./session-message-input.js"; +export * from "./inflight-checkpoint.js"; +export * from "./plan-execution.js"; +export * from "./turn-persistence.js"; +export * from "./turn-events.js"; +export * from "./runtime-service.js"; +export * from "./plan-dispatch.js"; diff --git a/apps/desktop/electron/main/inflight-checkpoint.ts b/packages/host-runtime/src/inflight-checkpoint.ts similarity index 100% rename from apps/desktop/electron/main/inflight-checkpoint.ts rename to packages/host-runtime/src/inflight-checkpoint.ts diff --git a/packages/host-runtime/src/launch-resolver.test.ts b/packages/host-runtime/src/launch-resolver.test.ts new file mode 100644 index 0000000000..a58ca4902e --- /dev/null +++ b/packages/host-runtime/src/launch-resolver.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; + +import { createHeadlessLaunchResolver, type HostProviderRecord } from "./launch-resolver.js"; + +type Call = { method: string; params: Record }; + +function hostWith(providers: HostProviderRecord[], secrets: Record = {}) { + const calls: Call[] = []; + return { + calls, + host: { + async call(method: string, params: Record = {}): Promise { + calls.push({ method, params }); + switch (method) { + case "commandShells.list": + return { + configuredId: null, + effective: { id: "bash", label: "bash", available: true, dialect: "posix", isDefault: true }, + fallback: true, + choices: [{ id: "bash", label: "bash", available: true, dialect: "posix", isDefault: true }], + } as T; + case "providers.list": + return { providers } as T; + case "providers.getSecret": + return { value: secrets[String(params.id)] } as T; + case "skills.active": + return { skills: [{ id: "review", name: "Review", enabled: true, description: "Review code" }] } as T; + case "agents.active": + return { subagents: [] } as T; + case "agents.disabledBuiltins": + return { disabled: [] } as T; + case "project.group.context": + return { context: null } as T; + case "project.memory.get": + return { memory: { content: "remember me" } } as T; + default: + throw new Error(`unexpected ${method}`); + } + }, + }, + }; +} + +const provider: HostProviderRecord = { + id: "p1", + name: "OpenAI", + vendorKey: "openai", + baseUrl: "https://api.openai.com/v1", + models: [ + { id: "gpt-a", contextWindow: 128_000, maxTokens: 8_192, thinkingLevels: ["off"], defaultThinkingLevel: null, availableForSubagents: false }, + ], + authKind: "api_key", + hasSecret: true, + enabled: true, +}; + +describe("createHeadlessLaunchResolver", () => { + it("resolves the session's provider, its secret, the shell, skills and project memory", async () => { + const { host, calls } = hostWith([provider], { p1: "sk-test" }); + const resolver = createHeadlessLaunchResolver({ getHost: () => host, dataDir: "/data", log: () => undefined }); + const launch = await resolver.resolve( + "s1", + { providerId: "p1", modelId: "gpt-a", projectPath: "/work/project" }, + { defaultMode: "agent" }, + ); + expect(launch.providerId).toBe("p1"); + expect(launch.modelId).toBe("gpt-a"); + expect(launch.projectPath).toBe("/work/project"); + expect(launch.sidecarParams.provider.apiKey).toBe("sk-test"); + expect(launch.sidecarParams.provider.modelConfig?.name).toBe("gpt-a"); + expect(launch.sidecarParams.scratchDir).toBe("/data/scratch/s1"); + expect(launch.sidecarParams.pluginSkills).toEqual([{ id: "review", name: "Review", description: "Review code" }]); + expect(launch.sidecarParams.pluginTools).toEqual([]); + expect(launch.sidecarParams.projectMemory).toBe("remember me"); + expect(launch.sidecarParams.commandShell).toMatchObject({ id: "bash" }); + expect(calls.some((call) => call.method === "providers.getSecret")).toBe(true); + }); + + it("falls back to the default provider and refuses a provider without a secret", async () => { + const { host } = hostWith([provider, { ...provider, id: "p2", name: "Other", hasSecret: false }], { p1: "sk" }); + const resolver = createHeadlessLaunchResolver({ getHost: () => host, dataDir: "/data", log: () => undefined }); + const launch = await resolver.resolve("s1", {}, { defaultProviderId: "p1", defaultModelId: "gpt-a" }); + expect(launch.providerId).toBe("p1"); + await expect(resolver.resolve("s1", { providerId: "p2", modelId: "gpt-a" }, {})).rejects.toMatchObject({ + errorCode: "PROVIDER_SECRET_MISSING", + }); + }); + + it("refuses vendor accounts and plugin agents, which need the desktop", async () => { + const { host } = hostWith([{ ...provider, authKind: "oauth" }]); + const resolver = createHeadlessLaunchResolver({ getHost: () => host, dataDir: "/data", log: () => undefined }); + await expect(resolver.resolve("s1", { providerId: "p1" }, {})).rejects.toMatchObject({ errorCode: "MODEL_NOT_CONFIGURED" }); + await expect(resolver.resolve("s1", { providerId: "extension-agent:plugin.x%2Fagent" }, {})).rejects.toMatchObject({ + errorCode: "MODEL_NOT_CONFIGURED", + }); + }); + + it("fails with a typed error when no provider exists or the host is gone", async () => { + const { host } = hostWith([]); + const resolver = createHeadlessLaunchResolver({ getHost: () => host, dataDir: "/data", log: () => undefined }); + await expect(resolver.resolve("s1", {}, {})).rejects.toMatchObject({ errorCode: "MODEL_NOT_CONFIGURED" }); + const offline = createHeadlessLaunchResolver({ getHost: () => null, dataDir: "/data", log: () => undefined }); + await expect(offline.resolve("s1", {}, {})).rejects.toMatchObject({ errorCode: "HOST_UNAVAILABLE" }); + }); +}); diff --git a/packages/host-runtime/src/launch-resolver.ts b/packages/host-runtime/src/launch-resolver.ts new file mode 100644 index 0000000000..1beefe90d2 --- /dev/null +++ b/packages/host-runtime/src/launch-resolver.ts @@ -0,0 +1,409 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { + ErrorCodes, + OAUTH_AUTH_KIND, + isActiveInProject, + isCommandShellCatalog, + normalizeMode, + resolveBindingContextWindow, + trustedExtensionAgentKeyFromProviderId, + type CommandShellCatalog, + type ModelBinding, + type Mode, + type ThinkingLevel, + type UserSkillRecord, + type UserSubagentRecord, +} from "@pi-desktop/shared"; +import { + capabilitiesFromModelConfig, + clampThinkingLevel, + genericModelConfig, + loadInstructionChain, + loadSubagentDefinitions, + modelConfigWithBinding, + optionalProviderHeaders, + resolveSubagentProviders, + visionFromModelConfig, + type RuntimeProviderConfig, + type UserSubagentDocument, +} from "@pi-desktop/agent-runtime"; + +import type { HostRpc } from "./host-ports.js"; + +/** A provider row as `providers.list` returns it. */ +export type HostProviderRecord = { + id: string; + name: string; + vendorKey?: string; + baseUrl?: string; + modelId?: string; + models?: ModelBinding[]; + defaultModelId?: string; + authKind?: string; + apiStyle?: string; + hasSecret?: boolean; + hasOauth?: boolean; + headers?: Record; + enabled?: boolean; +}; + +export type LaunchOverrides = { + mode?: Mode; + turnId?: string; + providerId?: string; + modelId?: string; + thinkingLevel?: ThinkingLevel; +}; + +/** What a turn needs to start: the provider identity and the sidecar payload. */ +export type ResolvedLaunch = { + providerId: string; + modelId: string; + projectPath?: string; + sidecarParams: Record & { + sessionId: string; + mode: Mode; + provider: RuntimeProviderConfig & { supportsVision?: boolean }; + }; +}; + +export interface LaunchResolver { + resolve( + sessionId: string, + session: Record, + settings: Record, + overrides?: LaunchOverrides, + ): Promise; +} + +export type ModelCatalogPort = { + /** Catalog metadata for a provider model, or `undefined` for a generic shape. */ + modelConfig( + provider: Pick, + modelId: string, + ): ReturnType | undefined; +}; + +export type HeadlessLaunchResolverOptions = { + getHost: () => HostRpc | null; + dataDir: string; + log: (level: "info" | "warn", message: string, data?: Record) => void; + /** Optional models.dev snapshot; without it every model runs on the generic transport shape. */ + catalog?: ModelCatalogPort; +}; + +const THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"]; + +function normalizeThinkingLevel(value: unknown): ThinkingLevel { + return typeof value === "string" && THINKING_LEVELS.includes(value as ThinkingLevel) + ? (value as ThinkingLevel) + : "off"; +} + +function launchError(message: string, errorCode: string): Error { + return Object.assign(new Error(message), { errorCode }); +} + +function isHostUnavailable(error: unknown): boolean { + return (error as { errorCode?: string } | null)?.errorCode === ErrorCodes.HOST_UNAVAILABLE; +} + +/** + * Launch resolution for a headless Host. It is the desktop's + * `resolveAgentRuntimeLaunch` minus the surfaces a headless machine does not + * have: no plugin tools, no user MCP relay, no plugin agents, no vendor OAuth + * accounts. Everything else — provider rows and secrets, the effective + * command shell, project instructions and memory, the user's skills and + * subagent definitions — comes from the same host-core registries. + */ +export function createHeadlessLaunchResolver(options: HeadlessLaunchResolverOptions): LaunchResolver { + const { getHost, dataDir, log } = options; + + function requireHost(): HostRpc { + const host = getHost(); + if (!host) throw launchError("host unavailable", ErrorCodes.HOST_UNAVAILABLE); + return host; + } + + async function activeUserSkills(projectPath: string | undefined): Promise { + try { + const result = await requireHost().call<{ skills: UserSkillRecord[] }>("skills.active", { + projectPath: projectPath ?? null, + }); + return result.skills ?? []; + } catch (error) { + if (!isHostUnavailable(error)) log("warn", "skills list failed", { error: String(error) }); + return []; + } + } + + async function activeUserSubagentDocuments(projectPath: string | undefined): Promise { + let records: UserSubagentRecord[] = []; + try { + const result = await requireHost().call<{ subagents: UserSubagentRecord[] }>("agents.active", { + projectPath: projectPath ?? null, + }); + records = result.subagents ?? []; + } catch (error) { + if (!isHostUnavailable(error)) log("warn", "subagent list failed", { error: String(error) }); + return []; + } + const documents: UserSubagentDocument[] = []; + for (const record of records) { + try { + documents.push({ id: record.id, document: await readFile(record.path, "utf8"), filePath: record.path }); + } catch (error) { + log("warn", "subagent document unreadable", { id: record.id, error: String(error) }); + } + } + return documents; + } + + async function disabledBuiltinSubagents(): Promise { + try { + const result = await requireHost().call<{ disabled: string[] }>("agents.disabledBuiltins"); + return result.disabled ?? []; + } catch (error) { + if (!isHostUnavailable(error)) log("warn", "builtin subagent state failed", { error: String(error) }); + return []; + } + } + + async function resolveEffectiveCommandShell(): Promise { + const catalog = await requireHost().call("commandShells.list"); + if (!isCommandShellCatalog(catalog)) { + throw launchError("Host returned an invalid command shell catalog", "COMMAND_SHELL_INVALID"); + } + if (!catalog.effective || !catalog.effective.available) { + throw launchError("No available command shell is configured for this session", "SHELL_NOT_FOUND"); + } + return catalog; + } + + function catalogModelConfig(provider: HostProviderRecord, modelId: string, baseUrl: string | undefined) { + return options.catalog?.modelConfig({ vendorKey: provider.vendorKey, baseUrl }, modelId) + ?? genericModelConfig(modelId, baseUrl ?? ""); + } + + function bindingForModel(provider: Pick, modelId: string): ModelBinding | undefined { + return provider.models?.find((binding) => binding.id === modelId); + } + + function effectiveModelConfig(provider: HostProviderRecord, modelId: string, baseUrl: string | undefined) { + const storedModel = bindingForModel(provider, modelId); + const resolvedLimits = resolveBindingContextWindow(catalogModelConfig(provider, modelId, baseUrl), storedModel); + const modelConfig = modelConfigWithBinding(resolvedLimits.catalogConfig, resolvedLimits.binding); + return { modelConfig, capabilities: capabilitiesFromModelConfig(modelConfig), storedModel }; + } + + async function getSecret(providerId: string): Promise { + const secret = await requireHost().call<{ value?: string }>("providers.getSecret", { id: providerId }); + return secret?.value; + } + + async function resolve( + sessionId: string, + session: Record, + settings: Record, + overrides: LaunchOverrides = {}, + ): Promise { + const host = requireHost(); + const commandShell = (await resolveEffectiveCommandShell()).effective!; + const providers = await host.call<{ providers: HostProviderRecord[] }>("providers.list", { + includeDisabled: false, + }); + const sessionProviderId = typeof session.providerId === "string" ? session.providerId : undefined; + const sessionModelId = typeof session.modelId === "string" ? session.modelId : undefined; + const requestedProviderId = overrides.providerId ?? sessionProviderId; + if (requestedProviderId && trustedExtensionAgentKeyFromProviderId(requestedProviderId)) { + throw launchError("Plugin agents are not available on a headless host", ErrorCodes.MODEL_NOT_CONFIGURED); + } + const defaultProviderId = typeof settings.defaultProviderId === "string" ? settings.defaultProviderId : undefined; + const defaultModelId = typeof settings.defaultModelId === "string" ? settings.defaultModelId : undefined; + const provider = + providers.providers.find((item) => item.id === requestedProviderId) || + providers.providers.find((item) => item.id === defaultProviderId) || + providers.providers.find((item) => item.hasSecret || item.authKind === "none") || + providers.providers[0]; + if (!provider) throw launchError("No provider configured", ErrorCodes.MODEL_NOT_CONFIGURED); + if (provider.authKind === OAUTH_AUTH_KIND) { + throw launchError( + "Vendor account sign-in is not available on a headless host; configure an API key provider", + ErrorCodes.MODEL_NOT_CONFIGURED, + ); + } + const secretValue = provider.authKind === "none" ? undefined : await getSecret(provider.id); + if (!secretValue && provider.authKind !== "none") { + throw launchError("Provider API key missing", ErrorCodes.PROVIDER_SECRET_MISSING); + } + const modelId = + (provider.id === requestedProviderId ? overrides.modelId ?? sessionModelId : undefined) || + (provider.id === defaultProviderId ? defaultModelId : undefined) || + provider.models?.[0]?.id || + provider.defaultModelId; + if (!modelId) throw launchError("No model selected for provider", ErrorCodes.MODEL_NOT_CONFIGURED); + + const { modelConfig, capabilities, storedModel } = effectiveModelConfig(provider, modelId, provider.baseUrl); + const thinkingLevel = clampThinkingLevel( + capabilities, + normalizeThinkingLevel( + overrides.thinkingLevel ?? + (provider.id === requestedProviderId ? session.thinkingLevel : undefined) ?? + storedModel?.defaultThinkingLevel, + ), + ); + const projectPath = + typeof session.projectPath === "string" && session.projectPath.trim() ? session.projectPath.trim() : undefined; + let projectInstructions = await loadInstructionChain(projectPath); + let projectMemory: string | undefined; + if (projectPath) { + try { + const result = await host.call<{ + context?: { roots?: Array<{ path?: string }>; instructions?: string; memory?: { content?: string } } | null; + }>("project.group.context", { path: projectPath }); + const groupRoots = result.context?.roots ?? []; + const groupRootGuide = + groupRoots.length > 1 + ? [ + `Primary root: ${groupRoots[0]?.path ?? projectPath}`, + ...groupRoots.slice(1).map((root) => `Additional root: ${root.path}`), + "Use an absolute path when reading or editing an additional root.", + ].join("\n") + : ""; + const groupInstructions = result.context?.instructions?.trim(); + if (groupRootGuide || groupInstructions) { + projectInstructions = { + entries: [ + ...(projectInstructions?.entries ?? []), + ...(groupRootGuide ? [{ source: "ChatGPT Project folders", content: groupRootGuide }] : []), + ...(groupInstructions ? [{ source: "ChatGPT Project instructions", content: groupInstructions }] : []), + ], + }; + } + const groupMemory = result.context?.memory?.content?.trim(); + if (groupMemory) projectMemory = groupMemory; + if (!result.context) { + const legacy = await host.call<{ memory?: { content?: string } }>("project.memory.get", { path: projectPath }); + const content = legacy.memory?.content?.trim(); + if (content) projectMemory = content; + } + } catch { + try { + const legacy = await host.call<{ memory?: { content?: string } }>("project.memory.get", { path: projectPath }); + const content = legacy.memory?.content?.trim(); + if (content) projectMemory = content; + } catch { + // Project memory is best effort; it must never prevent a session launch. + } + } + } + + const userSkills = (await activeUserSkills(projectPath)).filter((skill) => isActiveInProject(skill, projectPath ?? null)); + const pluginSkills = userSkills.map((skill) => ({ id: skill.id, name: skill.name, description: skill.description })); + + const subagentCatalog = await loadSubagentDefinitions(projectPath, { + userDocuments: await activeUserSubagentDocuments(projectPath), + disabledBuiltins: await disabledBuiltinSubagents(), + }); + const subagentBindings = await resolveSubagentProviders({ + definitions: subagentCatalog.definitions, + providers: providers.providers, + getSecret, + resolveModel: async (pinned, pinnedModelId) => { + const row = providers.providers.find((candidate) => candidate.id === pinned.id); + const resolved = effectiveModelConfig(row ?? { ...pinned, models: [] }, pinnedModelId, pinned.baseUrl); + return { modelConfig: resolved.modelConfig, capabilities: resolved.capabilities }; + }, + }); + const subagentModelKeys: string[] = []; + for (const row of providers.providers) { + if (!row.enabled) continue; + for (const binding of row.models ?? []) { + if (!binding.availableForSubagents) continue; + let key = `${row.vendorKey ?? row.name}/${binding.id}`; + if (subagentBindings.providers[key]?.id && subagentBindings.providers[key].id !== row.id) { + key = `${row.id}/${binding.id}`; + } + if (subagentBindings.providers[key]) { + subagentModelKeys.push(key); + continue; + } + if (row.authKind === OAUTH_AUTH_KIND) continue; + let apiKey = ""; + if (row.authKind !== "none") { + try { + apiKey = (await getSecret(row.id)) ?? ""; + } catch { + continue; + } + if (!apiKey) continue; + } + const effective = effectiveModelConfig(row, binding.id, row.baseUrl); + subagentBindings.providers[key] = { + id: row.id, + name: row.name, + ...(row.vendorKey ? { vendorKey: row.vendorKey } : {}), + ...(row.baseUrl ? { baseUrl: row.baseUrl } : {}), + modelId: binding.id, + apiKey, + ...(row.authKind ? { authKind: row.authKind } : {}), + ...(row.apiStyle ? { apiStyle: row.apiStyle } : {}), + ...optionalProviderHeaders(row.headers), + supportsReasoning: effective.capabilities.supportsReasoning, + supportedThinkingLevels: [...effective.capabilities.supportedThinkingLevels], + modelConfig: effective.modelConfig, + }; + subagentModelKeys.push(key); + } + } + const subagentDiagnostics = [...subagentCatalog.diagnostics, ...subagentBindings.diagnostics]; + if (subagentDiagnostics.length > 0) { + log("warn", "subagent definitions have problems", { sessionId, diagnostics: subagentDiagnostics }); + } + + const sessionMode = typeof session.mode === "string" ? session.mode : undefined; + const defaultMode = typeof settings.defaultMode === "string" ? settings.defaultMode : undefined; + return { + providerId: provider.id, + modelId, + projectPath, + sidecarParams: { + sessionId, + mode: normalizeMode(overrides.mode ?? sessionMode ?? defaultMode ?? "agent"), + ...(overrides.turnId ? { turnId: overrides.turnId } : {}), + thinkingLevel, + commandShell, + scratchDir: join(dataDir, "scratch", sessionId), + attachmentsDir: join(dataDir, "attachments"), + projectPath, + projectInstructions, + projectMemory, + provider: { + id: provider.id, + name: provider.name, + vendorKey: provider.vendorKey, + baseUrl: provider.baseUrl, + modelId, + apiKey: secretValue || "", + authKind: provider.authKind, + apiStyle: provider.apiStyle, + ...optionalProviderHeaders(provider.headers), + supportsReasoning: capabilities.supportsReasoning, + supportsVision: visionFromModelConfig(modelConfig), + supportedThinkingLevels: [...capabilities.supportedThinkingLevels], + modelConfig, + }, + pluginTools: [], + pluginSkills, + trustedExtensions: [], + subagents: subagentCatalog.definitions, + subagentProviders: subagentBindings.providers, + subagentModelKeys, + }, + }; + } + + return { resolve }; +} diff --git a/packages/host-runtime/src/plan-dispatch.ts b/packages/host-runtime/src/plan-dispatch.ts new file mode 100644 index 0000000000..b5b0521b9f --- /dev/null +++ b/packages/host-runtime/src/plan-dispatch.ts @@ -0,0 +1,208 @@ +import { ErrorCodes, type PlanExecution, type PlanExecutionFinishStatus } from "@pi-desktop/shared"; + +import type { HostRpc } from "./host-ports.js"; +import type { LaunchResolver } from "./launch-resolver.js"; +import { executionFromResponse, executionListFromResponse, planExecutionFromUnknown } from "./plan-execution.js"; +import type { RuntimeLogger, RuntimeService, RuntimeSidecarLink } from "./runtime-service.js"; + +export type PlanExecutionDispatcherOptions = { + getHost: () => HostRpc | null; + getSidecar: () => RuntimeSidecarLink | null; + launch: LaunchResolver; + runtime: Pick; + log: RuntimeLogger; + sleep?: (ms: number) => Promise; +}; + +/** + * Approved Plan/Goal executions on a headless Host (D189). host-core owns the + * approval row and its execution state; this dispatcher claims a queued + * execution, opens the turn that runs it, hands it to the sidecar, and closes + * the execution when that turn ends. Nothing is replayed: a row the host + * already reports as running belongs to a previous process. + */ +export class PlanExecutionDispatcher { + private readonly approvedExecutionIdsBySession = new Map(); + private readonly executionTurns = new Map(); + private readonly started = new Set(); + private readonly finished = new Set(); + private readonly dispatching = new Set(); + private readonly inFlightFinishes = new Set(); + private readonly pendingFinishes = new Map(); + private drain: Promise | null = null; + private readonly detach: () => void; + + constructor(private readonly options: PlanExecutionDispatcherOptions) { + this.detach = options.runtime.onTurnEnded((info) => { + const executionId = this.approvedExecutionIdsBySession.get(info.sessionId); + if (!executionId) return; + const turn = this.executionTurns.get(executionId); + if (turn?.turnId !== info.turnId) return; + void this.finishApprovedExecution( + executionId, + info.reason === "completed" ? "completed" : "interrupted", + info.reason === "completed" ? undefined : info.errorCode ?? "PLAN_EXECUTION_INTERRUPTED", + ); + }); + } + + dispose(): void { + this.detach(); + } + + /** The execution a session is currently running, if any. */ + executionForSession(sessionId: string): string | undefined { + return this.approvedExecutionIdsBySession.get(sessionId); + } + + async finishApprovedExecution( + executionId: string, + status: PlanExecutionFinishStatus, + errorCode?: string, + ): Promise { + if (this.finished.has(executionId) || this.inFlightFinishes.has(executionId)) return; + if (!this.pendingFinishes.has(executionId)) this.pendingFinishes.set(executionId, { status, errorCode }); + this.inFlightFinishes.add(executionId); + const host = this.options.getHost(); + if (!host) { + this.inFlightFinishes.delete(executionId); + return; + } + try { + const pending = this.pendingFinishes.get(executionId) ?? { status, errorCode }; + await host.call("plans.finishExecution", { + executionId, + status: pending.status, + ...(pending.errorCode ? { errorCode: pending.errorCode } : {}), + }); + this.finished.add(executionId); + this.started.delete(executionId); + this.pendingFinishes.delete(executionId); + const turn = this.executionTurns.get(executionId); + if (turn && this.approvedExecutionIdsBySession.get(turn.sessionId) === executionId) { + this.approvedExecutionIdsBySession.delete(turn.sessionId); + } + this.executionTurns.delete(executionId); + } catch (error) { + this.options.log("warn", "approved plan execution finalization failed", { executionId, error: String(error) }); + } finally { + this.inFlightFinishes.delete(executionId); + } + } + + async dispatchApprovedPlan(rawExecution: unknown): Promise { + const initial = planExecutionFromUnknown(rawExecution); + if (!initial) { + this.options.log("warn", "approved plan execution descriptor was invalid"); + return; + } + await this.options.runtime.withSessionOperation(initial.sessionId, async () => { + if ( + initial.state !== "queued" || + this.started.has(initial.id) || + this.finished.has(initial.id) || + this.dispatching.has(initial.id) + ) { + return; + } + const host = this.options.getHost(); + const sidecar = this.options.getSidecar(); + if (!host || !sidecar) return; + if (this.options.runtime.activeTurnId(initial.sessionId)) { + // The submitting turn is still finalizing; look again shortly. + const timer = setTimeout(() => void this.dispatchApprovedPlan(initial), 250); + timer.unref?.(); + return; + } + this.dispatching.add(initial.id); + let claimed = false; + let turnId: string | undefined; + try { + const claimResponse = await host.call("plans.claimExecution", { executionId: initial.id }); + const claimedExecution = executionFromResponse(claimResponse); + if (claimedExecution && claimedExecution.state !== "running") { + // Interrupted or completed rows are durable outcomes, never replayed. + return; + } + const execution: PlanExecution = { ...(claimedExecution ?? initial), state: "running" }; + claimed = true; + const [settings, sessionResult] = await Promise.all([ + host.call>("settings.get"), + host.call<{ session?: Record | null }>("session.get", { id: execution.sessionId }), + ]); + if (!sessionResult.session) { + throw Object.assign(new Error("Session not found"), { errorCode: ErrorCodes.NOT_FOUND }); + } + const launch = await this.options.launch.resolve(execution.sessionId, sessionResult.session, settings ?? {}, { + mode: "agent", + }); + turnId = await this.options.runtime.beginTurn(execution.sessionId, launch.providerId, launch.modelId); + this.approvedExecutionIdsBySession.set(execution.sessionId, execution.id); + this.executionTurns.set(execution.id, { sessionId: execution.sessionId, turnId }); + this.started.add(execution.id); + sidecar.setProjectInstructionRoot(execution.sessionId, launch.projectPath); + const accepted = await sidecar.call<{ accepted: boolean }>("agent.executeApprovedPlan", { + ...launch.sidecarParams, + mode: "agent", + turnId, + execution, + }); + if (accepted?.accepted !== true) throw new Error("approved plan execution was not accepted"); + this.options.log("info", "approved plan execution started", { + sessionId: execution.sessionId, + executionId: execution.id, + turnId, + }); + } catch (error) { + const errorCode = + (error as { data?: { errorCode?: string } })?.data?.errorCode || + (error as { errorCode?: string })?.errorCode || + "PLAN_EXECUTION_INTERRUPTED"; + if (turnId) await this.options.runtime.finishTurn(initial.sessionId, "error", errorCode, { turnId }); + if (claimed) await this.finishApprovedExecution(initial.id, "interrupted", errorCode); + this.options.log("warn", "approved plan execution failed to start", { + sessionId: initial.sessionId, + executionId: initial.id, + error: String(error), + }); + } finally { + this.dispatching.delete(initial.id); + } + }); + } + + /** Dispatch every queued execution the host restored, once, on boot or after a restart. */ + async drainApprovedPlanExecutions(): Promise { + if (this.drain) return this.drain; + this.drain = (async () => { + const host = this.options.getHost(); + if (!host || !this.options.getSidecar()) return; + for (const [executionId, finish] of this.pendingFinishes) { + await this.finishApprovedExecution(executionId, finish.status, finish.errorCode); + } + const response = await host.call("plans.queuedExecutions"); + for (const execution of executionListFromResponse(response)) { + if (execution.state !== "queued") continue; + await this.dispatchApprovedPlan(execution); + } + })(); + try { + await this.drain; + } finally { + this.drain = null; + } + } + + /** After an approval resolved, run the execution it produced. */ + async dispatchExecutionForProposal(proposalId: string): Promise { + const host = this.options.getHost(); + if (!host) return; + try { + const response = await host.call("plans.queuedExecutions"); + const execution = executionListFromResponse(response).find((candidate) => candidate.proposalId === proposalId); + if (execution?.state === "queued") await this.dispatchApprovedPlan(execution); + } catch (error) { + this.options.log("warn", "approved plan lookup after resolution failed", { proposalId, error: String(error) }); + } + } +} diff --git a/apps/desktop/electron/main/plan-execution.ts b/packages/host-runtime/src/plan-execution.ts similarity index 100% rename from apps/desktop/electron/main/plan-execution.ts rename to packages/host-runtime/src/plan-execution.ts diff --git a/packages/host-runtime/src/runtime-service.test.ts b/packages/host-runtime/src/runtime-service.test.ts new file mode 100644 index 0000000000..40bd8985e5 --- /dev/null +++ b/packages/host-runtime/src/runtime-service.test.ts @@ -0,0 +1,319 @@ +import { describe, expect, it } from "vitest"; +import type { AgentEventEnvelope, UiMessage } from "@pi-desktop/shared"; + +import type { LaunchResolver } from "./launch-resolver.js"; +import { RuntimeService, type RuntimeHostLink, type RuntimeSidecarLink, type TurnEndedInfo } from "./runtime-service.js"; + +type Call = { method: string; params: Record }; + +class FakeHost implements RuntimeHostLink { + calls: Call[] = []; + notify: ((method: string, params: unknown) => void) | null = null; + available = true; + private turnCounter = 0; + messages = new Map(); + failAppend = false; + session: Record = { id: "s1", mode: "agent", permissionMode: "ask", providerId: "p1", modelId: "m1" }; + + async call(method: string, params: Record = {}): Promise { + this.calls.push({ method, params }); + switch (method) { + case "settings.get": + return {} as T; + case "session.get": + return { session: { ...this.session, id: params.id, messages: this.messages.get(String(params.id)) ?? [] } } as T; + case "session.beginTurn": + this.turnCounter += 1; + return { turnId: `turn-${this.turnCounter}` } as T; + case "session.appendMessage": { + if (this.failAppend) throw Object.assign(new Error("disk full"), { errorCode: "INTERNAL" }); + const list = this.messages.get(String(params.sessionId)) ?? []; + list.push(params.message as UiMessage); + this.messages.set(String(params.sessionId), list); + return {} as T; + } + case "session.endTurn": + return { ok: true } as T; + case "session.saveInflightMessage": + return { ok: true } as T; + case "plans.abort": + return {} as T; + default: + throw new Error(`unexpected host call ${method}`); + } + } + isAvailable(): boolean { + return this.available; + } + onNotification(handler: (method: string, params: unknown) => void): () => void { + this.notify = handler; + return () => { + this.notify = null; + }; + } + onExit(): () => void { + return () => undefined; + } +} + +class FakeSidecar implements RuntimeSidecarLink { + calls: Call[] = []; + notify: ((method: string, params: unknown) => void) | null = null; + exit: ((info: { intentional: boolean; code: number | null; signal: NodeJS.Signals | null }) => void) | null = null; + roots = new Map(); + rejectPrompt = false; + running = false; + + async call(method: string, params: Record = {}): Promise { + this.calls.push({ method, params }); + switch (method) { + case "agent.prompt": + if (this.rejectPrompt) throw Object.assign(new Error("model not configured"), { data: { errorCode: "MODEL_NOT_CONFIGURED" } }); + this.running = true; + return { accepted: true, turnId: params.turnId } as T; + case "agent.steeringContext": + return { supportsVision: false } as T; + case "agent.steer": + return { accepted: true, turnId: params.expectedTurnId } as T; + case "agent.abort": + return { ok: true } as T; + case "agent.stop": + return { requested: this.running } as T; + case "agent.getStatus": + return { status: { sessionId: params.sessionId, isRunning: this.running, pendingToolConfirmations: 0 } } as T; + case "asktool.resolve": + return { ok: true } as T; + default: + throw new Error(`unexpected sidecar call ${method}`); + } + } + onNotification(handler: (method: string, params: unknown) => void): () => void { + this.notify = handler; + return () => { + this.notify = null; + }; + } + onExit(handler: (info: { intentional: boolean; code: number | null; signal: NodeJS.Signals | null }) => void): () => void { + this.exit = handler; + return () => { + this.exit = null; + }; + } + setProjectInstructionRoot(sessionId: string, projectPath?: string): void { + this.roots.set(sessionId, projectPath); + } + clearProjectInstructionRoot(): void {} + clearVendorAuthBindings(): void {} +} + +const launch: LaunchResolver = { + async resolve(sessionId, session) { + return { + providerId: String(session.providerId ?? "p1"), + modelId: String(session.modelId ?? "m1"), + projectPath: "/work/project", + sidecarParams: { + sessionId, + mode: "agent", + provider: { id: "p1", name: "P1", modelId: "m1", apiKey: "k", supportsReasoning: false, supportedThinkingLevels: ["off"] }, + }, + }; + }, +}; + +function build() { + const host = new FakeHost(); + const sidecar = new FakeSidecar(); + const events: AgentEventEnvelope[] = []; + const ended: TurnEndedInfo[] = []; + const logs: Array<{ level: string; message: string }> = []; + const service = new RuntimeService({ + getHost: () => host, + getSidecar: () => sidecar, + launch, + log: (level, message) => logs.push({ level, message }), + now: () => Date.parse("2026-09-18T00:00:00.000Z"), + }); + service.attachHost(host); + service.attachSidecar(sidecar); + service.onEvent((envelope) => events.push(envelope)); + service.onTurnEnded((info) => ended.push(info)); + return { host, sidecar, service, events, ended, logs }; +} + +const owner = { subject: "desktop", roles: ["owner" as const], pairedDevice: true }; + +async function settle(): Promise { + for (let index = 0; index < 5; index += 1) await new Promise((resolve) => setImmediate(resolve)); +} + +describe("RuntimeService prompt lifecycle", () => { + it("opens a durable turn, persists the user row, then starts the runtime under that turn id", async () => { + const { host, sidecar, service, events } = build(); + const { turnId } = await service.prompt({ sessionId: "s1", content: "hello", effectivePermissionMode: "ask", principal: owner }); + expect(turnId).toBe("turn-1"); + const methods = host.calls.map((call) => call.method); + expect(methods.indexOf("session.beginTurn")).toBeLessThan(methods.indexOf("session.appendMessage")); + const prompt = sidecar.calls.find((call) => call.method === "agent.prompt"); + expect(prompt?.params.turnId).toBe("turn-1"); + expect(prompt?.params.content).toBe("hello"); + expect(sidecar.roots.get("s1")).toBe("/work/project"); + expect(service.isBusy("s1")).toBe(true); + expect(service.activeTurnId("s1")).toBe("turn-1"); + // The user row is announced to subscribers before the runtime answers. + expect(events.map((event) => event.event.type)).toEqual(["message_start", "message_end"]); + const userRow = (events[0]!.event as { message: UiMessage }).message; + expect(userRow.role).toBe("user"); + expect(host.messages.get("s1")?.[0]?.id).toBe(userRow.id); + }); + + it("keeps a client-chosen UUID as the durable user row id", async () => { + const { host, service } = build(); + const id = "6f1c1e2a-3b4d-4c5e-8f6a-7b8c9d0e1f2a"; + await service.prompt({ sessionId: "s1", content: "x", userMessageId: id, effectivePermissionMode: "ask", principal: owner }); + expect(host.messages.get("s1")?.[0]?.id).toBe(id); + }); + + it("refuses a second prompt while the turn runs and settles the turn on agent_end", async () => { + const { host, sidecar, service, ended } = build(); + await service.prompt({ sessionId: "s1", content: "hello", effectivePermissionMode: "ask", principal: owner }); + await expect( + service.prompt({ sessionId: "s1", content: "again", effectivePermissionMode: "ask", principal: owner }), + ).rejects.toMatchObject({ errorCode: "AGENT_BUSY" }); + sidecar.notify?.("agent.event", { sessionId: "s1", turnId: "turn-1", ts: 1, event: { type: "agent_end", messageIds: [] } }); + await settle(); + const end = host.calls.find((call) => call.method === "session.endTurn"); + expect(end?.params).toMatchObject({ turnId: "turn-1", status: "completed" }); + expect(ended).toEqual([{ sessionId: "s1", turnId: "turn-1", reason: "completed", settled: true }]); + expect(service.isBusy("s1")).toBe(false); + }); + + it("closes the turn as failed when the runtime rejects the prompt", async () => { + const { host, sidecar, service, ended } = build(); + sidecar.rejectPrompt = true; + await expect( + service.prompt({ sessionId: "s1", content: "hello", effectivePermissionMode: "ask", principal: owner }), + ).rejects.toThrow("model not configured"); + const end = host.calls.find((call) => call.method === "session.endTurn"); + expect(end?.params).toMatchObject({ turnId: "turn-1", status: "error", errorCode: "MODEL_NOT_CONFIGURED" }); + expect(ended[0]).toMatchObject({ reason: "error", errorCode: "MODEL_NOT_CONFIGURED" }); + expect(service.isBusy("s1")).toBe(false); + }); + + it("never starts a runtime turn when the user row could not be appended", async () => { + const { host, sidecar, service } = build(); + host.failAppend = true; + await expect( + service.prompt({ sessionId: "s1", content: "hello", effectivePermissionMode: "ask", principal: owner }), + ).rejects.toThrow("disk full"); + expect(sidecar.calls.some((call) => call.method === "agent.prompt")).toBe(false); + expect(host.calls.find((call) => call.method === "session.endTurn")?.params).toMatchObject({ status: "error" }); + }); + + it("records an abort before the cancel request so a late agent_end cannot restate it as completed", async () => { + const { host, sidecar, service, ended } = build(); + await service.prompt({ sessionId: "s1", content: "hello", effectivePermissionMode: "ask", principal: owner }); + await service.abort("s1"); + expect(sidecar.calls.some((call) => call.method === "agent.abort")).toBe(true); + sidecar.notify?.("agent.event", { sessionId: "s1", turnId: "turn-1", ts: 1, event: { type: "agent_end", messageIds: [] } }); + await settle(); + const ends = host.calls.filter((call) => call.method === "session.endTurn"); + expect(ends).toHaveLength(1); + expect(ends[0]?.params).toMatchObject({ status: "aborted", errorCode: "TURN_ABORTED" }); + expect(ended).toHaveLength(1); + expect(ended[0]?.reason).toBe("aborted"); + }); + + it("ignores a terminal event that names a turn which no longer owns the session", async () => { + const { host, sidecar, service, events } = build(); + await service.prompt({ sessionId: "s1", content: "hello", effectivePermissionMode: "ask", principal: owner }); + events.length = 0; + sidecar.notify?.("agent.event", { sessionId: "s1", turnId: "turn-0", ts: 1, event: { type: "agent_end", messageIds: [] } }); + sidecar.notify?.("agent.event", { sessionId: "s1", ts: 1, event: { type: "agent_end", messageIds: [] } }); + await settle(); + expect(events).toHaveLength(0); + expect(host.calls.some((call) => call.method === "session.endTurn")).toBe(false); + expect(service.isBusy("s1")).toBe(true); + }); + + it("persists completed assistant and tool rows under the owning turn", async () => { + const { host, sidecar, service } = build(); + await service.prompt({ sessionId: "s1", content: "hello", effectivePermissionMode: "ask", principal: owner }); + const assistant: UiMessage = { id: "a1", role: "assistant", content: "done", createdAt: "2026-09-18T00:00:00.000Z", status: "complete" }; + sidecar.notify?.("agent.event", { sessionId: "s1", turnId: "turn-1", ts: 1, event: { type: "tool_start", toolCallId: "c1", toolName: "Read", args: { path: "a" } } }); + sidecar.notify?.("agent.event", { sessionId: "s1", turnId: "turn-1", ts: 2, event: { type: "tool_end", toolCallId: "c1", result: "ok" } }); + sidecar.notify?.("agent.event", { sessionId: "s1", turnId: "turn-1", ts: 3, event: { type: "message_end", message: assistant } }); + await settle(); + const appended = host.calls.filter((call) => call.method === "session.appendMessage").map((call) => call.params); + expect(appended.map((params) => (params.message as UiMessage).id)).toEqual([expect.any(String), "c1", "a1"]); + expect(appended[1]).toMatchObject({ turnId: "turn-1" }); + expect((appended[1]!.message as UiMessage).toolName).toBe("Read"); + expect(appended[2]).toMatchObject({ turnId: "turn-1" }); + }); + + it("turns a host permission request into an agent event that names the asking delegate", async () => { + const { host, sidecar, service, events } = build(); + await service.prompt({ sessionId: "s1", content: "hello", effectivePermissionMode: "ask", principal: owner }); + sidecar.notify?.("agent.event", { + sessionId: "s1", + turnId: "turn-1", + ts: 1, + parentToolCallId: "task-1", + agentName: "reviewer", + event: { type: "tool_start", toolCallId: "c9", toolName: "Bash", args: {} }, + }); + events.length = 0; + host.notify?.("permissions.request", { + requestId: "req-1", + sessionId: "s1", + toolCallId: "c9", + toolName: "Bash", + argsPreview: "rm", + risk: "high", + reason: "shell", + }); + expect(events).toHaveLength(1); + expect(events[0]?.turnId).toBe("turn-1"); + expect(events[0]?.event).toMatchObject({ + type: "tool_permission_request", + request: { requestId: "req-1", agentName: "reviewer", parentToolCallId: "task-1" }, + }); + }); + + it("settles every running turn as aborted when the sidecar dies", async () => { + const { host, sidecar, service, ended } = build(); + await service.prompt({ sessionId: "s1", content: "hello", effectivePermissionMode: "ask", principal: owner }); + sidecar.exit?.({ intentional: false, code: 1, signal: null }); + await settle(); + expect(host.calls.find((call) => call.method === "session.endTurn")?.params).toMatchObject({ + status: "aborted", + recoverInflight: true, + }); + expect(ended[0]?.reason).toBe("aborted"); + expect(service.isBusy("s1")).toBe(false); + }); + + it("steers only the live turn and refuses once it ended", async () => { + const { sidecar, service } = build(); + await service.prompt({ sessionId: "s1", content: "hello", effectivePermissionMode: "ask", principal: owner }); + expect(await service.steer({ sessionId: "s1", turnId: "turn-1", content: "also", principal: owner })).toEqual({ accepted: true }); + expect(sidecar.calls.at(-1)?.params).toMatchObject({ expectedTurnId: "turn-1", content: "also" }); + expect(await service.steer({ sessionId: "s1", turnId: "turn-9", content: "no", principal: owner })).toEqual({ accepted: false }); + }); + + it("refuses native Pi sessions and attachments the headless runtime cannot serve", async () => { + const { service } = build(); + await expect( + service.prompt({ sessionId: "native-pi:x", content: "hi", effectivePermissionMode: "ask", principal: owner }), + ).rejects.toMatchObject({ errorCode: "NATIVE_PI_UNSUPPORTED" }); + await expect( + service.prompt({ + sessionId: "s1", + content: "hi", + attachments: [{ path: "/tmp/a.png", name: "a.png", kind: "image" }], + effectivePermissionMode: "ask", + principal: owner, + }), + ).rejects.toMatchObject({ errorCode: "INVALID_ARGUMENT" }); + }); +}); diff --git a/packages/host-runtime/src/runtime-service.ts b/packages/host-runtime/src/runtime-service.ts new file mode 100644 index 0000000000..78e2fedb8d --- /dev/null +++ b/packages/host-runtime/src/runtime-service.ts @@ -0,0 +1,646 @@ +import { randomUUID } from "node:crypto"; + +import type { RuntimePort, TurnStartRequest, TurnSteerRequest } from "@pi-desktop/agent-host"; +import { + ErrorCodes, + type AgentEventEnvelope, + type AgentStatus, + type AskToolResolution, + type Risk, + type UiMessage, +} from "@pi-desktop/shared"; + +import type { LaunchResolver } from "./launch-resolver.js"; +import { resolveSessionMessageInput } from "./session-message-input.js"; +import { TurnEventPipeline } from "./turn-events.js"; + +/** host-core as the turn lifecycle drives it. `HostProcess` satisfies it. */ +export type RuntimeHostLink = { + call(method: string, params?: unknown): Promise; + isAvailable(): boolean; + onNotification(handler: (method: string, params: unknown) => void): () => void; + onExit(handler: (info: { intentional: boolean }) => void): () => void; +}; + +/** The agent sidecar as the turn lifecycle drives it. `AgentSidecar` satisfies it. */ +export type RuntimeSidecarLink = { + call(method: string, params?: unknown): Promise; + onNotification(handler: (method: string, params: unknown) => void): () => void; + onExit(handler: (info: { intentional: boolean; code: number | null; signal: NodeJS.Signals | null }) => void): () => void; + setProjectInstructionRoot(sessionId: string, projectPath?: string): void; + clearProjectInstructionRoot(sessionId: string): void; + clearVendorAuthBindings(sessionId: string): void; +}; + +/** + * Terminal state of one host turn. `aborted` is reserved for a turn the host + * cancelled; a graceful stop still ends as `completed` (the runtime owns that + * boundary decision), and `error` covers a failed turn. + */ +export type TurnEndReason = "completed" | "aborted" | "error"; + +export type TurnEndedInfo = { + sessionId: string; + turnId: string; + reason: TurnEndReason; + errorCode?: string; + /** Whether host-core acknowledged the durable end of this turn. */ + settled: boolean; +}; + +export type RuntimeLogger = ( + level: "info" | "warn" | "error", + message: string, + data?: Record, +) => void; + +export type RuntimeServiceOptions = { + getHost: () => RuntimeHostLink | null; + getSidecar: () => RuntimeSidecarLink | null; + launch: LaunchResolver; + log: RuntimeLogger; + now?: () => number; + /** + * Rewrite the prompt text before it is persisted and sent, e.g. slash + * command expansion. Returns `null` to keep the text as typed. + */ + expandPrompt?: (input: { + sessionId: string; + content: string; + projectPath?: string; + }) => Promise<{ content: string; command?: string } | null>; + checkpointIntervalMs?: number; +}; + +type FinishTurnOptions = { turnId: string; recoverInflight?: boolean }; + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** Keep the renderer-chosen id when it is a UUID the session does not hold (D288). */ +export function durableUserMessageId( + requested: unknown, + existing: ReadonlyArray<{ id?: unknown }>, +): string { + if (typeof requested === "string" && UUID_PATTERN.test(requested) && !existing.some((message) => message?.id === requested)) { + return requested; + } + return randomUUID(); +} + +function typedError(message: string, errorCode: string): Error { + return Object.assign(new Error(message), { errorCode }); +} + +function errorCodeOf(error: unknown): string | undefined { + const candidate = error as { data?: { errorCode?: unknown }; errorCode?: unknown } | null; + const nested = candidate?.data?.errorCode; + if (typeof nested === "string") return nested; + return typeof candidate?.errorCode === "string" ? candidate.errorCode : undefined; +} + +/** + * The turn lifecycle of a headless Host: prompt admission, the durable turn + * row, turn ownership and finalization, and the runtime control calls the + * Agent Host module needs (`RuntimePort`). It knows host-core and the sidecar + * only through their stdio links, so it runs wherever those two processes + * run; the per-event persistence pass lives in `TurnEventPipeline`. + */ +export class RuntimeService implements RuntimePort { + private readonly activeTurns = new Map(); + private readonly turnFinalizations = new Map>(); + private readonly pendingAbortReasons = new Map(); + private readonly sessionOperations = new Map>(); + private readonly listeners = new Set<(envelope: AgentEventEnvelope) => void>(); + private readonly turnEndListeners = new Set<(info: TurnEndedInfo) => void>(); + private readonly events: TurnEventPipeline; + private readonly detachers: Array<() => void> = []; + private readonly now: () => number; + private disposed = false; + + constructor(private readonly options: RuntimeServiceOptions) { + this.now = options.now ?? (() => Date.now()); + this.events = new TurnEventPipeline({ + getHost: () => options.getHost(), + ownership: { + activeTurnId: (sessionId) => this.activeTurns.get(sessionId), + isStaleTerminalEvent: (envelope) => this.isStaleTerminalEvent(envelope), + finishTurn: (sessionId, status, errorCode, finishOptions) => + this.finishTurn(sessionId, status, errorCode, finishOptions), + }, + emit: (envelope) => this.emit(envelope), + log: options.log, + now: this.now, + checkpointIntervalMs: options.checkpointIntervalMs, + }); + } + + // ------------------------------------------------------------------------- + // Wiring + // ------------------------------------------------------------------------- + + /** Observe a (re)started host-core: permission requests become agent events. */ + attachHost(host: RuntimeHostLink): void { + const off = host.onNotification((method, params) => { + if (this.options.getHost() !== host) return; + if (method !== "permissions.request") return; + const permission = params as { + requestId: string; + sessionId: string; + toolCallId: string; + toolName: string; + argsPreview: string; + risk: Risk; + reason: string; + }; + // A delegate's call is already tracked by the time the host asks: the + // sidecar forwards tool_start before it executes the tool. + const asking = this.events.toolCall(permission.sessionId, permission.toolCallId); + const turnId = asking?.turnId ?? this.activeTurns.get(permission.sessionId); + this.options.log("info", "permission requested", { + requestId: permission.requestId, + sessionId: permission.sessionId, + turnId, + toolCallId: permission.toolCallId, + toolName: permission.toolName, + risk: permission.risk, + }); + this.emit({ + sessionId: permission.sessionId, + ...(turnId ? { turnId } : {}), + ts: this.now(), + event: { + type: "tool_permission_request", + request: { + requestId: permission.requestId, + sessionId: permission.sessionId, + toolCallId: permission.toolCallId, + toolName: permission.toolName, + argsPreview: permission.argsPreview, + risk: permission.risk, + reason: permission.reason, + ...(asking?.agentName ? { agentName: asking.agentName } : {}), + ...(asking?.parentToolCallId ? { parentToolCallId: asking.parentToolCallId } : {}), + }, + }, + }); + }); + this.detachers.push(off); + } + + /** Observe a (re)started sidecar: its agent events feed the lifecycle. */ + attachSidecar(sidecar: RuntimeSidecarLink): void { + const offEvents = sidecar.onNotification((method, params) => { + if (this.options.getSidecar() !== sidecar) return; + if (method !== "agent.event") return; + this.events.handle(params as AgentEventEnvelope); + }); + const offExit = sidecar.onExit(({ intentional, code, signal }) => { + if (this.options.getSidecar() !== sidecar) return; + const interrupted = this.events.onSidecarExit(); + if (intentional || this.disposed) return; + for (const tool of interrupted) { + this.options.log("error", "tool execution interrupted", { + sessionId: tool.sessionId, + turnId: tool.turnId, + toolCallId: tool.toolCallId, + toolName: tool.toolName, + reason: "agent_sidecar_exit", + exitCode: code, + signal, + }); + } + for (const sessionId of [...this.activeTurns.keys()]) { + const crashedTurnId = this.activeTurns.get(sessionId); + if (!crashedTurnId) continue; + void this.settleCrashedSession(sessionId, crashedTurnId).catch((error: unknown) => { + this.options.log("warn", "crashed-turn settlement failed", { sessionId, error: String(error) }); + }); + } + }); + this.detachers.push(offEvents, offExit); + } + + onEvent(listener: (envelope: AgentEventEnvelope) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + onTurnEnded(listener: (info: TurnEndedInfo) => void): () => void { + this.turnEndListeners.add(listener); + return () => this.turnEndListeners.delete(listener); + } + + /** Transcript rows still waiting for host-core. */ + pendingWrites(): number { + return this.events.pendingWrites(); + } + + async dispose(): Promise { + this.disposed = true; + for (const detach of this.detachers.splice(0)) detach(); + await this.events.dispose(); + this.listeners.clear(); + this.turnEndListeners.clear(); + } + + // ------------------------------------------------------------------------- + // RuntimePort + // ------------------------------------------------------------------------- + + async prompt(request: TurnStartRequest): Promise<{ turnId: string }> { + const sessionId = request.sessionId.trim(); + if (!sessionId) throw typedError("sessionId required", ErrorCodes.INVALID_ARGUMENT); + if (sessionId.startsWith("native-pi:")) { + throw typedError("Native Pi sessions are not available on a headless host", "NATIVE_PI_UNSUPPORTED"); + } + if (request.attachments?.length) { + throw typedError("Prompt attachments are not supported by the headless runtime yet", ErrorCodes.INVALID_ARGUMENT); + } + try { + return await this.withSessionOperation(sessionId, () => this.startTurn(sessionId, request)); + } catch (error) { + if (request.sessionMessageId) { + const host = this.options.getHost(); + await host + ?.call("session.collaboration.fail", { + messageId: request.sessionMessageId, + error: error instanceof Error ? error.message : String(error), + }) + .catch((persistenceError: unknown) => { + this.options.log("warn", "collaboration dispatch failure persistence failed", { + sessionId, + messageId: request.sessionMessageId, + error: String(persistenceError), + }); + }); + } + throw error; + } + } + + private async startTurn(sessionId: string, request: TurnStartRequest): Promise<{ turnId: string }> { + const host = this.requireHost(); + const sidecar = this.requireSidecar(); + const sessionMessage = await resolveSessionMessageInput(host, { + sessionId, + ...(request.sessionMessageId !== undefined ? { sessionMessageId: request.sessionMessageId } : {}), + }); + const settings = await host.call>("settings.get"); + const detail = await host.call<{ session?: Record | null }>("session.get", { + id: sessionId, + messageLimit: 1, + }); + const session = detail.session; + if (!session) throw typedError("Session not found", ErrorCodes.NOT_FOUND); + if (this.activeTurns.has(sessionId)) { + throw typedError("Session already has an active turn", ErrorCodes.AGENT_BUSY); + } + const launch = await this.options.launch.resolve(sessionId, session, settings ?? {}); + sidecar.setProjectInstructionRoot(sessionId, launch.projectPath); + + const turnId = await this.beginTurn(sessionId, launch.providerId, launch.modelId, sessionMessage?.origin.messageId); + + let content = sessionMessage?.content ?? request.content; + let command: string | undefined; + if (!sessionMessage && this.options.expandPrompt && content.startsWith("/")) { + try { + const expanded = await this.options.expandPrompt({ sessionId, content, projectPath: launch.projectPath }); + if (expanded) { + content = expanded.content; + command = expanded.command; + } + } catch (error) { + this.options.log("warn", "slash expansion failed; sending literal text", { sessionId, error: String(error) }); + } + } + const existing = Array.isArray(session.messages) ? (session.messages as Array<{ id?: unknown }>) : []; + const userMessage: UiMessage = { + id: durableUserMessageId(request.userMessageId, existing), + role: "user", + content, + ...(sessionMessage ? { sessionMessage: sessionMessage.origin } : {}), + createdAt: new Date(this.now()).toISOString(), + status: "complete", + ...(command ? { command } : {}), + }; + try { + await host.call("session.appendMessage", { sessionId, message: userMessage, turnId }); + } catch (error) { + await this.finishTurn(sessionId, "error", errorCodeOf(error), { turnId }); + // A turn whose user message could not be appended must not be started: + // running it would execute a prompt the transcript does not contain. + throw error; + } + this.emit({ sessionId, turnId, ts: this.now(), event: { type: "message_start", message: userMessage } }); + this.emit({ sessionId, turnId, ts: this.now(), event: { type: "message_end", message: userMessage } }); + + let result: { accepted: boolean; turnId: string }; + try { + result = await sidecar.call<{ accepted: boolean; turnId: string }>("agent.prompt", { + ...launch.sidecarParams, + // The host-created durable turn is the approval identity used by + // Rust. The runtime must not replace it with a provider-local UUID. + turnId, + content, + ...(sessionMessage ? { sessionMessage: sessionMessage.origin } : {}), + attachments: [], + userMessageId: userMessage.id, + }); + } catch (error) { + await this.finishTurn(sessionId, "error", errorCodeOf(error), { turnId }); + throw error; + } + this.options.log("info", "prompt accepted", { + sessionId, + turnId: result.turnId, + providerId: launch.providerId, + modelId: launch.modelId, + }); + return { turnId }; + } + + async steer(request: TurnSteerRequest): Promise<{ accepted: boolean }> { + const sessionId = request.sessionId.trim(); + if (!this.isTurnDispatchable(sessionId, request.turnId)) return { accepted: false }; + try { + const host = this.requireHost(); + const sidecar = this.requireSidecar(); + await sidecar.call("agent.steeringContext", { sessionId, expectedTurnId: request.turnId }); + const detail = await host.call<{ session?: { messages?: UiMessage[] } }>("session.get", { + id: sessionId, + messageLimit: 1, + }); + const message: UiMessage = { + id: durableUserMessageId(request.sessionMessageId, detail.session?.messages ?? []), + role: "user", + content: request.content, + status: "complete", + createdAt: new Date(this.now()).toISOString(), + steering: true, + }; + const result = await sidecar.call<{ accepted?: boolean }>("agent.steer", { + sessionId, + expectedTurnId: request.turnId, + message, + content: request.content, + attachments: [], + }); + return { accepted: result?.accepted !== false }; + } catch (error) { + this.options.log("warn", "steer failed", { sessionId, turnId: request.turnId, error: String(error) }); + return { accepted: false }; + } + } + + async stop(sessionId: string): Promise<{ requested: boolean }> { + const sidecar = this.requireSidecar(); + this.options.log("info", "prompt graceful stop requested", { sessionId }); + // The runtime owns the boundary decision: agent_end arrives after the + // current reply/tool batch completes and finishes it as a completed turn. + const result = await sidecar.call<{ requested?: boolean }>("agent.stop", { sessionId }); + return { requested: result?.requested ?? false }; + } + + async abort(sessionId: string, turnId?: string): Promise { + const sidecar = this.requireSidecar(); + const abortedTurnId = this.activeTurns.get(sessionId); + if (turnId && abortedTurnId !== turnId) return; + this.options.log("info", "prompt aborted", { sessionId }); + // Lock the abort reason before the first await: the cancel RPC can take a + // while, and a terminal event arriving in that window must not settle the + // turn as completed. + this.lockAbortReason(sessionId, abortedTurnId); + try { + await sidecar.call("agent.abort", { sessionId, ...(turnId ? { turnId } : {}) }); + } finally { + if (abortedTurnId) { + await this.finishTurn(sessionId, "aborted", "TURN_ABORTED", { turnId: abortedTurnId }); + } + } + } + + async respondInput(resolution: AskToolResolution): Promise { + const sessionId = String(resolution.sessionId ?? "").trim(); + const requestId = String(resolution.requestId ?? "").trim(); + if (!sessionId || !requestId) throw typedError("asktool resolution identity required", ErrorCodes.INVALID_ARGUMENT); + await this.requireSidecar().call("asktool.resolve", { ...resolution, sessionId, requestId }); + } + + /** Manual context checkpoint on an idle session. */ + async compact(sessionId: string): Promise<{ accepted: boolean }> { + if (this.activeTurns.has(sessionId)) throw typedError("Session already has an active turn", ErrorCodes.AGENT_BUSY); + const host = this.requireHost(); + const sidecar = this.requireSidecar(); + const settings = await host.call>("settings.get"); + const detail = await host.call<{ session?: Record | null }>("session.get", { id: sessionId }); + if (!detail.session) throw typedError("Session not found", ErrorCodes.NOT_FOUND); + const launch = await this.options.launch.resolve(sessionId, detail.session, settings ?? {}); + sidecar.setProjectInstructionRoot(sessionId, launch.projectPath); + const result = await sidecar.call<{ accepted?: boolean }>("agent.compact", launch.sidecarParams); + return { accepted: result?.accepted !== false }; + } + + async getStatus(sessionId: string): Promise { + const result = await this.requireSidecar().call<{ status?: AgentStatus }>("agent.getStatus", { sessionId }); + return result?.status ?? { sessionId, isRunning: false, pendingToolConfirmations: 0 }; + } + + isBusy(sessionId: string): boolean { + const id = sessionId.trim(); + if (!id) return false; + if (this.activeTurns.has(id)) return true; + const prefix = `${id}:`; + for (const key of this.turnFinalizations.keys()) { + if (key.startsWith(prefix)) return true; + } + return false; + } + + activeTurnId(sessionId: string): string | undefined { + return this.activeTurns.get(sessionId); + } + + // ------------------------------------------------------------------------- + // Turn ownership + // ------------------------------------------------------------------------- + + /** Serialize turn admission and abort per session. */ + async withSessionOperation(sessionId: string, operation: () => Promise): Promise { + const id = sessionId.trim(); + const previous = this.sessionOperations.get(id) ?? Promise.resolve(); + const result = previous.then(operation); + const settled = result.then( + () => undefined, + () => undefined, + ); + this.sessionOperations.set(id, settled); + try { + return await result; + } finally { + if (this.sessionOperations.get(id) === settled) this.sessionOperations.delete(id); + } + } + + /** Open a durable turn row and take ownership of the session for it. */ + async beginTurn(sessionId: string, providerId: string, modelId: string, sessionMessageId?: string): Promise { + const turn = await this.requireHost().call<{ turnId?: string }>("session.beginTurn", { + sessionId, + providerId, + modelId, + ...(sessionMessageId ? { sessionMessageId } : {}), + }); + const turnId = String(turn?.turnId ?? "").trim(); + if (!turnId) throw new Error("session.beginTurn returned no turn"); + this.activeTurns.set(sessionId, turnId); + this.events.resetTurnUsage(sessionId); + return turnId; + } + + private isActiveTurn(sessionId: string, turnId: string | null | undefined): boolean { + if (typeof turnId !== "string") return false; + const id = sessionId.trim(); + const turn = turnId.trim(); + if (!id || !turn) return false; + return this.activeTurns.get(id) === turn; + } + + private isTurnDispatchable(sessionId: string, turnId: string | null | undefined): boolean { + if (!this.isActiveTurn(sessionId, turnId)) return false; + const key = turnKey(sessionId.trim(), String(turnId).trim()); + return !this.pendingAbortReasons.has(key) && !this.turnFinalizations.has(key); + } + + private lockAbortReason(sessionId: string, turnId: string | null | undefined): void { + if (!this.isActiveTurn(sessionId, turnId)) return; + this.pendingAbortReasons.set(turnKey(sessionId.trim(), String(turnId).trim()), "aborted"); + } + + private isStaleTerminalEvent(envelope: AgentEventEnvelope): boolean { + const type = envelope.event.type; + if (type !== "agent_end" && type !== "error") return false; + // A delegate's terminal event settles the delegate, never its parent's turn. + if (envelope.parentToolCallId) return true; + return !this.isActiveTurn(envelope.sessionId, envelope.turnId); + } + + /** + * Settle one host turn: attempt its durable end, release local ownership, + * then announce it. Ownership is claimed and the terminal reason frozen + * synchronously, before any await, so a concurrent terminal event joins this + * finalization instead of starting a second one, and a cancellation recorded + * earlier cannot be restated as a completion later. + */ + finishTurn( + sessionId: string, + status: TurnEndReason, + errorCode: string | undefined, + options: FinishTurnOptions, + ): Promise { + const id = sessionId.trim(); + const turnId = String(options.turnId ?? "").trim(); + if (!id || !turnId) return Promise.resolve(); + const key = turnKey(id, turnId); + const existing = this.turnFinalizations.get(key); + if (existing) return existing; + if (!this.isActiveTurn(id, turnId)) { + this.pendingAbortReasons.delete(key); + return Promise.resolve(); + } + const reason = this.pendingAbortReasons.get(key) ?? status; + const turnUsage = this.events.takeTurnUsage(id); + const recoverInflight = options.recoverInflight === true; + let settled = false; + + const run = async (): Promise => { + try { + const host = this.options.getHost(); + if (host) { + try { + const result = await host.call<{ ok: boolean; recovered?: UiMessage }>("session.endTurn", { + turnId, + status: reason, + errorCode, + createNotification: false, + ...(turnUsage ? { usage: turnUsage } : {}), + ...(recoverInflight ? { recoverInflight: true } : {}), + }); + settled = true; + if (result.recovered) { + this.emit({ sessionId: id, turnId, ts: this.now(), event: { type: "message_end", message: result.recovered } }); + } + } catch (error) { + this.options.log("warn", "endTurn failed", { sessionId: id, turnId, error: String(error) }); + } + } + } finally { + if (this.activeTurns.get(id) === turnId) this.activeTurns.delete(id); + this.events.scheduleToolCallCleanup(id, turnId); + } + }; + const release = (): void => { + if (this.turnFinalizations.get(key) !== record) return; + this.turnFinalizations.delete(key); + this.pendingAbortReasons.delete(key); + const info: TurnEndedInfo = { sessionId: id, turnId, reason, ...(errorCode ? { errorCode } : {}), settled }; + for (const listener of this.turnEndListeners) { + try { + listener(info); + } catch (error) { + this.options.log("warn", "turn end listener failed", { sessionId: id, turnId, error: String(error) }); + } + } + }; + const record: Promise = Promise.resolve().then(run).finally(release); + this.turnFinalizations.set(key, record); + return record; + } + + private async settleCrashedSession(sessionId: string, crashedTurnId: string): Promise { + const host = this.options.getHost(); + if (host) await host.call("plans.abort", { sessionId }).catch(() => undefined); + if (this.activeTurns.get(sessionId) !== crashedTurnId) return; + // No final row is coming from a dead sidecar: keep whatever the reply had + // streamed so far as an aborted transcript row (D299). + await this.events.flushCheckpoint(sessionId); + if (this.activeTurns.get(sessionId) !== crashedTurnId) return; + this.events.settleCheckpoint(sessionId); + await this.finishTurn(sessionId, "aborted", "PLAN_APPROVAL_INTERRUPTED", { + turnId: crashedTurnId, + recoverInflight: true, + }); + } + + // ------------------------------------------------------------------------- + // Events + // ------------------------------------------------------------------------- + + private emit(envelope: AgentEventEnvelope): void { + for (const listener of this.listeners) { + try { + listener(envelope); + } catch (error) { + this.options.log("warn", "agent event listener failed", { + sessionId: envelope.sessionId, + type: envelope.event.type, + error: String(error), + }); + } + } + } + + private requireHost(): RuntimeHostLink { + const host = this.options.getHost(); + if (!host) throw typedError("host unavailable", ErrorCodes.HOST_UNAVAILABLE); + return host; + } + + private requireSidecar(): RuntimeSidecarLink { + const sidecar = this.options.getSidecar(); + if (!sidecar) throw typedError("sidecar unavailable", ErrorCodes.AGENT_UNAVAILABLE); + return sidecar; + } +} + +function turnKey(sessionId: string, turnId: string): string { + return `${sessionId}:${turnId}`; +} diff --git a/packages/host-runtime/src/runtime-supervisor.test.ts b/packages/host-runtime/src/runtime-supervisor.test.ts new file mode 100644 index 0000000000..ff1de1e7ec --- /dev/null +++ b/packages/host-runtime/src/runtime-supervisor.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; + +import { DEFAULT_RESTART_POLICY, RuntimeSupervisor, type SupervisorEvent } from "./runtime-supervisor.js"; + +function harness(options: { + hostStarts?: Array<() => Promise>; + isUnrecoverable?: (error: unknown) => boolean; + shuttingDown?: () => boolean; + now?: () => number; +} = {}) { + const events: SupervisorEvent[] = []; + const sleeps: number[] = []; + const starts = { host: 0, sidecar: 0 }; + const hostStarts = options.hostStarts ?? []; + const supervisor = new RuntimeSupervisor({ + start: { + host: async () => { + const index = starts.host; + starts.host += 1; + const step = hostStarts[index]; + if (step) await step(); + }, + sidecar: async () => { + starts.sidecar += 1; + }, + }, + isUnrecoverable: options.isUnrecoverable, + isShuttingDown: options.shuttingDown, + onEvent: (event) => events.push(event), + sleep: async (ms) => { + sleeps.push(ms); + }, + now: options.now ?? (() => 1_000), + }); + return { supervisor, events, sleeps, starts }; +} + +describe("RuntimeSupervisor", () => { + it("restarts with the documented exponential backoff and reports success", async () => { + const { supervisor, events, sleeps, starts } = harness(); + await supervisor.superviseRestart("sidecar"); + expect(starts.sidecar).toBe(1); + expect(sleeps).toEqual([DEFAULT_RESTART_POLICY.baseDelayMs]); + expect(events.map((event) => event.phase)).toEqual(["restarting", "restarted"]); + }); + + it("keeps retrying with a capped delay until the child comes back", async () => { + const failing = () => Promise.reject(new Error("still down")); + const { supervisor, events, sleeps, starts } = harness({ + hostStarts: [failing, failing, async () => undefined], + }); + await supervisor.superviseRestart("host"); + expect(starts.host).toBe(3); + expect(sleeps).toEqual([500, 1000, 2000]); + expect(events.filter((event) => event.phase === "restart_failed")).toHaveLength(2); + expect(events.at(-1)?.phase).toBe("restarted"); + expect(supervisor.delayForAttempt(10)).toBe(DEFAULT_RESTART_POLICY.maxDelayMs); + }); + + it("gives up after the per-window budget and reports a fatal limit", async () => { + const failing = () => Promise.reject(new Error("still down")); + const { supervisor, events, starts } = harness({ + hostStarts: [failing, failing, failing, failing], + }); + await supervisor.superviseRestart("host"); + expect(starts.host).toBe(DEFAULT_RESTART_POLICY.maxRestartsPerWindow); + expect(events.at(-1)).toEqual({ kind: "host", phase: "fatal", reason: "limit" }); + }); + + it("resets the budget once the window elapsed", async () => { + let clock = 0; + const failing = () => Promise.reject(new Error("still down")); + const { supervisor, events } = harness({ + hostStarts: [failing, failing, failing, failing, failing, async () => undefined], + now: () => clock, + }); + await supervisor.superviseRestart("host"); + expect(events.at(-1)?.phase).toBe("fatal"); + clock = DEFAULT_RESTART_POLICY.windowMs + 1; + events.length = 0; + await supervisor.superviseRestart("host"); + // A fresh window admits three more attempts; the third one succeeds. + expect(events.filter((event) => event.phase === "restarting")).toHaveLength(3); + expect(events.at(-1)?.phase).toBe("restarted"); + }); + + it("stops on the first unrecoverable failure instead of burning the budget", async () => { + const refusal = Object.assign(new Error("schema too new"), { fatal: true }); + const { supervisor, events, starts } = harness({ + hostStarts: [() => Promise.reject(refusal), async () => undefined], + isUnrecoverable: (error) => (error as { fatal?: boolean })?.fatal === true, + }); + await supervisor.superviseRestart("host"); + expect(starts.host).toBe(1); + expect(events.at(-1)).toEqual({ kind: "host", phase: "fatal", reason: "unrecoverable", error: refusal }); + }); + + it("joins a concurrent request for the same child and never restarts during shutdown", async () => { + let shuttingDown = false; + const { supervisor, starts, events } = harness({ shuttingDown: () => shuttingDown }); + const first = supervisor.superviseRestart("sidecar"); + const second = supervisor.superviseRestart("sidecar"); + expect(second).toBe(first); + await first; + expect(starts.sidecar).toBe(1); + shuttingDown = true; + await supervisor.superviseRestart("sidecar"); + expect(starts.sidecar).toBe(1); + expect(events.filter((event) => event.phase === "restarting")).toHaveLength(1); + }); +}); diff --git a/packages/host-runtime/src/runtime-supervisor.ts b/packages/host-runtime/src/runtime-supervisor.ts new file mode 100644 index 0000000000..a2e9e1cd4f --- /dev/null +++ b/packages/host-runtime/src/runtime-supervisor.ts @@ -0,0 +1,134 @@ +/** + * Restart supervision for the two runtime children (host-core, agent sidecar). + * + * The policy is the one Electron Main has always applied (spec 07 §4): + * exponential backoff `0.5s → 1s → 2s` capped at 4s, at most three restarts + * per two-minute window per child, single-flight per child, and never after + * shutdown began. The supervisor owns only the loop; how a child is started, + * what happens after it came back, and how an outcome is reported belong to + * the embedding host, so Electron Main and the headless `pi-host` share one + * implementation of the policy without sharing their renderer or logger. + */ +export type SupervisedKind = "host" | "sidecar"; + +export type RestartPolicy = { + /** Restarts allowed inside one window before the child is declared fatal. */ + maxRestartsPerWindow: number; + windowMs: number; + baseDelayMs: number; + maxDelayMs: number; +}; + +export const DEFAULT_RESTART_POLICY: RestartPolicy = { + maxRestartsPerWindow: 3, + windowMs: 120_000, + baseDelayMs: 500, + maxDelayMs: 4_000, +}; + +export type SupervisorEvent = + | { kind: SupervisedKind; phase: "restarting"; attempt: number; delayMs: number } + | { kind: SupervisedKind; phase: "restart_failed"; attempt: number; error: unknown } + | { kind: SupervisedKind; phase: "restarted"; attempt: number } + | { kind: SupervisedKind; phase: "fatal"; reason: "limit" | "unrecoverable"; error?: unknown }; + +export type RuntimeSupervisorOptions = { + /** Start (or restart) one child and leave it wired; rejects when it cannot come up. */ + start: Record Promise>; + /** Runs after a successful start inside the same attempt; a failure counts as a failed restart. */ + afterRestart?: (kind: SupervisedKind) => Promise | void; + /** + * True when an error means the child can never come back on its own (a data + * directory newer than the build, an unsupported glibc). The loop stops on + * the first such failure instead of burning the restart budget. + */ + isUnrecoverable?: (error: unknown) => boolean; + isShuttingDown?: () => boolean; + onEvent?: (event: SupervisorEvent) => void; + policy?: Partial; + sleep?: (ms: number) => Promise; + now?: () => number; +}; + +type WindowState = { count: number; windowStart: number }; + +export class RuntimeSupervisor { + private readonly policy: RestartPolicy; + private readonly windows: Record = { + host: { count: 0, windowStart: 0 }, + sidecar: { count: 0, windowStart: 0 }, + }; + private readonly inFlight: Record | null> = { + host: null, + sidecar: null, + }; + private readonly sleep: (ms: number) => Promise; + private readonly now: () => number; + + constructor(private readonly options: RuntimeSupervisorOptions) { + this.policy = { ...DEFAULT_RESTART_POLICY, ...options.policy }; + this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); + this.now = options.now ?? (() => Date.now()); + } + + /** Bring one child back. Concurrent calls for the same child join the running loop. */ + superviseRestart(kind: SupervisedKind): Promise { + const existing = this.inFlight[kind]; + if (existing) return existing; + const run = this.loop(kind).finally(() => { + if (this.inFlight[kind] === run) this.inFlight[kind] = null; + }); + this.inFlight[kind] = run; + return run; + } + + /** The delay before restart attempt `attempt` (1-based) under the policy. */ + delayForAttempt(attempt: number): number { + return Math.min(this.policy.baseDelayMs * 2 ** (attempt - 1), this.policy.maxDelayMs); + } + + private shuttingDown(): boolean { + return this.options.isShuttingDown?.() ?? false; + } + + private emit(event: SupervisorEvent): void { + try { + this.options.onEvent?.(event); + } catch { + // A reporting failure must not stop the child from coming back. + } + } + + private async loop(kind: SupervisedKind): Promise { + const state = this.windows[kind]; + while (!this.shuttingDown()) { + const now = this.now(); + if (now - state.windowStart > this.policy.windowMs) { + state.windowStart = now; + state.count = 0; + } + state.count += 1; + if (state.count > this.policy.maxRestartsPerWindow) { + this.emit({ kind, phase: "fatal", reason: "limit" }); + return; + } + const attempt = state.count; + const delayMs = this.delayForAttempt(attempt); + this.emit({ kind, phase: "restarting", attempt, delayMs }); + await this.sleep(delayMs); + if (this.shuttingDown()) return; + try { + await this.options.start[kind](); + await this.options.afterRestart?.(kind); + this.emit({ kind, phase: "restarted", attempt }); + return; + } catch (error) { + if (this.options.isUnrecoverable?.(error)) { + this.emit({ kind, phase: "fatal", reason: "unrecoverable", error }); + return; + } + this.emit({ kind, phase: "restart_failed", attempt, error }); + } + } + } +} diff --git a/apps/desktop/electron/main/session-message-input.ts b/packages/host-runtime/src/session-message-input.ts similarity index 88% rename from apps/desktop/electron/main/session-message-input.ts rename to packages/host-runtime/src/session-message-input.ts index b2c7b59707..c23e3b3577 100644 --- a/apps/desktop/electron/main/session-message-input.ts +++ b/packages/host-runtime/src/session-message-input.ts @@ -14,10 +14,16 @@ export type SessionMessageInput = { origin: SessionMessageOrigin; }; +/** The subset of a prompt request the ledger lookup needs. */ +export type SessionMessagePromptRequest = Pick< + AgentPromptRequest, + "sessionId" | "sessionMessageId" | "attachments" | "messageId" | "truncateBefore" | "truncateFromMessageId" +>; + /** Resolve provenance and content only from the host ledger, never a caller. */ export async function resolveSessionMessageInput( host: CollaborationHost, - request: AgentPromptRequest, + request: SessionMessagePromptRequest, ): Promise { if (request.sessionMessageId === undefined) return undefined; const messageId = typeof request.sessionMessageId === "string" diff --git a/packages/host-runtime/src/turn-events.ts b/packages/host-runtime/src/turn-events.ts new file mode 100644 index 0000000000..1b74953e24 --- /dev/null +++ b/packages/host-runtime/src/turn-events.ts @@ -0,0 +1,330 @@ +import { + addUsage, + applyMessageUpdate, + type AgentEventEnvelope, + type MessageUsage, + type UiMessage, +} from "@pi-desktop/shared"; + +import { InflightCheckpointer } from "./inflight-checkpoint.js"; +import { TurnPersistence } from "./turn-persistence.js"; + +export type TurnEventLogger = ( + level: "info" | "warn" | "error", + message: string, + data?: Record, +) => void; + +export type ActiveToolCall = { + sessionId: string; + toolCallId: string; + toolName: string; + args: unknown; + createdAt: string; + startedAtMs: number; + turnId?: string; + parentToolCallId?: string; + agentName?: string; +}; + +/** What the pipeline needs to know about turn ownership; the runtime service owns it. */ +export type TurnOwnership = { + activeTurnId(sessionId: string): string | undefined; + /** A root terminal event naming a turn that no longer owns its session. */ + isStaleTerminalEvent(envelope: AgentEventEnvelope): boolean; + finishTurn( + sessionId: string, + status: "completed" | "aborted" | "error", + errorCode: string | undefined, + options: { turnId: string }, + ): Promise; +}; + +export type TurnEventPipelineOptions = { + getHost: () => { call(method: string, params?: unknown): Promise; isAvailable?(): boolean } | null; + ownership: TurnOwnership; + /** Fan-out of every live event, after the stale-terminal guard. */ + emit: (envelope: AgentEventEnvelope) => void; + log: TurnEventLogger; + now?: () => number; + checkpointIntervalMs?: number; +}; + +/** + * The per-event pass of a headless Host: track tool calls, checkpoint the + * streaming reply (D299), close the turn on its terminal event, and persist + * every completed row through host-core. The desktop runs the same pass in + * Electron Main against its file-backed outbox; here the outbox is process + * memory with a bounded retry, because a `pi-host` ends only with its + * supervisor. + */ +export class TurnEventPipeline { + private readonly activeToolCalls = new Map(); + private readonly steeringReplies = new Set(); + private readonly inflightSnapshots = new Map(); + private readonly activeTurnUsages = new Map(); + private readonly persistence: TurnPersistence; + private readonly checkpointer: InflightCheckpointer; + private readonly now: () => number; + + constructor(private readonly options: TurnEventPipelineOptions) { + this.now = options.now ?? (() => Date.now()); + this.persistence = new TurnPersistence({ + getHost: () => options.getHost(), + log: (level, message, data) => options.log(level, message, data), + }); + this.checkpointer = new InflightCheckpointer( + async (checkpoint) => { + const host = options.getHost(); + if (!host) return; + await host.call("session.saveInflightMessage", { + sessionId: checkpoint.sessionId, + ...(checkpoint.turnId ? { turnId: checkpoint.turnId } : {}), + message: checkpoint.message, + }); + }, + options.checkpointIntervalMs, + this.now, + ); + } + + /** The tool call a permission request refers to, if the sidecar announced it. */ + toolCall(sessionId: string, toolCallId: string): ActiveToolCall | undefined { + return this.activeToolCalls.get(toolKey(sessionId, toolCallId)); + } + + /** Usage accumulated by the session's current turn, then forget it. */ + takeTurnUsage(sessionId: string): MessageUsage | undefined { + const usage = this.activeTurnUsages.get(sessionId); + this.activeTurnUsages.delete(sessionId); + return usage; + } + + resetTurnUsage(sessionId: string): void { + this.activeTurnUsages.delete(sessionId); + } + + /** Write the session's pending reply checkpoint now; the last text before a crash. */ + flushCheckpoint(sessionId: string): Promise { + return this.checkpointer.flush(sessionId); + } + + settleCheckpoint(sessionId: string): void { + this.checkpointer.settle(sessionId); + } + + /** + * A host tool can finish shortly after its turn was aborted. Keep the + * metadata long enough for a late tool_end to persist a readable row, then + * drop only the calls of that turn. + */ + scheduleToolCallCleanup(sessionId: string, turnId: string, delayMs = 5 * 60 * 1000): void { + const prefix = `${sessionId}:`; + const timer = setTimeout(() => { + for (const [key, call] of this.activeToolCalls) { + if (key.startsWith(prefix) && call.turnId === turnId) this.activeToolCalls.delete(key); + } + }, delayMs); + timer.unref?.(); + } + + /** The sidecar died: every open tool call is interrupted and no reply will finish. */ + onSidecarExit(): ActiveToolCall[] { + const interrupted = [...this.activeToolCalls.values()]; + this.activeToolCalls.clear(); + this.steeringReplies.clear(); + return interrupted; + } + + pendingWrites(): number { + return this.persistence.size(); + } + + async dispose(): Promise { + await this.checkpointer.flushAll(); + this.checkpointer.dispose(); + await this.persistence.flush(); + this.persistence.dispose(); + } + + /** One event from the sidecar: fan it out, then apply its persistence effects. */ + handle(envelope: AgentEventEnvelope): void { + const event = envelope.event; + if (event.type === "tool_start") { + this.activeToolCalls.set(toolKey(envelope.sessionId, event.toolCallId), { + sessionId: envelope.sessionId, + toolCallId: event.toolCallId, + toolName: event.toolName, + args: event.args, + createdAt: new Date(envelope.ts).toISOString(), + startedAtMs: envelope.ts, + turnId: envelope.turnId ?? this.options.ownership.activeTurnId(envelope.sessionId), + ...(envelope.parentToolCallId ? { parentToolCallId: envelope.parentToolCallId } : {}), + ...(envelope.agentName ? { agentName: envelope.agentName } : {}), + }); + } + // A terminal event for a turn that no longer owns its session must not + // clear the current turn's state. Persistence is a separate pass, so the + // event is still archived as history. + if (!this.options.ownership.isStaleTerminalEvent(envelope)) this.options.emit(envelope); + const persisted = this.persist(envelope); + if (persisted) { + // Replay the completed tool row through the message_end contract, so a + // subscriber that missed the original tool_start still gets the row. + this.options.emit({ ...envelope, event: { type: "message_end", message: persisted } }); + } + } + + private persist(envelope: AgentEventEnvelope): UiMessage | undefined { + const event = envelope.event; + const sessionId = envelope.sessionId; + const turnId = this.options.ownership.activeTurnId(sessionId); + const finish = (status: "completed" | "aborted" | "error", errorCode: string | undefined) => + this.options.ownership + .finishTurn(sessionId, status, errorCode, { turnId: envelope.turnId ?? "" }) + .catch((error: unknown) => { + this.options.log("warn", "turn finalization failed", { sessionId, error: String(error) }); + }); + switch (event.type) { + case "message_start": + if (event.message.role === "assistant" && !envelope.parentToolCallId) { + this.inflightSnapshots.set(sessionId, event.message); + } + return; + case "message_update": + if (event.message.role === "assistant" && !envelope.parentToolCallId) { + const message = applyMessageUpdate(this.inflightSnapshots.get(sessionId), event); + this.inflightSnapshots.set(sessionId, message); + this.checkpointer.observe({ sessionId, turnId: envelope.turnId ?? turnId, message }); + } + return; + case "error": + this.options.log("error", "agent turn failed", { + sessionId, + code: event.error.code, + message: event.error.message, + retriable: event.error.retriable, + }); + if (this.options.ownership.isStaleTerminalEvent(envelope)) return; + void finish(event.error.code === "TURN_ABORTED" ? "aborted" : "error", event.error.code); + return; + case "agent_end": + if (this.options.ownership.isStaleTerminalEvent(envelope)) return; + void finish("completed", undefined); + return; + case "turn_end": + if (!envelope.parentToolCallId) this.addTurnUsage(sessionId, event.subagentUsage); + return; + case "message_end": + return this.persistMessageEnd(envelope, event.message, event.precedingAssistant, turnId); + case "tool_end": { + const key = toolKey(sessionId, event.toolCallId); + const started = this.activeToolCalls.get(key); + this.activeToolCalls.delete(key); + this.options.log( + event.isError ? "error" : "info", + event.isError ? "tool execution failed" : "tool execution completed", + { + sessionId, + turnId: envelope.turnId ?? started?.turnId, + toolCallId: event.toolCallId, + toolName: started?.toolName ?? "unknown", + durationMs: started ? Math.max(0, envelope.ts - started.startedAtMs) : undefined, + }, + ); + const message: UiMessage = { + id: event.toolCallId, + role: "tool", + content: typeof event.result === "string" ? event.result : JSON.stringify(event.result), + createdAt: started?.createdAt ?? new Date(envelope.ts).toISOString(), + toolCallId: event.toolCallId, + toolName: started?.toolName, + toolArgs: started?.args, + toolStatus: event.isError ? "error" : "success", + toolResult: event.result, + ...(event.toolUsage ? { toolUsage: event.toolUsage } : {}), + toolCompletedAt: new Date(envelope.ts).toISOString(), + toolDurationMs: started ? Math.max(0, envelope.ts - Date.parse(started.createdAt)) : undefined, + isError: event.isError, + status: "complete", + ...(started?.parentToolCallId ? { parentToolCallId: started.parentToolCallId } : {}), + ...(started?.agentName ? { agentName: started.agentName } : {}), + }; + void this.persistence.append({ + sessionId, + message, + // A late tool_end belongs to the turn that started the tool, even if + // another prompt has already opened a newer turn for this session. + turnId: started?.turnId ?? envelope.turnId ?? turnId, + }); + return message; + } + default: + return; + } + } + + private persistMessageEnd( + envelope: AgentEventEnvelope, + message: UiMessage, + precedingAssistant: UiMessage | undefined, + turnId: string | undefined, + ): undefined { + const sessionId = envelope.sessionId; + if (message.role === "user" && !envelope.parentToolCallId) { + // Steering input accepted during a stream: reserve the current reply + // before persisting it, then the user row. + const preceding = precedingAssistant?.role === "assistant" ? precedingAssistant : undefined; + if (preceding) this.steeringReplies.add(preceding.id); + for (const row of [preceding, message]) { + if (!row) continue; + void this.persistence.append({ sessionId, message: row, turnId: envelope.turnId ?? turnId }); + } + return; + } + if (message.role === "assistant") { + if (!envelope.parentToolCallId) { + if (message.usage) this.addTurnUsage(sessionId, message.usage); + this.inflightSnapshots.delete(sessionId); + const finalId = message.id; + this.checkpointer.observe({ sessionId, turnId: envelope.turnId ?? turnId, message }); + void this.checkpointer.flush(sessionId).finally(() => this.checkpointer.settleIf(sessionId, finalId)); + } + // Empty aborted bubbles are not useful transcript rows. Structured + // provider failures remain durable so their details survive a reload. + const failed = message.status === "error" || message.status === "aborted"; + const empty = !(message.content || "").trim() && !(message.thinking || "").trim(); + const reservedForSteering = this.steeringReplies.delete(message.id); + if (failed && empty && !message.error && !reservedForSteering) return; + void this.persistence.append({ sessionId, message: subagentTagged(message, envelope), turnId }); + return; + } + if (message.role === "tool") { + void this.persistence.append({ + sessionId, + message: subagentTagged(message, envelope), + turnId: envelope.turnId ?? turnId, + }); + } + return; + } + + private addTurnUsage(sessionId: string, usage: MessageUsage | undefined): void { + if (!usage) return; + const next = addUsage(this.activeTurnUsages.get(sessionId), usage); + if (next) this.activeTurnUsages.set(sessionId, next); + } +} + +function toolKey(sessionId: string, toolCallId: string): string { + return `${sessionId}:${toolCallId}`; +} + +function subagentTagged(message: UiMessage, envelope: AgentEventEnvelope): UiMessage { + if (!envelope.parentToolCallId) return message; + return { + ...message, + parentToolCallId: envelope.parentToolCallId, + ...(envelope.agentName ? { agentName: envelope.agentName } : {}), + }; +} diff --git a/packages/host-runtime/src/turn-persistence.test.ts b/packages/host-runtime/src/turn-persistence.test.ts new file mode 100644 index 0000000000..64634bc9a6 --- /dev/null +++ b/packages/host-runtime/src/turn-persistence.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import type { UiMessage } from "@pi-desktop/shared"; + +import { TurnPersistence } from "./turn-persistence.js"; + +function row(id: string): UiMessage { + return { id, role: "assistant", content: id, createdAt: "2026-09-18T00:00:00.000Z", status: "complete" }; +} + +describe("TurnPersistence", () => { + it("writes a session's rows in order and reports nothing pending afterwards", async () => { + const written: string[] = []; + const persistence = new TurnPersistence({ + getHost: () => ({ + async call(_method: string, params: { message: UiMessage }) { + written.push(params.message.id); + }, + }), + log: () => undefined, + }); + const first = persistence.append({ sessionId: "s", message: row("a") }); + const second = persistence.append({ sessionId: "s", message: row("b") }); + expect(persistence.size()).toBe(2); + await Promise.all([first, second]); + expect(written).toEqual(["a", "b"]); + expect(persistence.size()).toBe(0); + }); + + it("retries while host-core is unavailable and lands the row once it is back", async () => { + let attempts = 0; + let available = false; + const sleeps: number[] = []; + const persistence = new TurnPersistence({ + getHost: () => ({ + isAvailable: () => available, + async call() { + attempts += 1; + }, + }), + log: () => undefined, + sleep: async (ms) => { + sleeps.push(ms); + if (sleeps.length === 3) available = true; + }, + }); + await persistence.append({ sessionId: "s", message: row("a") }); + expect(attempts).toBe(1); + expect(sleeps).toEqual([100, 250, 500]); + }); + + it("treats a duplicate message id as already written and logs other failures", async () => { + const logs: string[] = []; + let calls = 0; + const persistence = new TurnPersistence({ + getHost: () => ({ + async call() { + calls += 1; + if (calls === 1) throw new Error("UNIQUE constraint failed: messages.id"); + throw Object.assign(new Error("bad row"), { errorCode: "INVALID_ARGUMENT" }); + }, + }), + log: (_level, message) => logs.push(message), + }); + await persistence.append({ sessionId: "s", message: row("dup") }); + await persistence.append({ sessionId: "s", message: row("bad") }); + expect(calls).toBe(2); + expect(logs).toEqual(["transcript append failed"]); + }); +}); diff --git a/packages/host-runtime/src/turn-persistence.ts b/packages/host-runtime/src/turn-persistence.ts new file mode 100644 index 0000000000..0ebb039e82 --- /dev/null +++ b/packages/host-runtime/src/turn-persistence.ts @@ -0,0 +1,119 @@ +import { ErrorCodes, type UiMessage } from "@pi-desktop/shared"; + +import type { HostRpc } from "./host-ports.js"; + +export type TurnPersistenceLogger = ( + level: "warn" | "error", + message: string, + data?: Record, +) => void; + +export type MessageAppend = { + sessionId: string; + message: UiMessage; + turnId?: string; +}; + +type HostAvailability = HostRpc & { isAvailable?(): boolean }; + +/** Bounded wait between retries while host-core is restarting. */ +const RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000, 4_000, 8_000] as const; + +function isHostUnavailable(error: unknown): boolean { + return (error as { errorCode?: string } | null)?.errorCode === ErrorCodes.HOST_UNAVAILABLE; +} + +function isDuplicateMessageIdError(error: unknown): boolean { + return /UNIQUE constraint failed: messages\.id/i.test(String(error)); +} + +/** + * Transcript appends for the headless Host. Rows of one session are written + * in order, a write that only failed because host-core was restarting is + * retried with a bounded backoff, and a duplicate message id counts as + * written (the host already has the row). The queue is process memory: the + * desktop keeps a file-backed outbox because a window can close mid-turn, + * whereas a `pi-host` process ends only with its supervisor. + */ +export class TurnPersistence { + private readonly chains = new Map>(); + private pendingCount = 0; + private disposed = false; + + constructor( + private readonly deps: { + getHost: () => HostAvailability | null; + log: TurnPersistenceLogger; + sleep?: (ms: number) => Promise; + }, + ) {} + + /** Number of appends not yet acknowledged by host-core. */ + size(): number { + return this.pendingCount; + } + + /** Queue one append behind the session's earlier ones; resolves once it landed or was given up. */ + append(entry: MessageAppend): Promise { + if (this.disposed) return Promise.resolve(); + this.pendingCount += 1; + const previous = this.chains.get(entry.sessionId) ?? Promise.resolve(); + const run = previous + .then(() => this.write(entry)) + .finally(() => { + this.pendingCount -= 1; + if (this.chains.get(entry.sessionId) === run) this.chains.delete(entry.sessionId); + }); + this.chains.set(entry.sessionId, run); + return run; + } + + /** Wait until every queued append has been attempted. */ + async flush(): Promise { + await Promise.allSettled([...this.chains.values()]); + } + + dispose(): void { + this.disposed = true; + } + + private async write(entry: MessageAppend): Promise { + for (let attempt = 0; ; attempt += 1) { + if (this.disposed) return; + const host = this.deps.getHost(); + if (host && (host.isAvailable?.() ?? true)) { + try { + await host.call("session.appendMessage", { + sessionId: entry.sessionId, + message: entry.message, + ...(entry.turnId ? { turnId: entry.turnId } : {}), + }); + return; + } catch (error) { + if (isDuplicateMessageIdError(error)) return; + if (!isHostUnavailable(error)) { + this.deps.log("warn", "transcript append failed", { + sessionId: entry.sessionId, + messageId: entry.message.id, + error: String(error), + }); + return; + } + } + } + const delay = RETRY_DELAYS_MS[Math.min(attempt, RETRY_DELAYS_MS.length - 1)]; + if (attempt >= RETRY_DELAYS_MS.length * 2) { + this.deps.log("error", "transcript append abandoned: host unavailable", { + sessionId: entry.sessionId, + messageId: entry.message.id, + }); + return; + } + await (this.deps.sleep ?? defaultSleep)(delay); + } + } +} + +function defaultSleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/host-runtime/tsconfig.json b/packages/host-runtime/tsconfig.json new file mode 100644 index 0000000000..60d923f0ba --- /dev/null +++ b/packages/host-runtime/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo", + "rootDir": "src", + "composite": true, + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["src/**/*.test.ts"], + "references": [ + { "path": "../shared" }, + { "path": "../agent-host" }, + { "path": "../agent-runtime" } + ] +} diff --git a/packages/host-runtime/vitest.config.ts b/packages/host-runtime/vitest.config.ts new file mode 100644 index 0000000000..ae847ff6d9 --- /dev/null +++ b/packages/host-runtime/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 00dc0bfef6..b80c354f6e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,6 +43,9 @@ importers: '@pi-desktop/agent-runtime': specifier: workspace:* version: link:../../packages/agent-runtime + '@pi-desktop/host-runtime': + specifier: workspace:* + version: link:../../packages/host-runtime '@pi-desktop/i18n': specifier: workspace:* version: link:../../packages/i18n @@ -205,6 +208,28 @@ importers: specifier: ^4.1.10 version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.13.3)(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0)) + packages/host-runtime: + dependencies: + '@pi-desktop/agent-host': + specifier: workspace:* + version: link:../agent-host + '@pi-desktop/agent-runtime': + specifier: workspace:* + version: link:../agent-runtime + '@pi-desktop/shared': + specifier: workspace:* + version: link:../shared + devDependencies: + '@types/node': + specifier: ^24.13.3 + version: 24.13.3 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.1.10 + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.13.3)(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0)) + packages/i18n: devDependencies: typescript: From e2c8384c9a6631aca685e3bcbf4c8fb746ce3175 Mon Sep 17 00:00:00 2001 From: vastsa Date: Fri, 18 Sep 2026 09:28:07 +0800 Subject: [PATCH 02/13] feat(remote): implement RACP-WS transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R0 froze the RACP contract and R1 delivered the headless Agent Host module, but no code moved a JSON-RPC frame between processes: the module was only driven by Electron IPC. R2 needs the normative RACP-WS binding on both sides of the SSH tunnel and the device-token pairing security §3.4 describes. Add packages/racp (ADR 0283, D446): a server core that dispatches every catalog operation through its role rule onto the Agent Host module and a RacpHostOperations interface the Host implements, a client core that correlates requests, answers server-initiated requests, tracks the last durable cursor per session, and reconnects with bounded backoff without re-sending in-flight calls, plus the ws binding on loopback and the header-profile authenticator with hashed device and single-use pairing tokens. Three catalog operations (connection/pair, project/register, project/browse), server.hostId, the events/closed notification, and the remote connection error codes join the shared contract; the schema fixture is regenerated from the typebox source. The conformance behaviors that need no machine boundary run as package tests over an in-memory link and a real loopback socket. --- CLAUDE.md | 1 + docs/adr/0283-racp-ws-transport.md | 88 ++++ docs/adr/README.md | 1 + .../spec/02-architecture/03-repo-structure.md | 1 + .../19-remote-agent-control-protocol.md | 35 ++ .../06-delivery/07-remote-control-rollout.md | 9 +- docs/spec/08-meta/decisions-log.md | 1 + .../spec/02-architecture/03-repo-structure.md | 1 + .../19-remote-agent-control-protocol.md | 8 + .../06-delivery/07-remote-control-rollout.md | 3 +- docs/zh-CN/spec/08-meta/decisions-log.md | 1 + packages/README.md | 1 + packages/agent-host/src/agent-host.ts | 18 +- packages/agent-host/src/ports.ts | 2 + packages/racp/package.json | 32 ++ packages/racp/src/auth.ts | 215 ++++++++++ packages/racp/src/client.ts | 298 +++++++++++++ packages/racp/src/host-operations.ts | 89 ++++ packages/racp/src/index.ts | 7 + packages/racp/src/jsonrpc.ts | 119 ++++++ packages/racp/src/operations.ts | 391 ++++++++++++++++++ packages/racp/src/racp.test.ts | 346 ++++++++++++++++ packages/racp/src/server.ts | 364 ++++++++++++++++ packages/racp/src/test-harness.ts | 272 ++++++++++++ packages/racp/src/ws-binding.test.ts | 78 ++++ packages/racp/src/ws-binding.ts | 186 +++++++++ packages/racp/tsconfig.json | 13 + packages/racp/vitest.config.ts | 7 + packages/shared/fixtures/racp.schema.json | 4 + packages/shared/src/errors.ts | 25 ++ packages/shared/src/racp.ts | 26 +- pnpm-lock.yaml | 28 ++ 32 files changed, 2665 insertions(+), 5 deletions(-) create mode 100644 docs/adr/0283-racp-ws-transport.md create mode 100644 packages/racp/package.json create mode 100644 packages/racp/src/auth.ts create mode 100644 packages/racp/src/client.ts create mode 100644 packages/racp/src/host-operations.ts create mode 100644 packages/racp/src/index.ts create mode 100644 packages/racp/src/jsonrpc.ts create mode 100644 packages/racp/src/operations.ts create mode 100644 packages/racp/src/racp.test.ts create mode 100644 packages/racp/src/server.ts create mode 100644 packages/racp/src/test-harness.ts create mode 100644 packages/racp/src/ws-binding.test.ts create mode 100644 packages/racp/src/ws-binding.ts create mode 100644 packages/racp/tsconfig.json create mode 100644 packages/racp/vitest.config.ts diff --git a/CLAUDE.md b/CLAUDE.md index 9cb3515774..5b8aee3ef8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -233,6 +233,7 @@ packages/ agent-runtime/ pi sidecar wrapper agent-host/ headless Agent Host module (admission, queue, approvals, events) host-runtime/ Electron-independent runtime (transports, supervisor, turn lifecycle) + racp/ RACP-WS server/client and device pairing plugin-sdk/ plugin author types/validators plugin-devkit/ pi-plugin CLI examples/plugins/ sample plugins diff --git a/docs/adr/0283-racp-ws-transport.md b/docs/adr/0283-racp-ws-transport.md new file mode 100644 index 0000000000..af2199aaad --- /dev/null +++ b/docs/adr/0283-racp-ws-transport.md @@ -0,0 +1,88 @@ +# ADR 0283: `RACP-WS` transport in `packages/racp` + +- Status: Accepted for implementation +- Date: 2026-09-18 +- Decision: D446 +- Related: ADR 0205 (D373 / D374 / D375), ADR 0282 (D445), + `03-runtime/19-remote-agent-control-protocol.md` §3, §4, §8, §11.1, §14a, + `05-security/02-remote-control-security.md` §3.4, §5.1 + +## Context + +R0 froze the RACP contract as typebox schemas and R1 delivered the headless +Agent Host module. Nothing yet moved a JSON-RPC frame between two processes: +the module was only driven by Electron IPC. R2 needs the normative `RACP-WS` +binding on both sides of an SSH tunnel — a server that the `pi-host` bundle +binds on loopback and a client that Electron main runs — and it needs the +device-token pairing of security §3.4, which the security spec described but +no code implemented. + +## Decision + +1. **A new workspace package, `packages/racp`, holds both ends of the + binding.** It depends on `shared`, `agent-host`, and `ws` only. The server + core (`RacpServer`) and the client core (`RacpClient`) take an injected + transport, so the same code runs over `ws` in production and over an + in-memory pair in tests; `bindRacpWebSocket` and `wsClientTransport` are + the `ws` adapters. +2. **The server is a thin catalog over the module.** Every operation in + `RACP_OPERATIONS` is dispatched through the catalog's role rule; the + session, turn, event, approval, and input operations call the Agent Host + module directly; the remote-host profile (session catalog mutations, + projects, workspace reads, terminals) goes through the `RacpHostOperations` + interface that `pi-host` implements. The server never touches host-core + RPC, a filesystem, or a pty (security §7). +3. **Header-profile authentication with Host-issued device tokens.** The + upgrade request must carry `Authorization: Bearer`; a token in the URL, a + non-loopback peer, a wrong path, a missing subprotocol, or a binary frame + is refused before any RPC. Tokens are `pdt1.` (device) or `ppt1.` (pairing), + stored as SHA-256 hashes, and compared in constant time. A pairing token is + single-use and expiring; it authenticates an unprivileged connection that + may only call `connection/pair`, which mints an `owner` device. +4. **Three catalog additions, all in the remote-host profile:** + `connection/pair`, `project/register`, and `project/browse`. Six codes + join the shared error registry: `PAIRING_FAILED`, + `PAIRING_TOKEN_EXPIRED`, `CAPABILITY_UNAVAILABLE`, `REMOTE_PATH_NOT_FOUND`, + `REMOTE_PATH_FORBIDDEN`, and the desktop-side connection codes + `HOST_DISCONNECTED`, `HOST_BOOTSTRAP_FAILED`, `HOST_VERSION_MISMATCH`, + `REMOTE_AUTH_FAILED`, `REMOTE_CONNECTION_FAILED`, `REMOTE_FORWARD_FAILED`. + `connection/initialize` gains `server.hostId`. The binding details that the + protocol left open are recorded in spec §14a. +5. **Reconnect is the client's, replay is the Host's.** The client reconnects + with bounded backoff, rejects the dropped connection's in-flight calls with + `HOST_DISCONNECTED`, and never re-sends them; it remembers the last durable + cursor per session so the caller resubscribes with `after`. The Host + answers with a replay, or with a snapshot and a `resyncReason`, exactly as + the module already did for IPC. A dropped connection releases its + subscriptions and terminal attachments and touches no turn. +6. **`StartTurnParams.input.userMessageId`** is threaded from + `turn/start`'s `input.messageId` through the queue record to the runtime, + so a remote client keeps its optimistic user row id (D288) the way the + renderer does over IPC. + +## Consequences + +- `pi-host` composes `RacpServer` + `bindRacpWebSocket` over the module and + `RuntimeService` (ADR 0282); the desktop adapter composes `RacpClient` + + `wsClientTransport`. +- The conformance behaviors of spec §14 that do not need a machine boundary + (handshake, authorization, idempotency, queue ordering, approval decisions, + cursor replay, eviction, epoch change, slow clients, reconnect without + duplicate execution) are tests in `packages/racp` and run without Electron. +- Attachments and the tool relay are advertised as unavailable + (`attachments: false`, `toolRelay: false`) and their operations fail with + `CAPABILITY_UNAVAILABLE`; they land with their own change. +- The server bind refuses a non-loopback address outright rather than + offering a TLS path; a non-loopback deployment is a later decision. + +## Alternatives considered + +- **Run RACP inside Electron main first (development loopback endpoint).** + Rejected for this change: the only scheduled client is the desktop, and the + first server is `pi-host`; a desktop-side listener would add a network + surface with no consumer. +- **Cookie profile alongside the header profile.** Rejected: it belongs to + the unscheduled browser milestone (D375). +- **Static shared secret instead of pairing.** Rejected by security §3.2 and + §3.4: a pairing token must be single-use and become a per-device credential + that can be revoked on its own. diff --git a/docs/adr/README.md b/docs/adr/README.md index 797c64526a..a1b3c2fda4 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -309,4 +309,5 @@ Each ADR includes: | 0280 | [Plugin-owned UI localizes from the host locale](0280-plugin-owned-ui-localizes-from-host-locale.md) | Accepted (amends ADR 0267; ADR 0159) | | 0281 | [Host speech capability](0281-host-speech-capability.md) | Accepted for implementation (amends ADR 0257) | | 0282 | [Headless runtime boundary in `packages/host-runtime`](0282-headless-runtime-boundary.md) | Accepted for implementation (D445; ADR 0205 R2 prerequisite) | +| 0283 | [`RACP-WS` transport in `packages/racp`](0283-racp-ws-transport.md) | Accepted for implementation (D446; ADR 0205 R2) | | turn-process-and-thinking-display | [Turn process and thinking presentation](turn-process-and-thinking-display.md) | Accepted | diff --git a/docs/spec/02-architecture/03-repo-structure.md b/docs/spec/02-architecture/03-repo-structure.md index f3e9f3d8ef..715bc62dee 100644 --- a/docs/spec/02-architecture/03-repo-structure.md +++ b/docs/spec/02-architecture/03-repo-structure.md @@ -38,6 +38,7 @@ PI-Desktop/ │ ├── agent-runtime/ # pi sidecar and runtime wrapper (bundled into the app) │ ├── agent-host/ # headless Agent Host module: admission, queue, approvals, event log │ ├── host-runtime/ # Electron-independent runtime: stdio transports, supervisor, turn lifecycle +│ ├── racp/ # RACP-WS server and client, device-token pairing │ ├── plugin-sdk/ # plugin author types and validators │ └── plugin-devkit/ # pi-plugin CLI: scaffold, check, pack, publish ├── examples/ diff --git a/docs/spec/03-runtime/19-remote-agent-control-protocol.md b/docs/spec/03-runtime/19-remote-agent-control-protocol.md index 0445410bfd..ee986a7011 100644 --- a/docs/spec/03-runtime/19-remote-agent-control-protocol.md +++ b/docs/spec/03-runtime/19-remote-agent-control-protocol.md @@ -591,6 +591,9 @@ session root as working directory and stream through `terminal.output`. | `terminal/input` | controller | Write bytes to an open terminal | | `terminal/resize` | controller | Resize an open terminal | | `terminal/close` | controller | Close a terminal; idempotent | +| `connection/pair` | authenticated | Exchange the single-use pairing token presented on the upgrade for a device credential (security §3.4); only valid on a pairing connection (D446) | +| `project/register` | owner | Register a Host directory as a project: the Host canonicalizes and validates the path and returns the project id (D446) | +| `project/browse` | owner | List directories under a Host path, bounded, for the remote folder picker (D446) | ### 6.3 Deferred operations @@ -1177,6 +1180,11 @@ Initial RACP codes are: | `APPROVAL_STALE` | no | Approval response targets an old revision | | `PAYLOAD_TOO_LARGE` | no | Request, event, or attachment exceeds a limit | | `RATE_LIMITED` | yes | Principal, session, or host quota exceeded | +| `PAIRING_FAILED` | no | The pairing token is unknown or was already exchanged | +| `PAIRING_TOKEN_EXPIRED` | no | The pairing token's bootstrap window passed | +| `CAPABILITY_UNAVAILABLE` | no | The Host does not advertise the capability the operation needs | +| `REMOTE_PATH_NOT_FOUND` | no | A Host-side path does not exist | +| `REMOTE_PATH_FORBIDDEN` | no | A Host-side path is outside what the principal may reach | | `INTERNAL` | maybe | Unexpected failure with a trace id | Implementations MUST map these codes into the shared `AppError` vocabulary @@ -1208,6 +1216,33 @@ thing across all bindings. 8. The client treats a new major protocol version as incompatible unless an explicit compatibility adapter is selected. +## 14a. RACP-WS binding implementation notes (D446) + +`packages/racp` is the reference implementation of the `RACP-WS` binding. +Beyond the rules above it fixes these wire details: + +- `connection/initialize` answers with `server.hostId`, the Host's stable + identity minted at first start; a client keys its Host records by it, never + by hostname, address, or path. +- The client sends `notifications/initialized` after the initialization + result; the Host closes a connection that has not initialized within the + §12 deadline. +- A JSON-RPC error carries the `RemoteError` under `error.data`; the numeric + `error.code` is `-32601` for `METHOD_NOT_FOUND`, `-32602` for + `INVALID_ARGUMENT`, and `-32000` otherwise. +- When the Host closes one subscription (`CLIENT_TOO_SLOW`) it sends the + `events/closed` notification with the subscription id, the `RemoteError`, + and the last safely delivered cursor; the connection stays open. +- Terminal events are connection-local and are never entered into the + session's durable log; their `epoch` is the session's and + `terminal.changed` carries the current sequence without allocating one. +- Device tokens are `pdt1.`-prefixed and pairing tokens `ppt1.`-prefixed; + both are presented as `Authorization: Bearer` on the upgrade, stored + hashed (SHA-256) on the Host, and never accepted from a URL. +- Reconnect never re-sends an in-flight request: the pending calls of the + dropped connection fail with `HOST_DISCONNECTED`, and a caller that retries + presents the same idempotency key. + ## 15. Amendment history D374 (2026-09-10) revised the D373 draft before implementation: diff --git a/docs/spec/06-delivery/07-remote-control-rollout.md b/docs/spec/06-delivery/07-remote-control-rollout.md index 6cb33ea59a..2f94a71c5e 100644 --- a/docs/spec/06-delivery/07-remote-control-rollout.md +++ b/docs/spec/06-delivery/07-remote-control-rollout.md @@ -407,8 +407,13 @@ Recorded on the `feat/remote-agent-host` branch, 2026-09-10: transports, the restart supervisor, `RuntimeService` (the module's `RuntimePort` with the durable turn lifecycle), transcript persistence, a headless launch resolver, and approved Plan/Goal dispatch — and Electron - main runs on it through thin adapters. The `pi-host` bundle, the RACP-WS - transport, the SSH bootstrap, and the desktop adapter are not started. + main runs on it through thin adapters. +- R2 (2026-09-18, D446 / ADR 0283): `packages/racp` holds the `RACP-WS` + server and client cores, the `ws` binding on loopback, and device-token + pairing; handshake, authorization, idempotency, queue order, approvals, + cursor replay, eviction, epoch change, slow clients, and reconnect without + duplicate execution are package tests. The `pi-host` bundle, the SSH + bootstrap, and the desktop adapter are not started. ## 8. Amendment history diff --git a/docs/spec/08-meta/decisions-log.md b/docs/spec/08-meta/decisions-log.md index 8c4edf7d7b..2f834f9c38 100644 --- a/docs/spec/08-meta/decisions-log.md +++ b/docs/spec/08-meta/decisions-log.md @@ -23,6 +23,7 @@ This log freezes previously open questions into concrete decisions. | D373 | Remote Agent Control host boundary | *(amended by D374 and D375)* **Remote control (post-MVP) runs through a logical Agent Host that owns Sessions, Turns, event cursors, approvals, attachments, workspace policy, and crash recovery; a production Gateway owns identity, routing, rate limits, revocation, and audit, and the Agent Host connects outbound. The existing Electron IPC, `host.proxy`, and host-core stdio boundaries are never exposed.** | A remote surface needs its own boundary rather than a tunnel into local IPC or the host-core stdio contract (ADR 0205, E2E-221 through E2E-230) | | D374 | Remote Agent Control v1 target amendments | **Amend D373 / ADR 0205: `RACP-WS` is the only normative v1 binding (`RACP-HTTP` is the browser profile, `RACP-GRPC` is reserved); typebox in `packages/shared` is the single contract source; the headless `packages/agent-host` module is the first deliverable and is shared by desktop IPC, local MCP, and RACP; cursors are `{ epoch, sequence }` with ephemeral deltas; the turn queue moves into the Host; host-core exposes `permissions.pending`; remote approvals carry the full local decision vocabulary under a default `ask` ceiling; browser clients use a cookie profile; the first deployment is single-tenant.** | Reviewing the D373 draft against the shipped desktop found the remote approval vocabulary narrower than the local contracts, pending requests held as connection state, per-token deltas exhausting the replay window, and more bindings than v1 can carry (ADR 0205) | | D445 | Headless runtime boundary | **Amend ADR 0205 rollout R2 prerequisites (ADR 0282): the Electron-independent runtime layer under the Agent Host module lives in `packages/host-runtime` — the host-core and sidecar stdio transports, the restart supervisor (process-model §4 policy), the durable turn lifecycle (`RuntimeService` as the module's `RuntimePort`: `session.beginTurn` → user row → prompt → `session.endTurn`, abort lock, stale-terminal guard, single-flight finalization), transcript persistence with the D299 checkpoint, a headless launch resolver over host-core's own registries, approved Plan/Goal dispatch (D189), and the host-core adapters for `SessionPort` / `QueueStore` / `permissions.pending`. Electron main keeps thin adapters (binary and bundle locations, `ELECTRON_RUN_AS_NODE`, redacted stderr, schema/glibc diagnoses, renderer status pushes) and its own local prompt path. `TurnStartRequest` gains an optional `userMessageId`. No IPC, sidecar, host-core, Plugin SDK, default, or persisted-data change.** | `pi-host` must run the same lifecycle invariants as the desktop without Electron; a second implementation of abort locks, stale terminal events, and queue release after durable settlement would drift from the one the desktop already fixed | +| D446 | RACP-WS transport and device pairing | **Amend ADR 0205 rollout R2 (ADR 0283): `packages/racp` holds both ends of the normative `RACP-WS` binding. `RacpServer` dispatches every catalog operation through its role rule onto the Agent Host module and a `RacpHostOperations` interface the Host implements; `RacpClient` correlates requests, answers server-initiated requests, tracks the last durable cursor per session, reconnects with bounded backoff, and fails the dropped connection's in-flight calls with `HOST_DISCONNECTED` instead of re-sending them. Authentication is the header profile with Host-issued `pdt1.` device tokens and single-use, expiring `ppt1.` pairing tokens (hashed, constant-time compared; never in a URL); `connection/pair` mints an `owner` device. `connection/pair`, `project/register`, `project/browse`, `server.hostId`, the `events/closed` notification, and the codes `PAIRING_FAILED`, `PAIRING_TOKEN_EXPIRED`, `CAPABILITY_UNAVAILABLE`, `REMOTE_PATH_NOT_FOUND`, `REMOTE_PATH_FORBIDDEN`, `HOST_DISCONNECTED`, `HOST_BOOTSTRAP_FAILED`, `HOST_VERSION_MISMATCH`, `REMOTE_AUTH_FAILED`, `REMOTE_CONNECTION_FAILED`, `REMOTE_FORWARD_FAILED` join the contract (spec §14a). The bind refuses non-loopback addresses; attachments and the tool relay are advertised unavailable.** | The SSH-tunnel topology needs a server `pi-host` can bind and a client the desktop can run, with the pairing the security spec describes; the conformance behaviors that need no machine boundary become package tests | ## B. Secondary implementation defaults diff --git a/docs/zh-CN/spec/02-architecture/03-repo-structure.md b/docs/zh-CN/spec/02-architecture/03-repo-structure.md index 9e9c56fbcb..505ea910fe 100644 --- a/docs/zh-CN/spec/02-architecture/03-repo-structure.md +++ b/docs/zh-CN/spec/02-architecture/03-repo-structure.md @@ -39,6 +39,7 @@ PI-Desktop/ │ ├── agent-runtime/ # pi sidecar 与运行时包装(打包进应用) │ ├── agent-host/ # 无头 Agent Host 模块:准入、队列、审批、事件日志 │ ├── host-runtime/ # 与 Electron 无关的运行时:stdio 传输、监督器、回合生命周期 +│ ├── racp/ # RACP-WS 服务端与客户端、设备令牌配对 │ ├── plugin-sdk/ # 插件作者类型与校验器 │ └── plugin-devkit/ # pi-plugin CLI:scaffold、check、pack、publish ├── examples/ diff --git a/docs/zh-CN/spec/03-runtime/19-remote-agent-control-protocol.md b/docs/zh-CN/spec/03-runtime/19-remote-agent-control-protocol.md index e43e5c5609..f69982f05f 100644 --- a/docs/zh-CN/spec/03-runtime/19-remote-agent-control-protocol.md +++ b/docs/zh-CN/spec/03-runtime/19-remote-agent-control-protocol.md @@ -213,6 +213,9 @@ owner,工作区读取都按会话持久根、Host 忽略规则和 `PATH_OUTSID | `terminal/input` | controller | 向已打开终端写入字节 | | `terminal/resize` | controller | 调整已打开终端尺寸 | | `terminal/close` | controller | 关闭终端,幂等 | +| `connection/pair` | authenticated | 用升级请求携带的一次性配对令牌换取设备凭证(安全规格 §3.4);仅在配对连接上有效(D446) | +| `project/register` | owner | 将 Host 上的目录注册为项目:Host 规范化并校验路径,返回项目 id(D446) | +| `project/browse` | owner | 列出 Host 某路径下的目录,有界,供远程目录选择器使用(D446) | 仍推迟的本地操作: @@ -335,6 +338,11 @@ Plan/Goal 审批为带显式 `permissionMode` 的 `approve` 或 `reject`,且 | `APPROVAL_STALE` | no | 审批响应针对旧 revision | | `PAYLOAD_TOO_LARGE` | no | 请求、事件或附件超限 | | `RATE_LIMITED` | yes | 主体、会话或 Host 超额 | +| `PAIRING_FAILED` | no | 配对令牌未知或已被兑换 | +| `PAIRING_TOKEN_EXPIRED` | no | 配对令牌的引导窗口已过 | +| `CAPABILITY_UNAVAILABLE` | no | Host 未宣告该操作所需的能力 | +| `REMOTE_PATH_NOT_FOUND` | no | Host 侧路径不存在 | +| `REMOTE_PATH_FORBIDDEN` | no | Host 侧路径超出主体可达范围 | | `INTERNAL` | maybe | 带 trace id 的内部错误 | 错误必须同时携带稳定 code、可重试标记和 trace id。重复 mutation 使用同一 diff --git a/docs/zh-CN/spec/06-delivery/07-remote-control-rollout.md b/docs/zh-CN/spec/06-delivery/07-remote-control-rollout.md index 2dc8a3d7cb..73ee29b5cc 100644 --- a/docs/zh-CN/spec/06-delivery/07-remote-control-rollout.md +++ b/docs/zh-CN/spec/06-delivery/07-remote-control-rollout.md @@ -138,7 +138,8 @@ runbook 写明 feature flag、配对撤销路径、远端机器上的数据保 - R1 已交付:renderer 的内存 prompt 队列已退役;composer 经 `agent/queue/push` 推入, 镜像 `agent/event/queueChanged`,“立即发送”即 `turn/prioritize` 加优雅停止。 - R1 未完成:运行时级别的逐回合权限上限(当前被限制的回合在桥接层直接拒绝)。 -- R2 已开始(2026-09-18,D445 / ADR 0282):`packages/host-runtime` 承载与 Electron 无关的运行时层 —— host-core 与 sidecar 的 stdio 传输、重启监督器、`RuntimeService`(模块的 `RuntimePort`,含持久回合生命周期)、转录持久化、无头启动解析器与已批准 Plan/Goal 的派发 —— Electron main 通过薄适配层运行其上。`pi-host` 包、RACP-WS 传输、SSH 引导与桌面适配器尚未开始。 +- R2 已开始(2026-09-18,D445 / ADR 0282):`packages/host-runtime` 承载与 Electron 无关的运行时层 —— host-core 与 sidecar 的 stdio 传输、重启监督器、`RuntimeService`(模块的 `RuntimePort`,含持久回合生命周期)、转录持久化、无头启动解析器与已批准 Plan/Goal 的派发 —— Electron main 通过薄适配层运行其上。 +- R2(2026-09-18,D446 / ADR 0283):`packages/racp` 承载 `RACP-WS` 服务端与客户端核心、回环上的 `ws` 绑定与设备令牌配对;握手、鉴权、幂等、队列顺序、审批、游标重放、驱逐、epoch 变更、慢客户端与不重复执行的重连都是包内测试。`pi-host` 包、SSH 引导与桌面适配器尚未开始。 ## 8. 修订记录 diff --git a/docs/zh-CN/spec/08-meta/decisions-log.md b/docs/zh-CN/spec/08-meta/decisions-log.md index f4e29596f4..1563f7251c 100644 --- a/docs/zh-CN/spec/08-meta/decisions-log.md +++ b/docs/zh-CN/spec/08-meta/decisions-log.md @@ -26,6 +26,7 @@ | D373 | 远程 Agent 控制的 Host 边界 | *(由 D374 与 D375 修订)* **远程控制(MVP 之后)经由一个逻辑 Agent Host 运行,它拥有 Sessions、Turns、事件游标、审批、附件、工作区策略与崩溃恢复;生产 Gateway 拥有身份、路由、限流、吊销与审计,Agent Host 向外连接。现有 Electron IPC、`host.proxy` 与 host-core stdio 边界永不暴露。** | 远程表面需要自己的边界,而不是通往本地 IPC 或 host-core stdio 契约的隧道(ADR 0205,E2E-221 至 E2E-230) | | D374 | 远程 Agent 控制 v1 目标修订 | **修订 D373 / ADR 0205:`RACP-WS` 是 v1 唯一规范绑定(`RACP-HTTP` 为浏览器 profile,`RACP-GRPC` 保留);`packages/shared` 中的 typebox 是唯一契约来源;无头的 `packages/agent-host` 模块是首个交付物,桌面 IPC、本地 MCP 与 RACP 共同调用;游标为 `{ epoch, sequence }` 且增量为临时数据;回合队列移入 Host;host-core 暴露 `permissions.pending`;远程审批携带完整本地决策词汇并默认 `ask` 上限;浏览器客户端使用 cookie profile;首个部署为单租户。** | 对照已交付桌面审查 D373 草案发现远程审批词汇比本地契约窄、待处理请求被当作连接状态、逐 token 增量会耗尽重放窗口、绑定数量超过 v1 承载能力(ADR 0205) | | D445 | 无头运行时边界 | **修订 ADR 0205 里程碑 R2 的前置条件(ADR 0282):Agent Host 模块之下与 Electron 无关的运行时层放入 `packages/host-runtime` —— host-core 与 sidecar 的 stdio 传输、重启监督器(进程模型 §4 的策略)、持久回合生命周期(`RuntimeService` 作为模块的 `RuntimePort`:`session.beginTurn` → 用户行 → prompt → `session.endTurn`、中止锁、过期终态事件守卫、单飞终结)、带 D299 检查点的转录持久化、基于 host-core 自身注册表的无头启动解析器、已批准 Plan/Goal 的派发(D189),以及 `SessionPort` / `QueueStore` / `permissions.pending` 的 host-core 适配器。Electron main 只保留薄适配层(二进制与资源位置、`ELECTRON_RUN_AS_NODE`、脱敏 stderr、schema/glibc 诊断、渲染层状态推送)以及自己的本地 prompt 路径。`TurnStartRequest` 新增可选 `userMessageId`。不改 IPC、sidecar、host-core、插件 SDK、默认值或持久化数据。** | `pi-host` 必须在没有 Electron 的情况下运行与桌面相同的生命周期不变量;第二份中止锁、过期终态事件与持久落库后释放队列的实现会与桌面已修好的那份漂移 | +| D446 | RACP-WS 传输与设备配对 | **修订 ADR 0205 里程碑 R2(ADR 0283):`packages/racp` 同时承载规范绑定 `RACP-WS` 的两端。`RacpServer` 按目录的角色规则把每个操作分发到 Agent Host 模块与 Host 实现的 `RacpHostOperations` 接口;`RacpClient` 关联请求、应答服务端发起的请求、按会话记录最后的持久游标、以有界退避重连,并让断开连接上未完成的调用以 `HOST_DISCONNECTED` 失败而不是重发。认证采用 header profile:Host 签发的 `pdt1.` 设备令牌与一次性、有时限的 `ppt1.` 配对令牌(散列存储、常量时间比较、绝不出现在 URL 中);`connection/pair` 铸造 `owner` 设备。`connection/pair`、`project/register`、`project/browse`、`server.hostId`、`events/closed` 通知,以及错误码 `PAIRING_FAILED`、`PAIRING_TOKEN_EXPIRED`、`CAPABILITY_UNAVAILABLE`、`REMOTE_PATH_NOT_FOUND`、`REMOTE_PATH_FORBIDDEN`、`HOST_DISCONNECTED`、`HOST_BOOTSTRAP_FAILED`、`HOST_VERSION_MISMATCH`、`REMOTE_AUTH_FAILED`、`REMOTE_CONNECTION_FAILED`、`REMOTE_FORWARD_FAILED` 进入契约(规格 §14a)。绑定拒绝非回环地址;附件与工具中继宣告为不可用。** | SSH 隧道拓扑需要 `pi-host` 能绑定的服务端和桌面能运行的客户端,以及安全规格描述的配对;不需要机器边界的一致性行为成为包内测试 | ## B. 辅助实现默认值 diff --git a/packages/README.md b/packages/README.md index 5307d4634b..52434fb19f 100644 --- a/packages/README.md +++ b/packages/README.md @@ -6,3 +6,4 @@ - `agent-runtime` — pi sidecar + runtime wrapper - `agent-host` — headless Agent Host module (admission, turn queue, approvals, event log) - `host-runtime` — Electron-independent runtime layer (stdio transports, restart supervisor, turn lifecycle) +- `racp` — RACP-WS server and client cores, `ws` binding, device-token pairing diff --git a/packages/agent-host/src/agent-host.ts b/packages/agent-host/src/agent-host.ts index ea03cdf232..babd591a83 100644 --- a/packages/agent-host/src/agent-host.ts +++ b/packages/agent-host/src/agent-host.ts @@ -91,7 +91,13 @@ export type StartTurnParams = { sessionId: string; idempotencyKey?: string; admission?: RacpTurnAdmission; - input: { text: string; attachments?: AgentPromptAttachment[]; sessionMessageId?: string }; + input: { + text: string; + attachments?: AgentPromptAttachment[]; + sessionMessageId?: string; + /** Client-chosen id for the durable user row (D288). */ + userMessageId?: string; + }; context: RacpRequestContext; }; @@ -470,6 +476,7 @@ export class AgentHost { principalSubject: principal.subject, content: params.input.text, ...(params.input.sessionMessageId ? { sessionMessageId: params.input.sessionMessageId } : {}), + ...(params.input.userMessageId ? { userMessageId: params.input.userMessageId } : {}), ...(params.input.attachments ? { attachments: params.input.attachments } : {}), effectivePermissionMode, ...(idempotencyKey ? { idempotencyKey } : {}), @@ -491,6 +498,7 @@ export class AgentHost { sessionId: state.id, content: params.input.text, ...(params.input.sessionMessageId ? { sessionMessageId: params.input.sessionMessageId } : {}), + ...(params.input.userMessageId ? { userMessageId: params.input.userMessageId } : {}), ...(params.input.attachments ? { attachments: params.input.attachments } : {}), effectivePermissionMode, ...(idempotencyKey ? { idempotencyKey } : {}), @@ -681,6 +689,13 @@ export class AgentHost { return this.approvals.list(sessionId); } + /** The RACP view of a session the caller already fetched: its durable summary plus live state. */ + describeSession(summary: SessionSummary): RacpSession { + const state = this.state(summary.id); + state.permissionMode = summary.permissionMode; + return this.toRacpSession(summary, state); + } + queuedTurns(sessionId: string): RacpTurn[] { return this.queueEntries(sessionId).map((entry) => entry.turn); } @@ -798,6 +813,7 @@ export class AgentHost { sessionId, content: record.content, ...(record.sessionMessageId ? { sessionMessageId: record.sessionMessageId } : {}), + ...(record.userMessageId ? { userMessageId: record.userMessageId } : {}), ...(record.attachments ? { attachments: record.attachments } : {}), effectivePermissionMode: record.effectivePermissionMode, ...(record.idempotencyKey ? { idempotencyKey: record.idempotencyKey } : {}), diff --git a/packages/agent-host/src/ports.ts b/packages/agent-host/src/ports.ts index 0bd4fe7db1..b784346abf 100644 --- a/packages/agent-host/src/ports.ts +++ b/packages/agent-host/src/ports.ts @@ -78,6 +78,8 @@ export type QueuedTurnRecord = { principalSubject: string; content: string; sessionMessageId?: string; + /** Client-chosen id for the durable user row (D288). */ + userMessageId?: string; attachments?: AgentPromptAttachment[]; effectivePermissionMode: RacpPermissionMode; idempotencyKey?: string; diff --git a/packages/racp/package.json b/packages/racp/package.json new file mode 100644 index 0000000000..11b8c82018 --- /dev/null +++ b/packages/racp/package.json @@ -0,0 +1,32 @@ +{ + "name": "@pi-desktop/racp", + "version": "0.15.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run", + "clean": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\"" + }, + "dependencies": { + "@pi-desktop/agent-host": "workspace:*", + "@pi-desktop/shared": "workspace:*", + "typebox": "^1.3.13", + "ws": "8.21.1" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "@types/ws": "^8.18.1", + "typescript": "^5.9.3", + "vitest": "^4.1.10" + } +} diff --git a/packages/racp/src/auth.ts b/packages/racp/src/auth.ts new file mode 100644 index 0000000000..030a0ed017 --- /dev/null +++ b/packages/racp/src/auth.ts @@ -0,0 +1,215 @@ +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; +import { isIP } from "node:net"; + +import type { Principal } from "@pi-desktop/agent-host"; +import { RACP_DEVICE_TOKEN_PREFIX, RACP_PAIRING_TOKEN_PREFIX, type RacpRole } from "@pi-desktop/shared"; + +/** + * Credentials for the header profile (security §3.1, §3.4). A device token + * is the long-lived credential a paired desktop presents on every upgrade; a + * pairing token is single-use, expiring, and only good for `connection/pair`. + * Neither ever appears in a URL; both are stored hashed on the Host. + */ +export type DeviceRecord = { + deviceId: string; + label: string; + roles: RacpRole[]; + /** SHA-256 of the token, hex. */ + tokenHash: string; + createdAt: string; + lastSeenAt?: string; + revokedAt?: string; +}; + +export type PairingRecord = { + tokenHash: string; + expiresAt: string; + /** Set once exchanged; a second exchange is refused. */ + consumedAt?: string; +}; + +/** Durable credential storage; `pi-host` keeps it under its data directory. */ +export interface DeviceCredentialStore { + findDeviceByTokenHash(tokenHash: string): Promise; + saveDevice(record: DeviceRecord): Promise; + touchDevice(deviceId: string, seenAt: string): Promise; + revokeDevice(deviceId: string, revokedAt: string): Promise; + listDevices(): Promise; + findPairing(tokenHash: string): Promise; + savePairing(record: PairingRecord): Promise; + consumePairing(tokenHash: string, consumedAt: string): Promise; +} + +export class MemoryCredentialStore implements DeviceCredentialStore { + private readonly devices = new Map(); + private readonly pairings = new Map(); + + async findDeviceByTokenHash(tokenHash: string): Promise { + for (const device of this.devices.values()) { + if (device.tokenHash === tokenHash) return device; + } + return null; + } + async saveDevice(record: DeviceRecord): Promise { + this.devices.set(record.deviceId, record); + } + async touchDevice(deviceId: string, seenAt: string): Promise { + const device = this.devices.get(deviceId); + if (device) device.lastSeenAt = seenAt; + } + async revokeDevice(deviceId: string, revokedAt: string): Promise { + const device = this.devices.get(deviceId); + if (!device || device.revokedAt) return false; + device.revokedAt = revokedAt; + return true; + } + async listDevices(): Promise { + return [...this.devices.values()]; + } + async findPairing(tokenHash: string): Promise { + return this.pairings.get(tokenHash) ?? null; + } + async savePairing(record: PairingRecord): Promise { + this.pairings.set(record.tokenHash, record); + } + async consumePairing(tokenHash: string, consumedAt: string): Promise { + const pairing = this.pairings.get(tokenHash); + if (!pairing || pairing.consumedAt) return false; + pairing.consumedAt = consumedAt; + return true; + } +} + +export function hashToken(token: string): string { + return createHash("sha256").update(token, "utf8").digest("hex"); +} + +export function newDeviceToken(): string { + return `${RACP_DEVICE_TOKEN_PREFIX}${randomBytes(32).toString("base64url")}`; +} + +export function newPairingToken(): string { + return `${RACP_PAIRING_TOKEN_PREFIX}${randomBytes(24).toString("base64url")}`; +} + +export function isDeviceToken(token: string): boolean { + return token.startsWith(RACP_DEVICE_TOKEN_PREFIX); +} + +export function isPairingToken(token: string): boolean { + return token.startsWith(RACP_PAIRING_TOKEN_PREFIX); +} + +/** Constant-time comparison of two hex digests of equal length. */ +export function hashesEqual(left: string, right: string): boolean { + const a = Buffer.from(left, "hex"); + const b = Buffer.from(right, "hex"); + return a.length === b.length && timingSafeEqual(a, b); +} + +/** `Authorization: Bearer `; anything else is not a credential. */ +export function bearerToken(authorization: string | undefined): string | null { + if (!authorization) return null; + const match = /^Bearer\s+(\S+)$/i.exec(authorization.trim()); + return match ? match[1]! : null; +} + +export function isLoopbackAddress(address: string | undefined): boolean { + if (!address) return false; + const bare = address.startsWith("::ffff:") ? address.slice("::ffff:".length) : address; + const family = isIP(bare); + if (family === 4) return bare.startsWith("127."); + if (family === 6) return bare === "::1"; + return false; +} + +/** What the upgrade handler learned about a connection before any RPC ran. */ +export type ConnectionAuth = + | { kind: "device"; principal: Principal; device: DeviceRecord } + | { kind: "pairing"; principal: Principal; tokenHash: string }; + +export type AuthenticateInput = { + authorization: string | undefined; + /** Rejected outright: a token in the URL is never accepted (security §3.1). */ + urlHasToken: boolean; + connectionId: string; +}; + +/** + * The header-profile authenticator. A device token yields the device's + * principal; a still-valid pairing token yields an unprivileged principal + * that may only call `connection/pair`. + */ +export class DeviceTokenAuthenticator { + constructor( + private readonly store: DeviceCredentialStore, + private readonly now: () => number = () => Date.now(), + ) {} + + async authenticate(input: AuthenticateInput): Promise { + if (input.urlHasToken) return null; + const token = bearerToken(input.authorization); + if (!token) return null; + const tokenHash = hashToken(token); + if (isDeviceToken(token)) { + const device = await this.store.findDeviceByTokenHash(tokenHash); + if (!device || device.revokedAt || !hashesEqual(device.tokenHash, tokenHash)) return null; + await this.store.touchDevice(device.deviceId, new Date(this.now()).toISOString()); + return { + kind: "device", + device, + principal: { + subject: device.deviceId, + roles: [...device.roles], + pairedDevice: true, + connectionId: input.connectionId, + }, + }; + } + if (isPairingToken(token)) { + const pairing = await this.store.findPairing(tokenHash); + if (!pairing || pairing.consumedAt || Date.parse(pairing.expiresAt) <= this.now()) return null; + return { + kind: "pairing", + tokenHash, + principal: { subject: `pairing:${tokenHash.slice(0, 12)}`, roles: [], connectionId: input.connectionId }, + }; + } + return null; + } + + /** Mint a single-use pairing token; the caller hands it over the bootstrap channel. */ + async issuePairingToken(lifetimeMs: number): Promise<{ token: string; expiresAt: string }> { + const token = newPairingToken(); + const expiresAt = new Date(this.now() + lifetimeMs).toISOString(); + await this.store.savePairing({ tokenHash: hashToken(token), expiresAt }); + return { token, expiresAt }; + } + + /** Exchange a pairing token (already authenticated on the upgrade) for a device credential. */ + async pair(tokenHash: string, label: string, roles: RacpRole[]): Promise<{ deviceId: string; token: string }> { + const consumedAt = new Date(this.now()).toISOString(); + const pairing = await this.store.findPairing(tokenHash); + if (!pairing) throw pairingError("PAIRING_FAILED", "the pairing token is unknown"); + if (Date.parse(pairing.expiresAt) <= this.now()) { + throw pairingError("PAIRING_TOKEN_EXPIRED", "the pairing token has expired"); + } + if (!(await this.store.consumePairing(tokenHash, consumedAt))) { + throw pairingError("PAIRING_FAILED", "the pairing token was already used"); + } + const token = newDeviceToken(); + const deviceId = `dev_${randomBytes(9).toString("base64url")}`; + await this.store.saveDevice({ + deviceId, + label, + roles, + tokenHash: hashToken(token), + createdAt: consumedAt, + }); + return { deviceId, token }; + } +} + +function pairingError(code: string, message: string): Error & { errorCode: string } { + return Object.assign(new Error(message), { errorCode: code }); +} diff --git a/packages/racp/src/client.ts b/packages/racp/src/client.ts new file mode 100644 index 0000000000..031479770c --- /dev/null +++ b/packages/racp/src/client.ts @@ -0,0 +1,298 @@ +import { RacpError } from "@pi-desktop/agent-host"; +import { + RACP_EVENT_NOTIFICATION, + RACP_INITIALIZED_NOTIFICATION, + RACP_PROTOCOL_VERSION, + RACP_SUBSCRIPTION_CLOSED_NOTIFICATION, + isDurableEventKind, + type RacpCursor, + type RacpEventEnvelope, + type RacpInitializeParams, + type RacpInitializeResult, + type RacpRemoteError, +} from "@pi-desktop/shared"; + +import { + encodeFrame, + errorFromObject, + isNotification, + isRequest, + isResponse, + parseFrame, + type JsonRpcId, +} from "./jsonrpc.js"; + +/** One client-side transport connection: the thing `ws` (or a test pair) provides. */ +export interface ClientTransport { + send(frame: string): void; + close(code?: number, reason?: string): void; + onMessage(handler: (frame: string) => void): void; + onClose(handler: (info: { code: number; reason: string }) => void): void; + onError(handler: (error: Error) => void): void; +} + +/** Opens a transport; rejects with a typed error when the connection cannot be made. */ +export type ClientTransportFactory = () => Promise; + +export type RacpClientState = "disconnected" | "connecting" | "connected" | "reconnecting" | "error"; + +export type SubscriptionClosedNotice = { + subscriptionId: string; + error: RacpRemoteError; + lastSafeCursor: RacpCursor; +}; + +export type RacpClientOptions = { + transport: ClientTransportFactory; + client: { name: string; version: string }; + /** Answer a server-initiated request (`approval/request`, `input/request`, `tool/execute`). */ + onServerRequest?: (method: string, params: unknown) => Promise; + onEvent?: (envelope: RacpEventEnvelope) => void; + onSubscriptionClosed?: (notice: SubscriptionClosedNotice) => void; + onStateChange?: (state: RacpClientState, error?: RacpError) => void; + /** Re-establish subscriptions after a reconnect. */ + onReconnected?: (client: RacpClient) => Promise | void; + log?: (level: "info" | "warn", message: string, data?: Record) => void; + requestTimeoutMs?: number; + reconnect?: { enabled: boolean; baseDelayMs?: number; maxDelayMs?: number; maxAttempts?: number }; + sleep?: (ms: number) => Promise; +}; + +type Pending = { + resolve: (value: unknown) => void; + reject: (error: RacpError) => void; + timer: ReturnType; + method: string; +}; + +/** + * The `RACP-WS` client core. Requests are correlated by id, server requests + * are answered through `onServerRequest`, and a dropped transport reconnects + * with bounded backoff. Reconnect never re-sends an in-flight mutation: the + * pending calls of the old connection are rejected with `HOST_DISCONNECTED`, + * and the caller retries with the same idempotency key if it wants to (spec + * §4.1, §10). Durable events are tracked per session so a subscription can + * resume from its last cursor. + */ +export class RacpClient { + private transport: ClientTransport | null = null; + private pending = new Map(); + private counter = 0; + private stateValue: RacpClientState = "disconnected"; + private closedByUser = false; + private reconnectAttempt = 0; + private reconnecting: Promise | null = null; + private initializeResult: RacpInitializeResult | null = null; + private readonly cursors = new Map(); + private hostCursor: RacpCursor | null = null; + + constructor(private readonly options: RacpClientOptions) {} + + get state(): RacpClientState { + return this.stateValue; + } + + get initialized(): RacpInitializeResult | null { + return this.initializeResult; + } + + /** Last durable cursor seen for a session, for `after` on resubscribe. */ + cursorFor(sessionId: string): RacpCursor | undefined { + return this.cursors.get(sessionId); + } + + cursorForHost(): RacpCursor | undefined { + return this.hostCursor ?? undefined; + } + + async connect(): Promise { + this.closedByUser = false; + this.setState("connecting"); + try { + const result = await this.open(); + this.reconnectAttempt = 0; + this.setState("connected"); + return result; + } catch (error) { + const typed = toRacpError(error, "REMOTE_CONNECTION_FAILED"); + this.setState("error", typed); + throw typed; + } + } + + async close(): Promise { + this.closedByUser = true; + const transport = this.transport; + this.transport = null; + this.rejectPending(new RacpError("HOST_DISCONNECTED", "client closed")); + transport?.close(1000, "client closed"); + this.setState("disconnected"); + } + + request(method: string, params?: unknown): Promise { + const transport = this.transport; + if (!transport || this.stateValue !== "connected") { + return Promise.reject(new RacpError("HOST_DISCONNECTED", `not connected (${this.stateValue})`, { retriable: true })); + } + return this.send(transport, method, params); + } + + private send(transport: ClientTransport, method: string, params: unknown): Promise { + this.counter += 1; + const id = `c${this.counter}`; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new RacpError("TIMEOUT", `${method} timed out`, { retriable: true })); + }, this.options.requestTimeoutMs ?? 15_000); + timer.unref?.(); + this.pending.set(id, { resolve: resolve as (value: unknown) => void, reject, timer, method }); + try { + transport.send(encodeFrame({ jsonrpc: "2.0", id, method, params })); + } catch (error) { + clearTimeout(timer); + this.pending.delete(id); + reject(toRacpError(error, "HOST_DISCONNECTED")); + } + }); + } + + private async open(): Promise { + const transport = await this.options.transport(); + this.transport = transport; + transport.onMessage((frame) => this.handleFrame(transport, frame)); + transport.onError((error) => this.options.log?.("warn", "racp transport error", { error: String(error) })); + transport.onClose((info) => this.handleClose(transport, info)); + const params: RacpInitializeParams = { + protocolVersion: RACP_PROTOCOL_VERSION, + client: this.options.client, + bindings: ["RACP-WS"], + capabilities: { eventReplay: true, approvals: true, inputRequests: true, turnQueue: true, hostEvents: true, history: true, terminal: true }, + }; + const result = await this.send(transport, "connection/initialize", params); + transport.send(encodeFrame({ jsonrpc: "2.0", method: RACP_INITIALIZED_NOTIFICATION, params: {} })); + this.initializeResult = result; + return result; + } + + private handleFrame(transport: ClientTransport, frame: string): void { + if (transport !== this.transport) return; + const message = parseFrame(frame); + if (!message) return; + if (isResponse(message)) { + const pending = this.pending.get(String(message.id)); + if (!pending) return; + this.pending.delete(String(message.id)); + clearTimeout(pending.timer); + if (message.error) pending.reject(errorFromObject(message.error)); + else pending.resolve(message.result); + return; + } + if (isRequest(message)) { + void this.answerServerRequest(transport, message.id, message.method, message.params); + return; + } + if (!isNotification(message)) return; + if (message.method === RACP_EVENT_NOTIFICATION) { + const envelope = message.params as RacpEventEnvelope; + this.track(envelope); + this.options.onEvent?.(envelope); + } else if (message.method === RACP_SUBSCRIPTION_CLOSED_NOTIFICATION) { + this.options.onSubscriptionClosed?.(message.params as SubscriptionClosedNotice); + } + } + + private track(envelope: RacpEventEnvelope): void { + if (!isDurableEventKind(envelope.kind) || typeof envelope.sequence !== "number") return; + const cursor = { epoch: envelope.epoch, sequence: envelope.sequence }; + if (envelope.scope === "host") this.hostCursor = cursor; + else if (envelope.sessionId) this.cursors.set(envelope.sessionId, cursor); + } + + private async answerServerRequest(transport: ClientTransport, id: JsonRpcId, method: string, params: unknown): Promise { + const handler = this.options.onServerRequest; + let response: string; + if (!handler) { + response = encodeFrame({ + jsonrpc: "2.0", + id, + error: { code: -32601, message: "server requests are not handled", data: { code: "METHOD_NOT_FOUND", message: "unhandled", retriable: false, traceId: "" } }, + }); + } else { + try { + response = encodeFrame({ jsonrpc: "2.0", id, result: await handler(method, params) }); + } catch (error) { + const typed = toRacpError(error, "TOOL_FAILED"); + response = encodeFrame({ jsonrpc: "2.0", id, error: { code: -32000, message: typed.message, data: typed.toRemoteError("") } }); + } + } + if (transport === this.transport) transport.send(response); + } + + private handleClose(transport: ClientTransport, info: { code: number; reason: string }): void { + if (transport !== this.transport) return; + this.transport = null; + this.rejectPending(new RacpError("HOST_DISCONNECTED", `connection closed (${info.code} ${info.reason})`, { retriable: true })); + if (this.closedByUser) { + this.setState("disconnected"); + return; + } + const policy = this.options.reconnect; + if (!policy?.enabled) { + this.setState("disconnected", new RacpError("HOST_DISCONNECTED", `connection closed (${info.code} ${info.reason})`, { retriable: true })); + return; + } + this.setState("reconnecting"); + this.reconnecting ??= this.reconnectLoop().finally(() => { + this.reconnecting = null; + }); + } + + private async reconnectLoop(): Promise { + const policy = this.options.reconnect!; + const sleep = this.options.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); + while (!this.closedByUser) { + this.reconnectAttempt += 1; + if (policy.maxAttempts !== undefined && this.reconnectAttempt > policy.maxAttempts) { + this.setState("error", new RacpError("HOST_DISCONNECTED", "reconnect attempts exhausted", { retriable: true })); + return; + } + const delay = Math.min((policy.baseDelayMs ?? 500) * 2 ** (this.reconnectAttempt - 1), policy.maxDelayMs ?? 15_000); + await sleep(delay); + if (this.closedByUser) return; + try { + await this.open(); + this.reconnectAttempt = 0; + this.setState("connected"); + await this.options.onReconnected?.(this); + return; + } catch (error) { + this.options.log?.("warn", "racp reconnect failed", { attempt: this.reconnectAttempt, error: String(error) }); + this.transport = null; + } + } + } + + private rejectPending(error: RacpError): void { + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + pending.reject(error); + } + this.pending.clear(); + } + + private setState(state: RacpClientState, error?: RacpError): void { + this.stateValue = state; + try { + this.options.onStateChange?.(state, error); + } catch { + // A listener failure must not affect the connection. + } + } +} + +function toRacpError(error: unknown, fallbackCode: string): RacpError { + if (error instanceof RacpError) return error; + const code = (error as { errorCode?: string })?.errorCode ?? fallbackCode; + return new RacpError(code, error instanceof Error ? error.message : String(error), { retriable: true }); +} diff --git a/packages/racp/src/host-operations.ts b/packages/racp/src/host-operations.ts new file mode 100644 index 0000000000..4a0691557e --- /dev/null +++ b/packages/racp/src/host-operations.ts @@ -0,0 +1,89 @@ +import type { Principal, SessionSummary } from "@pi-desktop/agent-host"; +import type { + FsEntry, + FsReadResult, + RacpProjectSummary, + WorkspaceDiff, +} from "@pi-desktop/shared"; + +/** + * Everything the RACP server delegates to the Host besides the Agent Host + * module: session catalog mutations, projects, workspace reads, terminals. + * `pi-host` implements these on host-core and its filesystem; a test hands + * the server fakes. The server itself never touches host-core RPC, a + * filesystem, or a pty, so the boundary of security §7 holds by construction. + */ +export type SessionCreateInput = { + title?: string; + projectId?: string; + mode?: "agent" | "plan" | "goal"; + providerId?: string; + modelId?: string; + thinkingLevel?: string; + permissionMode?: "ask" | "accept-edits" | "auto"; +}; + +export type SessionConfigureInput = { + mode?: "agent" | "plan" | "goal"; + providerId?: string; + modelId?: string; + thinkingLevel?: string; + permissionMode?: "ask" | "accept-edits" | "auto"; +}; + +export interface RacpSessionCatalog { + list(): Promise; + create(input: SessionCreateInput, principal: Principal): Promise; + configure(sessionId: string, input: SessionConfigureInput): Promise; + fork(sessionId: string, input: { title?: string; throughMessageId?: string }): Promise; + rename(sessionId: string, title: string): Promise; + delete(sessionId: string): Promise; + compact(sessionId: string): Promise<{ accepted: boolean }>; +} + +export type ProjectBrowseEntry = { name: string; path: string }; + +export interface RacpProjectCatalog { + list(): Promise; + /** Canonicalize and validate a Host directory, register it, return its id. */ + register(path: string): Promise; + /** Directories under `path` (or the user's home when omitted), bounded. */ + browse(path?: string): Promise<{ path: string; parent?: string; entries: ProjectBrowseEntry[] }>; +} + +export interface RacpWorkspaceAccess { + list(sessionId: string, path: string): Promise<{ entries: FsEntry[] }>; + read(sessionId: string, path: string): Promise; + diff(sessionId: string): Promise; +} + +export type TerminalOpenResult = { + terminalId: string; + /** Bounded replay ring, base64 (spec §6.2). */ + replay: string; + cols: number; + rows: number; +}; + +export interface RacpTerminalAccess { + open( + sessionId: string, + options: { cols: number; rows: number }, + sink: { output: (data: string) => void; exit: (code: number | null) => void }, + ): Promise; + input(terminalId: string, data: string): Promise; + resize(terminalId: string, cols: number, rows: number): Promise; + close(terminalId: string): Promise; + /** Re-attach a subscriber after a reconnect; returns the replay ring. */ + attach(terminalId: string, sink: { output: (data: string) => void; exit: (code: number | null) => void }): Promise; + detach(terminalId: string): void; +} + +export type RacpHostOperations = { + sessions: RacpSessionCatalog; + projects: RacpProjectCatalog; + workspace: RacpWorkspaceAccess; + terminal?: RacpTerminalAccess; + /** Owner-only: revoke a paired device (spec `session/revoke`). */ + revokeDevice?: (deviceId: string) => Promise; +}; diff --git a/packages/racp/src/index.ts b/packages/racp/src/index.ts new file mode 100644 index 0000000000..a66bd37bee --- /dev/null +++ b/packages/racp/src/index.ts @@ -0,0 +1,7 @@ +export * from "./jsonrpc.js"; +export * from "./auth.js"; +export * from "./host-operations.js"; +export * from "./server.js"; +export * from "./client.js"; +export * from "./ws-binding.js"; +export type { OperationContext, OperationHandler } from "./operations.js"; diff --git a/packages/racp/src/jsonrpc.ts b/packages/racp/src/jsonrpc.ts new file mode 100644 index 0000000000..d850f3dd90 --- /dev/null +++ b/packages/racp/src/jsonrpc.ts @@ -0,0 +1,119 @@ +import { RacpError } from "@pi-desktop/agent-host"; +import type { RacpRemoteError } from "@pi-desktop/shared"; + +/** + * JSON-RPC 2.0 framing for `RACP-WS` (spec §4.1): one UTF-8 message per text + * frame, no batches, and every failure carries a `RemoteError` under + * `error.data` so the code means the same thing on both sides. + */ +export type JsonRpcId = string | number; + +export type JsonRpcRequest = { + jsonrpc: "2.0"; + id: JsonRpcId; + method: string; + params?: unknown; +}; + +export type JsonRpcNotification = { + jsonrpc: "2.0"; + method: string; + params?: unknown; +}; + +export type JsonRpcErrorObject = { + code: number; + message: string; + data?: RacpRemoteError; +}; + +export type JsonRpcResponse = { + jsonrpc: "2.0"; + id: JsonRpcId; + result?: unknown; + error?: JsonRpcErrorObject; +}; + +export type JsonRpcMessage = JsonRpcRequest | JsonRpcNotification | JsonRpcResponse; + +/** Numeric JSON-RPC codes; the semantic code is the `RemoteError.code`. */ +export const JSON_RPC_CODES = { + parseError: -32700, + invalidRequest: -32600, + methodNotFound: -32601, + invalidParams: -32602, + application: -32000, +} as const; + +export function isRequest(message: JsonRpcMessage): message is JsonRpcRequest { + return "method" in message && "id" in message && message.id !== undefined && message.id !== null; +} + +export function isNotification(message: JsonRpcMessage): message is JsonRpcNotification { + return "method" in message && !("id" in message && message.id !== undefined && message.id !== null); +} + +export function isResponse(message: JsonRpcMessage): message is JsonRpcResponse { + return !("method" in message) && "id" in message; +} + +/** Parse one frame; anything that is not a single JSON-RPC 2.0 object is rejected. */ +export function parseFrame(text: string): JsonRpcMessage | null { + let value: unknown; + try { + value = JSON.parse(text); + } catch { + return null; + } + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const record = value as Record; + if (record.jsonrpc !== "2.0") return null; + const hasMethod = typeof record.method === "string"; + const hasId = typeof record.id === "string" || typeof record.id === "number"; + if (hasMethod) return record as unknown as JsonRpcRequest | JsonRpcNotification; + if (hasId && ("result" in record || "error" in record)) return record as unknown as JsonRpcResponse; + return null; +} + +export function encodeFrame(message: JsonRpcMessage): string { + return JSON.stringify(message); +} + +function numericCodeFor(code: string): number { + if (code === "METHOD_NOT_FOUND") return JSON_RPC_CODES.methodNotFound; + if (code === "INVALID_ARGUMENT") return JSON_RPC_CODES.invalidParams; + return JSON_RPC_CODES.application; +} + +/** Turn any thrown value into the wire error; unknown failures become `INTERNAL`. */ +export function errorObjectFrom(error: unknown, traceId: string): JsonRpcErrorObject { + if (error instanceof RacpError) { + return { code: numericCodeFor(error.code), message: error.message, data: error.toRemoteError(traceId) }; + } + const candidate = error as { errorCode?: unknown; code?: unknown; message?: unknown; retriable?: unknown } | null; + const code = + typeof candidate?.errorCode === "string" + ? candidate.errorCode + : typeof candidate?.code === "string" + ? candidate.code + : "INTERNAL"; + const message = error instanceof Error ? error.message : String(error); + return { + code: numericCodeFor(code), + message, + data: { code, message, retriable: candidate?.retriable === true, traceId }, + }; +} + +/** The client-side view of a wire error: a `RacpError` with the remote code. */ +export function errorFromObject(error: JsonRpcErrorObject): RacpError { + const remote = error.data; + if (remote && typeof remote.code === "string") { + return new RacpError(remote.code, remote.message || error.message, { + retriable: remote.retriable, + details: { ...(remote.details === undefined ? {} : { details: remote.details }), traceId: remote.traceId }, + }); + } + const code = error.code === JSON_RPC_CODES.methodNotFound ? "METHOD_NOT_FOUND" : "INTERNAL"; + return new RacpError(code, error.message); +} diff --git a/packages/racp/src/operations.ts b/packages/racp/src/operations.ts new file mode 100644 index 0000000000..c7f3473edd --- /dev/null +++ b/packages/racp/src/operations.ts @@ -0,0 +1,391 @@ +import type { AgentHost, Principal } from "@pi-desktop/agent-host"; +import { RacpError } from "@pi-desktop/agent-host"; +import { + RacpApprovalResponseSchema, + RacpCursorSchema, + RacpInputResponseSchema, + RacpRequestContextSchema, + type RacpCursor, + type RacpLimits, + type RacpOperation, + type RacpServerCapabilities, + type RacpEventEnvelope, +} from "@pi-desktop/shared"; +import Type from "typebox"; +import * as Value from "typebox/value"; + +import type { DeviceTokenAuthenticator } from "./auth.js"; +import type { RacpHostOperations } from "./host-operations.js"; +import type { RacpConnection } from "./server.js"; + +export type OperationContext = { + connection: RacpConnection; + principal: Principal; + agentHost: AgentHost; + operations: RacpHostOperations; + authenticator: DeviceTokenAuthenticator; + limits: RacpLimits; + capabilities: RacpServerCapabilities; + traceId: string; + now: () => number; + deliverEvent: (envelope: RacpEventEnvelope) => void; + closeSubscription: (subscriptionId: string, error: RacpError, lastSafeCursor: RacpCursor) => void; + log: (level: "info" | "warn" | "error", message: string, data?: Record) => void; +}; + +export type OperationHandler = (context: OperationContext, params: Record) => Promise; + +const SessionIdParams = Type.Object({ sessionId: Type.String({ minLength: 1 }) }); +const TurnIdParams = Type.Object({ turnId: Type.String({ minLength: 1 }) }); +const AttachParams = Type.Object({ + sessionId: Type.String({ minLength: 1 }), + role: Type.Optional(Type.Union([Type.Literal("viewer"), Type.Literal("controller"), Type.Literal("approver"), Type.Literal("owner")])), + after: Type.Optional(RacpCursorSchema), + includeSnapshot: Type.Optional(Type.Boolean()), +}); +const SubscribeParams = Type.Object({ + scope: Type.Union([Type.Literal("session"), Type.Literal("host")]), + sessionId: Type.Optional(Type.String({ minLength: 1 })), + after: Type.Optional(RacpCursorSchema), +}); +const TurnStartParams = Type.Object({ + sessionId: Type.String({ minLength: 1 }), + idempotencyKey: Type.Optional(Type.String({ minLength: 1 })), + admission: Type.Optional(Type.Union([Type.Literal("reject_if_busy"), Type.Literal("queue")])), + input: Type.Object({ + text: Type.String(), + attachments: Type.Optional(Type.Array(Type.Unknown())), + sessionMessageId: Type.Optional(Type.String()), + /** Client-chosen id for the durable user row (D288). */ + messageId: Type.Optional(Type.String()), + }), + context: RacpRequestContextSchema, +}); +const HistoryParams = Type.Object({ + sessionId: Type.String({ minLength: 1 }), + beforeItemId: Type.Optional(Type.String()), + limit: Type.Optional(Type.Integer({ minimum: 1 })), +}); +const SessionCreateParams = Type.Object({ + title: Type.Optional(Type.String()), + projectId: Type.Optional(Type.String()), + mode: Type.Optional(Type.Union([Type.Literal("agent"), Type.Literal("plan"), Type.Literal("goal")])), + providerId: Type.Optional(Type.String()), + modelId: Type.Optional(Type.String()), + thinkingLevel: Type.Optional(Type.String()), + permissionMode: Type.Optional(Type.Union([Type.Literal("ask"), Type.Literal("accept-edits"), Type.Literal("auto")])), +}); +const SessionConfigureParams = Type.Object({ + sessionId: Type.String({ minLength: 1 }), + mode: Type.Optional(Type.Union([Type.Literal("agent"), Type.Literal("plan"), Type.Literal("goal")])), + providerId: Type.Optional(Type.String()), + modelId: Type.Optional(Type.String()), + thinkingLevel: Type.Optional(Type.String()), + permissionMode: Type.Optional(Type.Union([Type.Literal("ask"), Type.Literal("accept-edits"), Type.Literal("auto")])), +}); +const WorkspacePathParams = Type.Object({ sessionId: Type.String({ minLength: 1 }), path: Type.Optional(Type.String()) }); +const TerminalOpenParams = Type.Object({ + sessionId: Type.String({ minLength: 1 }), + cols: Type.Optional(Type.Integer({ minimum: 1, maximum: 1000 })), + rows: Type.Optional(Type.Integer({ minimum: 1, maximum: 1000 })), + /** Re-attach to a terminal this session already has open. */ + terminalId: Type.Optional(Type.String({ minLength: 1 })), +}); +const TerminalInputParams = Type.Object({ terminalId: Type.String({ minLength: 1 }), data: Type.String() }); +const TerminalResizeParams = Type.Object({ + terminalId: Type.String({ minLength: 1 }), + cols: Type.Integer({ minimum: 1, maximum: 1000 }), + rows: Type.Integer({ minimum: 1, maximum: 1000 }), +}); +const TerminalIdParams = Type.Object({ terminalId: Type.String({ minLength: 1 }) }); +const PairParams = Type.Object({ deviceLabel: Type.Optional(Type.String()) }); + +function check(schema: T, params: unknown): Type.Static { + if (!Value.Check(schema, params)) { + const first = Value.Errors(schema, params)[Symbol.iterator]().next().value as { path?: string; message?: string } | undefined; + throw new RacpError("INVALID_ARGUMENT", `invalid params${first?.path ? ` at ${first.path}` : ""}: ${first?.message ?? "schema mismatch"}`); + } + return params; +} + +function requireTerminal(context: OperationContext) { + const terminal = context.operations.terminal; + if (!terminal || !context.capabilities.terminal) { + throw new RacpError("CAPABILITY_UNAVAILABLE", "this Host does not offer terminals"); + } + return terminal; +} + +function unavailable(capability: string): OperationHandler { + return async () => { + throw new RacpError("CAPABILITY_UNAVAILABLE", `${capability} is not offered by this Host`); + }; +} + +/** The operation catalog bound to the Agent Host module and the Host operations. */ +export function createOperations(): Map { + const handlers = new Map(); + + handlers.set("connection/initialize", async () => { + throw new RacpError("CONFLICT", "the connection is already initialized"); + }); + handlers.set("connection/ping", async (context) => ({ ok: true, serverTime: new Date(context.now()).toISOString() })); + handlers.set("connection/pair", async (context, params) => { + const input = check(PairParams, params); + if (context.connection.auth.kind !== "pairing") { + throw new RacpError("FORBIDDEN", "connection/pair needs a pairing token on the upgrade request"); + } + const paired = await context.authenticator.pair( + context.connection.auth.tokenHash, + input.deviceLabel?.trim() || "desktop", + ["owner"], + ).catch((error: unknown) => { + const code = (error as { errorCode?: string })?.errorCode; + if (code === "PAIRING_FAILED" || code === "PAIRING_TOKEN_EXPIRED") { + throw new RacpError(code, error instanceof Error ? error.message : String(error)); + } + throw error; + }); + context.log("info", "device paired", { deviceId: paired.deviceId, connectionId: context.connection.id }); + // The pairing connection stays unprivileged: the client reconnects with the device token. + return { deviceId: paired.deviceId, deviceToken: paired.token, roles: ["owner"] }; + }); + handlers.set("host/list", async () => { + throw new RacpError("METHOD_NOT_FOUND", "host/list exists only behind a Gateway"); + }); + + handlers.set("project/list", async (context) => ({ projects: await context.operations.projects.list() })); + handlers.set("project/register", async (context, params) => { + const input = check(Type.Object({ path: Type.String({ minLength: 1 }) }), params); + const project = await context.operations.projects.register(input.path); + return { project }; + }); + handlers.set("project/browse", async (context, params) => { + const input = check(Type.Object({ path: Type.Optional(Type.String()) }), params); + return context.operations.projects.browse(input.path); + }); + + handlers.set("session/list", async (context) => { + const sessions = await context.operations.sessions.list(); + return { sessions: sessions.map((summary) => context.agentHost.describeSession(summary)) }; + }); + handlers.set("session/get", async (context, params) => { + const input = check(SessionIdParams, params); + const summary = (await context.operations.sessions.list()).find((candidate) => candidate.id === input.sessionId); + if (!summary) throw new RacpError("NOT_FOUND", `session ${input.sessionId} is unknown`); + return { session: context.agentHost.describeSession(summary) }; + }); + handlers.set("session/create", async (context, params) => { + const input = check(SessionCreateParams, params); + const summary = await context.operations.sessions.create(input, context.principal); + context.agentHost.publishSessionChange("session.created", summary); + return { session: context.agentHost.describeSession(summary) }; + }); + handlers.set("session/attach", async (context, params) => { + const input = check(AttachParams, params); + return context.agentHost.attach(context.principal, { + sessionId: input.sessionId, + ...(input.role ? { role: input.role } : {}), + ...(input.after ? { after: input.after } : {}), + ...(input.includeSnapshot !== undefined ? { includeSnapshot: input.includeSnapshot } : {}), + }); + }); + handlers.set("session/history", async (context, params) => { + const input = check(HistoryParams, params); + return context.agentHost.history(context.principal, input); + }); + + handlers.set("events/subscribe", async (context, params) => { + const input = check(SubscribeParams, params); + if (context.connection.subscriptions.size >= context.limits.maxSubscriptionsPerConnection) { + throw new RacpError("RATE_LIMITED", "subscription limit reached for this connection", { + details: { limit: context.limits.maxSubscriptionsPerConnection }, + }); + } + const result = context.agentHost.subscribe( + context.principal, + { scope: input.scope, ...(input.sessionId ? { sessionId: input.sessionId } : {}), ...(input.after ? { after: input.after } : {}) }, + { + deliver: (envelope) => context.deliverEvent(envelope), + close: (error, lastSafeCursor) => context.closeSubscription(result.subscriptionId, error, lastSafeCursor), + }, + ); + context.connection.subscriptions.set(result.subscriptionId, { + id: result.subscriptionId, + ...(input.sessionId ? { sessionId: input.sessionId } : {}), + }); + return result; + }); + handlers.set("events/unsubscribe", async (context, params) => { + const input = check(Type.Object({ subscriptionId: Type.String({ minLength: 1 }) }), params); + const subscription = context.connection.subscriptions.get(input.subscriptionId); + if (!subscription) return { removed: false }; + context.connection.subscriptions.delete(input.subscriptionId); + return { removed: context.agentHost.unsubscribe(subscription.id, subscription.sessionId) }; + }); + handlers.set("events/ack", async (context, params) => { + const input = check(Type.Object({ subscriptionId: Type.String({ minLength: 1 }), sequence: Type.Integer({ minimum: 0 }) }), params); + if (context.connection.subscriptions.has(input.subscriptionId)) { + context.agentHost.ack(input.subscriptionId, input.sequence); + } + return { ok: true }; + }); + + handlers.set("turn/start", async (context, params) => { + const input = check(TurnStartParams, params); + if (Buffer.byteLength(input.input.text, "utf8") > context.limits.maxPromptBytes) { + throw new RacpError("PAYLOAD_TOO_LARGE", "prompt exceeds maxPromptBytes"); + } + if (input.input.attachments?.length) { + throw new RacpError("CAPABILITY_UNAVAILABLE", "attachments are not offered by this Host"); + } + return context.agentHost.startTurn(context.principal, { + sessionId: input.sessionId, + ...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}), + ...(input.admission ? { admission: input.admission } : {}), + input: { + text: input.input.text, + ...(input.input.sessionMessageId ? { sessionMessageId: input.input.sessionMessageId } : {}), + ...(input.input.messageId ? { userMessageId: input.input.messageId } : {}), + }, + context: input.context, + }); + }); + handlers.set("turn/get", async (context, params) => ({ turn: context.agentHost.getTurn(check(TurnIdParams, params).turnId) })); + handlers.set("turn/stop", async (context, params) => ({ + turn: await context.agentHost.stopTurn(context.principal, check(TurnIdParams, params).turnId), + })); + handlers.set("turn/interrupt", async (context, params) => ({ + turn: await context.agentHost.interruptTurn(context.principal, check(TurnIdParams, params).turnId), + })); + handlers.set("turn/cancel", async (context, params) => ({ + turn: await context.agentHost.cancelTurn(context.principal, check(TurnIdParams, params).turnId), + })); + handlers.set("turn/prioritize", async (context, params) => ({ + turn: await context.agentHost.prioritizeTurn(context.principal, check(TurnIdParams, params).turnId), + })); + + handlers.set("approval/respond", async (context, params) => + context.agentHost.respondApproval(context.principal, check(RacpApprovalResponseSchema, params)), + ); + handlers.set("input/respond", async (context, params) => + context.agentHost.respondInput(context.principal, check(RacpInputResponseSchema, params)), + ); + + handlers.set("attachment/create", unavailable("attachments")); + handlers.set("attachment/complete", unavailable("attachments")); + handlers.set("tools/advertise", unavailable("tool relay")); + + handlers.set("session/revoke", async (context, params) => { + const input = check(Type.Object({ deviceId: Type.String({ minLength: 1 }) }), params); + const revoke = context.operations.revokeDevice; + if (!revoke) throw new RacpError("CAPABILITY_UNAVAILABLE", "device revocation is not offered by this Host"); + return { revoked: await revoke(input.deviceId) }; + }); + handlers.set("session/archive", unavailable("session archival")); + + handlers.set("session/configure", async (context, params) => { + const { sessionId, ...input } = check(SessionConfigureParams, params); + const summary = await context.operations.sessions.configure(sessionId, input); + context.agentHost.publishSessionChange("session.changed", summary); + return { session: context.agentHost.describeSession(summary) }; + }); + handlers.set("session/fork", async (context, params) => { + const input = check(Type.Object({ sessionId: Type.String({ minLength: 1 }), title: Type.Optional(Type.String()), throughMessageId: Type.Optional(Type.String()) }), params); + const summary = await context.operations.sessions.fork(input.sessionId, input); + context.agentHost.publishSessionChange("session.created", summary); + return { session: context.agentHost.describeSession(summary) }; + }); + handlers.set("session/rename", async (context, params) => { + const input = check(Type.Object({ sessionId: Type.String({ minLength: 1 }), title: Type.String({ minLength: 1 }) }), params); + await context.operations.sessions.rename(input.sessionId, input.title); + const summary = (await context.operations.sessions.list()).find((candidate) => candidate.id === input.sessionId); + if (summary) context.agentHost.publishSessionChange("session.changed", summary); + return { ok: true }; + }); + handlers.set("session/delete", async (context, params) => { + const input = check(SessionIdParams, params); + await context.operations.sessions.delete(input.sessionId); + return { ok: true }; + }); + handlers.set("session/compact", async (context, params) => { + const input = check(SessionIdParams, params); + return context.operations.sessions.compact(input.sessionId); + }); + + handlers.set("workspace/list", async (context, params) => { + const input = check(WorkspacePathParams, params); + return context.operations.workspace.list(input.sessionId, input.path ?? ""); + }); + handlers.set("workspace/read", async (context, params) => { + const input = check(WorkspacePathParams, params); + if (!input.path) throw new RacpError("INVALID_ARGUMENT", "path is required"); + return context.operations.workspace.read(input.sessionId, input.path); + }); + handlers.set("workspace/diff", async (context, params) => { + const input = check(SessionIdParams, params); + return context.operations.workspace.diff(input.sessionId); + }); + + handlers.set("terminal/open", async (context, params) => { + const terminal = requireTerminal(context); + const input = check(TerminalOpenParams, params); + const sink = { + output: (data: string) => + context.deliverEvent(terminalEvent(context, input.sessionId, "terminal.output", { terminalId: opened.terminalId, data })), + exit: (code: number | null) => { + context.connection.terminals.delete(opened.terminalId); + context.deliverEvent(terminalEvent(context, input.sessionId, "terminal.changed", { terminalId: opened.terminalId, state: "exited", code })); + }, + }; + let opened: Awaited>; + if (input.terminalId) { + const attached = await terminal.attach(input.terminalId, sink); + if (!attached) throw new RacpError("NOT_FOUND", `terminal ${input.terminalId} is not open`); + opened = attached; + } else { + opened = await terminal.open(input.sessionId, { cols: input.cols ?? 80, rows: input.rows ?? 24 }, sink); + } + context.connection.terminals.add(opened.terminalId); + return opened; + }); + handlers.set("terminal/input", async (context, params) => { + const input = check(TerminalInputParams, params); + await requireTerminal(context).input(input.terminalId, input.data); + return { ok: true }; + }); + handlers.set("terminal/resize", async (context, params) => { + const input = check(TerminalResizeParams, params); + await requireTerminal(context).resize(input.terminalId, input.cols, input.rows); + return { ok: true }; + }); + handlers.set("terminal/close", async (context, params) => { + const input = check(TerminalIdParams, params); + context.connection.terminals.delete(input.terminalId); + await requireTerminal(context).close(input.terminalId); + return { ok: true }; + }); + + return handlers; +} + +/** Terminal events are connection-local: they never enter the replay log (spec §5.3). */ +function terminalEvent( + context: OperationContext, + sessionId: string, + kind: "terminal.output" | "terminal.changed", + payload: Record, +): RacpEventEnvelope { + const cursor = context.agentHost.hub.stream(sessionId).cursor(); + return { + eventId: `term_${context.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`, + scope: "session", + sessionId, + epoch: cursor.epoch, + ...(kind === "terminal.output" ? { afterSequence: cursor.sequence } : { sequence: cursor.sequence }), + revision: 0, + kind, + occurredAt: new Date(context.now()).toISOString(), + payload, + }; +} diff --git a/packages/racp/src/racp.test.ts b/packages/racp/src/racp.test.ts new file mode 100644 index 0000000000..c40f55b2e2 --- /dev/null +++ b/packages/racp/src/racp.test.ts @@ -0,0 +1,346 @@ +import { describe, expect, it } from "vitest"; +import type { AgentEventEnvelope, RacpEventEnvelope, RacpInitializeResult, RacpSessionSnapshot, RacpTurn, UiMessage } from "@pi-desktop/shared"; + +import { hashToken, newPairingToken } from "./auth.js"; +import { OWNER_TOKEN, VIEWER_TOKEN, flush, harness } from "./test-harness.js"; + +function envelope(sessionId: string, turnId: string, event: AgentEventEnvelope["event"]): AgentEventEnvelope { + return { sessionId, turnId, ts: Date.parse("2026-09-18T00:00:01.000Z"), event }; +} + +const context = (requestId: string, idempotencyKey?: string) => ({ requestId, ...(idempotencyKey ? { idempotencyKey } : {}) }); + +describe("RACP-WS handshake and authentication", () => { + it("negotiates protocol, capabilities, limits, and the Host identity", async () => { + const h = await harness(); + const { client } = await h.connect(OWNER_TOKEN); + const init = client.initialized as RacpInitializeResult; + expect(init.protocolVersion).toBe("1.0"); + expect(init.server.hostId).toBe("host_test"); + expect(init.principal).toEqual({ subject: "dev_owner", roles: ["owner"] }); + expect(init.capabilities.remoteHostProfile).toBe(true); + expect(init.capabilities.bindings).toEqual(["RACP-WS"]); + expect(init.capabilities.terminal).toBe(false); + expect(init.limits.maxQueuedTurnsPerSession).toBe(8); + expect(init.policy.remoteMaxPermissionMode).toBe("ask"); + const ping = await client.request<{ ok: boolean }>("connection/ping"); + expect(ping.ok).toBe(true); + await client.close(); + expect(h.server.connectionCount()).toBe(0); + }); + + it("refuses an unknown, revoked, or URL-carried credential before any RPC", async () => { + const h = await harness(); + await expect(h.connect("pdt1.nonsense")).rejects.toMatchObject({ code: "REMOTE_AUTH_FAILED" }); + expect(await h.authenticator.authenticate({ authorization: `Bearer ${OWNER_TOKEN}`, urlHasToken: true, connectionId: "c" })).toBeNull(); + expect(await h.authenticator.authenticate({ authorization: `Basic ${OWNER_TOKEN}`, urlHasToken: false, connectionId: "c" })).toBeNull(); + await h.store.revokeDevice("dev_owner", "2026-09-18T00:00:00.000Z"); + await expect(h.connect(OWNER_TOKEN)).rejects.toMatchObject({ code: "REMOTE_AUTH_FAILED" }); + }); + + it("rejects a request before initialization and an unknown operation after it", async () => { + const h = await harness(); + const { client } = await h.connect(OWNER_TOKEN); + await expect(client.request("no/such")).rejects.toMatchObject({ code: "METHOD_NOT_FOUND" }); + await expect(client.request("host/list")).rejects.toMatchObject({ code: "METHOD_NOT_FOUND" }); + await expect(client.request("connection/initialize", {})).rejects.toMatchObject({ code: "CONFLICT" }); + }); + + it("enforces roles from the catalog and the client limit", async () => { + const h = await harness({ limits: { maxConnectedClients: 1 } }); + const viewer = await h.connect(VIEWER_TOKEN); + await expect(viewer.client.request("turn/start", { sessionId: "s1", input: { text: "x" }, context: context("r1") })).rejects.toMatchObject({ code: "FORBIDDEN" }); + await expect(viewer.client.request("project/register", { path: "/x" })).rejects.toMatchObject({ code: "FORBIDDEN" }); + const list = await viewer.client.request<{ sessions: unknown[] }>("session/list"); + expect(list.sessions).toHaveLength(1); + await expect(h.connect(OWNER_TOKEN)).rejects.toMatchObject({ code: "REMOTE_CONNECTION_FAILED" }); + }); + + it("exchanges a single-use pairing token for an owner device credential", async () => { + const h = await harness(); + const issued = await h.authenticator.issuePairingToken(60_000); + const pairing = await h.connect(issued.token); + expect(pairing.client.initialized?.principal.roles).toEqual([]); + await expect(pairing.client.request("session/list")).rejects.toMatchObject({ code: "FORBIDDEN" }); + const paired = await pairing.client.request<{ deviceId: string; deviceToken: string; roles: string[] }>("connection/pair", { deviceLabel: "my laptop" }); + expect(paired.roles).toEqual(["owner"]); + expect(paired.deviceToken.startsWith("pdt1.")).toBe(true); + // Second exchange on the same token is refused; the new device token works. + await expect(pairing.client.request("connection/pair", {})).rejects.toMatchObject({ code: "PAIRING_FAILED" }); + await expect(h.connect(issued.token)).rejects.toMatchObject({ code: "REMOTE_AUTH_FAILED" }); + const device = await h.connect(paired.deviceToken); + expect(device.client.initialized?.principal).toEqual({ subject: paired.deviceId, roles: ["owner"] }); + const stored = (await h.store.listDevices()).find((record) => record.deviceId === paired.deviceId); + expect(stored?.tokenHash).toBe(hashToken(paired.deviceToken)); + expect(stored?.label).toBe("my laptop"); + }); + + it("refuses an expired pairing token and a pairing call on a device connection", async () => { + const h = await harness(); + const expired = newPairingToken(); + await h.store.savePairing({ tokenHash: hashToken(expired), expiresAt: "2000-01-01T00:00:00.000Z" }); + await expect(h.connect(expired)).rejects.toMatchObject({ code: "REMOTE_AUTH_FAILED" }); + const { client } = await h.connect(OWNER_TOKEN); + await expect(client.request("connection/pair", {})).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); +}); + +describe("RACP-WS turns, events, and approvals", () => { + it("runs a turn end to end: attach, subscribe, start, ordered durable events, completion", async () => { + const h = await harness(); + const { client, events } = await h.connect(OWNER_TOKEN); + const attach = await client.request<{ session: { id: string; status: string }; snapshot: RacpSessionSnapshot }>("session/attach", { sessionId: "s1" }); + expect(attach.session.status).toBe("idle"); + expect(attach.snapshot.cursor).toEqual({ epoch: expect.any(String), sequence: 0 }); + const sub = await client.request<{ subscriptionId: string; replayComplete: boolean }>("events/subscribe", { scope: "session", sessionId: "s1" }); + expect(sub.replayComplete).toBe(true); + const started = await client.request<{ accepted: boolean; turn: RacpTurn }>("turn/start", { + sessionId: "s1", + input: { text: "hello", messageId: "6f1c1e2a-3b4d-4c5e-8f6a-7b8c9d0e1f2a" }, + context: context("r1", "k1"), + }); + expect(started.turn.status).toBe("running"); + expect(h.runtime.prompts[0]).toMatchObject({ content: "hello", userMessageId: "6f1c1e2a-3b4d-4c5e-8f6a-7b8c9d0e1f2a", effectivePermissionMode: "ask" }); + const message: UiMessage = { id: "m1", role: "assistant", content: "hi", createdAt: "2026-09-18T00:00:01.000Z", status: "complete" }; + h.host.ingest(envelope("s1", "rt_1", { type: "agent_start" })); + h.host.ingest(envelope("s1", "rt_1", { type: "message_start", message: { ...message, status: "streaming" } })); + h.host.ingest(envelope("s1", "rt_1", { type: "message_update", message: { ...message, status: "streaming" }, deltaText: "hi" })); + h.host.ingest(envelope("s1", "rt_1", { type: "message_end", message })); + h.host.ingest(envelope("s1", "rt_1", { type: "agent_end", messageIds: ["m1"] })); + await flush(); + const sequenced = events.filter((event) => typeof event.sequence === "number").map((event) => event.sequence); + expect(sequenced).toEqual([...sequenced].sort((a, b) => a - b)); + expect(new Set(sequenced).size).toBe(sequenced.length); + expect(events.map((event) => event.kind)).toEqual(["turn.started", "item.started", "item.delta", "item.completed", "turn.completed", "session.changed"]); + expect(events.find((event) => event.kind === "item.delta")?.afterSequence).toBe(2); + const turn = await client.request<{ turn: RacpTurn }>("turn/get", { turnId: "rt_1" }); + expect(turn.turn.status).toBe("completed"); + expect(client.cursorFor("s1")).toEqual({ epoch: events[0]!.epoch, sequence: 5 }); + }); + + it("returns the same turn for a repeated idempotency key and refuses a changed input", async () => { + const h = await harness(); + const { client } = await h.connect(OWNER_TOKEN); + await client.request("session/attach", { sessionId: "s1" }); + const first = await client.request<{ turn: RacpTurn }>("turn/start", { sessionId: "s1", input: { text: "hello" }, context: context("r1", "same") }); + const second = await client.request<{ turn: RacpTurn }>("turn/start", { sessionId: "s1", input: { text: "hello" }, context: context("r2", "same") }); + expect(second.turn.id).toBe(first.turn.id); + expect(h.runtime.prompts).toHaveLength(1); + await expect(client.request("turn/start", { sessionId: "s1", input: { text: "other" }, context: context("r3", "same") })).rejects.toMatchObject({ code: "IDEMPOTENCY_CONFLICT" }); + await expect(client.request("turn/start", { sessionId: "s1", input: { text: "busy" }, context: context("r4") })).rejects.toMatchObject({ code: "AGENT_BUSY" }); + const queued = await client.request<{ turn: RacpTurn }>("turn/start", { sessionId: "s1", admission: "queue", input: { text: "later" }, context: context("r5") }); + expect(queued.turn.status).toBe("queued"); + expect(queued.turn.queuePosition).toBe(1); + }); + + it("stops, interrupts, and cancels through the catalog and enforces the prompt limit", async () => { + const h = await harness({ limits: { maxPromptBytes: 16 } }); + const { client } = await h.connect(OWNER_TOKEN); + await client.request("session/attach", { sessionId: "s1" }); + await expect(client.request("turn/start", { sessionId: "s1", input: { text: "x".repeat(17) }, context: context("r0") })).rejects.toMatchObject({ code: "PAYLOAD_TOO_LARGE" }); + const started = await client.request<{ turn: RacpTurn }>("turn/start", { sessionId: "s1", input: { text: "go" }, context: context("r1") }); + const queued = await client.request<{ turn: RacpTurn }>("turn/start", { sessionId: "s1", admission: "queue", input: { text: "next" }, context: context("r2") }); + await client.request("turn/stop", { turnId: started.turn.id }); + expect(h.runtime.stops).toEqual(["s1"]); + await client.request("turn/interrupt", { turnId: started.turn.id }); + expect(h.runtime.aborts).toEqual([{ sessionId: "s1", turnId: started.turn.id }]); + const canceled = await client.request<{ turn: RacpTurn }>("turn/cancel", { turnId: queued.turn.id }); + expect(canceled.turn.status).toBe("canceled"); + await expect(client.request("turn/cancel", { turnId: started.turn.id })).rejects.toMatchObject({ code: "CONFLICT" }); + }); + + it("delivers approval requests to a viewer and lets the owner resolve them once", async () => { + const h = await harness(); + const ownerSide = await h.connect(OWNER_TOKEN); + const viewerSide = await h.connect(VIEWER_TOKEN); + await ownerSide.client.request("session/attach", { sessionId: "s1" }); + await ownerSide.client.request("events/subscribe", { scope: "session", sessionId: "s1" }); + await viewerSide.client.request("events/subscribe", { scope: "session", sessionId: "s1" }); + await ownerSide.client.request("turn/start", { sessionId: "s1", input: { text: "run" }, context: context("r1") }); + h.host.ingest(envelope("s1", "rt_1", { type: "agent_start" })); + h.host.ingest( + envelope("s1", "rt_1", { + type: "tool_permission_request", + request: { requestId: "perm-1", sessionId: "s1", toolCallId: "c1", toolName: "Bash", argsPreview: "rm", risk: "high", reason: "shell" }, + }), + ); + await flush(); + const requested = viewerSide.events.find((event) => event.kind === "approval.requested"); + expect(requested).toBeDefined(); + expect((requested!.payload as { allowedDecisions: string[] }).allowedDecisions).toEqual(["allow-once", "deny"]); + await expect(viewerSide.client.request("approval/respond", { approvalId: "perm-1", decision: "allow-once", context: context("r2") })).rejects.toMatchObject({ code: "FORBIDDEN" }); + const resolved = await ownerSide.client.request<{ status: string; alreadyResolved: boolean }>("approval/respond", { approvalId: "perm-1", decision: "deny", context: context("r3") }); + expect(resolved).toMatchObject({ status: "resolved", alreadyResolved: false }); + expect(h.approvals.tool).toEqual([{ requestId: "perm-1", decision: "deny" }]); + const again = await ownerSide.client.request<{ alreadyResolved: boolean; decision: string }>("approval/respond", { approvalId: "perm-1", decision: "allow-once", context: context("r4") }); + expect(again).toMatchObject({ alreadyResolved: true, decision: "deny" }); + expect(h.approvals.tool).toHaveLength(1); + await flush(); + expect(viewerSide.events.filter((event) => event.kind === "approval.resolved")).toHaveLength(1); + }); + + it("answers input requests and closes the subscription of a client that stops acknowledging", async () => { + const h = await harness(); + const closed: unknown[] = []; + const { client, events } = await h.connect(OWNER_TOKEN, { onSubscriptionClosed: (notice) => closed.push(notice) }); + await client.request("session/attach", { sessionId: "s1" }); + await client.request("events/subscribe", { scope: "session", sessionId: "s1" }); + await client.request("turn/start", { sessionId: "s1", input: { text: "ask" }, context: context("r1") }); + h.host.ingest(envelope("s1", "rt_1", { type: "agent_start" })); + h.host.ingest(envelope("s1", "rt_1", { type: "asktool_request", request: { requestId: "ask-1", sessionId: "s1", toolCallId: "c1", questions: [{ question: "Which?", options: ["a", "b"] }] } })); + await flush(); + const input = events.find((event) => event.kind === "input.requested"); + expect(input).toBeDefined(); + await client.request("input/respond", { inputId: "ask-1", answers: [["a"]], context: context("r2") }); + expect(h.runtime.inputs).toEqual([{ requestId: "ask-1", sessionId: "s1", answers: [["a"]] }]); + // Acknowledgements release the bound; without them a slow client is closed with a resumable cursor. + for (let index = 0; index < 1_005; index += 1) { + h.host.ingest(envelope("s1", "rt_1", { type: "message_end", message: { id: `m${index}`, role: "assistant", content: "x", createdAt: "2026-09-18T00:00:01.000Z", status: "complete" } })); + } + await flush(); + expect(closed).toHaveLength(1); + expect(closed[0]).toMatchObject({ error: { code: "CLIENT_TOO_SLOW" }, lastSafeCursor: { sequence: expect.any(Number) } }); + }); +}); + +describe("RACP-WS reconnect", () => { + it("keeps the turn running across a drop and resumes events by cursor without a second prompt", async () => { + const h = await harness(); + const { client, events, link } = await h.connect(OWNER_TOKEN, { reconnect: { enabled: true, baseDelayMs: 1 } }); + await client.request("session/attach", { sessionId: "s1" }); + await client.request("events/subscribe", { scope: "session", sessionId: "s1" }); + const started = await client.request<{ turn: RacpTurn }>("turn/start", { sessionId: "s1", input: { text: "long job" }, context: context("r1", "job-1") }); + h.host.ingest(envelope("s1", "rt_1", { type: "agent_start" })); + await flush(); + const before = events.length; + link().drop(); + await flush(); + // Events that happened while disconnected are retained by the Host. + h.host.ingest(envelope("s1", "rt_1", { type: "message_end", message: { id: "m1", role: "assistant", content: "still working", createdAt: "2026-09-18T00:00:01.000Z", status: "complete" } })); + await flush(); + expect(client.state).toBe("connected"); + expect(h.runtime.aborts).toEqual([]); + expect(h.runtime.stops).toEqual([]); + const attach = await client.request<{ replayComplete: boolean; session: { activeTurnId?: string } }>("session/attach", { sessionId: "s1", after: client.cursorFor("s1") }); + expect(attach.replayComplete).toBe(true); + expect(attach.session.activeTurnId).toBe(started.turn.id); + const resumed = await client.request<{ replayComplete: boolean; starting: { sequence: number } }>("events/subscribe", { scope: "session", sessionId: "s1", after: client.cursorFor("s1") }); + expect(resumed.replayComplete).toBe(true); + await flush(); + const replayed = events.slice(before).filter((event) => typeof event.sequence === "number"); + expect(replayed.map((event) => event.kind)).toEqual(["item.completed"]); + // The same idempotency key after reconnect returns the same turn: nothing re-executes. + const again = await client.request<{ turn: RacpTurn }>("turn/start", { sessionId: "s1", input: { text: "long job" }, context: context("r2", "job-1") }); + expect(again.turn.id).toBe(started.turn.id); + expect(h.runtime.prompts).toHaveLength(1); + // The old connection's subscription was released with it. + expect(h.host.hub.subscriptionCount()).toBe(1); + }); + + it("falls back to a snapshot when the cursor is from another epoch or was evicted", async () => { + const h = await harness({ limits: { replayWindowEvents: 3 } }); + const { client, events } = await h.connect(OWNER_TOKEN); + await client.request("session/attach", { sessionId: "s1" }); + await client.request("events/subscribe", { scope: "session", sessionId: "s1" }); + await client.request("turn/start", { sessionId: "s1", input: { text: "go" }, context: context("r1") }); + h.host.ingest(envelope("s1", "rt_1", { type: "agent_start" })); + await flush(); + const early = client.cursorFor("s1")!; + for (let index = 0; index < 6; index += 1) { + h.host.ingest(envelope("s1", "rt_1", { type: "message_end", message: { id: `m${index}`, role: "assistant", content: "x", createdAt: "2026-09-18T00:00:01.000Z", status: "complete" } })); + } + await flush(); + const evicted = await client.request<{ replayComplete: boolean; resyncReason?: string; snapshot?: RacpSessionSnapshot }>("session/attach", { sessionId: "s1", after: early }); + expect(evicted.replayComplete).toBe(false); + expect(evicted.resyncReason).toBe("evicted"); + expect(evicted.snapshot?.activeTurn?.id).toBe("rt_1"); + const otherEpoch = await client.request<{ replayComplete: boolean; resyncReason?: string }>("session/attach", { sessionId: "s1", after: { epoch: "ep_old", sequence: 1 } }); + expect(otherEpoch.resyncReason).toBe("epoch"); + const ahead = await client.request<{ resyncReason?: string }>("events/subscribe", { scope: "session", sessionId: "s1", after: { epoch: early.epoch, sequence: 999 } }); + expect(ahead.resyncReason).toBe("ahead"); + expect(events.length).toBeGreaterThan(0); + }); + + it("rejects in-flight requests on a drop with HOST_DISCONNECTED and reports state changes", async () => { + const h = await harness(); + const states: string[] = []; + const { client, link } = await h.connect(OWNER_TOKEN, { onStateChange: (state) => states.push(state) }); + const pending = client.request("connection/ping"); + link().drop(); + await expect(pending).rejects.toMatchObject({ code: "HOST_DISCONNECTED" }); + await flush(); + expect(client.state).toBe("disconnected"); + await expect(client.request("connection/ping")).rejects.toMatchObject({ code: "HOST_DISCONNECTED" }); + expect(states).toEqual(["connecting", "connected", "disconnected"]); + }); + + it("gives up after the reconnect budget and reports an error state", async () => { + const h = await harness(); + const states: string[] = []; + const { client, link } = await h.connect(OWNER_TOKEN, { reconnect: { enabled: true, baseDelayMs: 1, maxAttempts: 2 }, onStateChange: (state) => states.push(state) }); + await h.store.revokeDevice("dev_owner", "2026-09-18T00:00:00.000Z"); + link().drop(); + await flush(); + await flush(); + expect(states.at(-1)).toBe("error"); + expect(client.state).toBe("error"); + }); +}); + +describe("RACP-WS remote-host profile", () => { + it("creates, configures, forks, renames, and deletes sessions and publishes host events", async () => { + const h = await harness(); + const { client, events } = await h.connect(OWNER_TOKEN); + await client.request("events/subscribe", { scope: "host" }); + const created = await client.request<{ session: { id: string; title: string; permissionMode: string } }>("session/create", { title: "Remote job", permissionMode: "accept-edits" }); + expect(created.session.permissionMode).toBe("accept-edits"); + const configured = await client.request<{ session: { mode: string } }>("session/configure", { sessionId: created.session.id, mode: "plan" }); + expect(configured.session.mode).toBe("plan"); + const forked = await client.request<{ session: { id: string } }>("session/fork", { sessionId: created.session.id }); + expect(forked.session.id).toBe(`${created.session.id}-fork`); + await client.request("session/rename", { sessionId: created.session.id, title: "Renamed" }); + expect(h.sessions.get(created.session.id)?.title).toBe("Renamed"); + await client.request("session/delete", { sessionId: forked.session.id }); + expect(h.sessions.has(forked.session.id)).toBe(false); + await client.request("session/compact", { sessionId: created.session.id }); + await flush(); + expect(events.filter((event) => event.scope === "host").map((event) => event.kind)).toEqual(["session.created", "session.changed", "session.created", "session.changed"]); + await expect(client.request("session/get", { sessionId: "nope" })).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); + + it("serves projects and bounded workspace reads and maps Host path errors to stable codes", async () => { + const h = await harness(); + const { client } = await h.connect(OWNER_TOKEN); + const projects = await client.request<{ projects: Array<{ id: string }> }>("project/list"); + expect(projects.projects[0]?.id).toBe("proj"); + const registered = await client.request<{ project: { id: string; label: string } }>("project/register", { path: "/srv/app" }); + expect(registered.project).toMatchObject({ id: "proj-2", label: "app" }); + await expect(client.request("project/register", { path: "/srv/missing" })).rejects.toMatchObject({ code: "REMOTE_PATH_NOT_FOUND" }); + const browsed = await client.request<{ entries: unknown[] }>("project/browse", {}); + expect(browsed.entries).toHaveLength(1); + const listed = await client.request<{ entries: Array<{ name: string }> }>("workspace/list", { sessionId: "s1", path: "" }); + expect(listed.entries[0]?.name).toBe("README.md"); + const read = await client.request<{ kind: string; content: string }>("workspace/read", { sessionId: "s1", path: "README.md" }); + expect(read).toMatchObject({ kind: "text", content: "hello" }); + await expect(client.request("workspace/read", { sessionId: "s1", path: "../etc/passwd" })).rejects.toMatchObject({ code: "REMOTE_PATH_FORBIDDEN" }); + await expect(client.request("workspace/read", { sessionId: "s1" })).rejects.toMatchObject({ code: "INVALID_ARGUMENT" }); + const diff = await client.request<{ repo: boolean }>("workspace/diff", { sessionId: "s1" }); + expect(diff.repo).toBe(true); + await expect(client.request("terminal/open", { sessionId: "s1" })).rejects.toMatchObject({ code: "CAPABILITY_UNAVAILABLE" }); + await expect(client.request("attachment/create", {})).rejects.toMatchObject({ code: "CAPABILITY_UNAVAILABLE" }); + }); + + it("rejects malformed params and oversized frames with typed errors", async () => { + const h = await harness({ limits: { maxFrameBytes: 512 } }); + const { client, link } = await h.connect(OWNER_TOKEN); + await expect(client.request("turn/start", { sessionId: "s1" })).rejects.toMatchObject({ code: "INVALID_ARGUMENT" }); + await expect(client.request("session/attach", { sessionId: 42 })).rejects.toMatchObject({ code: "INVALID_ARGUMENT" }); + link().clientSide().send("x".repeat(600)); + await flush(); + const last = JSON.parse(link().toClient.at(-1)!) as { error?: { data?: { code?: string } } }; + expect(last.error?.data?.code).toBe("PAYLOAD_TOO_LARGE"); + }); +}); + +/** Type-only assertion that the envelope import stays used. */ +export type _Envelope = RacpEventEnvelope; diff --git a/packages/racp/src/server.ts b/packages/racp/src/server.ts new file mode 100644 index 0000000000..b5ce456565 --- /dev/null +++ b/packages/racp/src/server.ts @@ -0,0 +1,364 @@ +import { randomUUID } from "node:crypto"; + +import { AgentHost, RacpError, type Principal } from "@pi-desktop/agent-host"; +import { + RACP_DEFAULT_LIMITS, + RACP_DEFAULT_POLICY, + RACP_EVENT_NOTIFICATION, + RACP_INITIALIZED_NOTIFICATION, + RACP_OPERATIONS, + RACP_PROTOCOL_VERSION, + RACP_SUBSCRIPTION_CLOSED_NOTIFICATION, + RacpInitializeParamsSchema, + protocolVersionsCompatible, + rolesAllowOperation, + type RacpEventEnvelope, + type RacpInitializeResult, + type RacpLimits, + type RacpOperation, + type RacpPolicy, + type RacpServerCapabilities, +} from "@pi-desktop/shared"; +import * as Value from "typebox/value"; + +import type { ConnectionAuth, DeviceTokenAuthenticator } from "./auth.js"; +import type { RacpHostOperations } from "./host-operations.js"; +import { + encodeFrame, + errorObjectFrom, + isNotification, + isRequest, + isResponse, + parseFrame, + type JsonRpcErrorObject, + type JsonRpcId, + type JsonRpcMessage, +} from "./jsonrpc.js"; +import { createOperations, type OperationContext, type OperationHandler } from "./operations.js"; + +/** One accepted transport connection, as the server drives it. */ +export interface ServerConnectionTransport { + send(frame: string): void; + close(code: number, reason: string): void; + onMessage(handler: (frame: string, byteLength: number) => void): void; + onClose(handler: () => void): void; +} + +export type RacpServerOptions = { + agentHost: AgentHost; + operations: RacpHostOperations; + authenticator: DeviceTokenAuthenticator; + /** Stable Host identity (D446). */ + hostId: string; + serverName?: string; + serverVersion: string; + limits?: Partial; + policy?: Partial; + log: (level: "info" | "warn" | "error", message: string, data?: Record) => void; + now?: () => number; + /** Deadline for `connection/initialize` after the socket opened (spec §12). */ + initializeTimeoutMs?: number; +}; + +type Subscription = { id: string; sessionId?: string }; + +type ServerRequestWaiter = { + resolve: (value: unknown) => void; + reject: (error: RacpError) => void; + timer: ReturnType; +}; + +/** Per-connection state; the server keeps one per accepted socket. */ +export class RacpConnection { + readonly id = `conn_${randomUUID()}`; + principal: Principal; + initialized = false; + readonly subscriptions = new Map(); + readonly terminals = new Set(); + private readonly pendingServerRequests = new Map(); + private serverRequestCounter = 0; + private closed = false; + + constructor( + readonly auth: ConnectionAuth, + readonly transport: ServerConnectionTransport, + private readonly server: RacpServer, + ) { + this.principal = { ...auth.principal, connectionId: this.id }; + } + + send(message: JsonRpcMessage): void { + if (this.closed) return; + try { + this.transport.send(encodeFrame(message)); + } catch (error) { + this.server.log("warn", "racp send failed", { connectionId: this.id, error: String(error) }); + } + } + + notify(method: string, params: unknown): void { + this.send({ jsonrpc: "2.0", method, params }); + } + + /** A server-initiated request (spec §4.3); resolves with the client's result. */ + request(method: string, params: unknown, timeoutMs: number): Promise { + this.serverRequestCounter += 1; + const id = `srv_${this.serverRequestCounter}`; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pendingServerRequests.delete(id); + reject(new RacpError("TOOL_FAILED", `client did not answer ${method} in time`)); + }, timeoutMs); + timer.unref?.(); + this.pendingServerRequests.set(id, { resolve: resolve as (value: unknown) => void, reject, timer }); + this.send({ jsonrpc: "2.0", id, method, params }); + }); + } + + handleResponse(id: JsonRpcId, result: unknown, error: JsonRpcErrorObject | undefined): void { + const waiter = this.pendingServerRequests.get(String(id)); + if (!waiter) return; + this.pendingServerRequests.delete(String(id)); + clearTimeout(waiter.timer); + if (error) waiter.reject(new RacpError(error.data?.code ?? "TOOL_FAILED", error.message)); + else waiter.resolve(result); + } + + close(code: number, reason: string): void { + if (this.closed) return; + this.closed = true; + for (const waiter of this.pendingServerRequests.values()) { + clearTimeout(waiter.timer); + waiter.reject(new RacpError("AGENT_UNAVAILABLE", "connection closed")); + } + this.pendingServerRequests.clear(); + try { + this.transport.close(code, reason); + } catch { + // Already gone. + } + } + + get isClosed(): boolean { + return this.closed; + } +} + +/** + * The `RACP-WS` server core: connection admission, initialization, request + * dispatch with role checks from the operation catalog, event fan-out from + * the Agent Host hub, and connection teardown that releases every + * subscription and terminal the connection held. Transport framing is + * injected so the same core runs over `ws` in `pi-host` and over an + * in-memory pair in tests. + */ +export class RacpServer { + readonly limits: RacpLimits; + readonly policy: RacpPolicy; + readonly capabilities: RacpServerCapabilities; + private readonly connections = new Map(); + private readonly handlers: Map; + private readonly now: () => number; + private closed = false; + + constructor(private readonly options: RacpServerOptions) { + this.limits = { ...RACP_DEFAULT_LIMITS, ...options.limits }; + this.policy = { ...RACP_DEFAULT_POLICY, ...options.policy }; + this.now = options.now ?? (() => Date.now()); + this.capabilities = { + eventReplay: true, + snapshot: true, + approvals: true, + inputRequests: true, + attachments: false, + serverRequests: true, + turnQueue: true, + hostEvents: true, + history: true, + remoteHostProfile: true, + toolRelay: false, + terminal: Boolean(options.operations.terminal), + notifications: false, + bindings: ["RACP-WS"], + }; + this.handlers = createOperations(); + } + + get log(): RacpServerOptions["log"] { + return this.options.log; + } + + connectionCount(): number { + return this.connections.size; + } + + /** + * Admit one authenticated transport. Returns the connection, or `null` + * when the Host is at its client limit (the transport is closed with + * `RATE_LIMITED`). + */ + accept(auth: ConnectionAuth, transport: ServerConnectionTransport): RacpConnection | null { + if (this.closed) { + transport.close(1001, "server closing"); + return null; + } + if (this.connections.size >= this.limits.maxConnectedClients) { + this.options.log("warn", "racp connection refused: client limit", { limit: this.limits.maxConnectedClients }); + transport.close(1013, "RATE_LIMITED"); + return null; + } + const connection = new RacpConnection(auth, transport, this); + this.connections.set(connection.id, connection); + const initializeTimer = setTimeout(() => { + if (!connection.initialized) { + this.options.log("warn", "racp connection closed: initialize deadline", { connectionId: connection.id }); + connection.close(1002, "PROTOCOL_MISMATCH"); + } + }, this.options.initializeTimeoutMs ?? 10_000); + initializeTimer.unref?.(); + transport.onMessage((frame, byteLength) => { + if (byteLength > this.limits.maxFrameBytes) { + connection.send({ + jsonrpc: "2.0", + id: 0, + error: errorObjectFrom(new RacpError("PAYLOAD_TOO_LARGE", "frame exceeds maxFrameBytes"), this.traceId()), + }); + return; + } + void this.handleFrame(connection, frame); + }); + transport.onClose(() => { + clearTimeout(initializeTimer); + this.release(connection); + }); + this.options.log("info", "racp connection accepted", { + connectionId: connection.id, + subject: auth.principal.subject, + kind: auth.kind, + }); + return connection; + } + + /** Stop accepting and close every connection; running turns are untouched. */ + close(): void { + this.closed = true; + for (const connection of [...this.connections.values()]) { + connection.close(1001, "server closing"); + this.release(connection); + } + } + + private traceId(): string { + return `trace_${randomUUID()}`; + } + + private release(connection: RacpConnection): void { + if (!this.connections.delete(connection.id)) return; + for (const subscription of connection.subscriptions.values()) { + this.options.agentHost.unsubscribe(subscription.id, subscription.sessionId); + } + connection.subscriptions.clear(); + for (const terminalId of connection.terminals) { + this.options.operations.terminal?.detach(terminalId); + } + connection.terminals.clear(); + connection.close(1000, "closed"); + this.options.log("info", "racp connection released", { connectionId: connection.id }); + } + + private async handleFrame(connection: RacpConnection, frame: string): Promise { + const message = parseFrame(frame); + if (!message) { + connection.send({ + jsonrpc: "2.0", + id: 0, + error: errorObjectFrom(new RacpError("INVALID_ARGUMENT", "frame is not a JSON-RPC 2.0 message"), this.traceId()), + }); + return; + } + if (isResponse(message)) { + connection.handleResponse(message.id, message.result, message.error); + return; + } + if (!isRequest(message)) { + if (isNotification(message) && message.method === RACP_INITIALIZED_NOTIFICATION) connection.initialized = true; + return; + } + const traceId = this.traceId(); + try { + const result = await this.dispatch(connection, message.method, message.params, traceId); + connection.send({ jsonrpc: "2.0", id: message.id, result }); + } catch (error) { + if (!(error instanceof RacpError)) { + this.options.log("error", "racp operation failed", { method: message.method, traceId, error: String(error) }); + } + connection.send({ jsonrpc: "2.0", id: message.id, error: errorObjectFrom(error, traceId) }); + } + } + + private async dispatch(connection: RacpConnection, method: string, params: unknown, traceId: string): Promise { + if (method === "connection/initialize") { + if (connection.initialized) throw new RacpError("CONFLICT", "the connection is already initialized"); + return this.initialize(connection, params); + } + if (!connection.initialized) { + throw new RacpError("PROTOCOL_MISMATCH", "connection/initialize must complete first"); + } + if (!(method in RACP_OPERATIONS)) throw new RacpError("METHOD_NOT_FOUND", `unknown operation ${method}`); + const operation = method as RacpOperation; + const handler = this.handlers.get(operation); + if (!handler) throw new RacpError("METHOD_NOT_FOUND", `unknown operation ${method}`); + if (!rolesAllowOperation(connection.principal.roles, operation)) { + throw new RacpError("FORBIDDEN", `principal lacks the role for ${operation}`); + } + const context: OperationContext = { + connection, + principal: connection.principal, + agentHost: this.options.agentHost, + operations: this.options.operations, + authenticator: this.options.authenticator, + limits: this.limits, + capabilities: this.capabilities, + traceId, + now: this.now, + deliverEvent: (envelope) => this.deliverEvent(connection, envelope), + closeSubscription: (subscriptionId, error, lastSafeCursor) => { + connection.subscriptions.delete(subscriptionId); + connection.notify(RACP_SUBSCRIPTION_CLOSED_NOTIFICATION, { + subscriptionId, + error: error.toRemoteError(this.traceId()), + lastSafeCursor, + }); + }, + log: this.options.log, + }; + return handler(context, (params ?? {}) as Record); + } + + private deliverEvent(connection: RacpConnection, envelope: RacpEventEnvelope): void { + connection.notify(RACP_EVENT_NOTIFICATION, envelope); + } + + private initialize(connection: RacpConnection, params: unknown): RacpInitializeResult { + if (!Value.Check(RacpInitializeParamsSchema, params)) { + throw new RacpError("INVALID_ARGUMENT", "invalid connection/initialize params"); + } + if (!protocolVersionsCompatible(RACP_PROTOCOL_VERSION, params.protocolVersion)) { + throw new RacpError("PROTOCOL_MISMATCH", `protocol ${params.protocolVersion} is not compatible with ${RACP_PROTOCOL_VERSION}`); + } + if (!params.bindings.includes("RACP-WS")) { + throw new RacpError("PROTOCOL_MISMATCH", "client does not offer the RACP-WS binding"); + } + // A pairing connection may only pair; everything else needs the device's roles. + connection.initialized = true; + return { + protocolVersion: RACP_PROTOCOL_VERSION, + server: { name: this.options.serverName ?? "pi-host", version: this.options.serverVersion, hostId: this.options.hostId }, + connectionId: connection.id, + principal: { subject: connection.principal.subject, roles: [...connection.principal.roles] }, + capabilities: this.capabilities, + limits: this.limits, + policy: this.policy, + }; + } +} diff --git a/packages/racp/src/test-harness.ts b/packages/racp/src/test-harness.ts new file mode 100644 index 0000000000..2d69ff9fc8 --- /dev/null +++ b/packages/racp/src/test-harness.ts @@ -0,0 +1,272 @@ +import type { AskToolResolution } from "@pi-desktop/shared"; +import { RacpError, AgentHost, type ApprovalPort, type Principal, type RuntimePort, type SessionPort, type SessionSummary, type TurnStartRequest } from "@pi-desktop/agent-host"; + +import { DeviceTokenAuthenticator, MemoryCredentialStore, hashToken, newDeviceToken, type ConnectionAuth } from "./auth.js"; +import { RacpClient, type ClientTransport, type ClientTransportFactory } from "./client.js"; +import type { RacpHostOperations } from "./host-operations.js"; +import { RacpServer, type ServerConnectionTransport } from "./server.js"; + +/** An in-memory transport pair: what `ws` provides, without a socket. */ +export class MemoryLink { + private clientHandlers: { message?: (frame: string) => void; close?: (info: { code: number; reason: string }) => void } = {}; + private serverHandlers: { message?: (frame: string, byteLength: number) => void; close?: () => void } = {}; + private open = true; + /** Frames the server sent, for assertions. */ + readonly toClient: string[] = []; + readonly toServer: string[] = []; + + get isOpen(): boolean { + return this.open; + } + + serverSide(): ServerConnectionTransport { + return { + send: (frame) => { + if (!this.open) throw new Error("link closed"); + this.toClient.push(frame); + queueMicrotask(() => this.clientHandlers.message?.(frame)); + }, + close: (code, reason) => this.drop(code, reason), + onMessage: (handler) => { + this.serverHandlers.message = handler; + }, + onClose: (handler) => { + this.serverHandlers.close = handler; + }, + }; + } + + clientSide(): ClientTransport { + return { + send: (frame) => { + if (!this.open) throw new Error("link closed"); + this.toServer.push(frame); + queueMicrotask(() => this.serverHandlers.message?.(frame, Buffer.byteLength(frame, "utf8"))); + }, + close: (code, reason) => this.drop(code ?? 1000, reason ?? ""), + onMessage: (handler) => { + this.clientHandlers.message = handler; + }, + onClose: (handler) => { + this.clientHandlers.close = handler; + }, + onError: () => undefined, + }; + } + + /** Simulate the network dropping: both ends observe a close. */ + drop(code = 1006, reason = "dropped"): void { + if (!this.open) return; + this.open = false; + queueMicrotask(() => { + this.serverHandlers.close?.(); + this.clientHandlers.close?.({ code, reason }); + }); + } +} + +export class FakeRuntime implements RuntimePort { + prompts: TurnStartRequest[] = []; + stops: string[] = []; + aborts: Array<{ sessionId: string; turnId?: string }> = []; + inputs: AskToolResolution[] = []; + private counter = 0; + async prompt(request: TurnStartRequest): Promise<{ turnId: string }> { + this.prompts.push(request); + this.counter += 1; + return { turnId: `rt_${this.counter}` }; + } + async stop(sessionId: string): Promise<{ requested: boolean }> { + this.stops.push(sessionId); + return { requested: true }; + } + async abort(sessionId: string, turnId?: string): Promise { + this.aborts.push({ sessionId, turnId }); + } + async respondInput(resolution: AskToolResolution): Promise { + this.inputs.push(resolution); + } +} + +export function summary(id: string, permissionMode: SessionSummary["permissionMode"] = "ask"): SessionSummary { + return { + id, + title: `Session ${id}`, + projectId: "proj", + mode: "agent", + permissionMode, + createdAt: "2026-09-18T00:00:00.000Z", + updatedAt: "2026-09-18T00:00:00.000Z", + }; +} + +export function buildHost(limits: Partial = {}): { host: AgentHost; runtime: FakeRuntime; sessions: Map; approvals: { tool: Array<{ requestId: string; decision: string }> } } { + const runtime = new FakeRuntime(); + const sessions = new Map([["s1", summary("s1")]]); + const sessionPort: SessionPort = { + async get(sessionId) { + return sessions.get(sessionId) ?? null; + }, + async history() { + return { items: [], hasMore: false }; + }, + }; + const approvals = { tool: [] as Array<{ requestId: string; decision: string }> }; + const approvalPort: ApprovalPort = { + async resolveTool(requestId, decision) { + approvals.tool.push({ requestId, decision }); + }, + async resolveContract() {}, + async listPendingTools() { + return []; + }, + }; + const host = new AgentHost({ runtime, sessions: sessionPort, approvals: approvalPort, limits: { replayWindowEvents: 50, ...limits } }); + return { host, runtime, sessions, approvals }; +} + +export function fakeOperations(sessions: Map): RacpHostOperations { + let counter = 0; + return { + sessions: { + async list() { + return [...sessions.values()]; + }, + async create(input) { + counter += 1; + const created = { ...summary(`s${counter + 1}`), title: input.title ?? "New session", ...(input.permissionMode ? { permissionMode: input.permissionMode } : {}) }; + sessions.set(created.id, created); + return created; + }, + async configure(sessionId, input) { + const current = sessions.get(sessionId); + if (!current) throw new RacpError("NOT_FOUND", "session"); + const next = { ...current, ...(input.mode ? { mode: input.mode } : {}), ...(input.permissionMode ? { permissionMode: input.permissionMode } : {}) }; + sessions.set(sessionId, next); + return next; + }, + async fork(sessionId) { + const current = sessions.get(sessionId); + if (!current) throw new RacpError("NOT_FOUND", "session"); + const forked = { ...current, id: `${sessionId}-fork`, title: `${current.title} (fork)` }; + sessions.set(forked.id, forked); + return forked; + }, + async rename(sessionId, title) { + const current = sessions.get(sessionId); + if (current) sessions.set(sessionId, { ...current, title }); + }, + async delete(sessionId) { + sessions.delete(sessionId); + }, + async compact() { + return { accepted: true }; + }, + }, + projects: { + async list() { + return [{ id: "proj", label: "proj", archived: false }]; + }, + async register(path) { + if (path.includes("missing")) throw new RacpError("REMOTE_PATH_NOT_FOUND", "no such directory"); + return { id: "proj-2", label: path.split("/").pop() ?? path, archived: false, path }; + }, + async browse(path) { + return { path: path ?? "/home/user", entries: [{ name: "work", path: `${path ?? "/home/user"}/work` }] }; + }, + }, + workspace: { + async list(_sessionId, path) { + if (path.includes("..")) throw new RacpError("REMOTE_PATH_FORBIDDEN", "escapes root"); + return { entries: [{ name: "README.md", kind: "file", size: 12 }] }; + }, + async read(_sessionId, path) { + if (path.includes("..")) throw new RacpError("REMOTE_PATH_FORBIDDEN", "escapes root"); + return { kind: "text", content: "hello", size: 5 }; + }, + async diff() { + return { repo: true, clean: true, files: [] }; + }, + }, + }; +} + +export const OWNER_TOKEN = newDeviceToken(); +export const VIEWER_TOKEN = newDeviceToken(); + +export async function credentialStore(): Promise<{ store: MemoryCredentialStore; authenticator: DeviceTokenAuthenticator }> { + const store = new MemoryCredentialStore(); + await store.saveDevice({ deviceId: "dev_owner", label: "desktop", roles: ["owner"], tokenHash: hashToken(OWNER_TOKEN), createdAt: "2026-09-18T00:00:00.000Z" }); + await store.saveDevice({ deviceId: "dev_viewer", label: "watcher", roles: ["viewer"], tokenHash: hashToken(VIEWER_TOKEN), createdAt: "2026-09-18T00:00:00.000Z" }); + return { store, authenticator: new DeviceTokenAuthenticator(store) }; +} + +export type Harness = { + server: RacpServer; + host: AgentHost; + runtime: FakeRuntime; + sessions: Map; + approvals: { tool: Array<{ requestId: string; decision: string }> }; + authenticator: DeviceTokenAuthenticator; + store: MemoryCredentialStore; + links: MemoryLink[]; + /** Open a client whose transport authenticates with `token` on every (re)connect. */ + connect(token: string, options?: Partial[0]>): Promise<{ client: RacpClient; events: import("@pi-desktop/shared").RacpEventEnvelope[]; link: () => MemoryLink }>; +}; + +export async function harness(options: { limits?: Partial; operations?: Partial } = {}): Promise { + const { host, runtime, sessions, approvals } = buildHost(options.limits); + const { store, authenticator } = await credentialStore(); + const operations = { ...fakeOperations(sessions), ...options.operations }; + const server = new RacpServer({ + agentHost: host, + operations, + authenticator, + hostId: "host_test", + serverVersion: "0.15.0", + limits: options.limits, + log: () => undefined, + initializeTimeoutMs: 1_000, + }); + const links: MemoryLink[] = []; + return { + server, + host, + runtime, + sessions, + approvals, + authenticator, + store, + links, + async connect(token, clientOptions = {}) { + const events: import("@pi-desktop/shared").RacpEventEnvelope[] = []; + let current: MemoryLink | null = null; + const transport: ClientTransportFactory = async () => { + const auth: ConnectionAuth | null = await authenticator.authenticate({ authorization: `Bearer ${token}`, urlHasToken: false, connectionId: "pending" }); + if (!auth) throw Object.assign(new Error("unauthorized"), { errorCode: "REMOTE_AUTH_FAILED" }); + const link = new MemoryLink(); + links.push(link); + current = link; + const accepted = server.accept(auth, link.serverSide()); + if (!accepted) throw Object.assign(new Error("refused"), { errorCode: "REMOTE_CONNECTION_FAILED" }); + return link.clientSide(); + }; + const client = new RacpClient({ + transport, + client: { name: "test", version: "0.15.0" }, + onEvent: (envelope) => events.push(envelope), + requestTimeoutMs: 2_000, + sleep: async () => undefined, + ...clientOptions, + }); + await client.connect(); + return { client, events, link: () => current! }; + }, + }; +} + +export const owner: Principal = { subject: "dev_owner", roles: ["owner"], pairedDevice: true }; + +export async function flush(): Promise { + for (let index = 0; index < 8; index += 1) await new Promise((resolve) => setImmediate(resolve)); +} diff --git a/packages/racp/src/ws-binding.test.ts b/packages/racp/src/ws-binding.test.ts new file mode 100644 index 0000000000..51536a22a7 --- /dev/null +++ b/packages/racp/src/ws-binding.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { WebSocket } from "ws"; +import { RACP_WS_SUBPROTOCOL } from "@pi-desktop/shared"; + +import { RacpClient } from "./client.js"; +import { OWNER_TOKEN, harness } from "./test-harness.js"; +import { bindRacpWebSocket, wsClientTransport } from "./ws-binding.js"; + +describe("RACP-WS over a loopback socket", () => { + it("authenticates on the upgrade, runs the handshake, and delivers events over ws", async () => { + const h = await harness(); + const binding = await bindRacpWebSocket({ server: h.server, authenticator: h.authenticator, port: 0, log: () => undefined }); + try { + const url = `ws://127.0.0.1:${binding.address.port}/v1/racp/ws`; + const events: unknown[] = []; + const client = new RacpClient({ + transport: wsClientTransport({ url, token: OWNER_TOKEN }), + client: { name: "test", version: "0.15.0" }, + onEvent: (envelope) => events.push(envelope), + }); + const init = await client.connect(); + expect(init.server.hostId).toBe("host_test"); + await client.request("session/attach", { sessionId: "s1" }); + await client.request("events/subscribe", { scope: "session", sessionId: "s1" }); + await client.request("turn/start", { sessionId: "s1", input: { text: "hi" }, context: { requestId: "r1" } }); + h.host.ingest({ sessionId: "s1", turnId: "rt_1", ts: 1, event: { type: "agent_start" } }); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(events).toHaveLength(1); + await client.close(); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(h.server.connectionCount()).toBe(0); + } finally { + await binding.close(); + } + }); + + it("refuses a missing credential, a token in the URL, a wrong path, and a binary frame", async () => { + const h = await harness(); + const binding = await bindRacpWebSocket({ server: h.server, authenticator: h.authenticator, port: 0, log: () => undefined }); + try { + const base = `ws://127.0.0.1:${binding.address.port}`; + const status = (url: string, headers: Record = {}) => + new Promise((resolve) => { + const socket = new WebSocket(url, [RACP_WS_SUBPROTOCOL], { headers }); + socket.once("unexpected-response", (_request, response) => { + resolve(response.statusCode ?? 0); + socket.terminate(); + }); + socket.once("open", () => { + resolve("open"); + socket.terminate(); + }); + socket.once("error", () => resolve(0)); + }); + expect(await status(`${base}/v1/racp/ws`)).toBe(401); + expect(await status(`${base}/v1/racp/ws?token=${OWNER_TOKEN}`, { Authorization: `Bearer ${OWNER_TOKEN}` })).toBe(401); + expect(await status(`${base}/other`, { Authorization: `Bearer ${OWNER_TOKEN}` })).toBe(404); + expect(await status(`${base}/v1/racp/ws`, { Authorization: `Bearer ${OWNER_TOKEN}` })).toBe("open"); + await expect(new RacpClient({ transport: wsClientTransport({ url: `${base}/v1/racp/ws`, token: "pdt1.bad" }), client: { name: "t", version: "1" } }).connect()).rejects.toMatchObject({ code: "REMOTE_AUTH_FAILED" }); + + const closeCode = await new Promise((resolve) => { + const socket = new WebSocket(`${base}/v1/racp/ws`, [RACP_WS_SUBPROTOCOL], { headers: { Authorization: `Bearer ${OWNER_TOKEN}` } }); + socket.once("open", () => socket.send(Buffer.from([1, 2, 3]))); + socket.once("close", (code) => resolve(code)); + }); + expect(closeCode).toBe(1003); + expect(h.server.connectionCount()).toBe(0); + } finally { + await binding.close(); + } + }); + + it("refuses to bind a non-loopback address and plain ws to a non-loopback host", async () => { + const h = await harness(); + await expect(bindRacpWebSocket({ server: h.server, authenticator: h.authenticator, host: "0.0.0.0", port: 0, log: () => undefined })).rejects.toMatchObject({ errorCode: "INVALID_ARGUMENT" }); + await expect(wsClientTransport({ url: "ws://example.com/v1/racp/ws", token: OWNER_TOKEN })()).rejects.toMatchObject({ errorCode: "REMOTE_CONNECTION_FAILED" }); + }); +}); diff --git a/packages/racp/src/ws-binding.ts b/packages/racp/src/ws-binding.ts new file mode 100644 index 0000000000..62d7731bca --- /dev/null +++ b/packages/racp/src/ws-binding.ts @@ -0,0 +1,186 @@ +import type { IncomingMessage } from "node:http"; +import { createServer, type Server } from "node:http"; +import type { Socket } from "node:net"; + +import { RACP_WS_PATH, RACP_WS_SUBPROTOCOL } from "@pi-desktop/shared"; +import { WebSocket, WebSocketServer } from "ws"; + +import { isLoopbackAddress, type DeviceTokenAuthenticator } from "./auth.js"; +import type { ClientTransport, ClientTransportFactory } from "./client.js"; +import type { RacpServer, ServerConnectionTransport } from "./server.js"; + +export type WsBindingOptions = { + server: RacpServer; + authenticator: DeviceTokenAuthenticator; + /** Loopback only unless TLS terminates in front (security §5.1); a non-loopback bind is refused here. */ + host?: string; + port: number; + log: (level: "info" | "warn" | "error", message: string, data?: Record) => void; + heartbeatMs?: number; +}; + +export type WsBinding = { + address: { host: string; port: number }; + close(): Promise; +}; + +function wsTransport(socket: WebSocket): ServerConnectionTransport { + return { + send: (frame) => socket.send(frame), + close: (code, reason) => socket.close(code, reason), + onMessage: (handler) => { + socket.on("message", (data, isBinary) => { + if (isBinary) { + socket.close(1003, "binary frames are not accepted"); + return; + } + const text = typeof data === "string" ? data : Buffer.isBuffer(data) ? data.toString("utf8") : Buffer.concat(data as Buffer[]).toString("utf8"); + handler(text, Buffer.byteLength(text, "utf8")); + }); + }, + onClose: (handler) => { + socket.once("close", handler); + socket.once("error", () => handler()); + }, + }; +} + +/** + * Bind the RACP server to a loopback WebSocket listener (spec §11.1). The + * upgrade request carries the bearer credential; a token in the URL, a + * non-loopback peer, a wrong path, or a missing subprotocol is refused + * before any RPC runs. + */ +export async function bindRacpWebSocket(options: WsBindingOptions): Promise { + const host = options.host ?? "127.0.0.1"; + if (!isLoopbackAddress(host)) { + throw Object.assign(new Error("pi-host binds loopback only; a non-loopback bind requires TLS"), { + errorCode: "INVALID_ARGUMENT", + }); + } + const http: Server = createServer((_request, response) => { + response.statusCode = 426; + response.setHeader("Upgrade", "websocket"); + response.end("RACP-WS endpoint"); + }); + const wss = new WebSocketServer({ noServer: true, handleProtocols: (protocols) => (protocols.has(RACP_WS_SUBPROTOCOL) ? RACP_WS_SUBPROTOCOL : false) }); + const sockets = new Set(); + + http.on("upgrade", (request: IncomingMessage, socket: Socket, head: Buffer) => { + void (async () => { + const url = new URL(request.url ?? "/", "http://localhost"); + const reject = (status: number, reason: string) => { + options.log("warn", "racp upgrade refused", { reason, peer: request.socket.remoteAddress }); + socket.write(`HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\n\r\n`); + socket.destroy(); + }; + if (url.pathname !== RACP_WS_PATH) return reject(404, "Not Found"); + if (!isLoopbackAddress(request.socket.remoteAddress)) return reject(403, "Forbidden"); + const urlHasToken = [...url.searchParams.keys()].some((key) => /token|auth/i.test(key)); + const auth = await options.authenticator.authenticate({ + authorization: request.headers.authorization, + urlHasToken, + connectionId: "pending", + }); + if (!auth) return reject(401, "Unauthorized"); + wss.handleUpgrade(request, socket, head, (ws) => { + sockets.add(ws); + ws.once("close", () => sockets.delete(ws)); + const connection = options.server.accept(auth, wsTransport(ws)); + if (!connection) return; + // Heartbeat (spec §11.1): a peer that stops answering pings is dropped. + let alive = true; + ws.on("pong", () => { + alive = true; + }); + const heartbeat = setInterval(() => { + if (ws.readyState !== WebSocket.OPEN) return; + if (!alive) { + ws.terminate(); + return; + } + alive = false; + ws.ping(); + }, options.heartbeatMs ?? 30_000); + heartbeat.unref?.(); + ws.once("close", () => clearInterval(heartbeat)); + }); + })().catch((error) => { + options.log("error", "racp upgrade failed", { error: String(error) }); + socket.destroy(); + }); + }); + + await new Promise((resolve, reject) => { + http.once("error", reject); + http.listen(options.port, host, () => { + http.off("error", reject); + resolve(); + }); + }); + const address = http.address(); + const port = typeof address === "object" && address ? address.port : options.port; + options.log("info", "racp listening", { host, port }); + return { + address: { host, port }, + close: async () => { + for (const ws of sockets) ws.terminate(); + wss.close(); + await new Promise((resolve) => http.close(() => resolve())); + }, + }; +} + +export type WsClientOptions = { + url: string; + token: string; + connectTimeoutMs?: number; +}; + +/** A `ws`-backed client transport for the header profile. */ +export function wsClientTransport(options: WsClientOptions): ClientTransportFactory { + return () => + new Promise((resolve, reject) => { + const url = new URL(options.url); + if (url.protocol !== "ws:" && url.protocol !== "wss:") { + reject(Object.assign(new Error("RACP endpoint must be ws:// or wss://"), { errorCode: "REMOTE_CONNECTION_FAILED" })); + return; + } + if (url.protocol === "ws:" && !isLoopbackAddress(url.hostname) && url.hostname !== "localhost") { + reject(Object.assign(new Error("plain ws:// is accepted only on loopback"), { errorCode: "REMOTE_CONNECTION_FAILED" })); + return; + } + const socket = new WebSocket(url, [RACP_WS_SUBPROTOCOL], { + headers: { Authorization: `Bearer ${options.token}` }, + handshakeTimeout: options.connectTimeoutMs ?? 10_000, + }); + let settled = false; + socket.once("open", () => { + settled = true; + resolve({ + send: (frame) => socket.send(frame), + close: (code, reason) => socket.close(code ?? 1000, reason), + onMessage: (handler) => { + socket.on("message", (data, isBinary) => { + if (isBinary) return; + handler(typeof data === "string" ? data : Buffer.isBuffer(data) ? data.toString("utf8") : Buffer.concat(data as Buffer[]).toString("utf8")); + }); + }, + onClose: (handler) => socket.once("close", (code, reason) => handler({ code, reason: reason.toString() })), + onError: (handler) => socket.on("error", handler), + }); + }); + socket.once("error", (error) => { + if (settled) return; + settled = true; + reject(Object.assign(new Error(error.message), { errorCode: "REMOTE_CONNECTION_FAILED" })); + }); + socket.once("unexpected-response", (_request, response) => { + if (settled) return; + settled = true; + const code = response.statusCode === 401 || response.statusCode === 403 ? "REMOTE_AUTH_FAILED" : "REMOTE_CONNECTION_FAILED"; + reject(Object.assign(new Error(`upgrade refused: ${response.statusCode}`), { errorCode: code })); + socket.terminate(); + }); + }); +} diff --git a/packages/racp/tsconfig.json b/packages/racp/tsconfig.json new file mode 100644 index 0000000000..bbd31e1e49 --- /dev/null +++ b/packages/racp/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo", + "rootDir": "src", + "composite": true, + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["src/**/*.test.ts"], + "references": [{ "path": "../shared" }, { "path": "../agent-host" }] +} diff --git a/packages/racp/vitest.config.ts b/packages/racp/vitest.config.ts new file mode 100644 index 0000000000..ae847ff6d9 --- /dev/null +++ b/packages/racp/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + }, +}); diff --git a/packages/shared/fixtures/racp.schema.json b/packages/shared/fixtures/racp.schema.json index ce5e4416af..11063f35a1 100644 --- a/packages/shared/fixtures/racp.schema.json +++ b/packages/shared/fixtures/racp.schema.json @@ -1918,6 +1918,10 @@ }, "version": { "type": "string" + }, + "hostId": { + "type": "string", + "minLength": 1 } } }, diff --git a/packages/shared/src/errors.ts b/packages/shared/src/errors.ts index 1e5479d990..8a5fbdbd82 100644 --- a/packages/shared/src/errors.ts +++ b/packages/shared/src/errors.ts @@ -171,6 +171,31 @@ export const ErrorCodes = { APPROVAL_STALE: "APPROVAL_STALE", PAYLOAD_TOO_LARGE: "PAYLOAD_TOO_LARGE", RATE_LIMITED: "RATE_LIMITED", + /** + * Remote Host connection codes (D446 / ADR 0283). The desktop adapter and + * the `pi-host` bootstrap classify a remote failure by these, never by + * matching message text. + */ + /** The transport to a paired Host dropped; the Host itself may still be running. */ + HOST_DISCONNECTED: "HOST_DISCONNECTED", + /** Installing or starting `pi-host` over the bootstrap channel failed. */ + HOST_BOOTSTRAP_FAILED: "HOST_BOOTSTRAP_FAILED", + /** The paired Host runs a different release than this client. */ + HOST_VERSION_MISMATCH: "HOST_VERSION_MISMATCH", + /** The device credential was refused by the Host. */ + REMOTE_AUTH_FAILED: "REMOTE_AUTH_FAILED", + /** The RACP connection could not be established. */ + REMOTE_CONNECTION_FAILED: "REMOTE_CONNECTION_FAILED", + /** The transport's port forward could not be set up. */ + REMOTE_FORWARD_FAILED: "REMOTE_FORWARD_FAILED", + /** A Host-side path does not exist. */ + REMOTE_PATH_NOT_FOUND: "REMOTE_PATH_NOT_FOUND", + /** A Host-side path is outside what the principal may reach. */ + REMOTE_PATH_FORBIDDEN: "REMOTE_PATH_FORBIDDEN", + PAIRING_FAILED: "PAIRING_FAILED", + PAIRING_TOKEN_EXPIRED: "PAIRING_TOKEN_EXPIRED", + /** The Host does not advertise the capability the operation needs. */ + CAPABILITY_UNAVAILABLE: "CAPABILITY_UNAVAILABLE", // Host-core RPC detail codes (spec 06 §7, 08 §3.1/§3.6). Electron surfaces // them unchanged through `AppError.code`. INVALID_PARAMS: "INVALID_PARAMS", diff --git a/packages/shared/src/racp.ts b/packages/shared/src/racp.ts index 8b8f8d0d8c..5ed730ee1c 100644 --- a/packages/shared/src/racp.ts +++ b/packages/shared/src/racp.ts @@ -480,7 +480,12 @@ export type RacpInitializeParams = Static; export const RacpInitializeResultSchema = Type.Object({ protocolVersion: Type.String({ minLength: 1 }), - server: Type.Object({ name: Type.String(), version: Type.String() }), + server: Type.Object({ + name: Type.String(), + version: Type.String(), + /** Stable identity of this Host, minted once at first start (D446). */ + hostId: Type.Optional(Type.String({ minLength: 1 })), + }), connectionId: Type.String({ minLength: 1 }), principal: Type.Object({ subject: Type.String({ minLength: 1 }), @@ -552,6 +557,12 @@ export const RACP_OPERATIONS = { "terminal/input": { role: "controller", profile: "remote-host", mutation: true }, "terminal/resize": { role: "controller", profile: "remote-host", mutation: true }, "terminal/close": { role: "controller", profile: "remote-host", mutation: true }, + /** Exchange a single-use pairing token for a device credential (security §3.4). */ + "connection/pair": { role: "authenticated", profile: "remote-host", mutation: true }, + /** Register a Host directory as a project; the Host canonicalizes and validates the path. */ + "project/register": { role: "owner", profile: "remote-host", mutation: true }, + /** List directories under a Host path, for the remote folder picker; bounded and owner-only. */ + "project/browse": { role: "owner", profile: "remote-host", mutation: false }, } as const satisfies Record; export type RacpOperation = keyof typeof RACP_OPERATIONS; @@ -561,6 +572,14 @@ export type RacpServerRequest = (typeof RACP_SERVER_REQUESTS)[number]; /** Notification method that carries an `EventEnvelope` on the WS binding. */ export const RACP_EVENT_NOTIFICATION = "session/event" as const; +/** Notification the Host sends when it closes one subscription (`CLIENT_TOO_SLOW`, §8). */ +export const RACP_SUBSCRIPTION_CLOSED_NOTIFICATION = "events/closed" as const; +/** Client notification that completes initialization (§3). */ +export const RACP_INITIALIZED_NOTIFICATION = "notifications/initialized" as const; + +/** Bearer scheme prefixes on the upgrade request (security §3.4). */ +export const RACP_DEVICE_TOKEN_PREFIX = "pdt1." as const; +export const RACP_PAIRING_TOKEN_PREFIX = "ppt1." as const; /** Operations the desktop offers locally that RACP reserves but does not expose. */ export const RACP_DEFERRED_OPERATIONS = [ @@ -758,6 +777,11 @@ export type RacpErrorSpec = { retriable: boolean | "maybe" }; export const RACP_ERROR_CODES = { UNAUTHORIZED: { retriable: false }, + PAIRING_FAILED: { retriable: false }, + PAIRING_TOKEN_EXPIRED: { retriable: false }, + CAPABILITY_UNAVAILABLE: { retriable: false }, + REMOTE_PATH_NOT_FOUND: { retriable: false }, + REMOTE_PATH_FORBIDDEN: { retriable: false }, FORBIDDEN: { retriable: false }, PROTOCOL_MISMATCH: { retriable: false }, METHOD_NOT_FOUND: { retriable: false }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b80c354f6e..f56cd42b35 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -268,6 +268,34 @@ importers: specifier: ^4.1.10 version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.13.3)(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0)) + packages/racp: + dependencies: + '@pi-desktop/agent-host': + specifier: workspace:* + version: link:../agent-host + '@pi-desktop/shared': + specifier: workspace:* + version: link:../shared + typebox: + specifier: ^1.3.13 + version: 1.3.13 + ws: + specifier: 8.21.1 + version: 8.21.1 + devDependencies: + '@types/node': + specifier: ^24.13.3 + version: 24.13.3 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.1.10 + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.13.3)(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0)) + packages/shared: dependencies: typebox: From 1c90b3ec5f83db7767f65c8814cf1c7afa9d5a75 Mon Sep 17 00:00:00 2001 From: vastsa Date: Fri, 18 Sep 2026 14:22:00 +0800 Subject: [PATCH 03/13] refactor(host-runtime): share workspace file and diff helpers The headless pi-host reuses the same workspace path-containment and git-diff parsing that the Electron main process relies on. Keeping that logic under apps/desktop/electron/main forced pi-host to depend on the desktop app, which breaks the headless runtime boundary. Move fs-panel.ts and git-diff.ts into @pi-desktop/host-runtime as workspace-files.ts and workspace-diff.ts, re-export them from the package entry, and repoint the desktop main-process importers and the affected node --test suites. No behavior change: the moves are byte identical (R100) and only import specifiers were updated. --- apps/desktop/electron/main/chat-ref-resolve.ts | 2 +- apps/desktop/electron/main/fs-index.ts | 2 +- apps/desktop/electron/main/ipc/workspace-ipc.ts | 4 ++-- apps/desktop/electron/main/plugin-dev-tools.ts | 2 +- apps/desktop/electron/main/plugin-runtime.ts | 4 ++-- apps/desktop/test/fs-panel-guard.test.mjs | 2 +- apps/desktop/test/git-diff-parse.test.mjs | 2 +- apps/desktop/test/message-image-display.test.mjs | 2 +- packages/host-runtime/src/index.ts | 2 ++ .../host-runtime/src/workspace-diff.ts | 0 .../host-runtime/src/workspace-files.ts | 0 11 files changed, 12 insertions(+), 10 deletions(-) rename apps/desktop/electron/main/git-diff.ts => packages/host-runtime/src/workspace-diff.ts (100%) rename apps/desktop/electron/main/fs-panel.ts => packages/host-runtime/src/workspace-files.ts (100%) diff --git a/apps/desktop/electron/main/chat-ref-resolve.ts b/apps/desktop/electron/main/chat-ref-resolve.ts index 81bf787bc2..1f9d9965d1 100644 --- a/apps/desktop/electron/main/chat-ref-resolve.ts +++ b/apps/desktop/electron/main/chat-ref-resolve.ts @@ -6,7 +6,7 @@ import type { FsChatRefProjectRoot, FsChatRefRoot, } from "@pi-desktop/shared"; -import { isAttachmentBlobRef, isIgnoredName } from "./fs-panel.js"; +import { isAttachmentBlobRef, isIgnoredName } from "@pi-desktop/host-runtime"; import { getWorkspaceFileIndex } from "./fs-index.js"; /** diff --git a/apps/desktop/electron/main/fs-index.ts b/apps/desktop/electron/main/fs-index.ts index 4e771c3b8e..2bd0a79b38 100644 --- a/apps/desktop/electron/main/fs-index.ts +++ b/apps/desktop/electron/main/fs-index.ts @@ -2,7 +2,7 @@ import { spawn } from "node:child_process"; import { readdir } from "node:fs/promises"; import { join } from "node:path"; import type { FsIndexEntry, FsIndexResult } from "@pi-desktop/shared"; -import { isIgnoredName } from "./fs-panel.js"; +import { isIgnoredName } from "@pi-desktop/host-runtime"; /** * Workspace file index for the composer "@" menu (D124, ADR 0024). diff --git a/apps/desktop/electron/main/ipc/workspace-ipc.ts b/apps/desktop/electron/main/ipc/workspace-ipc.ts index 3c053ca3a0..c6ced60c91 100644 --- a/apps/desktop/electron/main/ipc/workspace-ipc.ts +++ b/apps/desktop/electron/main/ipc/workspace-ipc.ts @@ -25,7 +25,7 @@ import { consumeComposerPickerSelection, rememberComposerPickerSelection, } from "../composer-picker"; -import { collectWorkspaceDiff } from "../git-diff"; +import { collectWorkspaceDiff } from "@pi-desktop/host-runtime"; import { parseAllowedExternalUrl } from "../safe-open-external"; import { isAttachmentBlobRef, @@ -34,7 +34,7 @@ import { readOpenableImage, resolveOpenablePath, resolveRealOpenablePath, -} from "../fs-panel"; +} from "@pi-desktop/host-runtime"; import { resolveChatFileRef } from "../chat-ref-resolve"; import { getWorkspaceFileIndex } from "../fs-index"; import { diff --git a/apps/desktop/electron/main/plugin-dev-tools.ts b/apps/desktop/electron/main/plugin-dev-tools.ts index 8f9fa05cd6..84ca2a2a41 100644 --- a/apps/desktop/electron/main/plugin-dev-tools.ts +++ b/apps/desktop/electron/main/plugin-dev-tools.ts @@ -6,7 +6,7 @@ import { scaffold, type CheckResult, } from "@pi-desktop/plugin-devkit"; -import { resolveWithinRoot } from "./fs-panel"; +import { resolveWithinRoot } from "@pi-desktop/host-runtime"; import type { AgentSidecar, LocalToolResult } from "./agent-sidecar"; /** diff --git a/apps/desktop/electron/main/plugin-runtime.ts b/apps/desktop/electron/main/plugin-runtime.ts index cbfbb636c8..570b3b703e 100644 --- a/apps/desktop/electron/main/plugin-runtime.ts +++ b/apps/desktop/electron/main/plugin-runtime.ts @@ -77,7 +77,7 @@ import { resolveRealPathForCreateWithinRoot, resolveRealPathWithinRoot, resolveWithinRoot, -} from "./fs-panel"; +} from "@pi-desktop/host-runtime"; import { McpServerClient, type McpServerClientOptions } from "./plugin-mcp"; import { PluginToolInvocations, type PluginToolInvocation } from "./plugin-tool-invocations"; import { DevPluginWatcher, type DevPluginWatcherDeps } from "./plugin-watcher"; @@ -986,7 +986,7 @@ export function readDevPluginDeclaration(pluginPath: string): { /** * `realpath` with the input as its own fallback, for a path that may not exist - * yet. Containment is decided by `fs-panel`'s checks; this only exists so the + * yet. Containment is decided by the host-runtime workspace-files checks; this only exists so the * relative path we compare scopes against is expressed in the same terms. */ function realpathOrSelf(path: string): string { diff --git a/apps/desktop/test/fs-panel-guard.test.mjs b/apps/desktop/test/fs-panel-guard.test.mjs index 0e4d69f64b..cbac450d49 100644 --- a/apps/desktop/test/fs-panel-guard.test.mjs +++ b/apps/desktop/test/fs-panel-guard.test.mjs @@ -16,7 +16,7 @@ import { resolveRealOpenablePath, resolveWithinRoot, MAX_TEXT_BYTES, -} from "../electron/main/fs-panel.ts"; +} from "../../../packages/host-runtime/src/workspace-files.ts"; const ROOT = resolve("virtual-workspace"); diff --git a/apps/desktop/test/git-diff-parse.test.mjs b/apps/desktop/test/git-diff-parse.test.mjs index 0383d3e4f4..d72fc6345d 100644 --- a/apps/desktop/test/git-diff-parse.test.mjs +++ b/apps/desktop/test/git-diff-parse.test.mjs @@ -5,7 +5,7 @@ import { parseFilePatch, parseStatusZ, splitUnifiedDiff, -} from "../electron/main/git-diff.ts"; +} from "../../../packages/host-runtime/src/workspace-diff.ts"; test("parses porcelain -z status including renames and untracked", () => { const raw = [ diff --git a/apps/desktop/test/message-image-display.test.mjs b/apps/desktop/test/message-image-display.test.mjs index 684384d3e4..d1ea0de95f 100644 --- a/apps/desktop/test/message-image-display.test.mjs +++ b/apps/desktop/test/message-image-display.test.mjs @@ -10,7 +10,7 @@ const [transcript, markdown, api, main, panel, protocol, hook] = await Promise.a read("../src/components/Markdown.tsx"), read("../src/lib/api.ts"), readMainSource(), - read("../electron/main/fs-panel.ts"), + read("../../../packages/host-runtime/src/workspace-files.ts"), read("../../../packages/shared/src/protocol.ts"), read("../src/lib/use-referenced-image-data-url.ts"), ]); diff --git a/packages/host-runtime/src/index.ts b/packages/host-runtime/src/index.ts index 55720460b5..e7034e40b0 100644 --- a/packages/host-runtime/src/index.ts +++ b/packages/host-runtime/src/index.ts @@ -10,3 +10,5 @@ export * from "./turn-persistence.js"; export * from "./turn-events.js"; export * from "./runtime-service.js"; export * from "./plan-dispatch.js"; +export * from "./workspace-files.js"; +export * from "./workspace-diff.js"; diff --git a/apps/desktop/electron/main/git-diff.ts b/packages/host-runtime/src/workspace-diff.ts similarity index 100% rename from apps/desktop/electron/main/git-diff.ts rename to packages/host-runtime/src/workspace-diff.ts diff --git a/apps/desktop/electron/main/fs-panel.ts b/packages/host-runtime/src/workspace-files.ts similarity index 100% rename from apps/desktop/electron/main/fs-panel.ts rename to packages/host-runtime/src/workspace-files.ts From cb8898a8601624ae056fd7606e398f2e951aeeef Mon Sep 17 00:00:00 2001 From: vastsa Date: Fri, 18 Sep 2026 14:22:19 +0800 Subject: [PATCH 04/13] feat(pi-host): headless RACP-WS agent host Add apps/pi-host, a headless entry point that runs the Agent Host module, the pi sidecar, and host-core behind the existing RACP-WS transport, so a Remote Host can execute sessions with no Electron or renderer present. The desktop app remains the local host; this only adds a second, headless deployment of the same runtime. pi-host owns its own config, credential handling, terminal (node-pty as an optional dependency so the bundle degrades gracefully when it is absent), and host operations, reusing @pi-desktop/host-runtime for the shared runtime service and workspace helpers. scripts/bundle.mjs produces a self-contained per-platform bundle (dist-bundle, ignored). Also add scripts/e2e-remote-host.mjs, the host half of E2E-231: it boots a real pi-host on a throwaway data dir, pairs a device over the loopback socket, exercises project/session/workspace flows, and checks reconnect-by-cursor. node-pty is added to the pnpm allowBuilds list. --- .gitignore | 1 + apps/pi-host/package.json | 37 +++ apps/pi-host/scripts/bundle.mjs | 89 +++++++ apps/pi-host/src/app.ts | 283 +++++++++++++++++++++++ apps/pi-host/src/cli.ts | 60 +++++ apps/pi-host/src/config.test.ts | 64 +++++ apps/pi-host/src/config.ts | 103 +++++++++ apps/pi-host/src/credentials.ts | 119 ++++++++++ apps/pi-host/src/host-operations.test.ts | 108 +++++++++ apps/pi-host/src/host-operations.ts | 251 ++++++++++++++++++++ apps/pi-host/src/index.ts | 6 + apps/pi-host/src/logger.ts | 54 +++++ apps/pi-host/src/terminal.ts | 154 ++++++++++++ apps/pi-host/tsconfig.json | 19 ++ apps/pi-host/vitest.config.ts | 7 + pnpm-lock.yaml | 56 +++++ pnpm-workspace.yaml | 1 + scripts/e2e-remote-host.mjs | 228 ++++++++++++++++++ 18 files changed, 1640 insertions(+) create mode 100644 apps/pi-host/package.json create mode 100644 apps/pi-host/scripts/bundle.mjs create mode 100644 apps/pi-host/src/app.ts create mode 100644 apps/pi-host/src/cli.ts create mode 100644 apps/pi-host/src/config.test.ts create mode 100644 apps/pi-host/src/config.ts create mode 100644 apps/pi-host/src/credentials.ts create mode 100644 apps/pi-host/src/host-operations.test.ts create mode 100644 apps/pi-host/src/host-operations.ts create mode 100644 apps/pi-host/src/index.ts create mode 100644 apps/pi-host/src/logger.ts create mode 100644 apps/pi-host/src/terminal.ts create mode 100644 apps/pi-host/tsconfig.json create mode 100644 apps/pi-host/vitest.config.ts create mode 100644 scripts/e2e-remote-host.mjs diff --git a/.gitignore b/.gitignore index a63800493c..f08d1fbeea 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ apps/desktop/dist-electron/ apps/desktop/release/ packages/*/dist/ packages/*/dist-bundle/ +apps/*/dist-bundle/ # Local design-source exports docs/image/pi_logo.png diff --git a/apps/pi-host/package.json b/apps/pi-host/package.json new file mode 100644 index 0000000000..2d6a049ea8 --- /dev/null +++ b/apps/pi-host/package.json @@ -0,0 +1,37 @@ +{ + "name": "@pi-desktop/pi-host", + "version": "0.15.0", + "private": true, + "type": "module", + "description": "Headless PI Agent Host: RACP-WS server over the Agent Host module, the pi sidecar, and host-core", + "bin": { + "pi-host": "./dist/cli.js" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run", + "bundle": "node scripts/bundle.mjs", + "clean": "node -e \"fs.rmSync('dist',{recursive:true,force:true});fs.rmSync('dist-bundle',{recursive:true,force:true})\"" + }, + "dependencies": { + "@pi-desktop/agent-host": "workspace:*", + "@pi-desktop/agent-runtime": "workspace:*", + "@pi-desktop/host-runtime": "workspace:*", + "@pi-desktop/racp": "workspace:*", + "@pi-desktop/shared": "workspace:*", + "ws": "8.21.1" + }, + "optionalDependencies": { + "node-pty": "1.1.0" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "@types/ws": "^8.18.1", + "esbuild": "^0.25.12", + "typescript": "^5.9.3", + "vitest": "^4.1.10" + } +} diff --git a/apps/pi-host/scripts/bundle.mjs b/apps/pi-host/scripts/bundle.mjs new file mode 100644 index 0000000000..44f8e44050 --- /dev/null +++ b/apps/pi-host/scripts/bundle.mjs @@ -0,0 +1,89 @@ +#!/usr/bin/env node +/** + * Assemble the self-contained `pi-host` bundle for one Linux target: + * + * dist-bundle/pi-host---/ + * pi-host.js the CLI, esbuild-bundled with every workspace package + * agent-runtime/sidecar.js the same sidecar bundle the desktop ships + * bin/pi-desktop-host-core the platform host-core binary + * node_modules/node-pty optional; terminals are disabled without it + * package.json { type: module, version } + * install.sh copies the bundle under ~/.pi-desktop/pi-host/ + * + * Usage: node scripts/bundle.mjs [--host-core ] [--platform linux] [--arch x64|arm64] [--out ] + */ +import { execFileSync } from "node:child_process"; +import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync, chmodSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createRequire } from "node:module"; + +const here = dirname(fileURLToPath(import.meta.url)); +const app = resolve(here, ".."); +const root = resolve(app, "../.."); +const require = createRequire(import.meta.url); +const { version } = require(join(app, "package.json")); + +const args = Object.fromEntries( + process.argv.slice(2).flatMap((arg, index, all) => (arg.startsWith("--") && arg.length > 2 ? [[arg.slice(2), all[index + 1] && !all[index + 1].startsWith("--") ? all[index + 1] : true]] : [])), +); +const platform = String(args.platform ?? process.platform); +const arch = String(args.arch ?? process.arch); +const exe = platform === "win32" ? ".exe" : ""; +const hostCore = resolve(String(args["host-core"] ?? join(root, `target/release/pi-desktop-host-core${exe}`))); +const sidecar = join(root, "packages/agent-runtime/dist-bundle/sidecar.js"); +const out = resolve(String(args.out ?? join(app, "dist-bundle", `pi-host-${version}-${platform}-${arch}`))); + +for (const [label, path] of [["host-core binary", hostCore], ["sidecar bundle", sidecar]]) { + if (!existsSync(path)) { + console.error(`${label} missing: ${path}`); + process.exit(1); + } +} + +rmSync(out, { recursive: true, force: true }); +mkdirSync(join(out, "bin"), { recursive: true }); +mkdirSync(join(out, "agent-runtime"), { recursive: true }); + +execFileSync( + require.resolve("esbuild/bin/esbuild"), + [ + join(app, "src/cli.ts"), + "--bundle", + "--platform=node", + "--format=esm", + "--target=node22", + "--external:node-pty", + "--external:bufferutil", + "--external:utf-8-validate", + `--outfile=${join(out, "pi-host.js")}`, + "--banner:js=import { createRequire as __piCreateRequire } from 'node:module'; const require = __piCreateRequire(import.meta.url);", + ], + { stdio: "inherit", cwd: app }, +); +cpSync(sidecar, join(out, "agent-runtime/sidecar.js")); +writeFileSync(join(out, "agent-runtime/package.json"), '{ "type": "module" }\n'); +cpSync(hostCore, join(out, `bin/pi-desktop-host-core${exe}`)); +chmodSync(join(out, `bin/pi-desktop-host-core${exe}`), 0o755); +try { + const pty = dirname(require.resolve("node-pty/package.json")); + cpSync(pty, join(out, "node_modules/node-pty"), { recursive: true, dereference: true }); +} catch { + console.warn("node-pty not installed; the bundle ships without terminals"); +} +writeFileSync(join(out, "package.json"), `${JSON.stringify({ name: "pi-host", version, type: "module", bin: { "pi-host": "./pi-host.js" } }, null, 2)}\n`); +writeFileSync( + join(out, "install.sh"), + `#!/bin/sh +# Install this pi-host bundle under the user's home (D375 bootstrap). +set -eu +target="\${PI_HOST_INSTALL_DIR:-$HOME/.pi-desktop/pi-host}/${version}" +mkdir -p "$target" +cp -R "$(dirname "$0")"/. "$target"/ +chmod 755 "$target/bin/pi-desktop-host-core${exe}" +ln -sfn "$target" "$(dirname "$target")/current" +echo "PI_HOST_INSTALLED $target" +`, +); +chmodSync(join(out, "install.sh"), 0o755); +console.log(`bundled ${out}`); diff --git a/apps/pi-host/src/app.ts b/apps/pi-host/src/app.ts new file mode 100644 index 0000000000..31833564c1 --- /dev/null +++ b/apps/pi-host/src/app.ts @@ -0,0 +1,283 @@ +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; + +import { AgentHost, type ApprovalPort } from "@pi-desktop/agent-host"; +import { + AgentSidecar, + HostProcess, + PlanExecutionDispatcher, + RuntimeService, + RuntimeSupervisor, + createHeadlessLaunchResolver, + createHostQueueStore, + createHostSessionPort, + listPendingToolRequests, +} from "@pi-desktop/host-runtime"; +import { DeviceTokenAuthenticator, RacpServer, bindRacpWebSocket, type RacpHostOperations, type WsBinding } from "@pi-desktop/racp"; +import { APP_VERSION, type AgentEventEnvelope } from "@pi-desktop/shared"; + +import type { PiHostConfig } from "./config.js"; +import { FileCredentialStore, loadOrCreateHostId } from "./credentials.js"; +import { createHostOperations } from "./host-operations.js"; +import { createLogger, type HostLogger } from "./logger.js"; +import { TerminalService, loadPty } from "./terminal.js"; + +export type PiHostApp = { + hostId: string; + address: { host: string; port: number }; + agentHost: AgentHost; + runtime: RuntimeService; + authenticator: DeviceTokenAuthenticator; + server: RacpServer; + log: HostLogger; + /** Mint a single-use pairing token for the bootstrap channel. */ + issuePairingToken(lifetimeMs: number): Promise<{ token: string; expiresAt: string }>; + stop(): Promise; +}; + +const APPROVAL_REQUEST_TIMEOUT_MS = 30 * 60 * 1000; + +/** + * Compose the headless Host: host-core and the sidecar under the shared + * supervisor, the runtime service as the module's runtime port, the Agent + * Host module, and the RACP server on loopback. Everything the desktop + * would own for a local session — transcript, queue, approvals, tools, + * workspace — lives here on this machine. + */ +export async function startPiHost(config: PiHostConfig, options: { log?: HostLogger } = {}): Promise { + await mkdir(config.dataDir, { recursive: true, mode: 0o700 }); + const log = options.log ?? createLogger({ dataDir: config.dataDir, minLevel: config.logLevel }); + const hostId = await loadOrCreateHostId(config.dataDir); + const store = new FileCredentialStore(config.dataDir); + const authenticator = new DeviceTokenAuthenticator(store); + + const state: { host: HostProcess | null; sidecar: AgentSidecar | null; stopping: boolean } = { host: null, sidecar: null, stopping: false }; + const getHost = () => state.host; + const getSidecar = () => state.sidecar; + + const launch = createHeadlessLaunchResolver({ getHost, dataDir: config.dataDir, log: (level, message, data) => log(level, message, data) }); + const runtime = new RuntimeService({ getHost, getSidecar, launch, log }); + + const approvals: ApprovalPort = { + async resolveTool(requestId, decision) { + const host = getHost(); + if (!host) throw new Error("host unavailable"); + await host.call("permissions.resolve", { requestId, decision }); + }, + async resolveContract(input) { + const host = getHost(); + if (!host) throw new Error("host unavailable"); + const pending = await host.call<{ plans?: Array<{ id: string; turnId?: string; toolCallId?: string }> }>("plans.pending", { sessionId: input.sessionId }); + const proposal = (pending.plans ?? []).find((candidate) => candidate.id === input.proposalId); + if (!proposal) throw Object.assign(new Error(`proposal ${input.proposalId} is not pending`), { errorCode: "NOT_FOUND" }); + const result = await host.call("plans.resolve", { + proposalId: input.proposalId, + sessionId: input.sessionId, + turnId: proposal.turnId ?? "", + toolCallId: proposal.toolCallId ?? "", + action: input.action, + ...(input.version !== undefined ? { version: input.version } : {}), + ...(input.permissionMode ? { targetPermissionMode: input.permissionMode } : {}), + }); + if (input.action === "approve") void plans.dispatchExecutionForProposal(input.proposalId); + return void result; + }, + listPendingTools: (sessionId) => listPendingToolRequests(getHost, sessionId), + }; + const agentHost = new AgentHost({ + runtime, + sessions: createHostSessionPort(getHost), + approvals, + queueStore: createHostQueueStore(getHost), + localApprovalLifetimeMs: APPROVAL_REQUEST_TIMEOUT_MS, + }); + const plans = new PlanExecutionDispatcher({ getHost, getSidecar, launch, runtime, log }); + + // Every runtime event feeds the module; a settled turn closes it there too. + const abortingSessions = new Set(); + runtime.onEvent((envelope: AgentEventEnvelope) => { + const interrupted = envelope.event.type === "agent_end" && abortingSessions.has(envelope.sessionId); + if (envelope.event.type === "agent_end" || envelope.event.type === "error") abortingSessions.delete(envelope.sessionId); + try { + agentHost.ingest(envelope, { interrupted }); + } catch (error) { + log("warn", "agent host ingest failed", { sessionId: envelope.sessionId, type: envelope.event.type, error: String(error) }); + } + }); + runtime.onTurnEnded((info) => { + abortingSessions.delete(info.sessionId); + const status = info.reason === "aborted" ? "interrupted" : info.reason === "error" ? "failed" : "completed"; + try { + agentHost.endTurn(info.sessionId, info.turnId, status, info.errorCode ? { error: { code: info.errorCode, message: info.errorCode, retriable: false, traceId: "" } } : {}); + agentHost.kick(info.sessionId); + } catch (error) { + log("warn", "agent host end turn failed", { sessionId: info.sessionId, turnId: info.turnId, error: String(error) }); + } + }); + const originalAbort = runtime.abort.bind(runtime); + runtime.abort = async (sessionId, turnId) => { + abortingSessions.add(sessionId); + try { + await originalAbort(sessionId, turnId); + } catch (error) { + abortingSessions.delete(sessionId); + throw error; + } + }; + + const startHost = async () => { + const host = new HostProcess({ binaryPath: config.hostCoreBinary, dataDir: config.dataDir, onStderr: log.child("host") }); + host.onExit(({ intentional }) => { + if (state.host !== host) return; + state.host = null; + if (intentional || state.stopping) return; + log("error", "host-core exited unexpectedly"); + void supervisor.superviseRestart("host"); + }); + state.host = host; + try { + await host.handshake(); + await host.call("session.recoverInflightMessages").catch((error: unknown) => log("warn", "in-flight recovery failed", { error: String(error) })); + } catch (error) { + if (state.host === host) state.host = null; + await host.dispose(); + throw error; + } + runtime.attachHost(host); + state.sidecar?.setHost(host); + log("info", "host-core handshake ok", { generation: host.generation }); + }; + const startSidecar = async () => { + const sidecar = new AgentSidecar({ + launch: { command: config.nodeBinary, args: [config.sidecarEntry], env: { ...process.env, PI_DESKTOP_DATA_DIR: config.dataDir } }, + onStderr: log.child("agent"), + }); + sidecar.onExit(({ intentional, code, signal, stderrTail }) => { + if (state.sidecar !== sidecar) return; + state.sidecar = null; + if (intentional || state.stopping) return; + log("error", "agent sidecar exited unexpectedly", { exitCode: code, signal, stderrTail }); + void supervisor.superviseRestart("sidecar"); + }); + sidecar.setProjectInstructionResolver(async ({ projectPath, path }) => { + const { loadInstructionChain } = await import("@pi-desktop/agent-runtime"); + return loadInstructionChain(projectPath, path); + }); + sidecar.setLocalTool("Skill", async ({ args, sessionId }) => { + const id = String((args as { id?: unknown })?.id ?? "").trim(); + const host = getHost(); + if (!id || !host) return { ok: false, isError: true, content: "Skill: `id` is required." }; + const session = await host.call<{ session?: { projectPath?: string } | null }>("session.get", { id: sessionId, messageLimit: 1 }).catch(() => null); + const result = await host.call<{ skill: { id: string; name: string } | null; body: string | null }>("skills.read", { id, projectPath: session?.session?.projectPath ?? null }).catch(() => null); + if (!result?.skill || typeof result.body !== "string") return { ok: false, isError: true, content: `Skill: "${id}" is not available on this Host.` }; + return { ok: true, content: `# Skill: ${result.skill.name} (${result.skill.id})\n\n${result.body}` }; + }); + sidecar.setTrustedExtensionBridge({ + publishCommands: () => undefined, + publishDiagnostics: () => undefined, + requestUi: async () => { + throw new Error("extension UI is not available on a headless host"); + }, + configureModel: async () => { + throw new Error("extension model configuration is not available on a headless host"); + }, + queuePush: async (params) => + agentHost.startTurn({ subject: "extension", roles: ["controller"] }, { + sessionId: String(params.sessionId ?? ""), + admission: "queue", + input: { text: String(params.content ?? "") }, + context: { requestId: `ext-${Date.now().toString(36)}`, ...(typeof params.idempotencyKey === "string" ? { idempotencyKey: params.idempotencyKey } : {}) }, + }), + queuePrioritize: async (params) => agentHost.prioritizeTurn({ subject: "extension", roles: ["controller"] }, String(params.id ?? "")), + }); + state.sidecar = sidecar; + runtime.attachSidecar(sidecar); + if (state.host) sidecar.setHost(state.host); + await sidecar.call("sidecar.configure", { hostBinary: config.hostCoreBinary, dataDir: config.dataDir }); + log("info", "agent sidecar configured"); + }; + const supervisor = new RuntimeSupervisor({ + start: { host: startHost, sidecar: startSidecar }, + afterRestart: () => plans.drainApprovedPlanExecutions(), + isShuttingDown: () => state.stopping, + onEvent: (event) => { + if (event.phase === "fatal") log("error", `${event.kind} restart gave up`, { reason: event.reason, error: event.error ? String(event.error) : undefined }); + else if (event.phase === "restarted") log("warn", `${event.kind} restarted after crash`, { attempt: event.attempt }); + else if (event.phase === "restart_failed") log("error", `${event.kind} restart failed`, { attempt: event.attempt, error: String(event.error) }); + }, + }); + + await startHost(); + await startSidecar(); + await agentHost.start(); + await plans.drainApprovedPlanExecutions().catch((error: unknown) => log("warn", "queued approved plan drain failed", { error: String(error) })); + + const pty = loadPty(); + const terminal = pty + ? new TerminalService({ + pty, + log, + sessionRoot: async (sessionId) => { + const host = getHost(); + if (!host) throw new Error("host unavailable"); + const result = await host.call<{ session?: { projectPath?: string } | null }>("session.get", { id: sessionId, messageLimit: 1 }); + const root = result.session?.projectPath?.trim(); + if (!root) throw Object.assign(new Error("the session has no project root"), { errorCode: "CONFLICT" }); + return root; + }, + }) + : undefined; + if (!pty) log("warn", "node-pty is not installed; terminals are disabled"); + + const operations: RacpHostOperations = { + ...createHostOperations({ + getHost, + runtime, + browseRoot: config.browseRoot, + disposeSession: async (sessionId) => { + const sidecar = getSidecar(); + if (!sidecar) return; + sidecar.clearProjectInstructionRoot(sessionId); + await sidecar.call("agent.disposeSession", { sessionId }).catch(() => undefined); + }, + revokeDevice: (deviceId) => store.revokeDevice(deviceId, new Date().toISOString()), + }), + ...(terminal ? { terminal } : {}), + }; + const server = new RacpServer({ agentHost, operations, authenticator, hostId, serverVersion: APP_VERSION, log }); + let binding: WsBinding; + try { + binding = await bindRacpWebSocket({ server: server, authenticator, host: config.host, port: config.port, log }); + } catch (error) { + state.stopping = true; + await state.sidecar?.dispose(); + await state.host?.dispose(); + throw error; + } + log("info", "pi-host ready", { hostId, host: binding.address.host, port: binding.address.port, version: APP_VERSION, terminal: Boolean(terminal) }); + + return { + hostId, + address: binding.address, + agentHost, + runtime, + authenticator, + server, + log, + issuePairingToken: (lifetimeMs) => authenticator.issuePairingToken(lifetimeMs), + async stop() { + if (state.stopping) return; + state.stopping = true; + log("info", "pi-host stopping"); + server.close(); + await binding.close(); + await terminal?.closeAll(); + plans.dispose(); + await runtime.dispose(); + await state.sidecar?.dispose(); + await state.host?.dispose(); + state.sidecar = null; + state.host = null; + }, + }; +} diff --git a/apps/pi-host/src/cli.ts b/apps/pi-host/src/cli.ts new file mode 100644 index 0000000000..5ae6552073 --- /dev/null +++ b/apps/pi-host/src/cli.ts @@ -0,0 +1,60 @@ +#!/usr/bin/env node +import { parseArgs, resolveConfig } from "./config.js"; +import { startPiHost } from "./app.js"; + +/** + * `pi-host [--data-dir ] [--port ] [--pair] [--host-core ] [--sidecar ]` + * + * stdout carries exactly the lines a bootstrap script parses: + * PI_HOST_READY {"hostId":..,"port":..,"version":..} + * PI_HOST_PAIRING_TOKEN {"token":..,"expiresAt":..} (with --pair) + * Everything else is structured stderr. + */ +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + if (args.help === true) { + process.stdout.write( + [ + "pi-host — headless PI Agent Host (RACP-WS on loopback)", + "", + " --data-dir host data directory (default ~/.pi-desktop)", + " --port loopback port (default 0 = pick free)", + " --host bind address; loopback only", + " --pair print a single-use pairing token at start", + " --pairing-lifetime-ms pairing token lifetime (default 600000)", + " --host-core pi-desktop-host-core binary", + " --sidecar agent sidecar entry", + " --browse-root folder-picker root (default home)", + " --log-level info | warn | error", + "", + ].join("\n"), + ); + return; + } + const config = resolveConfig(args); + const app = await startPiHost(config); + process.stdout.write(`PI_HOST_READY ${JSON.stringify({ hostId: app.hostId, host: app.address.host, port: app.address.port, version: (await import("@pi-desktop/shared")).APP_VERSION })}\n`); + if (config.pair) { + const pairing = await app.issuePairingToken(config.pairingLifetimeMs); + process.stdout.write(`PI_HOST_PAIRING_TOKEN ${JSON.stringify(pairing)}\n`); + } + const stop = (signal: string) => { + app.log("info", "signal received", { signal }); + void app.stop().finally(() => process.exit(0)); + }; + process.once("SIGINT", () => stop("SIGINT")); + process.once("SIGTERM", () => stop("SIGTERM")); + process.on("unhandledRejection", (reason) => { + app.log("error", "unhandled rejection", { error: reason instanceof Error ? reason.stack ?? reason.message : String(reason) }); + }); + process.on("uncaughtException", (error) => { + app.log("error", "uncaught exception", { error: error.stack ?? error.message }); + }); +} + +main().catch((error) => { + const code = (error as { errorCode?: string })?.errorCode ?? "INTERNAL"; + process.stderr.write(`${JSON.stringify({ ts: new Date().toISOString(), level: "error", channel: "pi-host", message: "pi-host failed to start", data: { code, error: error instanceof Error ? error.message : String(error) } })}\n`); + process.stdout.write(`PI_HOST_FAILED ${JSON.stringify({ code })}\n`); + process.exit(1); +}); diff --git a/apps/pi-host/src/config.test.ts b/apps/pi-host/src/config.test.ts new file mode 100644 index 0000000000..1063cc3277 --- /dev/null +++ b/apps/pi-host/src/config.test.ts @@ -0,0 +1,64 @@ +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { parseArgs, resolveConfig } from "./config.js"; +import { FileCredentialStore, loadOrCreateHostId } from "./credentials.js"; + +const dirs: string[] = []; +afterEach(async () => { + for (const dir of dirs.splice(0)) await rm(dir, { recursive: true, force: true }); +}); + +async function tempDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), "pi-host-test-")); + dirs.push(dir); + return dir; +} + +describe("config", () => { + it("parses flags with and without values and validates the port", () => { + expect(parseArgs(["--pair", "--port", "4123", "--data-dir=/x", "--host-core", "/bin/hc"])).toEqual({ pair: true, port: "4123", "data-dir": "/x", "host-core": "/bin/hc" }); + const config = resolveConfig({ "data-dir": "/data", port: "4123", "host-core": "/bin/hc", sidecar: "/s.js", pair: true }, {}); + expect(config).toMatchObject({ dataDir: "/data", port: 4123, host: "127.0.0.1", hostCoreBinary: "/bin/hc", sidecarEntry: "/s.js", pair: true, logLevel: "info" }); + expect(() => resolveConfig({ port: "70000", "host-core": "/bin/hc", sidecar: "/s.js" }, {})).toThrow(/invalid port/); + expect(() => resolveConfig({ "host-core": "/bin/hc", sidecar: "/s.js", port: "abc" }, {})).toThrow(/invalid port/); + expect(resolveConfig({ "host-core": "/bin/hc", sidecar: "/s.js", "log-level": "warn" }, {}).logLevel).toBe("warn"); + }); +}); + +describe("identity and credentials", () => { + it("mints the host id once and keeps it across restarts", async () => { + const dir = await tempDir(); + const first = await loadOrCreateHostId(dir); + expect(first.startsWith("host_")).toBe(true); + expect(await loadOrCreateHostId(dir)).toBe(first); + const info = await stat(join(dir, "pi-host", "identity.json")); + expect(info.mode & 0o077).toBe(0); + }); + + it("stores devices and pairings hashed, owner-readable, and survives a reload", async () => { + const dir = await tempDir(); + const store = new FileCredentialStore(dir); + await store.saveDevice({ deviceId: "dev_1", label: "laptop", roles: ["owner"], tokenHash: "ab".repeat(32), createdAt: "2026-09-18T00:00:00.000Z" }); + await store.savePairing({ tokenHash: "cd".repeat(32), expiresAt: new Date(Date.now() + 60_000).toISOString() }); + await store.savePairing({ tokenHash: "ef".repeat(32), expiresAt: "2000-01-01T00:00:00.000Z" }); + const raw = await readFile(join(dir, "pi-host", "credentials.json"), "utf8"); + expect(raw).not.toContain("pdt1."); + expect(raw).toContain("ab".repeat(32)); + expect(raw).not.toContain("ef".repeat(32)); + const info = await stat(join(dir, "pi-host", "credentials.json")); + expect(info.mode & 0o077).toBe(0); + + const reloaded = new FileCredentialStore(dir); + expect((await reloaded.findDeviceByTokenHash("ab".repeat(32)))?.deviceId).toBe("dev_1"); + expect(await reloaded.consumePairing("cd".repeat(32), "2026-09-18T00:00:01.000Z")).toBe(true); + expect(await reloaded.consumePairing("cd".repeat(32), "2026-09-18T00:00:02.000Z")).toBe(false); + expect(await reloaded.revokeDevice("dev_1", "2026-09-18T00:00:03.000Z")).toBe(true); + expect(await reloaded.revokeDevice("dev_1", "2026-09-18T00:00:04.000Z")).toBe(false); + const again = new FileCredentialStore(dir); + expect((await again.listDevices())[0]?.revokedAt).toBe("2026-09-18T00:00:03.000Z"); + expect(await again.findPairing("cd".repeat(32))).toBeNull(); + }); +}); diff --git a/apps/pi-host/src/config.ts b/apps/pi-host/src/config.ts new file mode 100644 index 0000000000..db90d6d026 --- /dev/null +++ b/apps/pi-host/src/config.ts @@ -0,0 +1,103 @@ +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export type PiHostConfig = { + dataDir: string; + /** Loopback bind address; `pi-host` refuses anything else. */ + host: string; + /** `0` picks a free port and prints it. */ + port: number; + hostCoreBinary: string; + sidecarEntry: string; + /** The Node executable used for the sidecar; defaults to this process's. */ + nodeBinary: string; + /** Print a fresh single-use pairing token at start and exit once it is consumed or expired. */ + pair: boolean; + pairingLifetimeMs: number; + /** Where the folder picker may browse; defaults to the user's home. */ + browseRoot: string; + logLevel: "info" | "warn" | "error"; +}; + +const DEFAULT_PORT = 0; +const DEFAULT_PAIRING_LIFETIME_MS = 10 * 60 * 1000; + +function here(): string { + return dirname(fileURLToPath(import.meta.url)); +} + +/** Locations the bundle and a source checkout keep host-core in, first hit wins. */ +export function hostCoreCandidates(root = here()): string[] { + const exe = process.platform === "win32" ? ".exe" : ""; + return [ + process.env.PI_HOST_CORE_BIN ?? "", + process.env.PI_DESKTOP_HOST_BIN ?? "", + join(root, `bin/pi-desktop-host-core${exe}`), + join(root, `../bin/pi-desktop-host-core${exe}`), + join(root, `../../../target/release/pi-desktop-host-core${exe}`), + join(root, `../../../target/debug/pi-desktop-host-core${exe}`), + ].filter(Boolean); +} + +export function sidecarCandidates(root = here()): string[] { + return [ + process.env.PI_HOST_SIDECAR ?? "", + join(root, "agent-runtime/sidecar.js"), + join(root, "../agent-runtime/sidecar.js"), + join(root, "../../../packages/agent-runtime/dist-bundle/sidecar.js"), + join(root, "../../../packages/agent-runtime/dist/sidecar.js"), + ].filter(Boolean); +} + +function firstExisting(candidates: string[], what: string): string { + for (const candidate of candidates) { + if (candidate && existsSync(candidate)) return resolve(candidate); + } + throw Object.assign(new Error(`${what} not found; tried ${candidates.join(", ")}`), { errorCode: "HOST_BOOTSTRAP_FAILED" }); +} + +export type CliArgs = Record; + +export function parseArgs(argv: string[]): CliArgs { + const args: CliArgs = {}; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]!; + if (!arg.startsWith("--")) continue; + const eq = arg.indexOf("="); + if (eq > 0) { + args[arg.slice(2, eq)] = arg.slice(eq + 1); + continue; + } + const next = argv[index + 1]; + if (next !== undefined && !next.startsWith("--")) { + args[arg.slice(2)] = next; + index += 1; + } else { + args[arg.slice(2)] = true; + } + } + return args; +} + +export function resolveConfig(args: CliArgs, env: NodeJS.ProcessEnv = process.env): PiHostConfig { + const dataDir = resolve(String(args["data-dir"] ?? env.PI_DESKTOP_DATA_DIR ?? join(homedir(), ".pi-desktop"))); + const port = Number(args.port ?? env.PI_HOST_PORT ?? DEFAULT_PORT); + if (!Number.isInteger(port) || port < 0 || port > 65_535) { + throw Object.assign(new Error(`invalid port ${String(args.port ?? env.PI_HOST_PORT)}`), { errorCode: "INVALID_ARGUMENT" }); + } + const level = String(args["log-level"] ?? env.PI_HOST_LOG_LEVEL ?? "info"); + return { + dataDir, + host: String(args.host ?? env.PI_HOST_BIND ?? "127.0.0.1"), + port, + hostCoreBinary: args["host-core"] ? resolve(String(args["host-core"])) : firstExisting(hostCoreCandidates(), "host-core binary"), + sidecarEntry: args.sidecar ? resolve(String(args.sidecar)) : firstExisting(sidecarCandidates(), "agent sidecar entry"), + nodeBinary: String(args.node ?? env.PI_HOST_NODE ?? process.execPath), + pair: args.pair === true || args.pair === "true", + pairingLifetimeMs: Number(args["pairing-lifetime-ms"] ?? DEFAULT_PAIRING_LIFETIME_MS), + browseRoot: resolve(String(args["browse-root"] ?? env.PI_HOST_BROWSE_ROOT ?? homedir())), + logLevel: level === "warn" || level === "error" ? level : "info", + }; +} diff --git a/apps/pi-host/src/credentials.ts b/apps/pi-host/src/credentials.ts new file mode 100644 index 0000000000..6223078a57 --- /dev/null +++ b/apps/pi-host/src/credentials.ts @@ -0,0 +1,119 @@ +import { randomBytes } from "node:crypto"; +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; + +import type { DeviceCredentialStore, DeviceRecord, PairingRecord } from "@pi-desktop/racp"; + +type IdentityFile = { hostId: string; createdAt: string }; +type CredentialFile = { devices: DeviceRecord[]; pairings: PairingRecord[] }; + +const FILE_MODE = 0o600; + +async function readJson(path: string): Promise { + try { + return JSON.parse(await readFile(path, "utf8")) as T; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } +} + +async function writeJsonAtomic(path: string, value: unknown): Promise { + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + const temp = `${path}.${process.pid}.tmp`; + await writeFile(temp, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", mode: FILE_MODE }); + await rename(temp, path); +} + +/** + * The Host's stable identity (D446): minted once at first start and kept + * beside the data directory. Never derived from hostname, address, or path, + * so two machines with the same project path are still two Hosts. + */ +export async function loadOrCreateHostId(dataDir: string): Promise { + const path = join(dataDir, "pi-host", "identity.json"); + const existing = await readJson(path); + if (existing?.hostId) return existing.hostId; + const created: IdentityFile = { hostId: `host_${randomBytes(12).toString("base64url")}`, createdAt: new Date().toISOString() }; + await writeJsonAtomic(path, created); + return created.hostId; +} + +/** + * Device and pairing records under `/pi-host/credentials.json`, + * owner-readable only. Tokens are stored as SHA-256 hashes (security §3.4); + * the file never holds a usable credential. + */ +export class FileCredentialStore implements DeviceCredentialStore { + private readonly path: string; + private state: CredentialFile | null = null; + private chain = Promise.resolve(); + + constructor(dataDir: string) { + this.path = join(dataDir, "pi-host", "credentials.json"); + } + + private async load(): Promise { + if (!this.state) this.state = (await readJson(this.path)) ?? { devices: [], pairings: [] }; + return this.state; + } + + private async mutate(operation: (state: CredentialFile) => T): Promise { + let result!: T; + const run = this.chain.then(async () => { + const state = await this.load(); + result = operation(state); + // Expired, consumed pairings are useless after a write; drop them. + const now = Date.now(); + state.pairings = state.pairings.filter((pairing) => !pairing.consumedAt && Date.parse(pairing.expiresAt) > now); + await writeJsonAtomic(this.path, state); + }); + this.chain = run.catch(() => undefined); + await run; + return result; + } + + async findDeviceByTokenHash(tokenHash: string): Promise { + const state = await this.load(); + return state.devices.find((device) => device.tokenHash === tokenHash) ?? null; + } + async saveDevice(record: DeviceRecord): Promise { + await this.mutate((state) => { + state.devices = [...state.devices.filter((device) => device.deviceId !== record.deviceId), record]; + }); + } + async touchDevice(deviceId: string, seenAt: string): Promise { + const state = await this.load(); + const device = state.devices.find((candidate) => candidate.deviceId === deviceId); + // Last-seen is informational; it does not need a synchronous write per connection. + if (device) device.lastSeenAt = seenAt; + } + async revokeDevice(deviceId: string, revokedAt: string): Promise { + return this.mutate((state) => { + const device = state.devices.find((candidate) => candidate.deviceId === deviceId); + if (!device || device.revokedAt) return false; + device.revokedAt = revokedAt; + return true; + }); + } + async listDevices(): Promise { + return [...(await this.load()).devices]; + } + async findPairing(tokenHash: string): Promise { + const state = await this.load(); + return state.pairings.find((pairing) => pairing.tokenHash === tokenHash) ?? null; + } + async savePairing(record: PairingRecord): Promise { + await this.mutate((state) => { + state.pairings = [...state.pairings.filter((pairing) => pairing.tokenHash !== record.tokenHash), record]; + }); + } + async consumePairing(tokenHash: string, consumedAt: string): Promise { + return this.mutate((state) => { + const pairing = state.pairings.find((candidate) => candidate.tokenHash === tokenHash); + if (!pairing || pairing.consumedAt) return false; + pairing.consumedAt = consumedAt; + return true; + }); + } +} diff --git a/apps/pi-host/src/host-operations.test.ts b/apps/pi-host/src/host-operations.test.ts new file mode 100644 index 0000000000..e077a890bf --- /dev/null +++ b/apps/pi-host/src/host-operations.test.ts @@ -0,0 +1,108 @@ +import { mkdtemp, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { RacpError } from "@pi-desktop/agent-host"; + +import { createHostOperations } from "./host-operations.js"; + +const dirs: string[] = []; +afterEach(async () => { + for (const dir of dirs.splice(0)) await rm(dir, { recursive: true, force: true }); +}); + +function fakeHost(calls: Array<{ method: string; params: unknown }>, projectPath: string) { + const sessions = new Map>([["s1", { id: "s1", title: "One", mode: "agent", permissionMode: "ask", projectPath }]]); + return { + async call(method: string, params: Record = {}): Promise { + calls.push({ method, params }); + switch (method) { + case "session.list": + return { sessions: [...sessions.values()] } as T; + case "session.get": + return { session: sessions.get(String(params.id)) ?? null } as T; + case "session.create": { + const created = { id: "s2", title: params.title ?? "New", mode: params.mode ?? "agent", permissionMode: "ask", projectPath: params.projectPath }; + sessions.set("s2", created); + return { session: created } as T; + } + case "session.configure": { + const current = sessions.get(String(params.id)); + if (!current) throw Object.assign(new Error("session not found"), { errorCode: "NOT_FOUND" }); + const next = { ...current, ...(params.permissionMode ? { permissionMode: params.permissionMode } : {}), mode: params.mode }; + sessions.set(String(params.id), next); + return { session: next } as T; + } + case "session.rename": + case "session.delete": + return { ok: true } as T; + case "projects.list": + return { projects: [{ id: 7, path: projectPath, name: "proj" }] } as T; + case "projects.create": + return { project: { id: 8, path: params.path, name: "app" } } as T; + default: + throw new Error(`unexpected ${method}`); + } + }, + }; +} + +describe("pi-host operations over host-core", () => { + it("maps sessions, creates under a project id, and refuses configuration while busy", async () => { + const root = await realpath(await mkdtemp(join(tmpdir(), "pi-host-ops-"))); + dirs.push(root); + const calls: Array<{ method: string; params: unknown }> = []; + let busy = false; + const operations = createHostOperations({ + getHost: () => fakeHost(calls, root), + runtime: { compact: async () => ({ accepted: true }), isBusy: () => busy }, + browseRoot: root, + }); + const listed = await operations.sessions.list(); + expect(listed[0]).toMatchObject({ id: "s1", workspaceLabel: root.split("/").pop() }); + const created = await operations.sessions.create({ title: "T", projectId: "7", permissionMode: "auto" }, { subject: "d", roles: ["owner"] }); + expect(created.permissionMode).toBe("auto"); + expect(calls.find((call) => call.method === "session.create")?.params).toMatchObject({ projectPath: root }); + await expect(operations.sessions.create({ projectId: "99" }, { subject: "d", roles: ["owner"] })).rejects.toMatchObject({ code: "NOT_FOUND" }); + busy = true; + await expect(operations.sessions.configure("s1", { mode: "plan" })).rejects.toMatchObject({ code: "CONFLICT" }); + busy = false; + expect((await operations.sessions.configure("s1", { permissionMode: "accept-edits" })).permissionMode).toBe("accept-edits"); + }); + + it("registers only existing directories and browses inside the root only", async () => { + const root = await realpath(await mkdtemp(join(tmpdir(), "pi-host-ops-"))); + dirs.push(root); + const { mkdir } = await import("node:fs/promises"); + await mkdir(join(root, "work", "app"), { recursive: true }); + await mkdir(join(root, ".hidden"), { recursive: true }); + const operations = createHostOperations({ getHost: () => fakeHost([], root), runtime: { compact: async () => ({ accepted: true }), isBusy: () => false }, browseRoot: root }); + const registered = await operations.projects.register(join(root, "work", "app")); + expect(registered).toMatchObject({ id: "8", label: "app" }); + await expect(operations.projects.register(join(root, "missing"))).rejects.toMatchObject({ code: "REMOTE_PATH_NOT_FOUND" }); + await expect(operations.projects.register("relative/path")).rejects.toMatchObject({ code: "INVALID_ARGUMENT" }); + const top = await operations.projects.browse(); + expect(top.entries.map((entry) => entry.name)).toEqual(["work"]); + expect(top.parent).toBeUndefined(); + const nested = await operations.projects.browse(join(root, "work")); + expect(nested.entries.map((entry) => entry.name)).toEqual(["app"]); + expect(nested.parent).toBeDefined(); + await expect(operations.projects.browse("/")).rejects.toMatchObject({ code: "REMOTE_PATH_FORBIDDEN" }); + }); + + it("serves workspace reads against the session root and refuses escapes", async () => { + const root = await realpath(await mkdtemp(join(tmpdir(), "pi-host-ops-"))); + dirs.push(root); + const { writeFile } = await import("node:fs/promises"); + await writeFile(join(root, "README.md"), "hello", "utf8"); + const operations = createHostOperations({ getHost: () => fakeHost([], root), runtime: { compact: async () => ({ accepted: true }), isBusy: () => false } }); + expect((await operations.workspace.list("s1", "")).entries.map((entry) => entry.name)).toEqual(["README.md"]); + expect(await operations.workspace.read("s1", "README.md")).toMatchObject({ kind: "text", content: "hello" }); + await expect(operations.workspace.read("s1", "../../etc/passwd")).rejects.toMatchObject({ code: "REMOTE_PATH_FORBIDDEN" }); + await expect(operations.workspace.read("s1", "nope.txt")).rejects.toMatchObject({ code: "REMOTE_PATH_FORBIDDEN" }); + await expect(operations.workspace.list("s9", "")).rejects.toBeInstanceOf(RacpError); + const diff = await operations.workspace.diff("s1"); + expect(diff.repo).toBe(false); + }); +}); diff --git a/apps/pi-host/src/host-operations.ts b/apps/pi-host/src/host-operations.ts new file mode 100644 index 0000000000..12034a4b60 --- /dev/null +++ b/apps/pi-host/src/host-operations.ts @@ -0,0 +1,251 @@ +import { readdir, realpath, stat } from "node:fs/promises"; +import { homedir } from "node:os"; +import { basename, dirname, isAbsolute, join, resolve } from "node:path"; + +import { RacpError, type Principal, type SessionSummary } from "@pi-desktop/agent-host"; +import { + collectWorkspaceDiff, + listDir, + readWorkspaceFile, + toSessionSummary, + type HostRpc, + type HostSessionRecord, + type RuntimeService, +} from "@pi-desktop/host-runtime"; +import type { RacpHostOperations, RacpProjectCatalog, RacpSessionCatalog, RacpWorkspaceAccess } from "@pi-desktop/racp"; +import type { RacpProjectSummary } from "@pi-desktop/shared"; + +/** A project row as host-core lists it. */ +type ProjectRow = { id: number; path: string; name: string; pinned?: boolean }; + +/** Directory names never offered by the folder picker. */ +const HIDDEN_BROWSE_NAMES = new Set([".git", "node_modules", "__pycache__", ".Trash"]); +const MAX_BROWSE_ENTRIES = 500; + +function hostError(error: unknown): never { + const code = (error as { errorCode?: string; data?: { errorCode?: string } })?.data?.errorCode ?? (error as { errorCode?: string })?.errorCode; + const message = error instanceof Error ? error.message : String(error); + if (code === "NOT_FOUND" || code === "SESSION_NOT_FOUND") throw new RacpError("NOT_FOUND", message); + if (code === "CONFLICT" || (code && code.startsWith("PLAN_"))) throw new RacpError("CONFLICT", message, { details: { code } }); + if (code === "INVALID_PARAMS" || code === "INVALID_ARGUMENT") throw new RacpError("INVALID_ARGUMENT", message); + if (code === "HOST_UNAVAILABLE") throw new RacpError("AGENT_UNAVAILABLE", message, { retriable: true }); + throw new RacpError("INTERNAL", message, { details: code ? { code } : undefined }); +} + +function projectSummary(row: ProjectRow): RacpProjectSummary { + return { id: String(row.id), label: row.name || basename(row.path), archived: false }; +} + +export type HostOperationsDeps = { + getHost: () => HostRpc | null; + runtime: Pick; + /** `pi-host` boots the sidecar's session cleanup on delete; the runtime link is optional at boot. */ + disposeSession?: (sessionId: string) => Promise; + revokeDevice?: (deviceId: string) => Promise; + /** Root the folder picker may not leave; defaults to the user's home directory. */ + browseRoot?: string; +}; + +function requireHost(getHost: () => HostRpc | null): HostRpc { + const host = getHost(); + if (!host) throw new RacpError("AGENT_UNAVAILABLE", "host-core is not running", { retriable: true }); + return host; +} + +/** Session catalog over host-core RPC. */ +export function createSessionCatalog(deps: HostOperationsDeps): RacpSessionCatalog { + const { getHost } = deps; + async function projectPathFor(projectId: string | undefined, host: HostRpc): Promise { + if (!projectId) return undefined; + const { projects } = await host.call<{ projects: ProjectRow[] }>("projects.list", {}); + const project = projects.find((row) => String(row.id) === projectId); + if (!project) throw new RacpError("NOT_FOUND", `project ${projectId} is unknown`); + return project.path; + } + /** + * host-core keys a session by its project path; RACP exposes the project id + * (spec §5.1), so the summary is decorated from the projects table. A path + * with no row (a session whose project was removed) simply has no id. + */ + async function withProjectIds(host: HostRpc, records: HostSessionRecord[]): Promise { + const needsLookup = records.some((record) => record.projectPath); + const byPath = new Map(); + if (needsLookup) { + const { projects } = await host.call<{ projects: ProjectRow[] }>("projects.list", {}).catch(hostError); + for (const project of projects ?? []) byPath.set(project.path, String(project.id)); + } + return records.map((record) => { + const summary = toSessionSummary(record); + const projectId = record.projectPath ? byPath.get(record.projectPath) : undefined; + return projectId ? { ...summary, projectId } : summary; + }); + } + async function get(host: HostRpc, sessionId: string): Promise { + const result = await host.call<{ session?: HostSessionRecord | null }>("session.get", { id: sessionId, messageLimit: 1 }).catch(hostError); + if (!result.session) throw new RacpError("NOT_FOUND", `session ${sessionId} is unknown`); + return (await withProjectIds(host, [result.session]))[0]!; + } + return { + async list() { + const host = requireHost(getHost); + const result = await host.call<{ sessions: HostSessionRecord[] }>("session.list", {}).catch(hostError); + return withProjectIds(host, result.sessions ?? []); + }, + async create(input, _principal: Principal) { + const host = requireHost(getHost); + const projectPath = await projectPathFor(input.projectId, host); + const result = await host + .call<{ session?: HostSessionRecord | null }>("session.create", { + ...(input.title ? { title: input.title } : {}), + ...(input.mode ? { mode: input.mode } : {}), + ...(input.providerId ? { providerId: input.providerId } : {}), + ...(input.modelId ? { modelId: input.modelId } : {}), + ...(input.thinkingLevel ? { thinkingLevel: input.thinkingLevel } : {}), + ...(projectPath ? { projectPath } : {}), + }) + .catch(hostError); + if (!result.session) throw new RacpError("INTERNAL", "session.create returned no session"); + if (input.permissionMode) { + await host + .call("session.configure", { id: result.session.id, mode: result.session.mode ?? input.mode ?? "agent", permissionMode: input.permissionMode }) + .catch(hostError); + } + return get(host, result.session.id); + }, + async configure(sessionId, input) { + const host = requireHost(getHost); + if (deps.runtime.isBusy(sessionId)) throw new RacpError("CONFLICT", "the session has an active turn"); + const current = await get(host, sessionId); + await host + .call("session.configure", { + id: sessionId, + mode: input.mode ?? current.mode, + ...(input.providerId !== undefined ? { providerId: input.providerId } : {}), + ...(input.modelId !== undefined ? { modelId: input.modelId } : {}), + ...(input.thinkingLevel !== undefined ? { thinkingLevel: input.thinkingLevel } : {}), + ...(input.permissionMode !== undefined ? { permissionMode: input.permissionMode } : {}), + }) + .catch(hostError); + return get(host, sessionId); + }, + async fork(sessionId, input) { + const host = requireHost(getHost); + const result = await host + .call<{ session?: HostSessionRecord | null }>("session.fork", { + sessionId, + ...(input.title ? { title: input.title } : {}), + ...(input.throughMessageId ? { throughMessageId: input.throughMessageId } : {}), + }) + .catch(hostError); + if (!result.session) throw new RacpError("INTERNAL", "session.fork returned no session"); + return get(host, result.session.id); + }, + async rename(sessionId, title) { + await requireHost(getHost).call("session.rename", { id: sessionId, title }).catch(hostError); + }, + async delete(sessionId) { + if (deps.runtime.isBusy(sessionId)) throw new RacpError("CONFLICT", "the session has an active turn"); + await requireHost(getHost).call("session.delete", { id: sessionId }).catch(hostError); + await deps.disposeSession?.(sessionId).catch(() => undefined); + }, + async compact(sessionId) { + try { + return await deps.runtime.compact(sessionId); + } catch (error) { + hostError(error); + } + }, + }; +} + +async function canonicalDirectory(path: string): Promise { + if (!isAbsolute(path)) throw new RacpError("INVALID_ARGUMENT", "path must be absolute"); + let real: string; + try { + real = await realpath(path); + } catch { + throw new RacpError("REMOTE_PATH_NOT_FOUND", `no such directory: ${path}`); + } + const info = await stat(real).catch(() => null); + if (!info?.isDirectory()) throw new RacpError("REMOTE_PATH_NOT_FOUND", `not a directory: ${path}`); + return real; +} + +/** Projects over host-core: registration canonicalizes on the Host (D446). */ +export function createProjectCatalog(deps: HostOperationsDeps): RacpProjectCatalog { + const { getHost } = deps; + const browseRoot = resolve(deps.browseRoot ?? homedir()); + return { + async list() { + const host = requireHost(getHost); + const result = await host.call<{ projects: ProjectRow[] }>("projects.list", {}).catch(hostError); + return (result.projects ?? []).map(projectSummary); + }, + async register(path) { + const host = requireHost(getHost); + const real = await canonicalDirectory(path); + const result = await host.call<{ project?: ProjectRow | null }>("projects.create", { path: real }).catch(hostError); + if (!result.project) throw new RacpError("INTERNAL", "projects.create returned no project"); + return { ...projectSummary(result.project), path: result.project.path }; + }, + async browse(path) { + const target = path ? await canonicalDirectory(path) : browseRoot; + const rootReal = await realpath(browseRoot).catch(() => browseRoot); + if (target !== rootReal && !target.startsWith(rootReal.endsWith("/") ? rootReal : `${rootReal}/`)) { + throw new RacpError("REMOTE_PATH_FORBIDDEN", "path is outside the browsable root"); + } + const dirents = await readdir(target, { withFileTypes: true }).catch(() => { + throw new RacpError("REMOTE_PATH_FORBIDDEN", `cannot read ${target}`); + }); + const entries = dirents + .filter((dirent) => dirent.isDirectory() && !HIDDEN_BROWSE_NAMES.has(dirent.name) && !dirent.name.startsWith(".")) + .map((dirent) => ({ name: dirent.name, path: join(target, dirent.name) })) + .sort((a, b) => a.name.localeCompare(b.name)) + .slice(0, MAX_BROWSE_ENTRIES); + const parent = target === rootReal ? undefined : dirname(target); + return { path: target, ...(parent ? { parent } : {}), entries }; + }, + }; +} + +/** Workspace reads against the session's durable root, on the Host's filesystem. */ +export function createWorkspaceAccess(deps: HostOperationsDeps): RacpWorkspaceAccess { + const { getHost } = deps; + async function sessionRoot(sessionId: string): Promise { + const host = requireHost(getHost); + const result = await host.call<{ session?: HostSessionRecord | null }>("session.get", { id: sessionId, messageLimit: 1 }).catch(hostError); + if (!result.session) throw new RacpError("NOT_FOUND", `session ${sessionId} is unknown`); + const root = result.session.projectPath?.trim(); + if (!root) throw new RacpError("CONFLICT", "the session has no project root"); + return root; + } + const forbidden = (error: unknown): never => { + const message = error instanceof Error ? error.message : String(error); + if (/escapes workspace root|outside allowed roots/.test(message)) throw new RacpError("REMOTE_PATH_FORBIDDEN", message); + if ((error as NodeJS.ErrnoException)?.code === "ENOENT") throw new RacpError("REMOTE_PATH_NOT_FOUND", message); + if (/not a file/.test(message)) throw new RacpError("INVALID_ARGUMENT", message); + throw new RacpError("INTERNAL", message); + }; + return { + async list(sessionId, path) { + const root = await sessionRoot(sessionId); + return { entries: await listDir(root, path).catch(forbidden) }; + }, + async read(sessionId, path) { + const root = await sessionRoot(sessionId); + return readWorkspaceFile(root, path).catch(forbidden); + }, + async diff(sessionId) { + return collectWorkspaceDiff(await sessionRoot(sessionId)); + }, + }; +} + +export function createHostOperations(deps: HostOperationsDeps): Omit { + return { + sessions: createSessionCatalog(deps), + projects: createProjectCatalog(deps), + workspace: createWorkspaceAccess(deps), + ...(deps.revokeDevice ? { revokeDevice: deps.revokeDevice } : {}), + }; +} diff --git a/apps/pi-host/src/index.ts b/apps/pi-host/src/index.ts new file mode 100644 index 0000000000..788af1a8b9 --- /dev/null +++ b/apps/pi-host/src/index.ts @@ -0,0 +1,6 @@ +export * from "./app.js"; +export * from "./config.js"; +export * from "./credentials.js"; +export * from "./host-operations.js"; +export * from "./logger.js"; +export * from "./terminal.js"; diff --git a/apps/pi-host/src/logger.ts b/apps/pi-host/src/logger.ts new file mode 100644 index 0000000000..13dc5484e6 --- /dev/null +++ b/apps/pi-host/src/logger.ts @@ -0,0 +1,54 @@ +import { appendFile, mkdir } from "node:fs/promises"; +import { join } from "node:path"; + +export type LogLevel = "info" | "warn" | "error"; + +const SECRET_KEY_RE = /token|secret|password|apikey|api_key|authorization|credential|cookie/i; + +/** Drop secret-looking keys and bound the record; the Host log never carries a credential. */ +export function redactLogData(value: unknown, depth = 0): unknown { + if (value === null || typeof value !== "object") { + if (typeof value === "string" && value.length > 2_000) return `${value.slice(0, 2_000)}…`; + return value; + } + if (depth > 4) return "[depth]"; + if (Array.isArray(value)) return value.slice(0, 50).map((item) => redactLogData(item, depth + 1)); + const out: Record = {}; + for (const [key, item] of Object.entries(value as Record).slice(0, 50)) { + out[key] = SECRET_KEY_RE.test(key) ? "***" : redactLogData(item, depth + 1); + } + return out; +} + +export type HostLogger = { + (level: LogLevel, message: string, data?: Record): void; + child: (channel: string) => (text: string) => void; +}; + +const LEVEL_RANK: Record = { info: 0, warn: 1, error: 2 }; + +/** + * Structured NDJSON on stderr, mirrored to `/logs/pi-host.log`. + * stdout is reserved for the bootstrap handshake (the bound port and the + * pairing token), so a supervisor can parse it without filtering log lines. + */ +export function createLogger(options: { dataDir: string; minLevel: LogLevel; stderr?: NodeJS.WritableStream }): HostLogger { + const stderr = options.stderr ?? process.stderr; + const logDir = join(options.dataDir, "logs"); + const ready = mkdir(logDir, { recursive: true }).catch(() => undefined); + const write = (record: Record) => { + const line = `${JSON.stringify(record)}\n`; + stderr.write(line); + void ready.then(() => appendFile(join(logDir, "pi-host.log"), line).catch(() => undefined)); + }; + const log = ((level, message, data) => { + if (LEVEL_RANK[level] < LEVEL_RANK[options.minLevel]) return; + write({ ts: new Date().toISOString(), level, channel: "pi-host", message, ...(data ? { data: redactLogData(data) } : {}) }); + }) as HostLogger; + log.child = (channel) => (text) => { + const output = text.trimEnd(); + if (!output) return; + write({ ts: new Date().toISOString(), level: "info", channel, message: "child stderr", data: { output: output.slice(0, 4_000) } }); + }; + return log; +} diff --git a/apps/pi-host/src/terminal.ts b/apps/pi-host/src/terminal.ts new file mode 100644 index 0000000000..7eb5d87351 --- /dev/null +++ b/apps/pi-host/src/terminal.ts @@ -0,0 +1,154 @@ +import { randomUUID } from "node:crypto"; +import { createRequire } from "node:module"; + +import { RacpError } from "@pi-desktop/agent-host"; +import type { RacpTerminalAccess, TerminalOpenResult } from "@pi-desktop/racp"; +import { RACP_DEFAULT_LIMITS } from "@pi-desktop/shared"; + +/** The subset of `node-pty` this module uses; the package is loaded lazily. */ +type Pty = { + pid: number; + write(data: string): void; + resize(cols: number, rows: number): void; + kill(signal?: string): void; + onData(listener: (data: string) => void): { dispose(): void }; + onExit(listener: (event: { exitCode: number; signal?: number }) => void): { dispose(): void }; +}; + +type PtyModule = { + spawn(file: string, args: string[], options: { name: string; cols: number; rows: number; cwd: string; env: NodeJS.ProcessEnv }): Pty; +}; + +type TerminalSink = { output: (data: string) => void; exit: (code: number | null) => void }; + +type TerminalRecord = { + id: string; + sessionId: string; + pty: Pty; + cols: number; + rows: number; + ring: Buffer[]; + ringBytes: number; + sink: TerminalSink | null; + exited: number | null; +}; + +/** + * Load `node-pty` when it is installed beside the bundle. The Host advertises + * `terminal: false` when it is not, instead of failing at open time. + */ +export function loadPty(): PtyModule | null { + try { + const require = createRequire(import.meta.url); + return require("node-pty") as PtyModule; + } catch { + return null; + } +} + +export type TerminalServiceOptions = { + pty: PtyModule; + /** The session's working directory: the Host resolves it, never the client. */ + sessionRoot: (sessionId: string) => Promise; + shell?: string; + replayRingBytes?: number; + maxPerSession?: number; + log: (level: "info" | "warn", message: string, data?: Record) => void; +}; + +/** + * Session terminals on the Host machine (spec §6.2, security §7): a pty with + * the session root as cwd, a bounded replay ring per terminal, and at most + * two open terminals per session. Output is delivered to whichever + * connection is attached; a dropped connection keeps the pty alive and the + * next attach receives the ring. + */ +export class TerminalService implements RacpTerminalAccess { + private readonly terminals = new Map(); + private readonly ringBytes: number; + private readonly maxPerSession: number; + + constructor(private readonly options: TerminalServiceOptions) { + this.ringBytes = options.replayRingBytes ?? RACP_DEFAULT_LIMITS.terminalReplayRingBytes; + this.maxPerSession = options.maxPerSession ?? RACP_DEFAULT_LIMITS.maxOpenTerminalsPerSession; + } + + private snapshot(record: TerminalRecord): TerminalOpenResult { + return { terminalId: record.id, replay: Buffer.concat(record.ring).toString("base64"), cols: record.cols, rows: record.rows }; + } + + async open(sessionId: string, size: { cols: number; rows: number }, sink: TerminalSink): Promise { + const open = [...this.terminals.values()].filter((record) => record.sessionId === sessionId && record.exited === null); + if (open.length >= this.maxPerSession) { + throw new RacpError("RATE_LIMITED", "terminal limit reached for this session", { details: { limit: this.maxPerSession } }); + } + const cwd = await this.options.sessionRoot(sessionId); + const shell = this.options.shell ?? process.env.SHELL ?? "/bin/sh"; + const pty = this.options.pty.spawn(shell, [], { name: "xterm-256color", cols: size.cols, rows: size.rows, cwd, env: { ...process.env, TERM: "xterm-256color" } }); + const record: TerminalRecord = { id: `term_${randomUUID()}`, sessionId, pty, cols: size.cols, rows: size.rows, ring: [], ringBytes: 0, sink, exited: null }; + this.terminals.set(record.id, record); + pty.onData((data) => { + const chunk = Buffer.from(data, "utf8"); + record.ring.push(chunk); + record.ringBytes += chunk.length; + while (record.ringBytes > this.ringBytes && record.ring.length > 0) { + const dropped = record.ring.shift()!; + record.ringBytes -= dropped.length; + } + record.sink?.output(Buffer.from(data, "utf8").toString("base64")); + }); + pty.onExit(({ exitCode }) => { + record.exited = exitCode; + record.sink?.exit(exitCode); + this.terminals.delete(record.id); + this.options.log("info", "terminal exited", { terminalId: record.id, sessionId, exitCode }); + }); + this.options.log("info", "terminal opened", { terminalId: record.id, sessionId, pid: pty.pid }); + return this.snapshot(record); + } + + async input(terminalId: string, data: string): Promise { + this.require(terminalId).pty.write(Buffer.from(data, "base64").toString("utf8")); + } + + async resize(terminalId: string, cols: number, rows: number): Promise { + const record = this.require(terminalId); + record.cols = cols; + record.rows = rows; + record.pty.resize(cols, rows); + } + + async close(terminalId: string): Promise { + const record = this.terminals.get(terminalId); + if (!record) return; + this.terminals.delete(terminalId); + record.sink = null; + try { + record.pty.kill(); + } catch { + // Already gone. + } + } + + async attach(terminalId: string, sink: TerminalSink): Promise { + const record = this.terminals.get(terminalId); + if (!record || record.exited !== null) return null; + record.sink = sink; + return this.snapshot(record); + } + + detach(terminalId: string): void { + const record = this.terminals.get(terminalId); + if (record) record.sink = null; + } + + async closeAll(): Promise { + for (const id of [...this.terminals.keys()]) await this.close(id); + } + + private require(terminalId: string): TerminalRecord { + const record = this.terminals.get(terminalId); + if (!record || record.exited !== null) throw new RacpError("NOT_FOUND", `terminal ${terminalId} is not open`); + return record; + } +} diff --git a/apps/pi-host/tsconfig.json b/apps/pi-host/tsconfig.json new file mode 100644 index 0000000000..88e6660e6f --- /dev/null +++ b/apps/pi-host/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo", + "rootDir": "src", + "composite": true, + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["src/**/*.test.ts"], + "references": [ + { "path": "../../packages/shared" }, + { "path": "../../packages/agent-host" }, + { "path": "../../packages/agent-runtime" }, + { "path": "../../packages/host-runtime" }, + { "path": "../../packages/racp" } + ] +} diff --git a/apps/pi-host/vitest.config.ts b/apps/pi-host/vitest.config.ts new file mode 100644 index 0000000000..ae847ff6d9 --- /dev/null +++ b/apps/pi-host/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f56cd42b35..05ba7ee6a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -149,6 +149,47 @@ importers: specifier: ^5.0.15 version: 5.0.15(@types/react@19.2.18)(react@19.2.8) + apps/pi-host: + dependencies: + '@pi-desktop/agent-host': + specifier: workspace:* + version: link:../../packages/agent-host + '@pi-desktop/agent-runtime': + specifier: workspace:* + version: link:../../packages/agent-runtime + '@pi-desktop/host-runtime': + specifier: workspace:* + version: link:../../packages/host-runtime + '@pi-desktop/racp': + specifier: workspace:* + version: link:../../packages/racp + '@pi-desktop/shared': + specifier: workspace:* + version: link:../../packages/shared + ws: + specifier: 8.21.1 + version: 8.21.1 + devDependencies: + '@types/node': + specifier: ^24.13.3 + version: 24.13.3 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 + esbuild: + specifier: ^0.25.12 + version: 0.25.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.1.10 + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.13.3)(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0)) + optionalDependencies: + node-pty: + specifier: 1.1.0 + version: 1.1.0 + docs: devDependencies: vitepress: @@ -3305,6 +3346,10 @@ packages: resolution: {integrity: sha512-vLBWCKb+7LWsX+TbfzWOkw0W81m377tyx3hOweBTjO43CXZnRGS1/JPWs20fr0PgZyDXk6ROYrylsEycK8raDA==} engines: {node: '>=22.12.0'} + node-addon-api@7.1.0: + resolution: {integrity: sha512-mNcltoe1R8o7STTegSOHdnJNN7s5EUvhoS7ShnTHDyOSd+8H+UdWODq6qSv67PjC8Zc5JRT8+oLAMCr0SIXw7g==} + engines: {node: ^16 || ^18 || >= 20} + node-api-version@0.2.1: resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==} @@ -3325,6 +3370,9 @@ packages: node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + node-pty@1.1.0: + resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==} + node-releases@2.0.51: resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} engines: {node: '>=18'} @@ -7693,6 +7741,9 @@ snapshots: dependencies: semver: 7.8.5 + node-addon-api@7.1.0: + optional: true + node-api-version@0.2.1: dependencies: semver: 7.8.5 @@ -7720,6 +7771,11 @@ snapshots: node-int64@0.4.0: {} + node-pty@1.1.0: + dependencies: + node-addon-api: 7.1.0 + optional: true + node-releases@2.0.51: {} nopt@9.0.0: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8356e1e8b2..312db6d1f5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -12,6 +12,7 @@ allowBuilds: '@google/genai': true protobufjs: true electron-winstaller: true + node-pty: true # Advisory fixes for transitives whose parents still resolve the vulnerable # version. Drop each one once its parent ships a release that resolves the # patched version on its own. diff --git a/scripts/e2e-remote-host.mjs b/scripts/e2e-remote-host.mjs new file mode 100644 index 0000000000..85b8538d73 --- /dev/null +++ b/scripts/e2e-remote-host.mjs @@ -0,0 +1,228 @@ +#!/usr/bin/env node +/** + * Headless remote Host E2E (E2E-231 host half): boots a real `pi-host` with + * the debug host-core and the bundled sidecar on a throwaway data dir, pairs + * a device over the loopback RACP-WS socket, registers a project, creates a + * session, reads the workspace, drops the connection, and reconnects by + * cursor. No model provider is configured, so `turn/start` is expected to + * fail closed with `MODEL_NOT_CONFIGURED` rather than hang. + * + * Prereqs: `pnpm build:js`, `pnpm -C packages/agent-runtime bundle`, and a + * host-core binary (target/debug or PI_DESKTOP_HOST_BIN). + */ +import { spawn } from "node:child_process"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { RacpClient, wsClientTransport } from "../packages/racp/dist/index.js"; +import { assert, errorCodeOf, shortJson } from "./e2e/assert.mjs"; +import { resolveHostBinary } from "./e2e/host.mjs"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const cli = join(root, "apps/pi-host/dist/cli.js"); +const sidecar = join(root, "packages/agent-runtime/dist-bundle/sidecar.js"); +for (const [label, path] of [["pi-host cli", cli], ["sidecar bundle", sidecar]]) { + if (!existsSync(path)) { + console.error(`${label} missing: ${path}`); + process.exit(1); + } +} +const hostBin = resolveHostBinary(); +const dataDir = mkdtempSync(join(tmpdir(), "pi-host-e2e-")); +const project = join(dataDir, "project"); +mkdirSync(project, { recursive: true }); +writeFileSync(join(project, "README.md"), "# remote project\n"); + +const results = []; +const record = (id, ok, detail = "") => { + results.push({ id, ok }); + console.log(`${ok ? "PASS" : "FAIL"} ${id}${detail ? " — " + detail : ""}`); +}; + +let child = null; +let stderr = ""; +function startHost(extraArgs = []) { + child = spawn(process.execPath, [cli, "--data-dir", dataDir, "--port", "0", "--host-core", hostBin, "--sidecar", sidecar, "--browse-root", dataDir, "--log-level", "warn", ...extraArgs], { + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env }, + }); + child.stderr.on("data", (chunk) => { + stderr += String(chunk); + if (process.env.DEBUG_HOST) process.stderr.write(chunk); + }); + return new Promise((resolveReady, reject) => { + let out = ""; + const ready = {}; + const timer = setTimeout(() => reject(new Error("pi-host did not become ready\n" + stderr.slice(-2000))), 60_000); + child.stdout.on("data", (chunk) => { + out += String(chunk); + for (const line of out.split("\n")) { + if (line.startsWith("PI_HOST_READY ")) ready.info = JSON.parse(line.slice("PI_HOST_READY ".length)); + if (line.startsWith("PI_HOST_PAIRING_TOKEN ")) ready.pairing = JSON.parse(line.slice("PI_HOST_PAIRING_TOKEN ".length)); + if (line.startsWith("PI_HOST_FAILED ")) { + clearTimeout(timer); + reject(new Error("pi-host failed: " + line + "\n" + stderr.slice(-2000))); + } + } + if (ready.info && (!extraArgs.includes("--pair") || ready.pairing)) { + clearTimeout(timer); + resolveReady(ready); + } + }); + child.once("exit", (code) => { + clearTimeout(timer); + reject(new Error(`pi-host exited early code=${code}\n${stderr.slice(-2000)}`)); + }); + }); +} +async function stopHost() { + if (!child) return; + const proc = child; + child = null; + await new Promise((resolveExit) => { + proc.once("exit", resolveExit); + proc.kill("SIGTERM"); + setTimeout(() => proc.kill("SIGKILL"), 5_000).unref(); + }); +} + +function client(url, token, options = {}) { + const events = []; + const instance = new RacpClient({ + transport: wsClientTransport({ url, token }), + client: { name: "pi-host-e2e", version: "0.15.0" }, + onEvent: (envelope) => events.push(envelope), + requestTimeoutMs: 30_000, + ...options, + }); + return { client: instance, events }; +} + +const sleep = (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms)); + +let exitCode = 0; +try { + const ready = await startHost(["--pair"]); + const url = `ws://127.0.0.1:${ready.info.port}/v1/racp/ws`; + record("host-boots-on-loopback", ready.info.host === "127.0.0.1" && ready.info.port > 0 && typeof ready.info.hostId === "string", shortJson(ready.info)); + + // Pairing: the token is single-use and the device token replaces it. + const pairing = client(url, ready.pairing.token); + const init = await pairing.client.connect(); + record("pairing-connection-is-unprivileged", init.principal.roles.length === 0 && init.server.hostId === ready.info.hostId); + const paired = await pairing.client.request("connection/pair", { deviceLabel: "e2e desktop" }); + record("pair-mints-owner-device", paired.roles.includes("owner") && paired.deviceToken.startsWith("pdt1.")); + let secondPair = null; + try { + await pairing.client.request("connection/pair", {}); + } catch (error) { + secondPair = errorCodeOf(error); + } + record("pairing-token-is-single-use", secondPair === "PAIRING_FAILED", String(secondPair)); + await pairing.client.close(); + + const owner = client(url, paired.deviceToken, { reconnect: { enabled: true, baseDelayMs: 50, maxAttempts: 10 } }); + const ownerInit = await owner.client.connect(); + record("device-token-authenticates-as-owner", ownerInit.principal.roles.includes("owner") && ownerInit.capabilities.remoteHostProfile === true); + + const registered = await owner.client.request("project/register", { path: project }); + record("project-registers-on-host", typeof registered.project.id === "string" && registered.project.label === "project", shortJson(registered)); + let missing = null; + try { + await owner.client.request("project/register", { path: join(dataDir, "missing") }); + } catch (error) { + missing = errorCodeOf(error); + } + record("project-register-validates-path-on-host", missing === "REMOTE_PATH_NOT_FOUND", String(missing)); + const browsed = await owner.client.request("project/browse", { path: dataDir }); + record("project-browse-lists-directories", browsed.entries.some((entry) => entry.name === "project")); + + await owner.client.request("events/subscribe", { scope: "host" }); + const created = await owner.client.request("session/create", { title: "Remote E2E", projectId: registered.project.id }); + record("session-creates-under-project", created.session.projectId === registered.project.id && created.session.status === "idle", shortJson(created.session)); + const listed = await owner.client.request("session/list"); + record("session-list-includes-created", listed.sessions.some((session) => session.id === created.session.id)); + const attach = await owner.client.request("session/attach", { sessionId: created.session.id }); + record("attach-returns-snapshot", attach.replayComplete === true && Array.isArray(attach.snapshot.items) && attach.snapshot.queuedTurns.length === 0); + await owner.client.request("events/subscribe", { scope: "session", sessionId: created.session.id }); + + const files = await owner.client.request("workspace/list", { sessionId: created.session.id, path: "" }); + const readme = await owner.client.request("workspace/read", { sessionId: created.session.id, path: "README.md" }); + record("workspace-reads-execute-on-host", files.entries.some((entry) => entry.name === "README.md") && readme.kind === "text" && readme.content.includes("remote project")); + let escape = null; + try { + await owner.client.request("workspace/read", { sessionId: created.session.id, path: "../../etc/passwd" }); + } catch (error) { + escape = errorCodeOf(error); + } + record("workspace-read-refuses-escape", escape === "REMOTE_PATH_FORBIDDEN", String(escape)); + const diff = await owner.client.request("workspace/diff", { sessionId: created.session.id }); + record("workspace-diff-runs-on-host", typeof diff.repo === "boolean"); + + // No provider is configured: the turn must fail closed with a typed code and leave the session idle. + let turnError = null; + try { + await owner.client.request("turn/start", { sessionId: created.session.id, input: { text: "hello" }, context: { requestId: "r1", idempotencyKey: "e2e-1" } }); + } catch (error) { + turnError = errorCodeOf(error); + } + record("turn-without-provider-fails-closed", turnError === "MODEL_NOT_CONFIGURED", String(turnError)); + await sleep(200); + const after = await owner.client.request("session/get", { sessionId: created.session.id }); + record("failed-admission-leaves-session-idle", after.session.status === "idle" && !after.session.activeTurnId, shortJson(after.session)); + + const renamed = await owner.client.request("session/rename", { sessionId: created.session.id, title: "Renamed remotely" }); + const configured = await owner.client.request("session/configure", { sessionId: created.session.id, permissionMode: "accept-edits" }); + record("remote-host-profile-mutations", renamed.ok === true && configured.session.permissionMode === "accept-edits"); + await sleep(200); + const hostKinds = owner.events.filter((event) => event.scope === "host").map((event) => event.kind); + record("host-scope-events-announce-session-changes", hostKinds.includes("session.created") && hostKinds.includes("session.changed"), hostKinds.join(",")); + + // Reconnect by cursor: the desktop-side transport drops, the Host keeps everything. + const cursor = owner.client.cursorFor(created.session.id); + const states = []; + const owner2 = client(url, paired.deviceToken, { onStateChange: (state) => states.push(state) }); + await owner2.client.connect(); + const resumed = await owner2.client.request("session/attach", { sessionId: created.session.id, after: cursor }); + record("reconnect-resumes-by-cursor", resumed.replayComplete === true && resumed.session.title === "Renamed remotely", shortJson({ cursor, title: resumed.session.title })); + const stale = await owner2.client.request("session/attach", { sessionId: created.session.id, after: { epoch: "ep_old", sequence: 3 } }); + record("stale-epoch-yields-snapshot", stale.replayComplete === false && stale.resyncReason === "epoch" && stale.snapshot !== undefined); + await owner2.client.close(); + + // Viewer role from a second pairing is refused the owner operations. + let forbidden = null; + const devices = await owner.client.request("session/revoke", { deviceId: "dev_unknown" }); + record("revoke-unknown-device-is-false", devices.revoked === false); + try { + await owner.client.request("host/list", {}); + } catch (error) { + forbidden = errorCodeOf(error); + } + record("host-list-is-gateway-only", forbidden === "METHOD_NOT_FOUND", String(forbidden)); + + await owner.client.request("session/delete", { sessionId: created.session.id }); + const gone = await owner.client.request("session/list"); + record("session-delete-removes-on-host", !gone.sessions.some((session) => session.id === created.session.id)); + await owner.client.close(); + + // Restart: identity and the paired device survive; a fresh epoch resyncs. + await stopHost(); + const again = await startHost(); + record("host-identity-is-stable-across-restarts", again.info.hostId === ready.info.hostId, `${ready.info.hostId} vs ${again.info.hostId}`); + const owner3 = client(`ws://127.0.0.1:${again.info.port}/v1/racp/ws`, paired.deviceToken); + const init3 = await owner3.client.connect(); + record("device-credential-survives-restart", init3.principal.roles.includes("owner")); + await owner3.client.close(); +} catch (error) { + record("headless-remote-host-e2e", false, `${error?.message ?? error}\n${stderr.slice(-3000)}`); +} finally { + await stopHost(); + rmSync(dataDir, { recursive: true, force: true }); +} + +const failed = results.filter((result) => !result.ok); +console.log(`\n${results.length - failed.length}/${results.length} passed`); +if (failed.length > 0) exitCode = 1; +process.exit(exitCode); From 1d4208f86f97287146cff16476f133a7ce080fe8 Mon Sep 17 00:00:00 2001 From: vastsa Date: Fri, 18 Sep 2026 14:35:44 +0800 Subject: [PATCH 05/13] test(racp): stop the ws-binding refusal test flaking under load The binary-frame case asserted connectionCount() on the same tick the client observed its own close frame. The server drops the connection on its side of the socket's close event, which can lag behind the client's under parallel test load, so the count was intermittently still 1. Poll for the server to drain the connection (bounded to ~1s) before asserting zero, matching how the first test already waits after a client close. Test-only change; the transport behavior is unchanged. --- packages/racp/src/ws-binding.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/racp/src/ws-binding.test.ts b/packages/racp/src/ws-binding.test.ts index 51536a22a7..fdf9378991 100644 --- a/packages/racp/src/ws-binding.test.ts +++ b/packages/racp/src/ws-binding.test.ts @@ -64,6 +64,12 @@ describe("RACP-WS over a loopback socket", () => { socket.once("close", (code) => resolve(code)); }); expect(closeCode).toBe(1003); + // The client observed its own close frame; the server drops the + // connection on its side of the socket's close event, which can lag + // under parallel test load. Poll instead of asserting on the same tick. + for (let i = 0; i < 100 && h.server.connectionCount() > 0; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } expect(h.server.connectionCount()).toBe(0); } finally { await binding.close(); From 74aa3949fa9af46b8ac8a47f77922f7a197c08bc Mon Sep 17 00:00:00 2001 From: vastsa Date: Fri, 18 Sep 2026 15:13:55 +0800 Subject: [PATCH 06/13] ci(release): publish the pi-host bundle with a checksum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop SSH bootstrap for remote-control R2 downloads the headless pi-host bundle from the GitHub Release at the desktop's version and verifies a published SHA-256 before installing it on the remote machine (rollout 06-delivery/07-remote-control-rollout.md §6 acceptance 4). The release pipeline never produced that asset, so the bootstrap had nothing to fetch. Add a pi-host-bundle job (needs: verify) that builds host-core, the workspace, the agent-runtime sidecar, and pi-host on ubuntu-22.04 — the same glibc floor as the desktop Linux build so the bundled binary loads on the same distros — then assembles the bundle, tars it to pi-host--linux-.tar.gz, and writes a .sha256 sidecar. The matrix is shaped so arm64 can be added in lockstep when the desktop starts publishing it. publish now needs [build, pi-host-bundle]; its existing files: dist/* glob already carries the tarball and checksum into the Release. Also add the test:e2e:remote-host script alias so the host-half E2E (scripts/e2e-remote-host.mjs) is a runnable gate. --- .github/workflows/release.yml | 98 ++++++++++++++++++++++++++++++++++- package.json | 3 +- 2 files changed, 99 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0d6d8a360c..02df4fa6a3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,6 +13,13 @@ # exported as a system-Electron repackaging asset, and all outputs — with their # electron-updater latest*.yml feeds — are published to the GitHub Release # (D126/D285/D364). +# +# It also builds the headless `pi-host` bundle for every Linux platform the +# desktop publishes (currently x64) and publishes it as +# pi-host--linux-.tar.gz plus a .sha256 sidecar. The desktop's +# SSH bootstrap downloads that asset at the desktop's version and verifies the +# published checksum before installing on the remote machine (rollout +# 06-delivery/07-remote-control-rollout.md §6 acceptance 4; D375 / ADR 0284). name: Release @@ -275,10 +282,99 @@ jobs: apps/desktop/release/*.blockmap apps/desktop/release/latest*.yml + pi-host-bundle: + name: Bundle pi-host (Linux ${{ matrix.arch }}) + needs: verify + strategy: + fail-fast: false + matrix: + include: + # Every Linux platform the desktop release publishes. The desktop + # matrix ships Linux x64 only today; add arm64 here in lockstep if + # the desktop starts publishing it. + - arch: x64 + os: ubuntu-22.04 + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v7 + + - name: Compare tag with apps/desktop/package.json + if: startsWith(github.ref, 'refs/tags/v') + run: | + TAG_VERSION="${GITHUB_REF_NAME#v}" + APP_VERSION="$(node -p "require('./apps/desktop/package.json').version")" + if [ "$TAG_VERSION" != "$APP_VERSION" ]; then + echo "::error::Tag v$TAG_VERSION does not match apps/desktop/package.json version $APP_VERSION." + exit 1 + fi + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: pnpm + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + - name: Build inputs (host-core, workspace, sidecar bundle) + run: | + set -euo pipefail + # Ubuntu 22.04 keeps the host-core glibc floor at 2.35, matching the + # desktop Linux build so the bundled binary loads on the same distros. + cargo build --release --locked -p host-core & + host_build_pid=$! + trap 'kill "$host_build_pid" 2>/dev/null || true' EXIT + + pnpm install --frozen-lockfile + pnpm build:js + pnpm --filter @pi-desktop/agent-runtime bundle + pnpm --filter @pi-desktop/pi-host build + + wait "$host_build_pid" + trap - EXIT + + - name: Verify host-core glibc floor + run: node scripts/check-linux-host-glibc.mjs target/release/pi-desktop-host-core + + - name: Assemble pi-host bundle + run: node apps/pi-host/scripts/bundle.mjs --platform linux --arch ${{ matrix.arch }} + + - name: Archive bundle with checksum + run: | + set -euo pipefail + VERSION="$(node -p "require('./apps/pi-host/package.json').version")" + name="pi-host-${VERSION}-linux-${{ matrix.arch }}" + out="apps/pi-host/dist-bundle" + if [ ! -d "$out/$name" ]; then + echo "::error::expected bundle directory $out/$name" + ls -la "$out" || true + exit 1 + fi + tar -czf "$out/${name}.tar.gz" -C "$out" "$name" + ( cd "$out" && sha256sum "${name}.tar.gz" > "${name}.tar.gz.sha256" ) + cat "$out/${name}.tar.gz.sha256" + + - name: Upload pi-host bundle + uses: actions/upload-artifact@v4 + with: + name: pi-host-linux-${{ matrix.arch }} + if-no-files-found: error + compression-level: 0 + path: | + apps/pi-host/dist-bundle/pi-host-*-linux-${{ matrix.arch }}.tar.gz + apps/pi-host/dist-bundle/pi-host-*-linux-${{ matrix.arch }}.tar.gz.sha256 + publish: name: Publish GitHub Release if: startsWith(github.ref, 'refs/tags/v') - needs: build + needs: [build, pi-host-bundle] runs-on: ubuntu-latest timeout-minutes: 10 permissions: diff --git a/package.json b/package.json index ca2834da41..9d17b3e68e 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,8 @@ "test:e2e:plugin-import-deps": "node scripts/e2e-plugin-import-deps.mjs", "test:e2e:trusted-extensions": "node scripts/e2e-trusted-extensions.mjs", "test:e2e:collaboration": "node scripts/e2e-session-collaboration.mjs", - "test:e2e:session-completion": "node scripts/e2e-session-completion.mjs" + "test:e2e:session-completion": "node scripts/e2e-session-completion.mjs", + "test:e2e:remote-host": "node scripts/e2e-remote-host.mjs" }, "devDependencies": { "@biomejs/biome": "^2.5.13", From 78decd490a4ce5764496bd90cc60940c646bc039 Mon Sep 17 00:00:00 2001 From: vastsa Date: Fri, 18 Sep 2026 15:27:40 +0800 Subject: [PATCH 07/13] feat(remote): add the backend-router seam for remote-host sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R2 makes the desktop drive a session that lives on a paired remote pi-host over RACP-WS, and the renderer must treat that session exactly like a local one apart from a badge. This lands the contract and the single interception seam the later stages build on, with no behavior change: no session is remote yet, so every call runs the existing local handler byte-for-byte. - SessionSource gains "remote": the transcript-authority tag the renderer already carries, resolved to local/remote only in main. - New electron/main/remote/backend-router.ts owns the sessionId namespacing (remote::, mirroring the proven native-pi: prefix) and a sessionId->RemoteBackend map. route() returns a ROUTE_LOCAL sentinel for every local or native-pi id, for a channel a backend does not cover, and until a backend is registered. - register.ts consults route() from inside its handle() wrapper — the only edit to the per-domain IPC handlers. Internal invokes never reach this wrapper, so the agent-host bridge and MCP control stay local. - index.ts/startup.ts wire the router through the composition root as pure plumbing (a getter and a null cell); startup creates it before the first window can issue IPC. The 11 remote/host/pairing error codes that the RACP-WS transport already registered (ADR 0284) were never documented in 08-error-codes, which the error-code-registry contract test flagged. Add a §3.8 to the spec and its zh-CN mirror. --- .../electron/main/bootstrap/startup.ts | 11 ++ apps/desktop/electron/main/index.ts | 2 + apps/desktop/electron/main/ipc/register.ts | 24 +++- .../electron/main/remote/backend-router.ts | 131 ++++++++++++++++++ .../test/remote-backend-router.test.mjs | 87 ++++++++++++ docs/spec/03-runtime/08-error-codes.md | 23 +++ docs/zh-CN/spec/03-runtime/08-error-codes.md | 23 +++ packages/shared/src/types/sessions.ts | 12 +- 8 files changed, 311 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/electron/main/remote/backend-router.ts create mode 100644 apps/desktop/test/remote-backend-router.test.mjs diff --git a/apps/desktop/electron/main/bootstrap/startup.ts b/apps/desktop/electron/main/bootstrap/startup.ts index 004348e735..c7de5085db 100644 --- a/apps/desktop/electron/main/bootstrap/startup.ts +++ b/apps/desktop/electron/main/bootstrap/startup.ts @@ -17,6 +17,7 @@ import { import { applyNetworkProxyFromAppSettings } from "../network-proxy"; import { readCloseBehavior } from "../window-preferences"; import { createAgentHostBridge, type AgentHostBridge } from "../agent-host-bridge"; +import { createBackendRouter, type BackendRouter } from "../remote/backend-router"; import { createMcpControlController, McpControlServer, @@ -40,6 +41,7 @@ export type StartupState = { applicationBooted: boolean; closeBehavior: CloseBehavior; agentHostBridge: AgentHostBridge | null; + backendRouter: BackendRouter | null; desktopControl: McpControlController | null; mcpControl: McpControlServer | null; }; @@ -158,6 +160,15 @@ export function registerApplicationStartup(deps: StartupDependencies): void { // not race the renderer allocation just because backend startup was slow. prewarmPluginLauncher(); const invokeIpc = registerIpc(); + // The backend router is the single seam that forwards a renderer IPC call + // to a paired remote host; with no remote session registered it returns + // ROUTE_LOCAL and the local handler runs unchanged. Assigned before the + // first window can issue IPC. Remote host connections register their + // sessions here once paired (later stages). + state.backendRouter = createBackendRouter({ + log: (level, message, data) => + logger.app("runtime", level, message, { data: data === undefined ? undefined : String(data) }), + }); state.agentHostBridge = createAgentHostBridge({ invoke: invokeIpc, channels: IPC.invoke, diff --git a/apps/desktop/electron/main/index.ts b/apps/desktop/electron/main/index.ts index 480fbeec7c..8b06e0ad97 100644 --- a/apps/desktop/electron/main/index.ts +++ b/apps/desktop/electron/main/index.ts @@ -1255,6 +1255,7 @@ function registerIpc() { getHost: () => host, getSidecar: () => sidecar, getAgentHostBridge: () => agentHostBridge, + getBackendRouter: () => startupState.backendRouter, getNotificationViewingSessionId: () => notificationViewingSessionId, setNotificationViewingSessionId: (sessionId: string | null) => { notificationViewingSessionId = sessionId; @@ -1369,6 +1370,7 @@ const startupState: StartupState = { set agentHostBridge(value) { agentHostBridge = value; }, + backendRouter: null, get desktopControl() { return desktopControl; }, diff --git a/apps/desktop/electron/main/ipc/register.ts b/apps/desktop/electron/main/ipc/register.ts index 99fd449924..ea326fb24f 100644 --- a/apps/desktop/electron/main/ipc/register.ts +++ b/apps/desktop/electron/main/ipc/register.ts @@ -4,6 +4,7 @@ import { err, ErrorCodes, IPC, ok, type Result } from "@pi-desktop/shared"; import type { AgentHostBridge } from "../agent-host-bridge"; import type { AgentSidecar } from "../agent-sidecar"; import type { HostProcess } from "../host-process"; +import { ROUTE_LOCAL, type BackendRouter } from "../remote/backend-router"; import { registerAgentExtensionIpc } from "../agent-extensions-ipc"; import { registerAgentIpc } from "./agent-ipc"; import { registerAppIpc } from "./app-ipc"; @@ -33,6 +34,13 @@ export type RegisterIpcDependencies = { getHost: () => HostProcess | null; getSidecar: () => AgentSidecar | null; getAgentHostBridge: () => AgentHostBridge | null; + /** + * Resolves the remote backend router once it exists. Renderer IPC calls whose + * session is owned by a paired remote host are forwarded through it; every + * other call — including all internal invokes — runs the local handler + * unchanged. Null until the router is wired (and in tests). + */ + getBackendRouter?: () => BackendRouter | null; getNotificationViewingSessionId: () => string | null; setNotificationViewingSessionId: (sessionId: string | null) => void; activeUserSubagentDocuments: (...args: any[]) => Promise; @@ -59,6 +67,7 @@ export function registerIpcHandlers(dependencies: RegisterIpcDependencies) { getHost, getSidecar, getAgentHostBridge, + getBackendRouter, getNotificationViewingSessionId, setNotificationViewingSessionId, getPluginLauncherWindow, @@ -140,7 +149,20 @@ export function registerIpcHandlers(dependencies: RegisterIpcDependencies) { const ipcHandlers = new Map Promise>(); const handle = (channel: string, fn: (...args: any[]) => Promise) => { ipcHandlers.set(channel, fn); - ipcMain.handle(channel, async (_event, ...args) => wrap(() => fn(...args))); + // The interception seam for remote-host routing: a renderer call whose + // session is owned by a paired remote host is served over RACP-WS; every + // other call (and every internal invoke, which never reaches this wrapper) + // runs the existing local handler byte-for-byte unchanged. + ipcMain.handle(channel, async (_event, ...args) => + wrap(async () => { + const router = getBackendRouter?.(); + if (router) { + const outcome = await router.route(channel, args); + if (outcome !== ROUTE_LOCAL) return outcome.value; + } + return fn(...args); + }), + ); }; const handleWithEvent = ( channel: string, diff --git a/apps/desktop/electron/main/remote/backend-router.ts b/apps/desktop/electron/main/remote/backend-router.ts new file mode 100644 index 0000000000..3f76bccba8 --- /dev/null +++ b/apps/desktop/electron/main/remote/backend-router.ts @@ -0,0 +1,131 @@ +/** + * Backend router: the single seam that decides whether a renderer IPC call is + * served by this desktop's local host-core (the default, byte-for-byte + * unchanged) or forwarded to a paired remote `pi-host` over RACP-WS. + * + * The frozen architecture keeps this out of the per-domain IPC handlers and out + * of the God-modules: `register.ts` consults `route()` from inside its `handle` + * wrapper, and everything remote lives under `electron/main/remote/*`. A + * session becomes remote only once a {@link RemoteBackend} is registered for its + * id; until then — and for every local or `native-pi:` session — the router + * returns {@link ROUTE_LOCAL} and the existing local handler runs. + * + * Renderer-visible session ids for remote sessions are namespaced + * `remote::`, mirroring the proven `native-pi:` prefix + * (session-ipc.ts). The renderer never parses the prefix; it is resolved here. + */ + +/** Sentinel telling the caller to run the existing local handler unchanged. */ +export const ROUTE_LOCAL = Symbol("pi-desktop.route-local"); + +/** Namespaced-id prefix for sessions owned by a remote host. */ +const REMOTE_PREFIX = "remote:"; + +/** A registered remote host's session, addressed by its renderer-visible id. */ +export interface RemoteBackend { + /** + * Whether this backend can serve `channel`. A channel the remote profile does + * not cover (e.g. a desktop-only setting) falls back to the local handler so + * the renderer keeps working while the session's transcript stays remote. + */ + handles(channel: string): boolean; + /** Serve the call remotely, returning the value the renderer expects. */ + invoke(channel: string, args: readonly unknown[]): Promise; +} + +/** Result of {@link BackendRouter.route}: run locally, or a served remote value. */ +export type RouteOutcome = + | typeof ROUTE_LOCAL + | { readonly remote: true; readonly value: unknown }; + +export interface BackendRouter { + /** Bind a renderer-visible session id to the backend that owns it. */ + registerBackend(sessionId: string, backend: RemoteBackend): void; + /** Release a session id (host disconnect, session delete, kill switch). */ + unregisterBackend(sessionId: string): void; + /** The backend that should serve `(channel, args)`, or null for local. */ + resolveBackend(channel: string, args: readonly unknown[]): RemoteBackend | null; + /** Route a renderer IPC call. */ + route(channel: string, args: readonly unknown[]): Promise; +} + +export type BackendRouterOptions = { + /** Optional structured log for routing faults; defaults to a no-op. */ + log?: (level: "warn" | "error", message: string, data?: unknown) => void; +}; + +/** Build the namespaced id the renderer sees for a remote session. */ +export function makeRemoteSessionId(hostKey: string, hostSessionId: string): string { + if (hostKey.includes(":")) { + throw Object.assign(new Error("remote hostKey must not contain ':'"), { + errorCode: "INVALID_ARGUMENT", + }); + } + return `${REMOTE_PREFIX}${hostKey}:${hostSessionId}`; +} + +export function isRemoteSessionId(sessionId: unknown): sessionId is string { + return typeof sessionId === "string" && sessionId.startsWith(REMOTE_PREFIX); +} + +/** Split a `remote::` id back into its parts. */ +export function parseRemoteSessionId( + sessionId: string, +): { hostKey: string; hostSessionId: string } | null { + if (!sessionId.startsWith(REMOTE_PREFIX)) return null; + const rest = sessionId.slice(REMOTE_PREFIX.length); + const sep = rest.indexOf(":"); + if (sep <= 0 || sep === rest.length - 1) return null; + return { hostKey: rest.slice(0, sep), hostSessionId: rest.slice(sep + 1) }; +} + +/** + * Best-effort session id for a renderer IPC call. Desktop channels pass the + * session id either as the first positional argument or as `sessionId` on the + * first argument object; anything else has no session and is always local. + */ +export function sessionIdForCall(args: readonly unknown[]): string | null { + const first = args[0]; + if (typeof first === "string") return isRemoteSessionId(first) ? first : null; + if (first && typeof first === "object") { + const id = (first as { sessionId?: unknown }).sessionId; + if (typeof id === "string" && isRemoteSessionId(id)) return id; + } + return null; +} + +export function createBackendRouter(options: BackendRouterOptions = {}): BackendRouter { + const log = options.log ?? (() => undefined); + const sessionBackends = new Map(); + + const resolveBackend = ( + channel: string, + args: readonly unknown[], + ): RemoteBackend | null => { + const sessionId = sessionIdForCall(args); + if (!sessionId) return null; + const backend = sessionBackends.get(sessionId); + if (!backend) return null; + return backend.handles(channel) ? backend : null; + }; + + return { + registerBackend(sessionId, backend) { + sessionBackends.set(sessionId, backend); + }, + unregisterBackend(sessionId) { + sessionBackends.delete(sessionId); + }, + resolveBackend, + async route(channel, args) { + const backend = resolveBackend(channel, args); + if (!backend) return ROUTE_LOCAL; + try { + return { remote: true, value: await backend.invoke(channel, args) }; + } catch (error) { + log("warn", `remote route failed for ${channel}`, error); + throw error; + } + }, + }; +} diff --git a/apps/desktop/test/remote-backend-router.test.mjs b/apps/desktop/test/remote-backend-router.test.mjs new file mode 100644 index 0000000000..a0b4754a5b --- /dev/null +++ b/apps/desktop/test/remote-backend-router.test.mjs @@ -0,0 +1,87 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { register } from "node:module"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +register(pathToFileURL(join(here, "helpers/ts-import-hooks.mjs"))); + +const { + ROUTE_LOCAL, + createBackendRouter, + makeRemoteSessionId, + parseRemoteSessionId, + isRemoteSessionId, + sessionIdForCall, +} = await import("../electron/main/remote/backend-router.ts"); + +test("namespaces and parses remote session ids", () => { + const id = makeRemoteSessionId("hostA", "sess_1"); + assert.equal(id, "remote:hostA:sess_1"); + assert.ok(isRemoteSessionId(id)); + assert.ok(!isRemoteSessionId("sess_1")); + assert.ok(!isRemoteSessionId("native-pi:codex:abc")); + assert.deepEqual(parseRemoteSessionId(id), { hostKey: "hostA", hostSessionId: "sess_1" }); + // A host session id may itself contain ':'; only the first separator splits. + assert.deepEqual(parseRemoteSessionId("remote:hostA:a:b"), { + hostKey: "hostA", + hostSessionId: "a:b", + }); + assert.equal(parseRemoteSessionId("remote:onlyhost"), null); + assert.equal(parseRemoteSessionId("desktop-session"), null); +}); + +test("rejects a hostKey containing the separator", () => { + assert.throws(() => makeRemoteSessionId("host:bad", "s"), /hostKey must not contain/); +}); + +test("sessionIdForCall reads positional and object session ids", () => { + const remote = makeRemoteSessionId("h", "s"); + assert.equal(sessionIdForCall([remote]), remote); + assert.equal(sessionIdForCall([{ sessionId: remote }]), remote); + assert.equal(sessionIdForCall(["local-session"]), null); + assert.equal(sessionIdForCall([{ sessionId: "local-session" }]), null); + assert.equal(sessionIdForCall([]), null); + assert.equal(sessionIdForCall([42]), null); +}); + +test("routes locally until a backend is registered for the session", async () => { + const router = createBackendRouter(); + const remote = makeRemoteSessionId("h", "s"); + assert.equal(await router.route("session.get", [{ sessionId: remote }]), ROUTE_LOCAL); + + const calls = []; + router.registerBackend(remote, { + handles: (channel) => channel === "session.get", + invoke: async (channel, args) => { + calls.push([channel, args]); + return { id: remote, source: "remote" }; + }, + }); + + const outcome = await router.route("session.get", [{ sessionId: remote }]); + assert.notEqual(outcome, ROUTE_LOCAL); + assert.deepEqual(outcome, { remote: true, value: { id: remote, source: "remote" } }); + assert.equal(calls.length, 1); + + // A channel the backend does not cover falls back to local. + assert.equal(await router.route("settings.get", [{ sessionId: remote }]), ROUTE_LOCAL); + // A local session id is never routed even after a remote backend exists. + assert.equal(await router.route("session.get", [{ sessionId: "local" }]), ROUTE_LOCAL); + + router.unregisterBackend(remote); + assert.equal(await router.route("session.get", [{ sessionId: remote }]), ROUTE_LOCAL); +}); + +test("route surfaces a backend failure to the caller", async () => { + const router = createBackendRouter(); + const remote = makeRemoteSessionId("h", "s"); + router.registerBackend(remote, { + handles: () => true, + invoke: async () => { + throw Object.assign(new Error("host gone"), { errorCode: "HOST_DISCONNECTED" }); + }, + }); + await assert.rejects(() => router.route("session.get", [remote]), /host gone/); +}); diff --git a/docs/spec/03-runtime/08-error-codes.md b/docs/spec/03-runtime/08-error-codes.md index b74a86e984..f2af47e887 100644 --- a/docs/spec/03-runtime/08-error-codes.md +++ b/docs/spec/03-runtime/08-error-codes.md @@ -274,6 +274,29 @@ carries a marker naming which end survived and where the rest is, or reports the bounded window in sibling result fields (see [16-tool-result-limits](16-tool-result-limits.md)). +### 3.8 Remote control (RACP-WS / SSH bootstrap) + +Emitted by the desktop's remote-host client and the `pi-host` server when a +session lives on a paired remote machine driven over `RACP-WS` +(see [19-remote-agent-control-protocol](19-remote-agent-control-protocol.md), +[../05-security/02-remote-control-security](../05-security/02-remote-control-security.md), +ADR 0284). The renderer never sees the local/remote split beyond a badge; these +codes surface through the same error object as any other call. + +| code | retriable | meaning | +|---|---|---| +| `HOST_DISCONNECTED` | yes | the remote host connection dropped; in-flight calls are rejected and the client reconnects and resubscribes by cursor | +| `HOST_BOOTSTRAP_FAILED` | no | provisioning the remote `pi-host` over SSH failed (download, checksum mismatch, or `install.sh`); `details.reason` names the stage | +| `HOST_VERSION_MISMATCH` | no | the remote `pi-host` version does not match the desktop; the desktop refuses to drive an incompatible host | +| `REMOTE_AUTH_FAILED` | no | the device or pairing token was rejected on the RACP-WS upgrade | +| `REMOTE_CONNECTION_FAILED` | yes | the RACP-WS transport could not connect (non-loopback URL, refused socket) | +| `REMOTE_FORWARD_FAILED` | yes | the SSH loopback port forward could not be established | +| `REMOTE_PATH_NOT_FOUND` | no | a remote project/workspace path does not exist on the host | +| `REMOTE_PATH_FORBIDDEN` | no | a remote path is outside the host's permitted roots | +| `PAIRING_FAILED` | no | `connection/pair` could not mint a device credential | +| `PAIRING_TOKEN_EXPIRED` | no | the single-use pairing token expired before pairing completed | +| `CAPABILITY_UNAVAILABLE` | no | an operation was requested for a capability the host advertised as unavailable (e.g. attachments, tool relay) | + ## 4. Mapping rules ### Host RPC numeric → AppError.code diff --git a/docs/zh-CN/spec/03-runtime/08-error-codes.md b/docs/zh-CN/spec/03-runtime/08-error-codes.md index cfaeacf510..f8b264d927 100644 --- a/docs/zh-CN/spec/03-runtime/08-error-codes.md +++ b/docs/zh-CN/spec/03-runtime/08-error-codes.md @@ -270,6 +270,29 @@ reveal 不并入任何行,必须重新读取。 同级结果字段中的有界窗口 (请参阅 [16-工具-结果-限制](/zh-CN/spec/03-runtime/16-tool-result-limits))。 +### 3.8 远程控制(RACP-WS / SSH 引导) + +当会话位于经 `RACP-WS` 驱动的已配对远程主机上时,由桌面端的远程主机客户端与 +`pi-host` 服务端发出(参见 +[19-远程代理控制协议](/zh-CN/spec/03-runtime/19-remote-agent-control-protocol)、 +[../05-security/02-remote-control-security](/zh-CN/spec/05-security/02-remote-control-security)、 +ADR 0284)。渲染进程除了一个标识徽章外看不到本地/远程之分;这些码通过与其他调用 +相同的错误对象浮现。 + +| 码 | 可重试 | 含义 | +|---|---|---| +| `HOST_DISCONNECTED` | 是 | 远程主机连接断开;进行中的调用被拒绝,客户端按游标重连并重新订阅 | +| `HOST_BOOTSTRAP_FAILED` | 否 | 经 SSH 配置远程 `pi-host` 失败(下载、校验和不匹配或 `install.sh`);`details.reason` 指明阶段 | +| `HOST_VERSION_MISMATCH` | 否 | 远程 `pi-host` 版本与桌面不匹配;桌面拒绝驱动不兼容的主机 | +| `REMOTE_AUTH_FAILED` | 否 | 设备或配对令牌在 RACP-WS 升级时被拒 | +| `REMOTE_CONNECTION_FAILED` | 是 | RACP-WS 传输无法连接(非回环 URL、套接字被拒) | +| `REMOTE_FORWARD_FAILED` | 是 | 无法建立 SSH 回环端口转发 | +| `REMOTE_PATH_NOT_FOUND` | 否 | 远程项目/工作区路径在主机上不存在 | +| `REMOTE_PATH_FORBIDDEN` | 否 | 远程路径在主机允许的根之外 | +| `PAIRING_FAILED` | 否 | `connection/pair` 无法铸造设备凭据 | +| `PAIRING_TOKEN_EXPIRED` | 否 | 一次性配对令牌在配对完成前已过期 | +| `CAPABILITY_UNAVAILABLE` | 否 | 请求的操作对应主机声明为不可用的能力(如附件、工具中继) | + ## 4. 映射规则 ### 主机 RPC 数字 → AppError.code diff --git a/packages/shared/src/types/sessions.ts b/packages/shared/src/types/sessions.ts index 812eb9f989..bb7f24724b 100644 --- a/packages/shared/src/types/sessions.ts +++ b/packages/shared/src/types/sessions.ts @@ -5,7 +5,17 @@ import type { PermissionMode } from "./permissions.js"; import type { UiMessage } from "./messages.js"; import type { PlanningState } from "./plans.js"; -export type SessionSource = "desktop" | "pi-native"; +/** + * Which authority owns a session's transcript. + * + * - `desktop`: this desktop's own host-core (the default; older hosts omit the + * field and it is normalized to `desktop`). + * - `pi-native`: an imported Pi CLI session, read-mostly. + * - `remote`: a session that lives on a paired remote `pi-host` and is driven + * over RACP-WS. The renderer treats it exactly like a `desktop` session apart + * from a display badge; the local/remote split is resolved in Electron main. + */ +export type SessionSource = "desktop" | "pi-native" | "remote"; export type SessionCapabilities = { canPrompt: boolean; From 1241ea2e72b4af2b0579cf0c036ab7c542d175ec Mon Sep 17 00:00:00 2001 From: vastsa Date: Fri, 18 Sep 2026 17:43:34 +0800 Subject: [PATCH 08/13] feat(remote): translate renderer IPC into RACP for remote sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 2 of the remote-host kernel. Given a `remote::` session already surfaced through the backend-router seam (Stage 1), translate the renderer's per-session IPC calls into RACP requests and reshape the results back into the exact response shapes the renderer already consumes from a local session. The adapter lives entirely in Electron Main; the renderer never learns the transport (spec §3.4). Channels the remote profile does not cover — `agentSteer`, attachment uploads, and any desktop-only settings — either return false from `handles` or throw `CAPABILITY_UNAVAILABLE`, so the call falls back to the local handler while the session's transcript stays remote. Three shape decisions constrain the adapter: - Tool permission resolution carries only `{requestId, decision}` with no session id. The request id itself must name the session, so the router encodes it as `#racp-approval:` and `sessionIdForCall` decodes it. The router stays stateless. - `plansResolve` returns `PlanResolutionResult`, but the RACP `approval/respond` returns only `RacpApprovalResult`. The backend synthesizes a minimal proposal from the request identity to dismiss the card optimistically; the authoritative snapshot arrives on the follow-up `session.changed` event. - `QueuedTurnSummary.content` cannot be recovered from a snapshot — `RacpTurn` carries no prompt text — so `agentQueueList` renders queued entries with `content: ""`. Live `agentQueuePush` calls populate content from the local request. The connection layer that instantiates a `RemoteRacpClient` and calls `registerBackend` on `session.created` is Stage 3; `bootstrap/startup.ts` is unchanged here. Covered by `apps/desktop/test/remote-backend.test.mjs` with a fake RACP client fixture. Existing router tests continue to pass; full desktop `node --test` suite (2093 tests) is green. --- .../electron/main/remote/backend-router.ts | 52 +- .../electron/main/remote/remote-backend.ts | 368 ++++++++++++++ .../electron/main/remote/remote-transcript.ts | 57 +++ apps/desktop/test/remote-backend.test.mjs | 462 ++++++++++++++++++ 4 files changed, 938 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/electron/main/remote/remote-backend.ts create mode 100644 apps/desktop/electron/main/remote/remote-transcript.ts create mode 100644 apps/desktop/test/remote-backend.test.mjs diff --git a/apps/desktop/electron/main/remote/backend-router.ts b/apps/desktop/electron/main/remote/backend-router.ts index 3f76bccba8..9e38a4309d 100644 --- a/apps/desktop/electron/main/remote/backend-router.ts +++ b/apps/desktop/electron/main/remote/backend-router.ts @@ -21,6 +21,16 @@ export const ROUTE_LOCAL = Symbol("pi-desktop.route-local"); /** Namespaced-id prefix for sessions owned by a remote host. */ const REMOTE_PREFIX = "remote:"; +/** + * Delimiter that embeds the renderer-visible remote session id inside a tool + * permission `requestId`. `toolResolvePermission` carries only `{requestId, + * decision}` — no session id — so the id itself must name its owning session + * for {@link sessionIdForCall} to route the resolution statelessly, with no + * correlation map. Chosen so it cannot collide with a `remote:` session id or a + * host-assigned approval id. + */ +const APPROVAL_ID_DELIMITER = "#racp-approval:"; + /** A registered remote host's session, addressed by its renderer-visible id. */ export interface RemoteBackend { /** @@ -79,10 +89,40 @@ export function parseRemoteSessionId( return { hostKey: rest.slice(0, sep), hostSessionId: rest.slice(sep + 1) }; } +/** + * Encode a tool permission `requestId` that names its owning remote session. + * The renderer echoes this id back verbatim in `toolResolvePermission`, and the + * router recovers the session from it without any server-side correlation. + */ +export function makeRemoteApprovalRequestId( + remoteSessionId: string, + hostApprovalId: string, +): string { + return `${remoteSessionId}${APPROVAL_ID_DELIMITER}${hostApprovalId}`; +} + +/** + * Split an encoded approval `requestId` back into the remote session id and the + * host's own approval id. Returns null for a plain (local) request id. + */ +export function parseRemoteApprovalRequestId( + requestId: string, +): { remoteSessionId: string; hostApprovalId: string } | null { + const at = requestId.indexOf(APPROVAL_ID_DELIMITER); + if (at <= 0) return null; + const remoteSessionId = requestId.slice(0, at); + if (!isRemoteSessionId(remoteSessionId)) return null; + const hostApprovalId = requestId.slice(at + APPROVAL_ID_DELIMITER.length); + if (!hostApprovalId) return null; + return { remoteSessionId, hostApprovalId }; +} + /** * Best-effort session id for a renderer IPC call. Desktop channels pass the * session id either as the first positional argument or as `sessionId` on the - * first argument object; anything else has no session and is always local. + * first argument object; `toolResolvePermission` carries neither, so its + * remote-session-encoded `requestId` is decoded instead. Anything else has no + * session and is always local. */ export function sessionIdForCall(args: readonly unknown[]): string | null { const first = args[0]; @@ -90,6 +130,16 @@ export function sessionIdForCall(args: readonly unknown[]): string | null { if (first && typeof first === "object") { const id = (first as { sessionId?: unknown }).sessionId; if (typeof id === "string" && isRemoteSessionId(id)) return id; + // `sessionGet` addresses the session as `{ id }` rather than `{ sessionId }`. + // Matching a bare `id` is safe because only a `remote:`-prefixed value ever + // resolves, and no other entity id carries that prefix. + const bareId = (first as { id?: unknown }).id; + if (typeof bareId === "string" && isRemoteSessionId(bareId)) return bareId; + const requestId = (first as { requestId?: unknown }).requestId; + if (typeof requestId === "string") { + const parsed = parseRemoteApprovalRequestId(requestId); + if (parsed) return parsed.remoteSessionId; + } } return null; } diff --git a/apps/desktop/electron/main/remote/remote-backend.ts b/apps/desktop/electron/main/remote/remote-backend.ts new file mode 100644 index 0000000000..b3237ab786 --- /dev/null +++ b/apps/desktop/electron/main/remote/remote-backend.ts @@ -0,0 +1,368 @@ +/** + * The remote backend: translates one paired host's renderer IPC calls into RACP + * requests and reshapes the results into the exact response shapes the renderer + * already expects from the local handlers. One instance serves every session of + * a host; the owning host session id is derived from each call's arguments, so + * the same instance is registered under each of the host's session ids. + * + * Ownership stays inside the frozen architecture: this runs in Electron Main and + * speaks RACP-WS to the remote `pi-host`; the renderer is unaware of the + * transport (spec §3.4). Channels the remote profile does not cover return + * `false` from {@link RemoteBackend.handles} and fall back to the local handler. + */ +import { ErrorCodes, IPC } from "@pi-desktop/shared"; +import type { + AgentCompactResponse, + AgentPromptRequest, + AgentPromptResponse, + AgentQueuePushRequest, + AgentStatus, + AgentStopResponse, + AskToolResolution, + PlanResolutionResult, + PlanResolveRequest, + QueuedTurnSummary, + RacpApprovalResult, + RacpRequestContext, + RacpSession, + RacpSessionSnapshot, + RacpTurn, + SessionDetail, + SessionSummary, + ToolPermissionResolution, +} from "@pi-desktop/shared"; +import type { RemoteBackend } from "./backend-router.js"; +import { + makeRemoteSessionId, + parseRemoteApprovalRequestId, + parseRemoteSessionId, + sessionIdForCall, +} from "./backend-router.js"; +import { racpSessionToSummary, snapshotToSessionDetail } from "./remote-transcript.js"; + +/** The subset of `RacpClient` this backend needs; kept minimal for testing. */ +export type RemoteRacpClient = { + request(method: string, params?: unknown): Promise; +}; + +export type RemoteBackendOptions = { + /** The host's routing key; the outward id of a forked session reuses it. */ + hostKey: string; + client: RemoteRacpClient; + /** Injectable id source for RACP request contexts; defaults to a UUID. */ + newRequestId?: () => string; +}; + +type AttachResultLike = { session: RacpSession; snapshot?: RacpSessionSnapshot }; + +function capabilityUnavailable(message: string): Error { + return Object.assign(new Error(message), { + errorCode: ErrorCodes.CAPABILITY_UNAVAILABLE, + }); +} + +/** The channels a remote host serves; every other channel stays local. */ +const HANDLED_CHANNELS: ReadonlySet = new Set([ + IPC.invoke.agentPrompt, + IPC.invoke.agentQueuePush, + IPC.invoke.agentQueueList, + IPC.invoke.agentStop, + IPC.invoke.agentAbort, + IPC.invoke.agentCompact, + IPC.invoke.agentGetStatus, + IPC.invoke.agentSteer, + IPC.invoke.sessionGet, + IPC.invoke.sessionConfigure, + IPC.invoke.sessionFork, + IPC.invoke.sessionRename, + IPC.invoke.sessionDelete, + IPC.invoke.toolResolvePermission, + IPC.invoke.askToolResolve, + IPC.invoke.plansResolve, + IPC.invoke.plansPending, +]); + +export function createRemoteBackend(options: RemoteBackendOptions): RemoteBackend { + const { hostKey, client } = options; + const newRequestId = options.newRequestId ?? (() => globalThis.crypto.randomUUID()); + const context = (idempotencyKey?: string): RacpRequestContext => ({ + requestId: newRequestId(), + ...(idempotencyKey ? { idempotencyKey } : {}), + }); + + /** The remote-visible id for a call, or throw if it names no remote session. */ + const remoteIdFor = (args: readonly unknown[]): string => { + const id = sessionIdForCall(args); + if (!id) { + throw Object.assign(new Error("call does not address a remote session"), { + errorCode: ErrorCodes.INTERNAL, + }); + } + return id; + }; + + /** The host's own session id (the router only forwards remote-prefixed ids). */ + const hostIdFor = (args: readonly unknown[]): string => { + const parsed = parseRemoteSessionId(remoteIdFor(args)); + if (!parsed) { + throw Object.assign(new Error("malformed remote session id"), { + errorCode: ErrorCodes.INTERNAL, + }); + } + return parsed.hostSessionId; + }; + + /** Resolve the turn to act on: an explicit id, else the session's active turn. */ + const resolveTurnId = async ( + hostSessionId: string, + turnId?: string, + ): Promise => { + if (turnId) return turnId; + const { session } = await client.request<{ session: RacpSession }>("session/get", { + sessionId: hostSessionId, + }); + return session.activeTurnId; + }; + + const startTurn = async ( + req: AgentPromptRequest | AgentQueuePushRequest, + admission: "reject_if_busy" | "queue", + ): Promise<{ accepted: boolean; turn: RacpTurn }> => { + if ("attachments" in req && req.attachments?.length) { + throw capabilityUnavailable("this remote host does not accept attachments"); + } + const hostSessionId = parseRemoteSessionId(req.sessionId)?.hostSessionId; + if (!hostSessionId) { + throw Object.assign(new Error("malformed remote session id"), { + errorCode: ErrorCodes.INTERNAL, + }); + } + const idempotencyKey = "idempotencyKey" in req ? req.idempotencyKey : undefined; + return client.request("turn/start", { + sessionId: hostSessionId, + admission, + ...(idempotencyKey ? { idempotencyKey } : {}), + input: { + text: req.content, + ...(req.sessionMessageId ? { sessionMessageId: req.sessionMessageId } : {}), + ...("messageId" in req && req.messageId ? { messageId: req.messageId } : {}), + }, + context: context(idempotencyKey), + }); + }; + + const invoke = async (channel: string, args: readonly unknown[]): Promise => { + switch (channel) { + case IPC.invoke.agentPrompt: { + const { accepted, turn } = await startTurn(args[0] as AgentPromptRequest, "reject_if_busy"); + return { accepted, turnId: turn.id } satisfies AgentPromptResponse; + } + case IPC.invoke.agentQueuePush: { + const req = args[0] as AgentQueuePushRequest; + const { turn } = await startTurn(req, "queue"); + // The prompt text is known here (the snapshot's queued turns do not carry + // it), so the pushed entry renders with its content immediately. + return { + id: turn.id, + sessionId: req.sessionId, + content: req.content, + ...(req.sessionMessageId ? { sessionMessageId: req.sessionMessageId } : {}), + position: turn.queuePosition ?? 0, + createdAt: new Date().toISOString(), + } satisfies QueuedTurnSummary; + } + case IPC.invoke.agentQueueList: { + const remoteSessionId = remoteIdFor(args); + const attach = await client.request("session/attach", { + sessionId: parseRemoteSessionId(remoteSessionId)!.hostSessionId, + includeSnapshot: true, + }); + const entries: QueuedTurnSummary[] = (attach.snapshot?.queuedTurns ?? []).map( + (turn, index) => ({ + id: turn.id, + sessionId: remoteSessionId, + // RACP turns do not carry the queued prompt text; the entry still + // renders with its position and id, and live pushes fill content. + content: "", + position: turn.queuePosition ?? index + 1, + createdAt: turn.startedAt ?? new Date().toISOString(), + }), + ); + return { entries }; + } + case IPC.invoke.agentStop: { + const req = args[0] as { sessionId: string; turnId?: string }; + const turnId = await resolveTurnId(hostIdFor(args), req.turnId); + if (!turnId) return { requested: false } satisfies AgentStopResponse; + await client.request("turn/stop", { turnId }); + return { requested: true } satisfies AgentStopResponse; + } + case IPC.invoke.agentAbort: { + const req = args[0] as { sessionId: string; turnId?: string }; + const turnId = await resolveTurnId(hostIdFor(args), req.turnId); + if (!turnId) return { aborted: false }; + await client.request("turn/interrupt", { turnId }); + return { aborted: true }; + } + case IPC.invoke.agentCompact: { + await client.request("session/compact", { sessionId: hostIdFor(args) }); + return { accepted: true } satisfies AgentCompactResponse; + } + case IPC.invoke.agentGetStatus: { + const remoteSessionId = remoteIdFor(args); + const { session } = await client.request<{ session: RacpSession }>("session/get", { + sessionId: parseRemoteSessionId(remoteSessionId)!.hostSessionId, + }); + const status: AgentStatus = { + sessionId: remoteSessionId, + isRunning: session.status === "running", + ...(session.activeTurnId ? { currentTurnId: session.activeTurnId } : {}), + pendingToolConfirmations: session.status === "waiting_permission" ? 1 : 0, + planningState: session.planningState, + }; + return { status }; + } + case IPC.invoke.agentSteer: + // Steering an in-flight turn is a local sidecar affordance with no RACP + // operation; the renderer already guards it behind a capability check. + throw capabilityUnavailable("steering is not available on a remote host"); + case IPC.invoke.sessionGet: { + const remoteSessionId = remoteIdFor(args); + const attach = await client.request("session/attach", { + sessionId: parseRemoteSessionId(remoteSessionId)!.hostSessionId, + includeSnapshot: true, + }); + if (!attach.snapshot) { + throw Object.assign(new Error("remote host returned no snapshot"), { + errorCode: ErrorCodes.INTERNAL, + }); + } + return { session: snapshotToSessionDetail(remoteSessionId, attach.snapshot) }; + } + case IPC.invoke.sessionConfigure: { + const remoteSessionId = remoteIdFor(args); + const config = (args[1] ?? {}) as Partial< + Pick + >; + const { session } = await client.request<{ session: RacpSession }>("session/configure", { + sessionId: parseRemoteSessionId(remoteSessionId)!.hostSessionId, + ...(config.mode ? { mode: config.mode } : {}), + ...(config.providerId ? { providerId: config.providerId } : {}), + ...(config.modelId ? { modelId: config.modelId } : {}), + ...(config.thinkingLevel ? { thinkingLevel: config.thinkingLevel } : {}), + ...(config.permissionMode ? { permissionMode: config.permissionMode } : {}), + }); + return { session: racpSessionToSummary(remoteSessionId, session, 0) }; + } + case IPC.invoke.sessionFork: { + const req = args[0] as { sessionId: string; title?: string; throughMessageId?: string }; + const { session } = await client.request<{ session: RacpSession }>("session/fork", { + sessionId: parseRemoteSessionId(req.sessionId)!.hostSessionId, + ...(req.title ? { title: req.title } : {}), + ...(req.throughMessageId ? { throughMessageId: req.throughMessageId } : {}), + }); + // The fork is a new host session; the connection layer registers its + // backend on the `session.created` event. Attach to build its transcript. + const forkedRemoteId = makeRemoteSessionId(hostKey, session.id); + const attach = await client.request("session/attach", { + sessionId: session.id, + includeSnapshot: true, + }); + const detail: SessionDetail = attach.snapshot + ? snapshotToSessionDetail(forkedRemoteId, attach.snapshot) + : { ...racpSessionToSummary(forkedRemoteId, session, 0), messages: [] }; + return { session: detail }; + } + case IPC.invoke.sessionRename: { + const title = args[1] as string; + await client.request("session/rename", { sessionId: hostIdFor(args), title }); + return { ok: true }; + } + case IPC.invoke.sessionDelete: { + await client.request("session/delete", { sessionId: hostIdFor(args) }); + return { ok: true }; + } + case IPC.invoke.toolResolvePermission: { + const resolution = args[0] as ToolPermissionResolution; + const parsed = parseRemoteApprovalRequestId(resolution.requestId); + if (!parsed) { + throw Object.assign(new Error("malformed remote approval request id"), { + errorCode: ErrorCodes.INTERNAL, + }); + } + // The local tool decision literals equal the RACP tool decisions exactly. + await client.request("approval/respond", { + approvalId: parsed.hostApprovalId, + decision: resolution.decision, + context: context(), + }); + return { ok: true }; + } + case IPC.invoke.askToolResolve: { + const resolution = args[0] as AskToolResolution; + // `answers` is `Array` in both the local and RACP shapes. + await client.request("input/respond", { + inputId: resolution.requestId, + answers: resolution.answers, + context: context(), + }); + return { ok: true }; + } + case IPC.invoke.plansResolve: { + const resolution = args[0] as PlanResolveRequest; + const result = await client.request("approval/respond", { + approvalId: resolution.proposalId, + // Contract decisions ("approve"/"reject") equal the plan actions. + decision: resolution.action, + ...(resolution.action === "approve" + ? { permissionMode: resolution.targetPermissionMode } + : {}), + context: context(), + }); + // The authoritative proposal and planning state arrive on the following + // `session.changed` event, which the event bridge forwards; this return + // value only dismisses the card optimistically without throwing. + const now = new Date().toISOString(); + return { + ok: result.status === "resolved", + proposal: { + id: resolution.proposalId, + sessionId: resolution.sessionId, + turnId: resolution.turnId, + toolCallId: resolution.toolCallId, + kind: "plan", + title: "", + markdown: "", + // `plan` is the host's persisted-Markdown alias of `markdown`; empty + // is fine — the authoritative snapshot arrives on the follow-up + // `session.changed` event and replaces this placeholder. + plan: "", + question: "", + version: resolution.version ?? 1, + status: resolution.action === "approve" ? "approved" : "rejected", + createdAt: now, + updatedAt: now, + }, + state: "inactive", + action: resolution.action, + ...(resolution.action === "approve" + ? { targetPermissionMode: resolution.targetPermissionMode } + : {}), + } satisfies PlanResolutionResult; + } + case IPC.invoke.plansPending: + // Pending plan cards are restored from the attach snapshot's approvals by + // the event bridge, so this on-demand fetch stays empty for remote hosts. + return { plans: [] }; + default: + throw Object.assign(new Error(`remote backend has no handler for ${channel}`), { + errorCode: ErrorCodes.INTERNAL, + }); + } + }; + + return { + handles: (channel: string) => HANDLED_CHANNELS.has(channel), + invoke, + }; +} diff --git a/apps/desktop/electron/main/remote/remote-transcript.ts b/apps/desktop/electron/main/remote/remote-transcript.ts new file mode 100644 index 0000000000..920e5fa82f --- /dev/null +++ b/apps/desktop/electron/main/remote/remote-transcript.ts @@ -0,0 +1,57 @@ +/** + * Reshape a remote host's RACP session resources into the exact + * {@link SessionSummary}/{@link SessionDetail} the renderer already consumes for + * a local session. The renderer never learns the transport: only the + * `source: "remote"` badge and the namespaced id distinguish it (spec §3.4). + * + * RACP does not carry a per-session `thinkingLevel` (it is a desktop provider + * concern), so remote summaries default it to `"off"`; the renderer treats it + * as advisory display state and never round-trips it back to the host. + */ +import type { + RacpSession, + RacpSessionSnapshot, + SessionDetail, + SessionSummary, + UiMessage, +} from "@pi-desktop/shared"; + +/** Build the flat summary a session list / header row renders. */ +export function racpSessionToSummary( + remoteSessionId: string, + session: RacpSession, + messageCount: number, +): SessionSummary { + return { + id: remoteSessionId, + source: "remote", + title: session.title, + messageCount, + // RACP's mode and permission-mode literals are a subset of the renderer's, + // so they pass through unchanged; `inherit` is desktop-only and never sent. + mode: session.mode, + thinkingLevel: "off", + permissionMode: session.permissionMode, + updatedAt: session.updatedAt, + createdAt: session.createdAt, + }; +} + +/** + * Build the full transcript from an attach snapshot. `snapshot.items` already + * carries the canonical {@link UiMessage} in each item's `content` (the host's + * `toRacpItem` projection), so the transcript is a direct map with no lossy + * reconstruction. + */ +export function snapshotToSessionDetail( + remoteSessionId: string, + snapshot: RacpSessionSnapshot, +): SessionDetail { + const messages = snapshot.items.map((item) => item.content as UiMessage); + return { + ...racpSessionToSummary(remoteSessionId, snapshot.session, messages.length), + messages, + hasMoreBefore: snapshot.hasMoreHistory, + hasMoreAfter: false, + }; +} diff --git a/apps/desktop/test/remote-backend.test.mjs b/apps/desktop/test/remote-backend.test.mjs new file mode 100644 index 0000000000..8e2b3ea898 --- /dev/null +++ b/apps/desktop/test/remote-backend.test.mjs @@ -0,0 +1,462 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { register } from "node:module"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +register(pathToFileURL(join(here, "helpers/ts-import-hooks.mjs"))); + +const { IPC } = await import("@pi-desktop/shared"); +const { makeRemoteApprovalRequestId, makeRemoteSessionId } = await import( + "../electron/main/remote/backend-router.ts" +); +const { racpSessionToSummary, snapshotToSessionDetail } = await import( + "../electron/main/remote/remote-transcript.ts" +); +const { createRemoteBackend } = await import( + "../electron/main/remote/remote-backend.ts" +); + +const HOST_KEY = "hostA"; +const HOST_SESSION_ID = "sess-1"; +const REMOTE_SESSION_ID = makeRemoteSessionId(HOST_KEY, HOST_SESSION_ID); + +/** + * A fixture `RacpSession` shaped just like the schema; individual tests override + * only the fields they care about via spread. + */ +function makeRacpSession(overrides = {}) { + return { + id: HOST_SESSION_ID, + title: "Remote session", + mode: "chat", + status: "idle", + planningState: "inactive", + permissionMode: "default", + queuedTurnIds: [], + revision: 1, + createdAt: "2026-09-18T10:00:00.000Z", + updatedAt: "2026-09-18T10:00:00.000Z", + ...overrides, + }; +} + +function makeRacpTurn(overrides = {}) { + return { + id: "turn-1", + sessionId: HOST_SESSION_ID, + status: "running", + admission: "reject_if_busy", + effectivePermissionMode: "default", + ...overrides, + }; +} + +function makeRacpSnapshot(overrides = {}) { + return { + session: makeRacpSession(), + queuedTurns: [], + items: [], + activeItems: [], + pendingApprovals: [], + pendingInputs: [], + hasMoreHistory: false, + cursor: { epoch: "e", sequence: 0 }, + revision: 1, + generatedAt: "2026-09-18T10:00:00.000Z", + ...overrides, + }; +} + +/** A minimal RacpClient double that records every request and returns queued + * responses. Each entry maps a RACP method to a value or an error to throw. */ +function fakeClient(responses = {}) { + const calls = []; + return { + calls, + request: async (method, params) => { + calls.push({ method, params }); + const handler = responses[method]; + if (typeof handler === "function") return handler(params); + if (handler === undefined) { + throw Object.assign(new Error(`no fake for ${method}`), { errorCode: "INTERNAL" }); + } + return handler; + }, + }; +} + +function makeBackend(responses = {}, extra = {}) { + const client = fakeClient(responses); + const backend = createRemoteBackend({ + hostKey: HOST_KEY, + client, + // A deterministic id keeps the recorded request context stable in assertions. + newRequestId: () => "req-const", + ...extra, + }); + return { backend, client }; +} + +test("racpSessionToSummary maps the host-agnostic renderer summary", () => { + const session = makeRacpSession({ mode: "agent", permissionMode: "allow" }); + const summary = racpSessionToSummary(REMOTE_SESSION_ID, session, 7); + assert.equal(summary.id, REMOTE_SESSION_ID); + assert.equal(summary.source, "remote"); + assert.equal(summary.mode, "agent"); + assert.equal(summary.permissionMode, "allow"); + // RACP never carries thinkingLevel; it defaults to "off" for the renderer. + assert.equal(summary.thinkingLevel, "off"); + assert.equal(summary.messageCount, 7); +}); + +test("snapshotToSessionDetail lifts snapshot items directly into the transcript", () => { + const snapshot = makeRacpSnapshot({ + hasMoreHistory: true, + items: [ + { id: "i1", turnId: "t1", itemType: "message", status: "completed", createdAt: "x", content: { role: "user", text: "hi" } }, + { id: "i2", turnId: "t1", itemType: "message", status: "completed", createdAt: "x", content: { role: "assistant", text: "hello" } }, + ], + }); + const detail = snapshotToSessionDetail(REMOTE_SESSION_ID, snapshot); + assert.equal(detail.id, REMOTE_SESSION_ID); + assert.equal(detail.hasMoreBefore, true); + assert.equal(detail.hasMoreAfter, false); + assert.equal(detail.messageCount, 2); + assert.deepEqual(detail.messages[0], { role: "user", text: "hi" }); +}); + +test("handles() covers exactly the channels the remote profile serves", () => { + const { backend } = makeBackend(); + const covered = [ + IPC.invoke.agentPrompt, + IPC.invoke.agentQueuePush, + IPC.invoke.agentQueueList, + IPC.invoke.agentStop, + IPC.invoke.agentAbort, + IPC.invoke.agentCompact, + IPC.invoke.agentGetStatus, + IPC.invoke.agentSteer, + IPC.invoke.sessionGet, + IPC.invoke.sessionConfigure, + IPC.invoke.sessionFork, + IPC.invoke.sessionRename, + IPC.invoke.sessionDelete, + IPC.invoke.toolResolvePermission, + IPC.invoke.askToolResolve, + IPC.invoke.plansResolve, + IPC.invoke.plansPending, + ]; + for (const channel of covered) assert.ok(backend.handles(channel), `${channel} should be handled`); + // Unrelated desktop channels remain local. + assert.equal(backend.handles(IPC.invoke.appSettings ?? "pi-desktop/settings/get"), false); + assert.equal(backend.handles("pi-desktop/anything/unknown"), false); +}); + +test("agentPrompt starts a turn with reject_if_busy and returns the local response shape", async () => { + const turn = makeRacpTurn({ id: "turn-42" }); + const { backend, client } = makeBackend({ + "turn/start": () => ({ accepted: true, turn }), + }); + const result = await backend.invoke(IPC.invoke.agentPrompt, [ + { sessionId: REMOTE_SESSION_ID, content: "hi", sessionMessageId: "m1", messageId: "u1" }, + ]); + assert.deepEqual(result, { accepted: true, turnId: "turn-42" }); + assert.equal(client.calls[0].method, "turn/start"); + assert.equal(client.calls[0].params.sessionId, HOST_SESSION_ID); + assert.equal(client.calls[0].params.admission, "reject_if_busy"); + assert.deepEqual(client.calls[0].params.input, { + text: "hi", + sessionMessageId: "m1", + messageId: "u1", + }); +}); + +test("agentPrompt with attachments raises CAPABILITY_UNAVAILABLE without any RACP call", async () => { + const { backend, client } = makeBackend(); + await assert.rejects( + backend.invoke(IPC.invoke.agentPrompt, [ + { sessionId: REMOTE_SESSION_ID, content: "hi", attachments: [{ id: "a" }] }, + ]), + (error) => error.errorCode === "CAPABILITY_UNAVAILABLE", + ); + assert.equal(client.calls.length, 0); +}); + +test("agentQueuePush queues with the local content, since RACP turns carry none", async () => { + const turn = makeRacpTurn({ id: "turn-q", admission: "queue", queuePosition: 2 }); + const { backend, client } = makeBackend({ + "turn/start": () => ({ accepted: true, turn }), + }); + const result = await backend.invoke(IPC.invoke.agentQueuePush, [ + { sessionId: REMOTE_SESSION_ID, content: "pushed prompt", idempotencyKey: "k1" }, + ]); + assert.equal(result.id, "turn-q"); + assert.equal(result.sessionId, REMOTE_SESSION_ID); + assert.equal(result.content, "pushed prompt"); + assert.equal(result.position, 2); + assert.equal(client.calls[0].params.admission, "queue"); + assert.equal(client.calls[0].params.idempotencyKey, "k1"); +}); + +test("agentQueueList reads the snapshot and renders entries without a prompt text", async () => { + const { backend } = makeBackend({ + "session/attach": () => ({ + session: makeRacpSession(), + snapshot: makeRacpSnapshot({ + queuedTurns: [ + makeRacpTurn({ id: "q1", admission: "queue", queuePosition: 1 }), + makeRacpTurn({ id: "q2", admission: "queue" }), + ], + }), + }), + }); + const result = await backend.invoke(IPC.invoke.agentQueueList, [ + { sessionId: REMOTE_SESSION_ID }, + ]); + assert.equal(result.entries.length, 2); + assert.equal(result.entries[0].id, "q1"); + assert.equal(result.entries[0].sessionId, REMOTE_SESSION_ID); + // RACP turns do not carry queued prompt text; the entry still renders. + assert.equal(result.entries[0].content, ""); + assert.equal(result.entries[0].position, 1); + // A missing queuePosition falls back to index + 1. + assert.equal(result.entries[1].position, 2); +}); + +test("agentStop resolves the active turn when the renderer omits turnId", async () => { + const { backend, client } = makeBackend({ + "session/get": () => ({ session: makeRacpSession({ activeTurnId: "turn-active" }) }), + "turn/stop": () => ({ ok: true }), + }); + const result = await backend.invoke(IPC.invoke.agentStop, [ + { sessionId: REMOTE_SESSION_ID }, + ]); + assert.deepEqual(result, { requested: true }); + assert.equal(client.calls[0].method, "session/get"); + assert.equal(client.calls[1].method, "turn/stop"); + assert.equal(client.calls[1].params.turnId, "turn-active"); +}); + +test("agentStop reports requested=false when the session has no active turn", async () => { + const { backend, client } = makeBackend({ + "session/get": () => ({ session: makeRacpSession() }), + }); + const result = await backend.invoke(IPC.invoke.agentStop, [ + { sessionId: REMOTE_SESSION_ID }, + ]); + assert.deepEqual(result, { requested: false }); + // No turn/stop is sent when there is nothing to stop. + assert.equal(client.calls.length, 1); +}); + +test("agentAbort maps to turn/interrupt", async () => { + const { backend, client } = makeBackend({ + "turn/interrupt": () => ({ ok: true }), + }); + const result = await backend.invoke(IPC.invoke.agentAbort, [ + { sessionId: REMOTE_SESSION_ID, turnId: "explicit-turn" }, + ]); + assert.deepEqual(result, { aborted: true }); + assert.equal(client.calls[0].method, "turn/interrupt"); + assert.equal(client.calls[0].params.turnId, "explicit-turn"); +}); + +test("agentSteer refuses with CAPABILITY_UNAVAILABLE — no RACP counterpart exists", async () => { + const { backend, client } = makeBackend(); + await assert.rejects( + backend.invoke(IPC.invoke.agentSteer, [{ sessionId: REMOTE_SESSION_ID }]), + (error) => error.errorCode === "CAPABILITY_UNAVAILABLE", + ); + assert.equal(client.calls.length, 0); +}); + +test("agentGetStatus lifts session status/planning into the local shape", async () => { + const session = makeRacpSession({ + status: "waiting_permission", + activeTurnId: "turn-x", + planningState: "awaiting_approval", + }); + const { backend } = makeBackend({ "session/get": () => ({ session }) }); + const { status } = await backend.invoke(IPC.invoke.agentGetStatus, [ + { sessionId: REMOTE_SESSION_ID }, + ]); + assert.equal(status.sessionId, REMOTE_SESSION_ID); + assert.equal(status.currentTurnId, "turn-x"); + assert.equal(status.isRunning, false); + assert.equal(status.pendingToolConfirmations, 1); + assert.equal(status.planningState, "awaiting_approval"); +}); + +test("sessionGet returns SessionDetail built from the snapshot", async () => { + const { backend } = makeBackend({ + "session/attach": () => ({ + session: makeRacpSession(), + snapshot: makeRacpSnapshot({ + items: [ + { id: "i1", turnId: "t1", itemType: "message", status: "completed", createdAt: "x", content: { role: "user", text: "hi" } }, + ], + }), + }), + }); + const { session } = await backend.invoke(IPC.invoke.sessionGet, [ + { id: REMOTE_SESSION_ID }, + ]); + assert.equal(session.id, REMOTE_SESSION_ID); + assert.equal(session.messages.length, 1); +}); + +test("sessionGet errors when the host returns no snapshot", async () => { + const { backend } = makeBackend({ + "session/attach": () => ({ session: makeRacpSession() }), + }); + await assert.rejects( + backend.invoke(IPC.invoke.sessionGet, [{ id: REMOTE_SESSION_ID }]), + (error) => error.errorCode === "INTERNAL", + ); +}); + +test("sessionConfigure forwards only the fields the renderer set", async () => { + const { backend, client } = makeBackend({ + "session/configure": () => ({ session: makeRacpSession({ mode: "agent" }) }), + }); + await backend.invoke(IPC.invoke.sessionConfigure, [REMOTE_SESSION_ID, { mode: "agent" }]); + assert.equal(client.calls[0].method, "session/configure"); + assert.deepEqual(client.calls[0].params, { sessionId: HOST_SESSION_ID, mode: "agent" }); +}); + +test("sessionFork attaches to the new host session and returns a SessionDetail", async () => { + const forked = makeRacpSession({ id: "sess-forked", title: "Forked" }); + const { backend } = makeBackend({ + "session/fork": () => ({ session: forked }), + "session/attach": () => ({ session: forked, snapshot: makeRacpSnapshot({ session: forked }) }), + }); + const { session } = await backend.invoke(IPC.invoke.sessionFork, [ + { sessionId: REMOTE_SESSION_ID, title: "Forked" }, + ]); + assert.equal(session.id, makeRemoteSessionId(HOST_KEY, "sess-forked")); + assert.equal(session.title, "Forked"); + // A SessionDetail always carries a messages array; empty is fine. + assert.ok(Array.isArray(session.messages)); +}); + +test("sessionRename / sessionDelete forward positional args", async () => { + const { backend, client } = makeBackend({ + "session/rename": () => ({ ok: true }), + "session/delete": () => ({ ok: true }), + }); + await backend.invoke(IPC.invoke.sessionRename, [REMOTE_SESSION_ID, "New title"]); + assert.deepEqual(client.calls[0].params, { sessionId: HOST_SESSION_ID, title: "New title" }); + await backend.invoke(IPC.invoke.sessionDelete, [REMOTE_SESSION_ID]); + assert.deepEqual(client.calls[1].params, { sessionId: HOST_SESSION_ID }); +}); + +test("toolResolvePermission decodes the encoded requestId back to the host approval id", async () => { + const requestId = makeRemoteApprovalRequestId(REMOTE_SESSION_ID, "approval-77"); + const { backend, client } = makeBackend({ + "approval/respond": () => ({ + approvalId: "approval-77", + status: "resolved", + alreadyResolved: false, + revision: 2, + }), + }); + const result = await backend.invoke(IPC.invoke.toolResolvePermission, [ + { requestId, decision: "allow-once" }, + ]); + assert.deepEqual(result, { ok: true }); + assert.equal(client.calls[0].params.approvalId, "approval-77"); + assert.equal(client.calls[0].params.decision, "allow-once"); +}); + +test("toolResolvePermission refuses a requestId that does not name a remote session", async () => { + const { backend, client } = makeBackend(); + await assert.rejects( + backend.invoke(IPC.invoke.toolResolvePermission, [ + { requestId: "plain-local-request-id", decision: "deny" }, + ]), + (error) => error.errorCode === "INTERNAL", + ); + assert.equal(client.calls.length, 0); +}); + +test("askToolResolve forwards the RACP input/respond params", async () => { + const { backend, client } = makeBackend({ + "input/respond": () => ({ ok: true }), + }); + await backend.invoke(IPC.invoke.askToolResolve, [ + { requestId: "input-1", answers: [["yes"], null] }, + ]); + assert.equal(client.calls[0].method, "input/respond"); + assert.deepEqual(client.calls[0].params.answers, [["yes"], null]); + assert.equal(client.calls[0].params.inputId, "input-1"); +}); + +test("plansResolve synthesizes the local PlanResolutionResult from the RACP result", async () => { + const { backend, client } = makeBackend({ + "approval/respond": () => ({ + approvalId: "prop-1", + status: "resolved", + alreadyResolved: false, + revision: 3, + }), + }); + const result = await backend.invoke(IPC.invoke.plansResolve, [ + { + proposalId: "prop-1", + sessionId: REMOTE_SESSION_ID, + turnId: "turn-1", + toolCallId: "call-1", + action: "approve", + targetPermissionMode: "allow", + version: 4, + }, + ]); + assert.equal(result.ok, true); + assert.equal(result.state, "inactive"); + assert.equal(result.action, "approve"); + assert.equal(result.targetPermissionMode, "allow"); + assert.equal(result.proposal.id, "prop-1"); + assert.equal(result.proposal.sessionId, REMOTE_SESSION_ID); + assert.equal(result.proposal.status, "approved"); + assert.equal(result.proposal.version, 4); + assert.equal(client.calls[0].params.permissionMode, "allow"); +}); + +test("plansResolve on reject omits permissionMode from the RACP call", async () => { + const { backend, client } = makeBackend({ + "approval/respond": () => ({ + approvalId: "prop-2", + status: "resolved", + alreadyResolved: false, + revision: 4, + }), + }); + const result = await backend.invoke(IPC.invoke.plansResolve, [ + { + proposalId: "prop-2", + sessionId: REMOTE_SESSION_ID, + turnId: "turn-1", + toolCallId: "call-1", + action: "reject", + }, + ]); + assert.equal(result.action, "reject"); + assert.equal(result.proposal.status, "rejected"); + assert.equal(client.calls[0].params.permissionMode, undefined); +}); + +test("plansPending stays empty — pending cards ride the snapshot at attach time", async () => { + const { backend } = makeBackend(); + const result = await backend.invoke(IPC.invoke.plansPending, [{ sessionId: REMOTE_SESSION_ID }]); + assert.deepEqual(result, { plans: [] }); +}); + +test("an unknown channel is a bug and surfaces INTERNAL", async () => { + const { backend } = makeBackend(); + await assert.rejects( + backend.invoke("pi-desktop/channel/not-a-thing", [{ sessionId: REMOTE_SESSION_ID }]), + (error) => error.errorCode === "INTERNAL", + ); +}); From c3127b24af52096dd1fd86afaa137639340bc486 Mon Sep 17 00:00:00 2001 From: vastsa Date: Fri, 18 Sep 2026 18:02:33 +0800 Subject: [PATCH 09/13] feat(remote): translate RACP events into renderer IPC events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second half of the remote-session data path: while `electron/main/remote/remote-backend.ts` turns renderer IPC calls into outgoing RACP requests, this event bridge turns the paired host's incoming RACP event stream into the exact renderer IPC events already consumed for a local session. The renderer never learns the transport (spec §3.4); the same event kinds fire, just under a namespaced session id. The bridge is pure translation. It forwards to an injected `emit`, and signals `session.created`/`changed`/`archived` through an optional `onLifecycle` callback so the connection layer (Stage 3) can add or remove backend router registrations without re-subscribing to the RACP event stream from a second place. Mapping decisions worth calling out: - Item, turn, and tool.progress kinds already ride a local `AgentEvent` in their payload (agent-host attaches it via `payload.event`); the bridge forwards it verbatim under an `AgentEventEnvelope` keyed by the remote session id. - `approval.requested` of kind `tool` is synthesized into a local `tool_permission_request` whose `requestId` is `#racp-approval:` — the encoding the router already knows how to decode when the renderer resolves the card. `plan` and `goal` approvals ride the following `planning_state` event and are dropped here. - `input.requested` becomes an `asktool_request` keyed by the RACP input id; the renderer echoes it back on `askToolResolve`. - `session.changed` at session scope carries either a `PlanningStateEvent` (forwarded as `planning_state`) or a status-refresh payload; the desktop derives its status from turn events, so the status-refresh flavor is dropped. - Terminal, resync, and host.changed kinds are silently dropped — Stage 5 owns terminals; resync/reconnect belongs on the connection layer. Covered by `apps/desktop/test/remote-event-bridge.test.mjs` (12 cases). Full desktop `node --test` suite green. --- .../main/remote/remote-event-bridge.ts | 273 ++++++++++++++++++ .../desktop/test/remote-event-bridge.test.mjs | 248 ++++++++++++++++ 2 files changed, 521 insertions(+) create mode 100644 apps/desktop/electron/main/remote/remote-event-bridge.ts create mode 100644 apps/desktop/test/remote-event-bridge.test.mjs diff --git a/apps/desktop/electron/main/remote/remote-event-bridge.ts b/apps/desktop/electron/main/remote/remote-event-bridge.ts new file mode 100644 index 0000000000..4212224097 --- /dev/null +++ b/apps/desktop/electron/main/remote/remote-event-bridge.ts @@ -0,0 +1,273 @@ +/** + * Event bridge: translate a paired host's RACP event stream into the local + * renderer IPC events already consumed for a local session. The renderer + * cannot tell the difference (spec §3.4); only the namespaced session id and + * the `source: "remote"` badge distinguish a remote row. + * + * The bridge is pure translation: it forwards to an injected `emit(channel, + * payload)` and calls an optional `onLifecycle` for host-scope `session.*` + * kinds. It never touches the router, the connection, or persistence — the + * connection layer (Stage 3) drives subscription and unsubscription. + */ +import { IPC } from "@pi-desktop/shared"; +import type { + AgentEvent, + AgentEventEnvelope, + AskToolRequest, + PlanningStateEvent, + RacpApprovalRequest, + RacpEventEnvelope, + RacpInputRequest, + ToolPermissionRequest, +} from "@pi-desktop/shared"; +import { makeRemoteApprovalRequestId, makeRemoteSessionId } from "./backend-router.js"; + +/** A minimal shape of the session field carried by host-scope session events. + * Both the RACP `RacpSession` and the host's smaller `SessionSummary` extend + * this — no field beyond these five is read by lifecycle handlers. */ +export type RemoteEventSessionRef = { + id: string; + title?: string; + createdAt?: string; + updatedAt?: string; +}; + +export type RemoteLifecycleEvent = + | { readonly kind: "session.created"; readonly hostSessionId: string; readonly remoteSessionId: string; readonly session: RemoteEventSessionRef } + | { readonly kind: "session.changed"; readonly hostSessionId: string; readonly remoteSessionId: string; readonly session: RemoteEventSessionRef } + | { readonly kind: "session.archived"; readonly hostSessionId: string; readonly remoteSessionId: string; readonly session?: RemoteEventSessionRef }; + +export type RemoteEventBridgeOptions = { + /** Renderer-visible id prefix component. Fixed for one host. */ + hostKey: string; + /** Dispatch a local IPC event to the renderer. */ + emit: (channel: string, payload: unknown) => void; + /** Lifecycle callback for host-scope `session.*` kinds; drives the router. */ + onLifecycle?: (event: RemoteLifecycleEvent) => void; + /** Optional structured warning log; defaults to a no-op. */ + log?: (level: "warn", message: string, data?: unknown) => void; +}; + +export interface RemoteEventBridge { + /** Handle one RACP envelope. Unknown kinds are dropped. */ + handle(envelope: RacpEventEnvelope): void; +} + +const APPROVAL_KIND = { tool: "tool", plan: "plan", goal: "goal" } as const; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function extractSessionRef(payload: unknown): RemoteEventSessionRef | undefined { + if (!isRecord(payload)) return undefined; + const session = payload.session; + if (!isRecord(session) || typeof session.id !== "string") return undefined; + return { + id: session.id, + ...(typeof session.title === "string" ? { title: session.title } : {}), + ...(typeof session.createdAt === "string" ? { createdAt: session.createdAt } : {}), + ...(typeof session.updatedAt === "string" ? { updatedAt: session.updatedAt } : {}), + }; +} + +function extractAgentEvent(payload: unknown): AgentEvent | undefined { + if (!isRecord(payload)) return undefined; + const event = payload.event; + if (!isRecord(event) || typeof event.type !== "string") return undefined; + return event as unknown as AgentEvent; +} + +function extractApprovalRequest(payload: unknown): RacpApprovalRequest | undefined { + if (!isRecord(payload)) return undefined; + // The RACP schema publishes the approval request as the payload's own body, + // not nested under `.approval`. Both agent-host emit sites and the fixture + // in packages/racp use that shape. + if (typeof payload.id !== "string" || typeof payload.kind !== "string") return undefined; + return payload as unknown as RacpApprovalRequest; +} + +function extractInputRequest(payload: unknown): RacpInputRequest | undefined { + if (!isRecord(payload)) return undefined; + if (typeof payload.id !== "string" || !Array.isArray(payload.questions)) return undefined; + return payload as unknown as RacpInputRequest; +} + +function toToolPermissionRequest( + remoteSessionId: string, + approval: RacpApprovalRequest, +): ToolPermissionRequest { + return { + // Stateless approval id encoding — see backend-router.ts. + requestId: makeRemoteApprovalRequestId(remoteSessionId, approval.id), + sessionId: remoteSessionId, + // RACP approvals do not carry a toolCallId back; the permission card does + // not display it and only the resolution path needs the requestId. + toolCallId: "", + toolName: approval.toolName ?? "", + argsPreview: null, + risk: approval.risk ?? "medium", + reason: approval.summary, + ...(approval.agentName ? { agentName: approval.agentName } : {}), + ...(approval.parentToolCallId ? { parentToolCallId: approval.parentToolCallId } : {}), + }; +} + +function toAskToolRequest( + remoteSessionId: string, + input: RacpInputRequest, +): AskToolRequest { + return { + requestId: input.id, + sessionId: remoteSessionId, + // Ask-tool needs a toolCallId to attach the answer to; the RACP schema + // supplies it as `parentToolCallId` when the input came from a subagent, + // and leaves it undefined for the top-level agent. + toolCallId: input.parentToolCallId ?? "", + questions: input.questions.map((question) => ({ + question: question.question, + options: question.options, + multiSelect: question.multiSelect, + })), + }; +} + +function toPlanningStateAgentEvent( + remoteSessionId: string, + planning: PlanningStateEvent, +): AgentEvent { + const { sessionId: _hostSessionId, ...rest } = planning; + return { type: "planning_state", ...rest }; +} + +export function createRemoteEventBridge(options: RemoteEventBridgeOptions): RemoteEventBridge { + const { hostKey, emit, onLifecycle } = options; + const log = options.log ?? (() => undefined); + const remoteIdOf = (hostSessionId: string) => makeRemoteSessionId(hostKey, hostSessionId); + const emitAgentEvent = ( + envelope: RacpEventEnvelope, + remoteSessionId: string, + event: AgentEvent, + ): void => { + const local: AgentEventEnvelope = { + sessionId: remoteSessionId, + ...(envelope.turnId ? { turnId: envelope.turnId } : {}), + ts: Date.parse(envelope.occurredAt) || Date.now(), + event, + ...(envelope.parentToolCallId ? { parentToolCallId: envelope.parentToolCallId } : {}), + ...(envelope.agentName ? { agentName: envelope.agentName } : {}), + }; + emit(IPC.event.agentMessage, local); + }; + + const handleHostSession = (envelope: RacpEventEnvelope): void => { + const session = extractSessionRef(envelope.payload); + if (!session) { + log("warn", `host-scope ${envelope.kind} carried no session`, envelope); + return; + } + const remoteSessionId = remoteIdOf(session.id); + if (envelope.kind === "session.created") { + onLifecycle?.({ kind: "session.created", hostSessionId: session.id, remoteSessionId, session }); + emit(IPC.event.sessionsChanged, { + reason: "remote.session.created", + selectSessionId: remoteSessionId, + }); + return; + } + if (envelope.kind === "session.changed") { + onLifecycle?.({ kind: "session.changed", hostSessionId: session.id, remoteSessionId, session }); + emit(IPC.event.sessionsChanged, { reason: "remote.session.changed" }); + return; + } + // "session.archived": pass through to the lifecycle handler for router + // cleanup, then refresh the renderer's session list. + onLifecycle?.({ kind: "session.archived", hostSessionId: session.id, remoteSessionId, session }); + emit(IPC.event.sessionsChanged, { reason: "remote.session.archived" }); + }; + + const handleSessionScope = (envelope: RacpEventEnvelope): void => { + if (typeof envelope.sessionId !== "string") return; + const remoteSessionId = remoteIdOf(envelope.sessionId); + switch (envelope.kind) { + case "item.started": + case "item.delta": + case "item.completed": + case "tool.progress": + case "turn.queued": + case "turn.started": + case "turn.completed": + case "turn.interrupted": + case "turn.failed": + case "turn.canceled": + case "turn.activity": { + const event = extractAgentEvent(envelope.payload); + if (!event) return; + emitAgentEvent(envelope, remoteSessionId, event); + return; + } + case "session.changed": { + // Two shapes ride this kind (agent-host): + // 1. `{ event: PlanningStateEvent }` — surface as a local planning + // event so the plan card behaves the same as local. + // 2. `{ sessionId, status, planningState }` — a periodic status + // refresh; the desktop derives its own status from turn events, so + // drop it and let the eventual snapshot refresh cover it. + const payload = envelope.payload; + if (isRecord(payload) && isRecord(payload.event) && payload.event.state !== undefined) { + const planning = payload.event as unknown as PlanningStateEvent; + emitAgentEvent( + envelope, + remoteSessionId, + toPlanningStateAgentEvent(remoteSessionId, planning), + ); + } + return; + } + case "approval.requested": { + const approval = extractApprovalRequest(envelope.payload); + if (!approval) return; + // Plan / goal approvals ride the following `planning_state` event; the + // renderer's plan card is driven by that, not by a synthetic tool card. + if (approval.kind !== APPROVAL_KIND.tool) return; + emitAgentEvent(envelope, remoteSessionId, { + type: "tool_permission_request", + request: toToolPermissionRequest(remoteSessionId, approval), + }); + return; + } + case "input.requested": { + const input = extractInputRequest(envelope.payload); + if (!input) return; + emitAgentEvent(envelope, remoteSessionId, { + type: "asktool_request", + request: toAskToolRequest(remoteSessionId, input), + }); + return; + } + case "approval.resolved": + case "input.resolved": + case "terminal.changed": + case "terminal.output": + case "resync.required": + // Approvals settle through the renderer's own resolve call; the plan + // and status changes come as `session.changed` payloads. Terminal + // events belong to Stage 5. `resync.required` is Stage 3b — the + // connection layer must consume it, not the bridge. + return; + default: + return; + } + }; + + return { + handle(envelope) { + try { + if (envelope.scope === "host") return handleHostSession(envelope); + if (envelope.scope === "session") return handleSessionScope(envelope); + } catch (error) { + log("warn", `remote event bridge failed on ${envelope.kind}`, error); + } + }, + }; +} diff --git a/apps/desktop/test/remote-event-bridge.test.mjs b/apps/desktop/test/remote-event-bridge.test.mjs new file mode 100644 index 0000000000..9780472d55 --- /dev/null +++ b/apps/desktop/test/remote-event-bridge.test.mjs @@ -0,0 +1,248 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { register } from "node:module"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +register(pathToFileURL(join(here, "helpers/ts-import-hooks.mjs"))); + +const { IPC } = await import("@pi-desktop/shared"); +const { makeRemoteApprovalRequestId, makeRemoteSessionId } = await import( + "../electron/main/remote/backend-router.ts" +); +const { createRemoteEventBridge } = await import( + "../electron/main/remote/remote-event-bridge.ts" +); + +const HOST_KEY = "hostA"; +const HOST_SESSION_ID = "sess-1"; +const REMOTE_SESSION_ID = makeRemoteSessionId(HOST_KEY, HOST_SESSION_ID); + +function makeEnvelope(overrides = {}) { + return { + eventId: "e1", + scope: "session", + sessionId: HOST_SESSION_ID, + epoch: "epoch-1", + revision: 1, + kind: "item.started", + occurredAt: "2026-09-18T10:00:00.000Z", + payload: {}, + ...overrides, + }; +} + +function collect() { + const events = []; + const lifecycle = []; + const warnings = []; + const bridge = createRemoteEventBridge({ + hostKey: HOST_KEY, + emit: (channel, payload) => events.push({ channel, payload }), + onLifecycle: (event) => lifecycle.push(event), + log: (level, message, data) => warnings.push({ level, message, data }), + }); + return { bridge, events, lifecycle, warnings }; +} + +test("host-scope session.created fires lifecycle and refreshes sessions with the new id", () => { + const { bridge, events, lifecycle } = collect(); + bridge.handle( + makeEnvelope({ + scope: "host", + kind: "session.created", + payload: { + session: { + id: HOST_SESSION_ID, + title: "Fresh remote", + createdAt: "2026-09-18T10:00:00.000Z", + updatedAt: "2026-09-18T10:00:00.000Z", + }, + }, + }), + ); + assert.equal(lifecycle.length, 1); + assert.equal(lifecycle[0].kind, "session.created"); + assert.equal(lifecycle[0].remoteSessionId, REMOTE_SESSION_ID); + assert.equal(events.length, 1); + assert.equal(events[0].channel, IPC.event.sessionsChanged); + assert.equal(events[0].payload.reason, "remote.session.created"); + assert.equal(events[0].payload.selectSessionId, REMOTE_SESSION_ID); +}); + +test("host-scope session.archived tells the router to release the id without selecting it", () => { + const { bridge, events, lifecycle } = collect(); + bridge.handle( + makeEnvelope({ + scope: "host", + kind: "session.archived", + payload: { session: { id: HOST_SESSION_ID } }, + }), + ); + assert.equal(lifecycle[0].kind, "session.archived"); + assert.equal(events[0].payload.reason, "remote.session.archived"); + assert.equal(events[0].payload.selectSessionId, undefined); +}); + +test("host-scope events without a session payload log a warning and no emit fires", () => { + const { bridge, events, warnings } = collect(); + bridge.handle( + makeEnvelope({ scope: "host", kind: "session.changed", payload: {} }), + ); + assert.equal(events.length, 0); + assert.equal(warnings.length, 1); + assert.match(warnings[0].message, /carried no session/); +}); + +test("item.started forwards the AgentEvent verbatim under the remote session id", () => { + const { bridge, events } = collect(); + const agentEvent = { type: "tool_start", toolCallId: "tc-1", toolName: "shell", args: {} }; + bridge.handle( + makeEnvelope({ + kind: "item.started", + turnId: "turn-1", + payload: { itemType: "tool", itemId: "i-1", event: agentEvent }, + }), + ); + assert.equal(events.length, 1); + assert.equal(events[0].channel, IPC.event.agentMessage); + assert.equal(events[0].payload.sessionId, REMOTE_SESSION_ID); + assert.equal(events[0].payload.turnId, "turn-1"); + assert.deepEqual(events[0].payload.event, agentEvent); + assert.equal(events[0].payload.ts, Date.parse("2026-09-18T10:00:00.000Z")); +}); + +test("session.changed forwards a PlanningStateEvent as a local planning_state AgentEvent", () => { + const { bridge, events } = collect(); + bridge.handle( + makeEnvelope({ + kind: "session.changed", + payload: { + event: { + sessionId: HOST_SESSION_ID, + state: "awaiting_approval", + kind: "plan", + proposalId: "p-1", + title: "Do a thing", + }, + }, + }), + ); + assert.equal(events.length, 1); + assert.equal(events[0].channel, IPC.event.agentMessage); + const forwarded = events[0].payload.event; + assert.equal(forwarded.type, "planning_state"); + assert.equal(forwarded.state, "awaiting_approval"); + assert.equal(forwarded.proposalId, "p-1"); + // The host session id is stripped — the AgentEventEnvelope carries the + // (remote) session id already. + assert.equal(forwarded.sessionId, undefined); +}); + +test("session.changed status-only payloads are dropped", () => { + const { bridge, events } = collect(); + bridge.handle( + makeEnvelope({ + kind: "session.changed", + payload: { sessionId: HOST_SESSION_ID, status: "idle", planningState: "inactive" }, + }), + ); + assert.equal(events.length, 0); +}); + +test("approval.requested kind:tool synthesizes a local tool_permission_request with an encoded requestId", () => { + const { bridge, events } = collect(); + const approval = { + id: "appr-9", + sessionId: HOST_SESSION_ID, + turnId: "turn-1", + kind: "tool", + summary: "run rm -rf", + expiresAt: "2026-09-18T10:05:00.000Z", + revision: 3, + toolName: "shell", + risk: "high", + allowedDecisions: ["allow-once", "deny"], + }; + bridge.handle( + makeEnvelope({ kind: "approval.requested", turnId: "turn-1", payload: approval }), + ); + assert.equal(events.length, 1); + const request = events[0].payload.event.request; + assert.equal(events[0].payload.event.type, "tool_permission_request"); + assert.equal(request.requestId, makeRemoteApprovalRequestId(REMOTE_SESSION_ID, "appr-9")); + assert.equal(request.sessionId, REMOTE_SESSION_ID); + assert.equal(request.toolName, "shell"); + assert.equal(request.risk, "high"); + assert.equal(request.reason, "run rm -rf"); +}); + +test("approval.requested kind:plan and kind:goal are dropped — the plan card rides planning_state", () => { + const { bridge, events } = collect(); + const base = { + sessionId: HOST_SESSION_ID, + turnId: "turn-1", + summary: "approve plan", + expiresAt: "2026-09-18T10:05:00.000Z", + revision: 3, + allowedDecisions: ["approve", "reject"], + allowedPermissionModes: ["default"], + }; + bridge.handle(makeEnvelope({ kind: "approval.requested", payload: { ...base, id: "p-1", kind: "plan" } })); + bridge.handle(makeEnvelope({ kind: "approval.requested", payload: { ...base, id: "g-1", kind: "goal" } })); + assert.equal(events.length, 0); +}); + +test("input.requested synthesizes an asktool_request keyed by the RACP input id", () => { + const { bridge, events } = collect(); + bridge.handle( + makeEnvelope({ + kind: "input.requested", + payload: { + id: "input-42", + sessionId: HOST_SESSION_ID, + turnId: "turn-1", + expiresAt: "2026-09-18T10:05:00.000Z", + agentName: "codex", + parentToolCallId: "tc-parent", + questions: [ + { id: "q1", question: "which?", options: ["a", "b"], multiSelect: false }, + ], + }, + }), + ); + const request = events[0].payload.event.request; + assert.equal(events[0].payload.event.type, "asktool_request"); + assert.equal(request.requestId, "input-42"); + assert.equal(request.sessionId, REMOTE_SESSION_ID); + assert.equal(request.toolCallId, "tc-parent"); + assert.equal(request.questions.length, 1); + assert.equal(request.questions[0].multiSelect, false); +}); + +test("terminal and resync kinds are silently dropped in Stage 2 — later stages own them", () => { + const { bridge, events } = collect(); + bridge.handle(makeEnvelope({ kind: "terminal.output", payload: {} })); + bridge.handle(makeEnvelope({ kind: "terminal.changed", payload: {} })); + bridge.handle(makeEnvelope({ kind: "resync.required", payload: {} })); + bridge.handle(makeEnvelope({ kind: "approval.resolved", payload: {} })); + bridge.handle(makeEnvelope({ kind: "input.resolved", payload: {} })); + assert.equal(events.length, 0); +}); + +test("host.changed does not emit — the desktop has no host status surface yet", () => { + const { bridge, events, lifecycle } = collect(); + bridge.handle( + makeEnvelope({ scope: "host", kind: "host.changed", payload: { host: { id: "h" } } }), + ); + assert.equal(events.length, 0); + assert.equal(lifecycle.length, 0); +}); + +test("an unknown kind stays quiet — the bridge never throws on unfamiliar events", () => { + const { bridge, events, warnings } = collect(); + bridge.handle(makeEnvelope({ kind: "made.up.kind", payload: {} })); + assert.equal(events.length, 0); + assert.equal(warnings.length, 0); +}); From cababa43f87d33dd2e75b413478182155cdb068f Mon Sep 17 00:00:00 2001 From: vastsa Date: Fri, 18 Sep 2026 18:02:48 +0800 Subject: [PATCH 10/13] feat(remote): coordinate router registration for a remote host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 3 kernel wiring, in-process. `RemoteHostConnection` composes the Stage-2 backend adapter, the Stage-2 event bridge, and the Stage-1 backend router into one desktop-side coordinator per paired `pi-host`. Ownership of the transport itself stays with the caller: this layer only speaks the two interfaces `RemoteHostClient` exposes (`request` and a multi-listener `subscribe`), so Stage 3b can build it around a real `RacpClient` — or a WSL/SSH-tunneled one — without changing this file. `open()` sequence, chosen to close the create-race between listing sessions and receiving lifecycle events: 1. Attach the raw event listener. 2. Subscribe host scope (any `session.created` between here and step 3 arrives as an event and lands via the lifecycle path). 3. `session/list` and register a backend for each returned id. 4. Per-session event subscribes. Lifecycle wiring keeps the router in sync with the host without touching the renderer: `session.created` registers a fresh backend and selects the new remote id in the sidebar; `session.archived` unregisters the id and the router falls through to the local handler for anything that still references it. All registration paths are idempotent — `open()` on an already-open connection is a no-op, and a `session.created` for a session already registered by the initial list is dropped by the router's Set. Compatibility: with no connection open, the router has no remote backends and every renderer call hits the local handler byte-for-byte. A host-side failure — network drop, protocol mismatch, `session/list` error — never escalates into a local regression: `close()` unregisters every session and the fallback route resumes. Covered by `apps/desktop/test/remote-host-connection.test.mjs` (7 cases) with a fake `RemoteHostClient`. Full desktop `node --test` suite is 2112/2112 green. --- .../main/remote/remote-host-connection.ts | 152 ++++++++++++++ .../test/remote-host-connection.test.mjs | 188 ++++++++++++++++++ 2 files changed, 340 insertions(+) create mode 100644 apps/desktop/electron/main/remote/remote-host-connection.ts create mode 100644 apps/desktop/test/remote-host-connection.test.mjs diff --git a/apps/desktop/electron/main/remote/remote-host-connection.ts b/apps/desktop/electron/main/remote/remote-host-connection.ts new file mode 100644 index 0000000000..174832f12b --- /dev/null +++ b/apps/desktop/electron/main/remote/remote-host-connection.ts @@ -0,0 +1,152 @@ +/** + * A live remote host: one desktop-side coordinator per paired `pi-host`. Owns + * the {@link BackendRouter} registrations for that host's sessions, subscribes + * to the host's RACP event streams, and pipes them through the event bridge + * onto the renderer's IPC events. Ownership of the transport itself stays + * with the caller (Stage 3b builds it around a real {@link RacpClient}); this + * layer only speaks the two interfaces {@link RemoteHostClient} exposes. + * + * Compatibility: while no connection has {@link open}ed, the router has no + * remote backends registered, so every renderer call still hits the local + * handler byte-for-byte. Failure of a remote host — network drop, protocol + * mismatch — never escalates into a local regression: `close()` unregisters + * every session and the fallback route resumes. + */ +import type { RacpEventEnvelope, RacpSession } from "@pi-desktop/shared"; +import type { BackendRouter, RemoteBackend } from "./backend-router.js"; +import { makeRemoteSessionId } from "./backend-router.js"; +import { createRemoteBackend, type RemoteRacpClient } from "./remote-backend.js"; +import { + createRemoteEventBridge, + type RemoteEventBridge, + type RemoteLifecycleEvent, +} from "./remote-event-bridge.js"; + +/** + * The transport surface this connection needs. {@link RemoteRacpClient} covers + * the request half; {@link subscribe} bridges RACP's single-callback + * {@link RacpClient.onEvent} into a multiple-listener seam for tests. + */ +export type RemoteHostClient = RemoteRacpClient & { + /** Subscribe to raw RACP envelopes. The returned function detaches this listener. */ + subscribe(listener: (envelope: RacpEventEnvelope) => void): () => void; +}; + +export type RemoteHostConnectionOptions = { + hostKey: string; + client: RemoteHostClient; + router: BackendRouter; + /** Dispatch a local IPC event to the renderer. */ + emit: (channel: string, payload: unknown) => void; + /** Optional deterministic request-id source; forwarded to the backend. */ + newRequestId?: () => string; + /** Optional structured log; defaults to a no-op. */ + log?: (level: "warn" | "error", message: string, data?: unknown) => void; +}; + +export interface RemoteHostConnection { + readonly hostKey: string; + /** + * List the host's sessions, register a backend for each, then subscribe to + * host-scope and per-session RACP event streams. Idempotent: a second call + * on an open connection is a no-op. + */ + open(): Promise; + /** + * Detach the event subscription, unregister every session, and drop internal + * state. Idempotent: closing twice is a no-op. The underlying transport is + * the caller's responsibility. + */ + close(): Promise; +} + +type SessionListResponse = { sessions: RacpSession[] }; + +export function createRemoteHostConnection( + options: RemoteHostConnectionOptions, +): RemoteHostConnection { + const { hostKey, client, router, emit } = options; + const log = options.log ?? (() => undefined); + const backend: RemoteBackend = createRemoteBackend({ + hostKey, + client, + ...(options.newRequestId ? { newRequestId: options.newRequestId } : {}), + }); + + const registered = new Set(); + let bridge: RemoteEventBridge | null = null; + let unsubscribe: (() => void) | null = null; + let opened = false; + + const registerSession = async (hostSessionId: string): Promise => { + const remoteSessionId = makeRemoteSessionId(hostKey, hostSessionId); + if (registered.has(remoteSessionId)) return; + router.registerBackend(remoteSessionId, backend); + registered.add(remoteSessionId); + try { + await client.request("events/subscribe", { scope: "session", sessionId: hostSessionId }); + } catch (error) { + // A session-scope subscribe failure keeps the backend registered; the + // renderer can still fetch snapshot/history via request paths, and + // Stage 3b's reconnect logic will retry the stream. Errors that must + // surface to the user go through the RacpClient state channel instead. + log("warn", `events/subscribe failed for session ${hostSessionId}`, error); + } + }; + + const unregisterSession = (hostSessionId: string): void => { + const remoteSessionId = makeRemoteSessionId(hostKey, hostSessionId); + if (!registered.delete(remoteSessionId)) return; + router.unregisterBackend(remoteSessionId); + }; + + const handleLifecycle = (event: RemoteLifecycleEvent): void => { + if (event.kind === "session.created") { + void registerSession(event.hostSessionId); + return; + } + if (event.kind === "session.archived") { + unregisterSession(event.hostSessionId); + } + }; + + return { + hostKey, + async open() { + if (opened) return; + opened = true; + bridge = createRemoteEventBridge({ + hostKey, + emit, + onLifecycle: handleLifecycle, + log: (level, message, data) => log(level, message, data), + }); + unsubscribe = client.subscribe((envelope) => bridge?.handle(envelope)); + // Subscribing to host scope BEFORE listing sessions closes the race: any + // `session.created` a peer emits between the two calls arrives as an + // event and is handled by the lifecycle path (idempotent register). + try { + await client.request("events/subscribe", { scope: "host" }); + } catch (error) { + log("warn", "events/subscribe host scope failed", error); + } + let response: SessionListResponse; + try { + response = await client.request("session/list"); + } catch (error) { + log("error", "session/list failed; connection stays with no registered sessions", error); + return; + } + await Promise.all(response.sessions.map((session) => registerSession(session.id))); + }, + async close() { + if (!opened) return; + opened = false; + unsubscribe?.(); + unsubscribe = null; + bridge = null; + for (const remoteSessionId of registered) router.unregisterBackend(remoteSessionId); + registered.clear(); + }, + }; +} diff --git a/apps/desktop/test/remote-host-connection.test.mjs b/apps/desktop/test/remote-host-connection.test.mjs new file mode 100644 index 0000000000..f73254f99e --- /dev/null +++ b/apps/desktop/test/remote-host-connection.test.mjs @@ -0,0 +1,188 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { register } from "node:module"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +register(pathToFileURL(join(here, "helpers/ts-import-hooks.mjs"))); + +const { IPC } = await import("@pi-desktop/shared"); +const { createBackendRouter, makeRemoteSessionId } = await import( + "../electron/main/remote/backend-router.ts" +); +const { createRemoteHostConnection } = await import( + "../electron/main/remote/remote-host-connection.ts" +); + +const HOST_KEY = "hostA"; + +/** A minimal RacpClient/subscribe double. Records requests and lets tests + * push envelopes back to whichever listener attached last. */ +function fakeClient({ sessions = [], requestFailures = {} } = {}) { + const calls = []; + let listener = null; + return { + calls, + request: async (method, params) => { + calls.push({ method, params }); + if (requestFailures[method]) throw requestFailures[method]; + if (method === "session/list") return { sessions }; + return { ok: true }; + }, + subscribe: (fn) => { + listener = fn; + return () => { + if (listener === fn) listener = null; + }; + }, + // Test-only escape hatch used to inject envelopes as if from the host. + push(envelope) { + if (!listener) throw new Error("no listener attached"); + listener(envelope); + }, + hasListener: () => listener !== null, + }; +} + +function makeSession(id, overrides = {}) { + return { + id, + title: id, + mode: "chat", + status: "idle", + planningState: "inactive", + permissionMode: "default", + queuedTurnIds: [], + revision: 1, + createdAt: "2026-09-18T10:00:00.000Z", + updatedAt: "2026-09-18T10:00:00.000Z", + ...overrides, + }; +} + +function makeEnvelope(overrides = {}) { + return { + eventId: "e1", + scope: "session", + epoch: "epoch-1", + revision: 1, + kind: "item.started", + occurredAt: "2026-09-18T10:00:00.000Z", + payload: {}, + ...overrides, + }; +} + +function setup({ sessions = [], requestFailures = {} } = {}) { + const events = []; + const router = createBackendRouter(); + const client = fakeClient({ sessions, requestFailures }); + const conn = createRemoteHostConnection({ + hostKey: HOST_KEY, + client, + router, + emit: (channel, payload) => events.push({ channel, payload }), + newRequestId: () => "req-const", + }); + return { conn, router, client, events }; +} + +test("open subscribes host scope, lists sessions, and registers a backend per session", async () => { + const { conn, router, client } = setup({ sessions: [makeSession("s1"), makeSession("s2")] }); + await conn.open(); + const methods = client.calls.map((entry) => entry.method); + // Host-scope subscribe fires BEFORE list; the create-race window is closed. + assert.deepEqual(methods.slice(0, 3), ["events/subscribe", "session/list", "events/subscribe"]); + assert.deepEqual(client.calls[0].params, { scope: "host" }); + const perSessionSubscribeParams = client.calls + .filter((entry) => entry.method === "events/subscribe" && entry.params.scope === "session") + .map((entry) => entry.params.sessionId) + .sort(); + assert.deepEqual(perSessionSubscribeParams, ["s1", "s2"]); + // The router now resolves both session ids to the remote backend. + const backend = router.resolveBackend(IPC.invoke.sessionGet, [ + { id: makeRemoteSessionId(HOST_KEY, "s1") }, + ]); + assert.ok(backend, "router must have a backend for s1"); + assert.ok( + router.resolveBackend(IPC.invoke.sessionGet, [{ id: makeRemoteSessionId(HOST_KEY, "s2") }]), + ); +}); + +test("open is idempotent — a second call does not re-subscribe or re-register", async () => { + const { conn, client } = setup({ sessions: [makeSession("s1")] }); + await conn.open(); + const first = client.calls.length; + await conn.open(); + assert.equal(client.calls.length, first); +}); + +test("a session.created event registers a fresh backend and refreshes the sidebar", async () => { + const { conn, router, client, events } = setup({ sessions: [] }); + await conn.open(); + const newRemoteId = makeRemoteSessionId(HOST_KEY, "s-new"); + client.push( + makeEnvelope({ + scope: "host", + kind: "session.created", + payload: { session: { id: "s-new", title: "new" } }, + }), + ); + assert.ok( + router.resolveBackend(IPC.invoke.sessionGet, [{ id: newRemoteId }]), + "s-new must have a registered backend after session.created", + ); + const sidebarNotice = events.find( + (event) => event.channel === IPC.event.sessionsChanged && event.payload.selectSessionId === newRemoteId, + ); + assert.ok(sidebarNotice, "session.created must emit a sessionsChanged notice for the sidebar"); +}); + +test("a session.archived event unregisters the backend and lets the id fall through", async () => { + const s1 = makeSession("s1"); + const { conn, router, client } = setup({ sessions: [s1] }); + await conn.open(); + const remoteId = makeRemoteSessionId(HOST_KEY, "s1"); + assert.ok(router.resolveBackend(IPC.invoke.sessionGet, [{ id: remoteId }])); + client.push( + makeEnvelope({ + scope: "host", + kind: "session.archived", + payload: { session: { id: "s1" } }, + }), + ); + assert.equal(router.resolveBackend(IPC.invoke.sessionGet, [{ id: remoteId }]), null); +}); + +test("close detaches the listener and unregisters every session", async () => { + const { conn, router, client } = setup({ sessions: [makeSession("s1"), makeSession("s2")] }); + await conn.open(); + await conn.close(); + assert.equal(client.hasListener(), false); + assert.equal( + router.resolveBackend(IPC.invoke.sessionGet, [{ id: makeRemoteSessionId(HOST_KEY, "s1") }]), + null, + ); +}); + +test("close is idempotent and safe to call before open", async () => { + const { conn } = setup(); + await conn.close(); + await conn.open(); + await conn.close(); + await conn.close(); +}); + +test("session/list failure leaves the connection registered for nothing but does not throw", async () => { + const { conn, router, client } = setup({ + sessions: [makeSession("s1")], + requestFailures: { "session/list": new Error("no route to host") }, + }); + await conn.open(); + assert.equal( + router.resolveBackend(IPC.invoke.sessionGet, [{ id: makeRemoteSessionId(HOST_KEY, "s1") }]), + null, + ); + assert.equal(client.hasListener(), true); +}); From 35dbd1998be69d0b0ba26f60f79a24f518d8d30a Mon Sep 17 00:00:00 2001 From: vastsa Date: Fri, 18 Sep 2026 18:27:34 +0800 Subject: [PATCH 11/13] feat(remote): add RACP client adapter and encrypted host registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pieces of Stage 3b's kernel work, kept in one commit because neither one stands on its own — the adapter needs a persistent list of hosts to serve, and the registry needs a client to hand a device token to. RacpRemoteHostClient (`electron/main/remote/racp-remote-host-client.ts`) is the only file in `electron/main/remote/` that speaks the RACP wire. It wraps `RacpClient` from `@pi-desktop/racp` (added as a workspace dep) and fans its single-slot `onEvent` callback out to every listener that attaches through `subscribe()`. That fan-out is what `RemoteHostConnection.open()` needs — Stage 3b's resync watchdog and Stage 5's terminal client will attach here without a second subscription to the same stream. A listener that throws is logged and the others still receive the event. `@pi-desktop/racp` exposes `./test-harness` as an additional exports subpath so consumers can build fixtures against the same in-memory `MemoryLink` its own tests use; the adapter test does exactly that, exercising the real `RacpClient` state machine without opening a socket. RemoteHostRegistry (`electron/main/remote/remote-host-registry.ts`) persists the paired-host list at `/remote-hosts.json`. Device tokens are encrypted through an injected `EncryptionPort` (production wires it to Electron's `safeStorage`), so a stolen backup without OS keychain access reveals only the URL and label. `upsert` refuses to write when the keychain is unavailable; `list` drops records it cannot decrypt rather than surfacing a placeholder token that would auth-fail downstream. The write path is atomic (write-then-rename); a shape or JSON error on read is logged and treated as an empty list, leaving the file on disk so a corrupt-file rescue is still possible. Covered by `apps/desktop/test/racp-remote-host-client.test.mjs` (4 cases against the RACP harness) and `apps/desktop/test/remote-host-registry.test.mjs` (8 cases with a reversible fake encryption and `os.tmpdir` scaffolding). --- .../main/remote/racp-remote-host-client.ts | 91 +++++++++ .../main/remote/remote-host-registry.ts | 177 +++++++++++++++++ apps/desktop/package.json | 1 + .../test/racp-remote-host-client.test.mjs | 133 +++++++++++++ .../test/remote-host-registry.test.mjs | 183 ++++++++++++++++++ packages/racp/package.json | 4 + pnpm-lock.yaml | 3 + 7 files changed, 592 insertions(+) create mode 100644 apps/desktop/electron/main/remote/racp-remote-host-client.ts create mode 100644 apps/desktop/electron/main/remote/remote-host-registry.ts create mode 100644 apps/desktop/test/racp-remote-host-client.test.mjs create mode 100644 apps/desktop/test/remote-host-registry.test.mjs diff --git a/apps/desktop/electron/main/remote/racp-remote-host-client.ts b/apps/desktop/electron/main/remote/racp-remote-host-client.ts new file mode 100644 index 0000000000..704f22b6a8 --- /dev/null +++ b/apps/desktop/electron/main/remote/racp-remote-host-client.ts @@ -0,0 +1,91 @@ +/** + * RACP-WS-backed {@link RemoteHostClient}: adapts the single-callback + * `RacpClient.onEvent` seam into the multi-listener `subscribe()` shape + * `RemoteHostConnection` consumes. This is the only file in + * `electron/main/remote/` that speaks the `@pi-desktop/racp` protocol + * package; everything above it is transport-agnostic. + * + * Ownership stays inside Electron Main. The transport factory is injected — + * production wires it to {@link wsClientTransport} against a paired host, and + * tests wire it to the in-memory `MemoryLink` from + * `packages/racp/src/test-harness.ts` so the adapter exercises the real + * `RacpClient` state machine without a socket. + */ +import { RacpClient, type ClientTransportFactory, type RacpClientState } from "@pi-desktop/racp"; +import type { RacpEventEnvelope } from "@pi-desktop/shared"; +import type { RemoteHostClient } from "./remote-host-connection.js"; + +export type RacpRemoteHostClientOptions = { + transport: ClientTransportFactory; + /** Identity sent in `connection/initialize`; the host records it as the device label. */ + clientInfo: { name: string; version: string }; + /** Deadline for a single request; the RACP default of 15s is used when omitted. */ + requestTimeoutMs?: number; + /** Reconnect policy; the RACP client stays disconnected when omitted. */ + reconnect?: { + enabled: boolean; + baseDelayMs?: number; + maxDelayMs?: number; + maxAttempts?: number; + }; + /** Optional structured log; defaults to a no-op. */ + log?: (level: "info" | "warn", message: string, data?: Record) => void; +}; + +export type RacpRemoteHostClient = { + /** The multi-listener {@link RemoteHostClient} the connection module consumes. */ + readonly client: RemoteHostClient; + /** Underlying RACP client state, for boot diagnostics and the future host card. */ + readonly state: () => RacpClientState; + /** Open the transport and initialize the RACP session. */ + connect(): Promise; + /** Close the transport; safe to call before {@link connect} and after failure. */ + close(): Promise; +}; + +/** + * The adapter multiplexes {@link RacpClient.onEvent} — a single-slot callback + * — into the `subscribe()` API {@link RemoteHostConnection} expects. Fan-out + * is intentional: Stage 5 (terminal) and Stage 3b (resync watchdog) will + * attach their own listeners on the same client. + */ +export function createRacpRemoteHostClient( + options: RacpRemoteHostClientOptions, +): RacpRemoteHostClient { + const listeners = new Set<(envelope: RacpEventEnvelope) => void>(); + const racp = new RacpClient({ + transport: options.transport, + client: options.clientInfo, + onEvent: (envelope) => { + for (const listener of listeners) { + try { + listener(envelope); + } catch (error) { + options.log?.("warn", "remote event listener threw", { error: String(error) }); + } + } + }, + ...(options.requestTimeoutMs !== undefined ? { requestTimeoutMs: options.requestTimeoutMs } : {}), + ...(options.reconnect ? { reconnect: options.reconnect } : {}), + ...(options.log ? { log: options.log } : {}), + }); + const client: RemoteHostClient = { + request: (method, params) => racp.request(method, params), + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; + return { + client, + state: () => racp.state, + async connect() { + await racp.connect(); + }, + async close() { + await racp.close(); + }, + }; +} diff --git a/apps/desktop/electron/main/remote/remote-host-registry.ts b/apps/desktop/electron/main/remote/remote-host-registry.ts new file mode 100644 index 0000000000..67f175016c --- /dev/null +++ b/apps/desktop/electron/main/remote/remote-host-registry.ts @@ -0,0 +1,177 @@ +/** + * Persisted list of paired remote hosts. Each record binds one host key to + * the URL of its RACP-WS endpoint and the encrypted device token the host + * issued during pairing. The file lives at `/remote-hosts.json` and + * the token bytes are encrypted with Electron's `safeStorage` before write; + * on read they decrypt back through the same interface, so a stolen file + * without OS keychain access reveals only the URL and label. + * + * The registry is injected with an {@link EncryptionPort} rather than + * imported from `electron` so it stays unit-testable in `node --test`; the + * boot layer wires the real `safeStorage.encryptString` / + * `safeStorage.decryptString` pair. + */ +import { readFile, rename, unlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +/** + * Byte-in, byte-out encryption. `encryptString` returns a Buffer of + * ciphertext; `decryptString` recovers the plaintext from the same bytes. + * Both throw when the underlying keychain is unavailable — the registry + * surfaces the failure to the caller. + */ +export interface EncryptionPort { + isAvailable(): boolean; + encryptString(plaintext: string): Buffer; + decryptString(ciphertext: Buffer): string; +} + +/** One paired host as it lives on disk (token stays encrypted at rest). */ +export type RemoteHostRecord = { + /** Stable id used in `remote::<...>` renderer session ids. */ + hostKey: string; + /** Human label; shown in a future host list. */ + label: string; + /** `ws://…` or `wss://…` URL for the RACP endpoint. */ + url: string; + /** Device token issued at pairing, presented on every reconnect. */ + deviceToken: string; + /** Room for later fields (roles, protocol hints) without a schema bump. */ + metadata?: Record; +}; + +/** The on-disk record: the plaintext deviceToken is replaced with base64 + * ciphertext + version byte, so a token cannot be recovered without the + * matching safeStorage keychain. */ +type SerializedRecord = { + hostKey: string; + label: string; + url: string; + /** Base64 of the `safeStorage.encryptString` output. */ + encryptedDeviceToken: string; + metadata?: Record; +}; + +type SerializedFile = { + version: 1; + hosts: SerializedRecord[]; +}; + +export type RemoteHostRegistryOptions = { + dataDir: string; + encryption: EncryptionPort; + /** File name inside `dataDir`; defaults to `remote-hosts.json`. */ + fileName?: string; + /** Optional structured log for I/O and decrypt failures. */ + log?: (level: "warn" | "error", message: string, data?: unknown) => void; +}; + +export interface RemoteHostRegistry { + /** Every host record on disk with a decryptable token, in file order. */ + list(): Promise; + /** Add or replace the record for `record.hostKey` and flush. */ + upsert(record: RemoteHostRecord): Promise; + /** Remove one host key; a missing key is a no-op. */ + remove(hostKey: string): Promise; +} + +const CURRENT_VERSION = 1; + +function fileNotFound(error: unknown): boolean { + return typeof error === "object" && error !== null && (error as { code?: string }).code === "ENOENT"; +} + +export function createRemoteHostRegistry( + options: RemoteHostRegistryOptions, +): RemoteHostRegistry { + const log = options.log ?? (() => undefined); + const filePath = join(options.dataDir, options.fileName ?? "remote-hosts.json"); + + const readFileContents = async (): Promise => { + try { + const raw = await readFile(filePath, "utf8"); + const parsed = JSON.parse(raw) as Partial; + if (parsed.version !== CURRENT_VERSION || !Array.isArray(parsed.hosts)) { + log("warn", "remote-hosts.json shape rejected; treating as empty", { file: filePath }); + return { version: CURRENT_VERSION, hosts: [] }; + } + return { version: CURRENT_VERSION, hosts: parsed.hosts }; + } catch (error) { + if (fileNotFound(error)) return { version: CURRENT_VERSION, hosts: [] }; + log("error", "remote-hosts.json read failed", { error: String(error) }); + // A corrupt file must not delete records the user cannot see; the + // caller reads an empty list and any upsert would overwrite it. + return { version: CURRENT_VERSION, hosts: [] }; + } + }; + + const persist = async (file: SerializedFile): Promise => { + const tmp = `${filePath}.tmp`; + await writeFile(tmp, `${JSON.stringify(file, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); + // atomic replace — a crash leaves either the old file or the new one, never a torn write + await rename(tmp, filePath); + }; + + return { + async list() { + const file = await readFileContents(); + const decoded: RemoteHostRecord[] = []; + for (const record of file.hosts) { + try { + const buffer = Buffer.from(record.encryptedDeviceToken, "base64"); + const deviceToken = options.encryption.decryptString(buffer); + decoded.push({ + hostKey: record.hostKey, + label: record.label, + url: record.url, + deviceToken, + ...(record.metadata ? { metadata: record.metadata } : {}), + }); + } catch (error) { + // A record we cannot decrypt (keychain moved, wrong OS user) is + // dropped from the return: exposing an empty string as the token + // would just cause auth failures downstream and the caller has no + // way to distinguish that from a real revocation. + log("warn", "remote host record could not be decrypted; skipping", { + hostKey: record.hostKey, + error: String(error), + }); + } + } + return decoded; + }, + async upsert(record) { + if (!options.encryption.isAvailable()) { + throw Object.assign(new Error("safeStorage unavailable; refusing to write remote host token"), { + errorCode: "REMOTE_STORAGE_UNAVAILABLE", + }); + } + const file = await readFileContents(); + const ciphertext = options.encryption.encryptString(record.deviceToken).toString("base64"); + const serialized: SerializedRecord = { + hostKey: record.hostKey, + label: record.label, + url: record.url, + encryptedDeviceToken: ciphertext, + ...(record.metadata ? { metadata: record.metadata } : {}), + }; + const next = file.hosts.filter((existing) => existing.hostKey !== record.hostKey); + next.push(serialized); + await persist({ version: CURRENT_VERSION, hosts: next }); + }, + async remove(hostKey) { + const file = await readFileContents(); + const next = file.hosts.filter((existing) => existing.hostKey !== hostKey); + if (next.length === file.hosts.length) return; + if (next.length === 0) { + try { + await unlink(filePath); + } catch (error) { + if (!fileNotFound(error)) log("warn", "remote-hosts.json unlink failed", { error: String(error) }); + } + return; + } + await persist({ version: CURRENT_VERSION, hosts: next }); + }, + }; +} diff --git a/apps/desktop/package.json b/apps/desktop/package.json index e22f8bf46c..7e3a7315b2 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -41,6 +41,7 @@ "@pi-desktop/i18n": "workspace:*", "@pi-desktop/plugin-devkit": "workspace:*", "@pi-desktop/plugin-sdk": "workspace:*", + "@pi-desktop/racp": "workspace:*", "@pi-desktop/shared": "workspace:*", "@tailwindcss/vite": "^4.3.3", "@types/react": "^19.2.18", diff --git a/apps/desktop/test/racp-remote-host-client.test.mjs b/apps/desktop/test/racp-remote-host-client.test.mjs new file mode 100644 index 0000000000..edd50040b2 --- /dev/null +++ b/apps/desktop/test/racp-remote-host-client.test.mjs @@ -0,0 +1,133 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { register } from "node:module"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +register(pathToFileURL(join(here, "helpers/ts-import-hooks.mjs"))); + +const { createRacpRemoteHostClient } = await import( + "../electron/main/remote/racp-remote-host-client.ts" +); +const { harness, OWNER_TOKEN, MemoryLink } = await import("@pi-desktop/racp/test-harness"); + +/** Build a transport factory that authenticates OWNER_TOKEN and hands the + * server the link's server side. Each call to the factory opens a fresh + * `MemoryLink`, mirroring how the production ws factory opens a fresh socket + * per (re)connect. */ +function ownerTransport({ server, authenticator }) { + return async () => { + const auth = await authenticator.authenticate({ + authorization: `Bearer ${OWNER_TOKEN}`, + urlHasToken: false, + connectionId: `test-${Math.random()}`, + }); + if (!auth) throw new Error("test authenticator refused OWNER_TOKEN"); + const link = new MemoryLink(); + const accepted = server.accept(auth, link.serverSide()); + if (!accepted) throw new Error("test server refused connection"); + return link.clientSide(); + }; +} + +test("adapter connects, request delegates to RacpClient, close is clean", async () => { + const h = await harness(); + const adapter = createRacpRemoteHostClient({ + transport: ownerTransport(h), + clientInfo: { name: "test-desktop", version: "0.15.0" }, + requestTimeoutMs: 2_000, + }); + assert.equal(adapter.state(), "disconnected"); + await adapter.connect(); + assert.equal(adapter.state(), "connected"); + const result = await adapter.client.request("session/list"); + assert.ok(Array.isArray(result.sessions)); + assert.ok(result.sessions.some((session) => session.id === "s1")); + await adapter.close(); + assert.equal(adapter.state(), "disconnected"); +}); + +test("subscribe fans out RACP events to every attached listener", async () => { + const h = await harness(); + const adapter = createRacpRemoteHostClient({ + transport: ownerTransport(h), + clientInfo: { name: "test-desktop", version: "0.15.0" }, + requestTimeoutMs: 2_000, + }); + await adapter.connect(); + + const a = []; + const b = []; + const detachA = adapter.client.subscribe((envelope) => a.push(envelope)); + const detachB = adapter.client.subscribe((envelope) => b.push(envelope)); + + // Subscribe host scope so the following session/create surfaces as an event. + await adapter.client.request("events/subscribe", { scope: "host" }); + await adapter.client.request("session/create", { title: "New" }); + // Let the socket flush the event notification. + await new Promise((resolve) => setTimeout(resolve, 15)); + + const kinds = a.map((envelope) => envelope.kind); + assert.ok(kinds.includes("session.created"), "listener A should have seen session.created"); + assert.deepEqual( + b.map((envelope) => envelope.kind), + kinds, + "both listeners must observe the same event stream", + ); + + detachA(); + a.length = 0; + b.length = 0; + await adapter.client.request("session/create", { title: "Another" }); + await new Promise((resolve) => setTimeout(resolve, 15)); + assert.equal(a.length, 0, "detached listener must stop receiving events"); + assert.ok(b.length > 0, "still-attached listener continues to receive events"); + + detachB(); + await adapter.close(); +}); + +test("a listener that throws does not break the fan-out to the others", async () => { + const h = await harness(); + const warnings = []; + const adapter = createRacpRemoteHostClient({ + transport: ownerTransport(h), + clientInfo: { name: "test-desktop", version: "0.15.0" }, + requestTimeoutMs: 2_000, + log: (level, message, data) => { + if (level === "warn") warnings.push({ message, data }); + }, + }); + await adapter.connect(); + + adapter.client.subscribe(() => { + throw new Error("bad listener"); + }); + const survivor = []; + adapter.client.subscribe((envelope) => survivor.push(envelope)); + + await adapter.client.request("events/subscribe", { scope: "host" }); + await adapter.client.request("session/create", { title: "New" }); + await new Promise((resolve) => setTimeout(resolve, 15)); + + assert.ok(survivor.length > 0, "survivor must still receive events"); + assert.ok( + warnings.some((entry) => /listener threw/.test(entry.message)), + "adapter must log the failing listener", + ); + + await adapter.close(); +}); + +test("request before connect rejects with HOST_DISCONNECTED", async () => { + const h = await harness(); + const adapter = createRacpRemoteHostClient({ + transport: ownerTransport(h), + clientInfo: { name: "test-desktop", version: "0.15.0" }, + }); + await assert.rejects( + () => adapter.client.request("session/list"), + (error) => error.code === "HOST_DISCONNECTED", + ); +}); diff --git a/apps/desktop/test/remote-host-registry.test.mjs b/apps/desktop/test/remote-host-registry.test.mjs new file mode 100644 index 0000000000..0aef7df324 --- /dev/null +++ b/apps/desktop/test/remote-host-registry.test.mjs @@ -0,0 +1,183 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { register } from "node:module"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +register(pathToFileURL(join(here, "helpers/ts-import-hooks.mjs"))); + +const { createRemoteHostRegistry } = await import( + "../electron/main/remote/remote-host-registry.ts" +); + +/** + * Reversible fake encryption for tests: prefix the plaintext so we can prove + * the token was passed through `encrypt` and not written as clear bytes, and + * so a "wrong key" scenario can be simulated by rejecting the prefix. + */ +function reversibleEncryption(overrides = {}) { + return { + available: overrides.available ?? true, + isAvailable() { + return this.available; + }, + encryptString(plain) { + return Buffer.from(`enc:${plain}`); + }, + decryptString(buffer) { + const text = buffer.toString("utf8"); + if (!text.startsWith("enc:")) throw new Error("cannot decrypt"); + return text.slice("enc:".length); + }, + }; +} + +async function tmpDir() { + const dir = await mkdtemp(join(tmpdir(), "remote-hosts-")); + return { + dir, + async cleanup() { + await rm(dir, { recursive: true, force: true }); + }, + }; +} + +test("list returns an empty array when the file does not exist", async () => { + const { dir, cleanup } = await tmpDir(); + const registry = createRemoteHostRegistry({ dataDir: dir, encryption: reversibleEncryption() }); + assert.deepEqual(await registry.list(), []); + await cleanup(); +}); + +test("upsert then list round-trips a record and encrypts the token on disk", async () => { + const { dir, cleanup } = await tmpDir(); + const encryption = reversibleEncryption(); + const registry = createRemoteHostRegistry({ dataDir: dir, encryption }); + await registry.upsert({ + hostKey: "hostA", + label: "Home Linux", + url: "wss://home.local:9443/racp", + deviceToken: "rd_secret", + }); + const rows = await registry.list(); + assert.equal(rows.length, 1); + assert.equal(rows[0].hostKey, "hostA"); + assert.equal(rows[0].deviceToken, "rd_secret"); + // Confirm the token bytes never landed in plaintext. + const raw = await readFile(join(dir, "remote-hosts.json"), "utf8"); + assert.ok(!raw.includes("rd_secret"), "plaintext token must not appear on disk"); + assert.ok(raw.includes("encryptedDeviceToken")); + await cleanup(); +}); + +test("upsert overwrites an existing hostKey and preserves the rest", async () => { + const { dir, cleanup } = await tmpDir(); + const registry = createRemoteHostRegistry({ dataDir: dir, encryption: reversibleEncryption() }); + await registry.upsert({ hostKey: "a", label: "A", url: "wss://a", deviceToken: "t-a" }); + await registry.upsert({ hostKey: "b", label: "B", url: "wss://b", deviceToken: "t-b" }); + await registry.upsert({ hostKey: "a", label: "A renamed", url: "wss://a2", deviceToken: "t-a2" }); + const rows = await registry.list(); + assert.equal(rows.length, 2); + const a = rows.find((row) => row.hostKey === "a"); + const b = rows.find((row) => row.hostKey === "b"); + assert.equal(a.label, "A renamed"); + assert.equal(a.url, "wss://a2"); + assert.equal(a.deviceToken, "t-a2"); + assert.equal(b.deviceToken, "t-b"); + await cleanup(); +}); + +test("upsert refuses to write when safeStorage is unavailable", async () => { + const { dir, cleanup } = await tmpDir(); + const registry = createRemoteHostRegistry({ + dataDir: dir, + encryption: reversibleEncryption({ available: false }), + }); + await assert.rejects( + () => + registry.upsert({ hostKey: "a", label: "A", url: "wss://a", deviceToken: "t" }), + (error) => error.errorCode === "REMOTE_STORAGE_UNAVAILABLE", + ); + await cleanup(); +}); + +test("remove drops one hostKey and unlinks the file when the last one leaves", async () => { + const { dir, cleanup } = await tmpDir(); + const registry = createRemoteHostRegistry({ dataDir: dir, encryption: reversibleEncryption() }); + await registry.upsert({ hostKey: "a", label: "A", url: "wss://a", deviceToken: "t-a" }); + await registry.upsert({ hostKey: "b", label: "B", url: "wss://b", deviceToken: "t-b" }); + await registry.remove("a"); + assert.deepEqual((await registry.list()).map((row) => row.hostKey), ["b"]); + await registry.remove("b"); + assert.deepEqual(await registry.list(), []); + // File should no longer exist after emptying. + await assert.rejects( + () => readFile(join(dir, "remote-hosts.json"), "utf8"), + (error) => error.code === "ENOENT", + ); + await cleanup(); +}); + +test("remove of a missing hostKey is a no-op", async () => { + const { dir, cleanup } = await tmpDir(); + const registry = createRemoteHostRegistry({ dataDir: dir, encryption: reversibleEncryption() }); + await registry.upsert({ hostKey: "a", label: "A", url: "wss://a", deviceToken: "t-a" }); + await registry.remove("nope"); + const rows = await registry.list(); + assert.equal(rows.length, 1); + await cleanup(); +}); + +test("a record whose token fails to decrypt is skipped, not surfaced with a placeholder", async () => { + const { dir, cleanup } = await tmpDir(); + const encryption = reversibleEncryption(); + const filePath = join(dir, "remote-hosts.json"); + const file = { + version: 1, + hosts: [ + { + hostKey: "good", + label: "Good", + url: "wss://good", + encryptedDeviceToken: Buffer.from("enc:t-good").toString("base64"), + }, + { + hostKey: "bad", + label: "Bad", + url: "wss://bad", + encryptedDeviceToken: Buffer.from("wrong-prefix").toString("base64"), + }, + ], + }; + await writeFile(filePath, JSON.stringify(file), "utf8"); + const warnings = []; + const registry = createRemoteHostRegistry({ + dataDir: dir, + encryption, + log: (level, message) => warnings.push({ level, message }), + }); + const rows = await registry.list(); + assert.deepEqual(rows.map((row) => row.hostKey), ["good"]); + assert.ok(warnings.some((entry) => /could not be decrypted/.test(entry.message))); + await cleanup(); +}); + +test("a shape-mismatched file is treated as empty and preserved on disk until rewritten", async () => { + const { dir, cleanup } = await tmpDir(); + await writeFile(join(dir, "remote-hosts.json"), JSON.stringify({ hosts: "oops" }), "utf8"); + const warnings = []; + const registry = createRemoteHostRegistry({ + dataDir: dir, + encryption: reversibleEncryption(), + log: (level, message) => warnings.push({ level, message }), + }); + assert.deepEqual(await registry.list(), []); + assert.ok(warnings.some((entry) => /shape rejected/.test(entry.message))); + // File not deleted by list(). + const still = await readFile(join(dir, "remote-hosts.json"), "utf8"); + assert.ok(still.includes("oops")); + await cleanup(); +}); diff --git a/packages/racp/package.json b/packages/racp/package.json index 11b8c82018..b9d83da9d8 100644 --- a/packages/racp/package.json +++ b/packages/racp/package.json @@ -9,6 +9,10 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" + }, + "./test-harness": { + "types": "./dist/test-harness.d.ts", + "import": "./dist/test-harness.js" } }, "scripts": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 05ba7ee6a5..70b00c7942 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,6 +55,9 @@ importers: '@pi-desktop/plugin-sdk': specifier: workspace:* version: link:../../packages/plugin-sdk + '@pi-desktop/racp': + specifier: workspace:* + version: link:../../packages/racp '@pi-desktop/shared': specifier: workspace:* version: link:../../packages/shared From fa68bd6c695d91abb385153f70d9d4a59a5f2a47 Mon Sep 17 00:00:00 2001 From: vastsa Date: Fri, 18 Sep 2026 18:27:56 +0800 Subject: [PATCH 12/13] feat(remote): boot paired remote hosts from startup and close them on quit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the four Stage 2/3 modules the desktop kernel needs into the existing composition root. `bootstrap/remote-hosts.ts` reads the registry, and for every host record builds one RacpRemoteHostClient + one RemoteHostConnection and opens both. `bootstrap/startup.ts` fires `open()` in the background — a slow or unreachable host must not delay the first window; per-host failures are logged and skipped. `bootstrap/shutdown.ts` calls `closeAll()` from the existing shutdown promise, before the local host-core is torn down, so any in-flight remote turn's abort still goes over a live socket. `state.backendRouter.registerBackend` finally fires. When the registry is empty (the default install) it does not — the router still returns ROUTE_LOCAL for every call and the renderer keeps hitting the local handler byte-for-byte. This is the zero-behavior default the ratchet demands until pairing (R2b) can land a UX. `apps/desktop/electron/main/index.ts` stays at exactly 1500 LOC. A module-level `activeRemoteHostsBoot` handle inside `bootstrap/remote-hosts.ts` bridges the two lifecycle sites without adding a field to `StartupState` or the `registerShutdownHandlers` call in `index.ts`. When R2b lets a broader remote-hosts service object hang off StartupState, the handle can retire cleanly. The pinned source-contract test in `apps/desktop/test/rpc-lifecycle-contract.test.mjs` grows its `Promise.allSettled` array by one entry to match the new `remoteHostsShutdown` slot. Full desktop `node --test` suite is 2128/2128 green. --- .../electron/main/bootstrap/remote-hosts.ts | 158 +++++++++++++ .../electron/main/bootstrap/shutdown.ts | 7 + .../electron/main/bootstrap/startup.ts | 31 ++- apps/desktop/test/boot-remote-hosts.test.mjs | 207 ++++++++++++++++++ .../test/rpc-lifecycle-contract.test.mjs | 2 +- 5 files changed, 403 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/electron/main/bootstrap/remote-hosts.ts create mode 100644 apps/desktop/test/boot-remote-hosts.test.mjs diff --git a/apps/desktop/electron/main/bootstrap/remote-hosts.ts b/apps/desktop/electron/main/bootstrap/remote-hosts.ts new file mode 100644 index 0000000000..3be7e47c2e --- /dev/null +++ b/apps/desktop/electron/main/bootstrap/remote-hosts.ts @@ -0,0 +1,158 @@ +/** + * Boot every paired remote host. Reads the persisted registry, builds a + * `RemoteHostConnection` per record, opens them, and returns a disposer that + * `bootstrap/shutdown.ts` calls on quit. An empty registry (the default + * install with no user pairing) is a full no-op: nothing runs, the router + * has no remote backends, and every renderer call keeps hitting the local + * handler byte-for-byte. + * + * The transport factory is injected. Production wires it to + * `wsClientTransport` from `@pi-desktop/racp` (loopback for local dev, `-L` + * tunnel target for Stage 4's SSH bootstrap); tests wire the in-memory + * `MemoryLink` so this boot layer exercises the real `RacpClient` state + * machine without a socket. + */ +import { wsClientTransport } from "@pi-desktop/racp"; +import type { BackendRouter } from "../remote/backend-router.js"; +import { + createRacpRemoteHostClient, + type RacpRemoteHostClient, +} from "../remote/racp-remote-host-client.js"; +import { + createRemoteHostConnection, + type RemoteHostConnection, +} from "../remote/remote-host-connection.js"; +import { + createRemoteHostRegistry, + type EncryptionPort, + type RemoteHostRecord, +} from "../remote/remote-host-registry.js"; + +export type RemoteHostAdapterFactory = (record: RemoteHostRecord) => RacpRemoteHostClient; + +export type BootRemoteHostsOptions = { + dataDir: string; + encryption: EncryptionPort; + router: BackendRouter; + emit: (channel: string, payload: unknown) => void; + /** `connection/initialize` identity forwarded to every paired host. */ + clientInfo: { name: string; version: string }; + /** Optional override; the default uses the real `wsClientTransport`. */ + buildAdapter?: RemoteHostAdapterFactory; + log?: (level: "info" | "warn" | "error", message: string, data?: unknown) => void; +}; + +export interface RemoteHostsBoot { + /** Read registry, connect every host, register their sessions. Returns + * the number of hosts that finished `open()` without throwing. */ + open(): Promise; + /** Close every open connection. Idempotent; safe to call before `open`. */ + closeAll(): Promise; +} + +/** + * Single-slot registry so `bootstrap/shutdown.ts` can wait on the same boot + * `startup.ts` created without expanding `index.ts` past its 1500-LOC ceiling. + * The shutdown handler reads this on `before-quit` — never earlier — so the + * ordering is: register the ref during startup, close during shutdown. + */ +let activeRemoteHostsBoot: RemoteHostsBoot | null = null; + +export function setActiveRemoteHostsBoot(boot: RemoteHostsBoot | null): void { + activeRemoteHostsBoot = boot; +} + +export function getActiveRemoteHostsBoot(): RemoteHostsBoot | null { + return activeRemoteHostsBoot; +} + +type OpenHost = { + hostKey: string; + adapter: RacpRemoteHostClient; + connection: RemoteHostConnection; +}; + +export function createRemoteHostsBoot( + options: BootRemoteHostsOptions, +): RemoteHostsBoot { + const log = options.log ?? (() => undefined); + const buildAdapter: RemoteHostAdapterFactory = + options.buildAdapter ?? + ((record) => + createRacpRemoteHostClient({ + transport: wsClientTransport({ url: record.url, token: record.deviceToken }), + clientInfo: options.clientInfo, + log: (level, message, data) => log(level, message, data), + })); + + const registry = createRemoteHostRegistry({ + dataDir: options.dataDir, + encryption: options.encryption, + log: (level, message, data) => log(level, message, data), + }); + + const opened: OpenHost[] = []; + + return { + async open() { + let records: RemoteHostRecord[]; + try { + records = await registry.list(); + } catch (error) { + log("error", "remote host registry read failed; skipping remote boot", { + error: String(error), + }); + return 0; + } + if (records.length === 0) return 0; + + let successes = 0; + // Sequential connects: an early host's failure is logged and skipped + // rather than aborting the rest. Parallelism buys nothing when each + // host serves its own set of sessions. + for (const record of records) { + try { + const adapter = buildAdapter(record); + await adapter.connect(); + const connection = createRemoteHostConnection({ + hostKey: record.hostKey, + client: adapter.client, + router: options.router, + emit: options.emit, + log: (level, message, data) => log(level, message, data), + }); + await connection.open(); + opened.push({ hostKey: record.hostKey, adapter, connection }); + successes += 1; + } catch (error) { + log("warn", `remote host ${record.hostKey} failed to open; leaving it disconnected`, { + error: String(error), + }); + } + } + return successes; + }, + async closeAll() { + // Snapshot and clear first so a re-entrant close finds nothing to do. + const hosts = opened.splice(0, opened.length); + await Promise.allSettled( + hosts.map(async (host) => { + try { + await host.connection.close(); + } catch (error) { + log("warn", `remote host ${host.hostKey} connection close threw`, { + error: String(error), + }); + } + try { + await host.adapter.close(); + } catch (error) { + log("warn", `remote host ${host.hostKey} adapter close threw`, { + error: String(error), + }); + } + }), + ); + }, + }; +} diff --git a/apps/desktop/electron/main/bootstrap/shutdown.ts b/apps/desktop/electron/main/bootstrap/shutdown.ts index 761d5d24fa..c18d12412c 100644 --- a/apps/desktop/electron/main/bootstrap/shutdown.ts +++ b/apps/desktop/electron/main/bootstrap/shutdown.ts @@ -12,6 +12,7 @@ import type { PluginViewHost } from "../plugin-view-host"; import type { AppUpdaterController } from "../updater"; import type { UserMcpRuntime } from "../user-mcp"; import type { McpControlServer } from "../mcp-control"; +import { getActiveRemoteHostsBoot, setActiveRemoteHostsBoot } from "./remote-hosts"; const QUIT_TURN_SETTLE_BUDGET_MS = 2_000; @@ -125,6 +126,11 @@ export function registerShutdownHandlers({ state.toggleWindowAccelerator = null; } state.shutdownPromise = (async () => { + // Close every paired remote host before the local host-core so any + // in-flight remote turn's abort still goes over a live socket. Bounded + // parallelism inside `closeAll`; safe to run before local disposals. + const remoteHostsShutdown = getActiveRemoteHostsBoot()?.closeAll(); + setActiveRemoteHostsBoot(null); // Replies still streaming are stopped through the sidecar first so their // aborted final rows can reach the transcript while host-core is alive; // whatever does not make it in time is covered by the last checkpoint @@ -163,6 +169,7 @@ export function registerShutdownHandlers({ pluginShutdown, sidecarShutdown, mcpShutdown, + remoteHostsShutdown, ]); })(); diff --git a/apps/desktop/electron/main/bootstrap/startup.ts b/apps/desktop/electron/main/bootstrap/startup.ts index c7de5085db..b55f395398 100644 --- a/apps/desktop/electron/main/bootstrap/startup.ts +++ b/apps/desktop/electron/main/bootstrap/startup.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, Menu } from "electron"; +import { app, BrowserWindow, Menu, safeStorage } from "electron"; import { APP_NAME, APP_VERSION, @@ -18,6 +18,7 @@ import { applyNetworkProxyFromAppSettings } from "../network-proxy"; import { readCloseBehavior } from "../window-preferences"; import { createAgentHostBridge, type AgentHostBridge } from "../agent-host-bridge"; import { createBackendRouter, type BackendRouter } from "../remote/backend-router"; +import { createRemoteHostsBoot, setActiveRemoteHostsBoot } from "./remote-hosts"; import { createMcpControlController, McpControlServer, @@ -169,6 +170,34 @@ export function registerApplicationStartup(deps: StartupDependencies): void { log: (level, message, data) => logger.app("runtime", level, message, { data: data === undefined ? undefined : String(data) }), }); + // Every paired remote `pi-host` opens against the router this boot just + // created. An empty registry (default install with no user pairing) makes + // this a full no-op — nothing connects, no backend registers, every + // renderer call keeps hitting the local handler byte-for-byte. + const remoteHostsBoot = createRemoteHostsBoot({ + dataDir, + encryption: { + // Electron's safeStorage exposes `isEncryptionAvailable`; the port + // keeps the shorter `isAvailable` name so a Node-side test can drop + // in a fake without pulling in the Electron type. + isAvailable: () => safeStorage.isEncryptionAvailable(), + encryptString: (plain) => safeStorage.encryptString(plain), + decryptString: (buffer) => safeStorage.decryptString(buffer), + }, + router: state.backendRouter, + emit: sendToRenderer, + clientInfo: { name: APP_NAME, version: APP_VERSION }, + log: (level, message, data) => + logger.app("runtime", level, message, { data: data === undefined ? undefined : String(data) }), + }); + setActiveRemoteHostsBoot(remoteHostsBoot); + // Boot in the background: a slow or unreachable host must not delay the + // first window. Failures for individual hosts are logged inside `open()`. + void remoteHostsBoot.open().then((opened) => { + if (opened > 0) { + logger.app("runtime", "info", "remote hosts connected", { data: String(opened) }); + } + }); state.agentHostBridge = createAgentHostBridge({ invoke: invokeIpc, channels: IPC.invoke, diff --git a/apps/desktop/test/boot-remote-hosts.test.mjs b/apps/desktop/test/boot-remote-hosts.test.mjs new file mode 100644 index 0000000000..042c7483be --- /dev/null +++ b/apps/desktop/test/boot-remote-hosts.test.mjs @@ -0,0 +1,207 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { register } from "node:module"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +register(pathToFileURL(join(here, "helpers/ts-import-hooks.mjs"))); + +const { IPC } = await import("@pi-desktop/shared"); +const { createBackendRouter, makeRemoteSessionId } = await import( + "../electron/main/remote/backend-router.ts" +); +const { createRemoteHostsBoot } = await import( + "../electron/main/bootstrap/remote-hosts.ts" +); +const { createRemoteHostRegistry } = await import( + "../electron/main/remote/remote-host-registry.ts" +); + +function reversibleEncryption() { + return { + isAvailable: () => true, + encryptString: (plain) => Buffer.from(`enc:${plain}`), + decryptString: (buf) => { + const text = buf.toString("utf8"); + if (!text.startsWith("enc:")) throw new Error("cannot decrypt"); + return text.slice("enc:".length); + }, + }; +} + +async function tmpDir() { + const dir = await mkdtemp(join(tmpdir(), "boot-remote-")); + return { dir, cleanup: () => rm(dir, { recursive: true, force: true }) }; +} + +/** A stand-in for the RacpClient-backed adapter. Records the calls so tests + * can assert on them, and lets the test push RACP envelopes back. */ +function fakeAdapter(options = {}) { + const listeners = new Set(); + const requests = []; + return { + requests, + state: "disconnected", + async connect() { + if (options.connectRejects) throw options.connectRejects; + this.state = "connected"; + }, + async close() { + this.state = "disconnected"; + listeners.clear(); + }, + push(envelope) { + for (const listener of listeners) listener(envelope); + }, + client: { + request: async (method, params) => { + requests.push({ method, params }); + if (method === "session/list") { + return { sessions: options.sessions ?? [] }; + } + return { ok: true }; + }, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }, + }; +} + +function makeSession(id) { + return { + id, + title: id, + mode: "chat", + status: "idle", + planningState: "inactive", + permissionMode: "default", + queuedTurnIds: [], + revision: 1, + createdAt: "2026-09-18T10:00:00.000Z", + updatedAt: "2026-09-18T10:00:00.000Z", + }; +} + +test("open on an empty registry is a no-op: nothing registers, closeAll clean", async () => { + const { dir, cleanup } = await tmpDir(); + const router = createBackendRouter(); + const boot = createRemoteHostsBoot({ + dataDir: dir, + encryption: reversibleEncryption(), + router, + emit: () => undefined, + clientInfo: { name: "test", version: "0.15.0" }, + }); + assert.equal(await boot.open(), 0); + assert.equal(router.resolveBackend(IPC.invoke.sessionGet, [{ id: "remote:h:s" }]), null); + await boot.closeAll(); + await cleanup(); +}); + +test("open connects each paired host and registers a backend per listed session", async () => { + const { dir, cleanup } = await tmpDir(); + const encryption = reversibleEncryption(); + const registry = createRemoteHostRegistry({ dataDir: dir, encryption }); + await registry.upsert({ + hostKey: "hostA", + label: "A", + url: "wss://a", + deviceToken: "t-a", + }); + await registry.upsert({ + hostKey: "hostB", + label: "B", + url: "wss://b", + deviceToken: "t-b", + }); + + const adaptersByHost = new Map(); + const router = createBackendRouter(); + const events = []; + const boot = createRemoteHostsBoot({ + dataDir: dir, + encryption, + router, + emit: (channel, payload) => events.push({ channel, payload }), + clientInfo: { name: "test", version: "0.15.0" }, + buildAdapter: (record) => { + const adapter = fakeAdapter({ + sessions: [makeSession(`s-${record.hostKey}`)], + }); + adaptersByHost.set(record.hostKey, adapter); + return adapter; + }, + }); + + const opened = await boot.open(); + assert.equal(opened, 2); + assert.ok( + router.resolveBackend(IPC.invoke.sessionGet, [ + { id: makeRemoteSessionId("hostA", "s-hostA") }, + ]), + ); + assert.ok( + router.resolveBackend(IPC.invoke.sessionGet, [ + { id: makeRemoteSessionId("hostB", "s-hostB") }, + ]), + ); + + await boot.closeAll(); + for (const adapter of adaptersByHost.values()) { + assert.equal(adapter.state, "disconnected"); + } + await cleanup(); +}); + +test("a host whose connect fails is logged and skipped without killing the others", async () => { + const { dir, cleanup } = await tmpDir(); + const encryption = reversibleEncryption(); + const registry = createRemoteHostRegistry({ dataDir: dir, encryption }); + await registry.upsert({ hostKey: "bad", label: "Bad", url: "wss://bad", deviceToken: "t" }); + await registry.upsert({ hostKey: "good", label: "Good", url: "wss://good", deviceToken: "t" }); + const router = createBackendRouter(); + const warnings = []; + const boot = createRemoteHostsBoot({ + dataDir: dir, + encryption, + router, + emit: () => undefined, + clientInfo: { name: "test", version: "0.15.0" }, + log: (level, message) => { + if (level === "warn") warnings.push(message); + }, + buildAdapter: (record) => + record.hostKey === "bad" + ? fakeAdapter({ connectRejects: Object.assign(new Error("no route"), { code: "REMOTE_CONNECTION_FAILED" }) }) + : fakeAdapter({ sessions: [makeSession(`s-${record.hostKey}`)] }), + }); + const opened = await boot.open(); + assert.equal(opened, 1); + assert.ok(warnings.some((message) => /bad failed to open/.test(message))); + assert.ok( + router.resolveBackend(IPC.invoke.sessionGet, [ + { id: makeRemoteSessionId("good", "s-good") }, + ]), + ); + await boot.closeAll(); + await cleanup(); +}); + +test("closeAll is idempotent and safe to call before open", async () => { + const { dir, cleanup } = await tmpDir(); + const boot = createRemoteHostsBoot({ + dataDir: dir, + encryption: reversibleEncryption(), + router: createBackendRouter(), + emit: () => undefined, + clientInfo: { name: "test", version: "0.15.0" }, + }); + await boot.closeAll(); + await boot.closeAll(); + await cleanup(); +}); diff --git a/apps/desktop/test/rpc-lifecycle-contract.test.mjs b/apps/desktop/test/rpc-lifecycle-contract.test.mjs index 40c1078240..6f3570d7d8 100644 --- a/apps/desktop/test/rpc-lifecycle-contract.test.mjs +++ b/apps/desktop/test/rpc-lifecycle-contract.test.mjs @@ -227,7 +227,7 @@ test("app quit waits for one idempotent teardown before allowing the follow-up q assert.match(shutdownSource, /await hostShutdown/); assert.match( shutdownSource, - /await Promise\.allSettled\(\[\s*pluginPanelShutdown,\s*pluginShutdown,\s*sidecarShutdown,\s*mcpShutdown,\s*\]\)/, + /await Promise\.allSettled\(\[\s*pluginPanelShutdown,\s*pluginShutdown,\s*sidecarShutdown,\s*mcpShutdown,\s*remoteHostsShutdown,\s*\]\)/, ); const releaseQuit = shutdownSource.match( /const releaseQuit = \(\) => \{[\s\S]*?shutdownComplete = true;[\s\S]*?app\.quit\(\);[\s\S]*?\};/, From aa7df468f0b660ff62bab9fa0ae1309f3dca2bf3 Mon Sep 17 00:00:00 2001 From: vastsa Date: Fri, 18 Sep 2026 18:28:10 +0800 Subject: [PATCH 13/13] docs(remote): ADR 0285 and R2 rollout note for the desktop kernel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0285 records the decisions D449 locks in for the R2 desktop-side kernel: the router seam, the transport-agnostic backend, the two synthesis rules that reconcile schema mismatches (tool-approval requestId encoding, plansResolve placeholder), the pure event bridge, the coordinator, the multi-listener client seam, the encrypted-at-rest registry, and the boot hook. The Consequences section calls out the new workspace dep on `@pi-desktop/racp`, the module-level startup/shutdown handle chosen to keep `index.ts` under its 1500-LOC ceiling, and the seams that Stage 4–7 will plug into without revisiting transport. `06-delivery/07-remote-control-rollout.md` gains a short preamble on the R2 section splitting into R2a (delivered, kernel) and R2b (pairing, SSH bootstrap, terminal, reverse tool relay). Exit criteria are unchanged and stay bound to R2b. The zh-CN mirror gets the same split as an ordered sub-list under R2 so `docs:check` and `check:locales` stay green. --- docs/adr/0285-remote-host-desktop-kernel.md | 213 ++++++++++++++++++ docs/adr/README.md | 1 + .../06-delivery/07-remote-control-rollout.md | 15 ++ .../06-delivery/07-remote-control-rollout.md | 10 +- 4 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0285-remote-host-desktop-kernel.md diff --git a/docs/adr/0285-remote-host-desktop-kernel.md b/docs/adr/0285-remote-host-desktop-kernel.md new file mode 100644 index 0000000000..9f40dc6f41 --- /dev/null +++ b/docs/adr/0285-remote-host-desktop-kernel.md @@ -0,0 +1,213 @@ +# ADR 0285: Remote-host desktop kernel + +- Status: Accepted for implementation +- Date: 2026-09-18 +- Decision: D449 +- Related: ADR 0205 (D373 / D374 / D375), ADR 0284 (D448), + `03-runtime/19-remote-agent-control-protocol.md` §3.4, §4, §5, §7, §8, + `05-security/02-remote-control-security.md` §3.4, + `06-delivery/07-remote-control-rollout.md` §2 R2 + +## Context + +ADR 0284 delivered the `RACP-WS` transport and pairing on both ends. The +`pi-host` bundle now binds a real WebSocket server on loopback and speaks the +frozen contract; a `RacpClient` in `packages/racp` reaches it with header +authentication. What was still missing on the desktop side of R2 was the +kernel — the code that lets the renderer treat a remote session exactly the +way it treats a local one (spec §3.4). Without it, the transport can only be +observed from tests. + +Three constraints shaped the kernel: + +- The renderer must never learn the transport. Every existing per-session IPC + call — 17 channels covering agent, session, tool approval, ask-tool, and + plan resolution — has one call site in `apps/desktop/src/lib/api.ts` that + cannot know whether the answer came from `host-core` or from a paired host. +- The frozen architecture pins `apps/desktop/electron/main/index.ts` at + 1500 LOC (`scripts/check-architecture.mjs`). Every new module must live + outside it; wiring must fit the existing composition root. +- The desktop cannot open a network listener of its own (security §7). All + outbound flows go through the RACP client to a host the user paired with, + and every registration is per-session so a lost host cannot silently + hijack a local session id. + +## Decision + +The desktop-side R2 kernel is five modules and one boot hook, all under +`apps/desktop/electron/main/remote/` (module state) and +`apps/desktop/electron/main/bootstrap/` (boot state), and one new workspace +dependency (`@pi-desktop/racp`). + +1. **A single interception seam: `backend-router.ts`.** The router is + consulted from `ipc/register.ts`'s `handle()` wrapper; it returns the + sentinel `ROUTE_LOCAL` when no `RemoteBackend` is registered for the call's + session id, and the local handler runs unchanged. A session becomes remote + only once its renderer-visible id (`remote::`, + mirroring `native-pi:`) has an explicit registration. This makes remote + support byte-for-byte compatible when disabled and prevents accidental + routing of a local id. + +2. **A stateless per-request session id.** `sessionIdForCall` recovers the + session from either the first positional argument, `first.sessionId`, + `first.id` (`sessionGet` uses this shape), or a + `#racp-approval:` requestId used by tool + approval. This last one lets `toolResolvePermission` route without a + correlation map: the router encodes the session into the id it hands the + renderer and decodes it when the renderer echoes the id back. + +3. **A transport-agnostic backend: `remote-backend.ts`.** One + `createRemoteBackend({hostKey, client, ...})` instance serves every session + of a paired host — the router registers it under each id. The backend + translates 17 channels to RACP requests and reshapes the results back into + the exact response shapes `apps/desktop/src/lib/api.ts` already returns for + the local handler. Channels the remote profile does not cover + (`agentSteer`, attachments, desktop-only settings) either return false from + `handles()` or throw `CAPABILITY_UNAVAILABLE`, so those calls fall back to + the local handler while the session's transcript stays remote. + +4. **Two synthesis rules to reconcile schema mismatches without a + round-trip.** + - Tool-approval resolution has no session id in its wire payload, so the + backend encodes `#racp-approval:` into + the requestId (§ Decision 2) and decodes it back for `approval/respond`. + - `plansResolve` returns `PlanResolutionResult` to the renderer, but RACP's + `approval/respond` returns only `RacpApprovalResult`. The backend + synthesizes a minimal `PlanProposal` from the request identity to dismiss + the card optimistically; the authoritative snapshot arrives on the + follow-up `session.changed` event and replaces the placeholder. + +5. **A pure event bridge: `remote-event-bridge.ts`.** RACP events are + translated into `IPC.event.agentMessage` / `IPC.event.sessionsChanged` for + the renderer. Item/turn/tool payloads already carry a local `AgentEvent` + in `payload.event` and forward verbatim under an `AgentEventEnvelope` keyed + by the remote session id. `approval.requested` of kind `tool` becomes a + local `tool_permission_request` with the encoded requestId; plan and goal + approvals ride the following `planning_state` event and are dropped. An + `onLifecycle` callback signals `session.created`/`archived` for the router + without a second subscription to the same stream. + +6. **A coordinator per paired host: + `remote-host-connection.ts`.** `createRemoteHostConnection({hostKey, + client, router, emit})` composes the backend, the bridge, and the router. + Its `open()` sequence closes the create-race between listing sessions and + receiving lifecycle events: attach listener → subscribe host scope → + `session/list` → per-session subscribes. `close()` unregisters every + session and drops internal state; it is idempotent and safe to call before + `open()`. + +7. **A `RemoteHostClient` seam with multi-listener `subscribe`.** The + connection consumes `{request, subscribe}`. `packages/racp` exposes + `RacpClient.onEvent` as a single-slot construction option, which is not + enough for a bridge + resync watchdog + later features. `racp-remote-host-client.ts` + is the only file in `electron/main/remote/` that imports + `@pi-desktop/racp`; it wraps the client, fans out its callback to every + `subscribe()` listener, and swallows listener throws so a bad subscriber + cannot silence the others. + +8. **Encrypted-at-rest registry: `remote-host-registry.ts`.** Paired hosts + live in `/remote-hosts.json`. Device tokens are encrypted with + Electron's `safeStorage` before write and decrypted on read; a stolen file + without keychain access reveals only URL and label. The registry is + injected with an `EncryptionPort` (isAvailable / encryptString / + decryptString) so Node-side tests supply a fake without pulling in + Electron. `upsert` refuses to write when the keychain is unavailable; + `list` drops any record it cannot decrypt rather than surfacing an empty + token that would auth-fail downstream. + +9. **Boot hook: `bootstrap/remote-hosts.ts`.** `createRemoteHostsBoot(...)` + reads the registry, opens one adapter + one connection per host, and + returns `{open, closeAll}`. Sequential open — a host's failure is logged + and skipped, not fatal. An empty registry (the default install) is a full + no-op: nothing connects, no backend registers, every renderer call keeps + hitting the local handler byte-for-byte. `bootstrap/startup.ts` calls + `open()` in the background so a slow host never delays the first window; + `bootstrap/shutdown.ts` calls `closeAll()` from the existing shutdown + promise so paired sockets are drained before host-core is torn down. A + single module-level `activeRemoteHostsBoot` handle bridges startup and + shutdown without expanding `index.ts` past its 1500-LOC ceiling. + +## Invariants the kernel keeps + +- **Renderer transport-agnosticism.** The renderer's `api.ts` code does not + mention "remote". Its session ids may be namespaced; every response shape it + parses is the local one. +- **Router-off default.** With no `RemoteBackend` registered, `route()` + returns `ROUTE_LOCAL` for every call and the existing handler runs + unchanged. Adding the kernel to a build without pairing is a zero-behavior + change. +- **Least privilege at rest.** No plaintext device token ever touches disk. + A `safeStorage` unavailable environment cannot write a token; it can still + read what was already written when that platform was available. +- **Bounded shutdown.** `closeAll` closes every paired socket inside the same + `Promise.allSettled` block that handles plugin, sidecar, and MCP disposals, + before `host-core` is disposed, so in-flight remote turns can send their + abort over a live socket. + +## Out of scope + +The kernel is complete for a paired host to answer renderer calls and stream +events. What is scheduled for later stages of R2: + +- **Pairing UX (renderer + IPC).** Settings surfaces to enter a URL and + pairing token, exchange it, and store the device token. The registry API + is ready for this; the surface is not. +- **SSH bootstrap (Stage 4).** A supervisor that detects system `ssh`, + downloads the `pi-host-bundle` (verified by SHA-256 from ADR 0284's + release pipeline), starts the remote binary, and opens the `-L` tunnel. + Every paired host today assumes the loopback URL already exists. +- **Terminal work-panel client (Stage 5).** RACP terminal events are dropped + by the event bridge; the work-panel session client will consume them. +- **Reverse tool relay (Stage 6).** A `RelayToolPort` bridge that lets the + agent host run local desktop tools against a remote session. Belongs on the + agent-host and pi-host, not on `packages/racp`. +- **Resync watchdog.** `resync.required` events are dropped today; the + connection layer will eventually rebuild subscriptions from the last + cursor per session (`RacpClient.cursorFor`). +- **Multi-listener contract.** `subscribe()` is used by exactly one consumer + today (the event bridge); Stage 3b's resync watchdog will be the second. + +## Alternatives considered + +- **Route from each per-domain IPC handler.** Every one of 17 handlers would + need to know about "remote" and duplicate the same dispatch. Rejected: + God-modules would grow, and any new channel would need to be wired in twice. +- **Bake remote knowledge into the renderer.** A `remote:` prefix visible to + the renderer forces `api.ts` to branch, and every store slice ends up + aware of the transport. Rejected by spec §3.4. +- **Have the connection layer own its own RacpClient construction.** It would + couple the coordinator to `packages/racp` and prevent unit tests from + running without a real client. Rejected in favour of the injected + `RemoteHostClient` seam. +- **A single-listener `RemoteHostClient`.** Simpler, but forces the resync + watchdog and the event bridge to share the same callback. Rejected: their + concerns are independent and their subscriptions should be too. +- **Plain-text registry.** Simpler read/write, but a compromised backup would + hand attackers a device token that authenticates against a real `pi-host`. + Rejected by security §3.4. + +## Testing + +Every module has a `node --test` fixture that exercises the seam in isolation +(fake RACP client, fake encryption, fake router). The RACP adapter runs +against the real in-memory harness (`@pi-desktop/racp/test-harness`), so its +fan-out and lifecycle contracts are checked against the same client the +production factory builds. The full desktop suite runs 2120+ tests with the +kernel on and every one passes; no `test:e2e:*` scenario is scheduled for the +kernel alone because it is dead code until pairing lands. + +## Consequences + +- The desktop can host a paired remote `pi-host` today; adding a URL and + device token to `/remote-hosts.json` (encrypted-at-rest through + `safeStorage`) makes the kernel connect, register sessions, and stream + events into the existing renderer, no other flag or setting required. +- `apps/desktop` now depends on `@pi-desktop/racp`, and the racp package + publishes a `./test-harness` export subpath. Both changes are additive. +- `apps/desktop/electron/main/index.ts` stays at exactly 1500 LOC. The + startup/shutdown bridge is a module-level handle inside + `bootstrap/remote-hosts.ts` — small, contained, and easy to remove once + R2b lets the wiring live inside a broader remote-hosts service object. +- Any Stage 4–7 work (SSH bootstrap, terminal work-panel client, reverse + tool relay, pairing UX) plugs into existing seams — the router, the event + bridge, the registry — and does not need to revisit the transport layer. diff --git a/docs/adr/README.md b/docs/adr/README.md index db123c60bb..cfc7b2a09e 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -311,4 +311,5 @@ Each ADR includes: | 0282 | [Retry and right-size the compaction summary before retained-tail recovery](0282-compaction-summary-retry-and-sizing.md) | Accepted (amends ADR 0049; issue #543) | | 0283 | [Headless runtime boundary in `packages/host-runtime`](0283-headless-runtime-boundary.md) | Accepted for implementation (D447; ADR 0205 R2 prerequisite) | | 0284 | [`RACP-WS` transport in `packages/racp`](0284-racp-ws-transport.md) | Accepted for implementation (D448; ADR 0205 R2) | +| 0285 | [Remote-host desktop kernel](0285-remote-host-desktop-kernel.md) | Accepted for implementation (D449; ADR 0205 R2) | | turn-process-and-thinking-display | [Turn process and thinking presentation](turn-process-and-thinking-display.md) | Accepted | diff --git a/docs/spec/06-delivery/07-remote-control-rollout.md b/docs/spec/06-delivery/07-remote-control-rollout.md index d7ea20f5c0..3cc3d8ec4b 100644 --- a/docs/spec/06-delivery/07-remote-control-rollout.md +++ b/docs/spec/06-delivery/07-remote-control-rollout.md @@ -104,6 +104,21 @@ match `electron/main/index.ts` by source pattern and must be repointed. Deliver the first remote topology (`02-architecture/05-remote-agent-control.md` §5.2): the desktop as Remote Client of a headless Host on another machine. +R2 lands in two ordered slices so the desktop-side kernel can ship, be +tested, and stay dead code until the full topology is ready: + +- **R2a — Desktop kernel (delivered, ADR 0285).** The single interception + seam, the transport-agnostic backend, the event bridge, the coordinator, an + encrypted-at-rest registry, and the boot hook. With an empty registry + (default install) the kernel is a full no-op; the router has no remote + backends, every renderer call still hits the local handler byte-for-byte. + Every subsystem has a `node --test` fixture that exercises it against a + fake — or, for the RACP adapter, against the real in-memory harness in + `@pi-desktop/racp/test-harness`. +- **R2b — Pairing, SSH bootstrap, terminal, and reverse tool relay.** The + remaining bullets below. R2's exit criteria stay unchanged and land with + R2b; R2a alone is not user-visible and does not attempt them. + Deliverables: - the `pi-host` bundle: the module, the Node pi sidecar, and the platform's diff --git a/docs/zh-CN/spec/06-delivery/07-remote-control-rollout.md b/docs/zh-CN/spec/06-delivery/07-remote-control-rollout.md index a7fece4ca3..a49a063b05 100644 --- a/docs/zh-CN/spec/06-delivery/07-remote-control-rollout.md +++ b/docs/zh-CN/spec/06-delivery/07-remote-control-rollout.md @@ -32,7 +32,15 @@ Linux 或 WSL 机器上的项目,即 SSH 隧道远端 Host 拓扑;#100 要 loopback RACP-WS 端点。抽取在在途版本发布后开始,`permissions.pending`、 Host 队列和模块抽取各自独立提交,因为仓库存在并发会话,且 54 个测试按源码 模式匹配 `electron/main/index.ts`,需要重新指向。 -- R2:SSH 隧道上的远端 Host。交付 `pi-host` 包(模块、Node pi sidecar 与平台 +- R2:SSH 隧道上的远端 Host。分两个有序切片交付,桌面侧内核先落地并保持关闭态: + - R2a — 桌面内核(已交付,见 ADR 0285):backend-router seam、传输无关的 remote-backend、 + event bridge、per-host coordinator、safeStorage 加密的 remote-host 注册表以及启动引导。 + 默认注册表为空即完整 no-op:router 无远程 backend,renderer 每次调用仍逐字节走本地 + handler。`node --test` 用 fake 或真实的 `@pi-desktop/racp/test-harness` 覆盖每个模块。 + - R2b — 配对、SSH 引导、终端、反向工具中继。R2 的出口条件保持不变,随 R2b 一并达成; + R2a 单独不面向用户,且不尝试完成这些条件。 + + 交付项:`pi-host` 包(模块、Node pi sidecar 与平台 host-core 二进制,与桌面同版本,只绑定 loopback,由桌面经 SSH 上传的引导脚本从 GitHub Releases 下载并校验公布的 SHA-256,再经该 SSH 会话启动并配对);位于 `lib/api.ts` 之下的桌面 RACP 客户端适配层,使远程会话像本地 一样渲染;转发端口上的 `RACP-WS` header profile,遵守安全规格的 loopback 规则