From 4bba2cf1d05b29dfe10bc7bbb7ec161cec3f3ed9 Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:33:42 +0000 Subject: [PATCH 01/28] fix(terminal): remove a closed card's run folder only after its launch files Closing a card started the run cleanup and the removal of runs/ at the same time, and a launch still being prepared (plugin prepare, environment wrap) could write run files after the folder was gone. A throw from the environment wrap also skipped the planned and contributed cleanup. Contributed launches are now tracked. Closing waits for pending launches, then the serialized exit/restart cleanups, then removes the run folders each pipeline created for this card object, and the card folder only when it is empty, so a card brought back under the same id keeps its new run. An unadopted launch releases its planned spawn and contribution in a finally block, and a launch whose card was closed or restarted in the same tick never asks its plugins. Run folders are removed with retries for files Windows still holds open. --- src/main/services/LaunchPipeline.ts | 41 +++- src/main/services/TerminalManager.ts | 313 +++++++++++++++------------ tests/launch-cleanup-order.test.mjs | 211 ++++++++++++++++++ tests/launch-file-lifecycle.test.mjs | 282 ++++++++++++++++++++++++ 4 files changed, 708 insertions(+), 139 deletions(-) create mode 100644 tests/launch-cleanup-order.test.mjs create mode 100644 tests/launch-file-lifecycle.test.mjs diff --git a/src/main/services/LaunchPipeline.ts b/src/main/services/LaunchPipeline.ts index 439370a5..f6adc53e 100644 --- a/src/main/services/LaunchPipeline.ts +++ b/src/main/services/LaunchPipeline.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { mkdir, rm, writeFile } from "node:fs/promises"; +import { mkdir, rm, rmdir, writeFile } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; import type { LaunchProfileId, @@ -73,6 +73,8 @@ export type PreparedLaunch = envSources: Record; /** A contributor runs the agent on another model than its vendor's: "auto" becomes accept-edits. */ thirdPartyModel: boolean; + /** This run's own folder below the card's folder; `cleanup` removes it. */ + runDirectory: string; cleanup(): Promise; } | { ok: false; reason: string }; @@ -118,9 +120,25 @@ export class LaunchPipeline { return rm(this.dependencies.runsRoot, { recursive: true, force: true }); } - /** Removes a closed session's file folders. */ - forgetSession(sessionId: string): Promise { - return rm(join(this.dependencies.runsRoot, safeSegment(sessionId)), { recursive: true, force: true }); + /** + * Removes a closed card's run folders that this pipeline prepared for it, then the card's folder if nothing else + * is left in it. A card brought back under the same id may already be writing a new run folder there; it is kept. + */ + async forgetSession(sessionId: string, runDirectories: Iterable): Promise { + const parent = join(this.dependencies.runsRoot, safeSegment(sessionId)); + const errors: unknown[] = []; + for (const runDirectory of new Set(runDirectories)) { + // Only a run folder directly below this card's folder is ever removed here. + if (dirname(resolve(runDirectory)) !== resolve(parent)) continue; + try { await removeRunFolder(runDirectory); } catch (error) { errors.push(error); } + } + try { + await rmdir(parent); + } catch (error) { + // Still in use by a newer run of the same card, or already gone; the startup sweep removes what is left. + if (!PARENT_KEPT_CODES.has((error as NodeJS.ErrnoException).code ?? "")) errors.push(error); + } + if (errors.length) throw new AggregateError(errors, "Launch run folders could not be removed."); } /** Launch policies that apply to this agent: every launch of it asks them, chosen or not. */ @@ -251,7 +269,7 @@ export class LaunchPipeline { ...policies.map((contributor) => this.ask(contributor, context, false)) ]); const runDirectory = join(this.dependencies.runsRoot, safeSegment(context.sessionId), randomUUID()); - const cleanup = (): Promise => rm(runDirectory, { recursive: true, force: true }); + const cleanup = (): Promise => removeRunFolder(runDirectory); const refuse = async (reason: string): Promise => { await cleanup().catch(() => undefined); return { ok: false, reason }; @@ -327,7 +345,7 @@ export class LaunchPipeline { args.push(...contributedArgs.map(expand)); thirdPartyModel ||= contribution.thirdPartyModel === true; } - return { ok: true, env, args, secrets, envSources, thirdPartyModel, cleanup }; + return { ok: true, env, args, secrets, envSources, thirdPartyModel, runDirectory, cleanup }; } private async ask( @@ -562,6 +580,17 @@ function unavailableReason(pluginId: string): string { return `Needs plugin ${pluginId} for its launch options; it is disabled, removed, or its native code is not trusted.`; } +/** Errors that leave a card's folder in place on purpose: another run still uses it, or it is gone. */ +const PARENT_KEPT_CODES = new Set(["ENOENT", "ENOTEMPTY", "EEXIST", "EBUSY", "EPERM"]); + +/** + * Removes one run folder. On Windows the agent may still hold a file there for a moment after its PTY exits, so + * busy/permission errors are retried briefly before giving up. + */ +function removeRunFolder(runDirectory: string): Promise { + return rm(runDirectory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +} + function safeSegment(value: string): string { return value.replace(/[^A-Za-z0-9._-]/g, "_").replace(/^\.+/, "_").slice(0, 128) || "_"; } diff --git a/src/main/services/TerminalManager.ts b/src/main/services/TerminalManager.ts index 2b70689d..3f310247 100644 --- a/src/main/services/TerminalManager.ts +++ b/src/main/services/TerminalManager.ts @@ -135,6 +135,15 @@ interface ManagedSession { launchToken: number; /** Removes the current run's plugin files; called when the process exits. */ launchCleanup: (() => Promise) | null; + /** Cleanup already started by an earlier exit; closing the card must drain it before deleting its parent. */ + launchCleanupPending?: Promise; + /** Whole contributed launches, including asynchronous preparation/wrapping and abandoned-run cleanup. */ + launchTasks?: Set>; + /** + * The pipelines that prepared launches for this card, each with the run folders it created for this card object, + * retained across host reconfiguration/restart. Closing removes only these, never a later card's under the same id. + */ + launchRunFolders?: Map, Set>; /** A restored grok card waits for its grid before launching; plugins still learn it is a restore. */ restoringLaunch: boolean; /** The environment was prepared or resumed in this run of the app, so it can be wrapped now. */ @@ -1130,6 +1139,37 @@ export class TerminalManager { } } + /** Serializes this card's run cleanup across exit/restart; callbacks retain their specific launch ownership. */ + private cleanupLaunchFiles(session: ManagedSession): Promise { + const cleanup = session.launchCleanup; + session.launchCleanup = null; + const previous = session.launchCleanupPending; + if (!cleanup) return previous ?? Promise.resolve(); + const pending = (async (): Promise => { + if (previous) await previous.catch(() => undefined); + await cleanup(); + })(); + session.launchCleanupPending = pending; + void pending.finally(() => { + if (session.launchCleanupPending === pending) delete session.launchCleanupPending; + }).catch(() => undefined); + return pending; + } + + /** Closing a card waits for producers of run files, not just the current process's adopted cleanup. */ + private async forgetLaunchFiles(session: ManagedSession): Promise { + while (session.launchTasks?.size) await Promise.allSettled([...session.launchTasks]); + try { await this.cleanupLaunchFiles(session); } + finally { + const errors: unknown[] = []; + // Different pipeline instances may share runsRoot; even their parent deletions must not overlap. + for (const [pipeline, runDirectories] of session.launchRunFolders ?? []) { + try { await pipeline.forgetSession(session.metadata.id, runDirectories); } catch (error) { errors.push(error); } + } + if (errors.length) throw new AggregateError(errors, "Launch file folders could not be removed."); + } + } + /** * Closes a card. A card in a plugin environment releases it: `keepEnvironmentData` is the person's * answer to "Keep environment data?" (kept unless they said no). Quitting releases nothing. @@ -1163,9 +1203,11 @@ export class TerminalManager { this.redaction.clear(`session:${id}`); this.releaseIsolation(id); session.launchToken += 1; - void session.launchCleanup?.().catch(() => undefined); - session.launchCleanup = null; - if (session.extras.options) void this.launchPipeline?.forgetSession(id).catch(() => undefined); + // launchToken and launch waiters were invalidated above: pending preparation/wrapping can now unwind before + // parent removal. Its original pipeline remains the owner even if the host configuration has since changed. + void this.forgetLaunchFiles(session).catch((error: unknown) => { + console.warn(`CanvasTTY could not remove the launch files of card ${id}.`, error); + }); session.agentBrowser?.cleanup(); session.agentRuntime?.cleanup(); session.agentOrchestration?.cleanup(); @@ -1872,7 +1914,9 @@ export class TerminalManager { ): void { const token = ++session.launchToken; const { metadata } = session; - void this.runContributedLaunch(id, session, token, resume, restoring, answerCaptureGrantExpiresAt) + const tasks = session.launchTasks ??= new Set(); + // Register before invoking plugin code, which may synchronously close or restart its card. + const task = Promise.resolve().then(() => this.runContributedLaunch(id, session, token, resume, restoring, answerCaptureGrantExpiresAt)) .catch((error: unknown): LaunchOutcome => { metadata.failureDetails = this.redactSecrets(`Launch refused: ${error instanceof Error ? error.message : String(error)}`); return "failed"; @@ -1886,7 +1930,9 @@ export class TerminalManager { this.emitSession(metadata, outcome === "failed" ? failureOrigin : null); this.schedulePersistence(); }) - .finally(() => this.wakeLaunchWaiters(session)); + .finally(() => { tasks.delete(task); this.wakeLaunchWaiters(session); }); + tasks.add(task); + void task.catch(() => undefined); } private async runContributedLaunch( @@ -1905,6 +1951,8 @@ export class TerminalManager { return "failed"; }; const environments = this.environments; + // The launch starts a microtask after it was registered; a card closed or restarted in that same tick is done. + if (!live()) return "superseded"; // 1. Place a new session where the person chose. The choice stays saved with the card until the plugin has // prepared it, so a card whose preparation was cut short (quit, crash) or failed never restores locally. @@ -1946,142 +1994,142 @@ export class TerminalManager { // 3. Chosen launch contributors, and the launch policies that apply. let contribution: LaunchContribution | null = null; - const trustedFolder = session.extras.environment ? undefined : this.personTrustedFolder(metadata.parentSessionId, metadata.cwd); - if (session.extras.options || this.policyApplies(metadata.provider)) { - const pipeline = this.launchPipeline; - if (!pipeline) return refuse(missingLaunchPlugins(Object.keys(session.extras.options ?? {}))); - const placedIn = session.extras.environment; - const prepared = await pipeline.prepare({ - sessionId: id, - provider: metadata.provider, - profile: metadata.profile, - role: metadata.role, - cwd: metadata.cwd, - ...(metadata.parentSessionId !== undefined ? { parentSessionId: metadata.parentSessionId } : {}), - restoring, - resume: resume !== null, - options: structuredClone(session.extras.options ?? {}) as Record>, - environment: placedIn ? { pluginId: placedIn.pluginId, kind: placedIn.kind } : null, - ...(trustedFolder ? { trustedFolder } : {}) - }); - if (!live()) { - if (prepared.ok) void prepared.cleanup().catch(() => undefined); - return "superseded"; + let adopted = false; + let plannedCleanup: (() => void) | undefined; + try { + const trustedFolder = session.extras.environment ? undefined : this.personTrustedFolder(metadata.parentSessionId, metadata.cwd); + if (session.extras.options || this.policyApplies(metadata.provider)) { + const pipeline = this.launchPipeline; + if (!pipeline) return refuse(missingLaunchPlugins(Object.keys(session.extras.options ?? {}))); + // This pipeline may write run files for the card, so closing it later removes its folder there. + const runFolders = session.launchRunFolders ??= new Map(); + const ownFolders = runFolders.get(pipeline) ?? new Set(); + runFolders.set(pipeline, ownFolders); + const placedIn = session.extras.environment; + const prepared = await pipeline.prepare({ + sessionId: id, + provider: metadata.provider, + profile: metadata.profile, + role: metadata.role, + cwd: metadata.cwd, + ...(metadata.parentSessionId !== undefined ? { parentSessionId: metadata.parentSessionId } : {}), + restoring, + resume: resume !== null, + options: structuredClone(session.extras.options ?? {}) as Record>, + environment: placedIn ? { pluginId: placedIn.pluginId, kind: placedIn.kind } : null, + ...(trustedFolder ? { trustedFolder } : {}) + }); + if (prepared.ok) { + contribution = prepared; + ownFolders.add(prepared.runDirectory); + } + if (!live()) return "superseded"; + if (!prepared.ok) return refuse(prepared.reason); + this.addLaunchSecrets(session, prepared.secrets); } - if (!prepared.ok) return refuse(prepared.reason); - contribution = prepared; - this.addLaunchSecrets(session, prepared.secrets); - } - const dropContribution = (): void => { - void contribution?.cleanup().catch(() => undefined); - }; - // 4. What the environment keeps of CanvasTTY's protection, and the isolation layer for this launch. - const keeps = environment ? this.environments?.keeps?.(environment) ?? {} : {}; - if (environment && keeps.launch !== true && metadata.profile !== "normal") { - dropContribution(); - return refuse(`${environment.label} does not pass the launch on unchanged (the plugin does not declare it), so the ${metadata.profile} profile's settings and CanvasTTY's hooks would not reach the agent there. Launch it in normal, or use an environment that keeps them.`); - } - const decision = this.launchIsolation(id, metadata.provider, metadata.profile, metadata.role, - environment ? { isolated: keeps.isolated === true, label: environment.label } : null); - if (decision.refuse) { - dropContribution(); - return refuse(decision.refuse); - } - if (environment && keeps.launch !== true) { - metadata.isolation = { ...(metadata.isolation ?? { state: "environment" }), - reason: `${metadata.isolation?.reason ? `${metadata.isolation.reason} ` : ""}Base protection and CanvasTTY's hooks do not reach the agent in ${environment.label}.` }; - } + // 4. What the environment keeps of CanvasTTY's protection, and the isolation layer for this launch. + const keeps = environment ? this.environments?.keeps?.(environment) ?? {} : {}; + if (environment && keeps.launch !== true && metadata.profile !== "normal") { + return refuse(`${environment.label} does not pass the launch on unchanged (the plugin does not declare it), so the ${metadata.profile} profile's settings and CanvasTTY's hooks would not reach the agent there. Launch it in normal, or use an environment that keeps them.`); + } + const decision = this.launchIsolation(id, metadata.provider, metadata.profile, metadata.role, + environment ? { isolated: keeps.isolated === true, label: environment.label } : null); + if (decision.refuse) { + return refuse(decision.refuse); + } + if (environment && keeps.launch !== true) { + metadata.isolation = { ...(metadata.isolation ?? { state: "environment" }), + reason: `${metadata.isolation?.reason ? `${metadata.isolation.reason} ` : ""}Base protection and CanvasTTY's hooks do not reach the agent in ${environment.label}.` }; + } - // 5. The host spawns the PTY; an environment only rewrites what is spawned, and the isolation layer wraps that. - let planned: PlannedSpawn | { failure: LaunchFailure }; - try { - planned = this.planSpawn(id, metadata.provider, decision.profile, metadata.cwd, resume, - session.captureResult, metadata.role, answerCaptureGrantExpiresAt, contribution, trustedFolder, Boolean(environment), decision.apply); - } catch (error) { - dropContribution(); - metadata.failureDetails = this.redactSecrets(error instanceof Error ? error.message : String(error)); - return "failed"; - } - if ("failure" in planned) { - dropContribution(); - applyLaunchFailure(metadata, planned.failure); - return "failed"; - } - const abandon = (): void => { - planned.cleanup(); - dropContribution(); - }; - this.noteConfiguredMode(id, metadata.provider, decision.profile, planned.env, planned.cwd); - let spawn: { command: string; args: string[] | string; cwd: string; env: Record } = planned; - if (environment && environments) { - if (typeof planned.args === "string") { - abandon(); - return refuse("this provider's Windows batch launcher cannot run in a plugin environment."); + // 5. The host spawns the PTY; an environment only rewrites what is spawned, and the isolation layer wraps that. + let planned: PlannedSpawn | { failure: LaunchFailure }; + try { + planned = this.planSpawn(id, metadata.provider, decision.profile, metadata.cwd, resume, + session.captureResult, metadata.role, answerCaptureGrantExpiresAt, contribution, trustedFolder, Boolean(environment), decision.apply); + } catch (error) { + metadata.failureDetails = this.redactSecrets(error instanceof Error ? error.message : String(error)); + return "failed"; } - const secretValues = new Set(contribution?.secrets ?? []); - const secretEnvNames = Object.keys(contribution?.env ?? {}).filter((key) => secretValues.has(contribution!.env[key]!)); - // The environment sees the launch's own variables, never CanvasTTY's reserved ones or secret values. - const visible = Object.fromEntries(Object.entries(planned.launchEnvironment) - .filter(([key]) => !RESERVED_ENV.test(key) && !secretEnvNames.includes(key))); - const wrapped = await environments.wrap(environment, { - sessionId: id, - provider: metadata.provider, - launch: { command: planned.command, args: planned.args, env: visible, cwd: planned.cwd }, - secretEnvNames, - takenEnv: new Set(Object.keys(planned.launchEnvironment)), - path: launchSearchPath(planned.env) - }); - if (!live()) { - abandon(); - return "superseded"; + if ("failure" in planned) { + applyLaunchFailure(metadata, planned.failure); + return "failed"; } - if (!wrapped.ok) { - abandon(); - return refuse(wrapped.reason); + plannedCleanup = planned.cleanup; + this.noteConfiguredMode(id, metadata.provider, decision.profile, planned.env, planned.cwd); + let spawn: { command: string; args: string[] | string; cwd: string; env: Record } = planned; + if (environment && environments) { + if (typeof planned.args === "string") { + return refuse("this provider's Windows batch launcher cannot run in a plugin environment."); + } + const secretValues = new Set(contribution?.secrets ?? []); + const secretEnvNames = Object.keys(contribution?.env ?? {}).filter((key) => secretValues.has(contribution!.env[key]!)); + // The environment sees the launch's own variables, never CanvasTTY's reserved ones or secret values. + const visible = Object.fromEntries(Object.entries(planned.launchEnvironment) + .filter(([key]) => !RESERVED_ENV.test(key) && !secretEnvNames.includes(key))); + const wrapped = await environments.wrap(environment, { + sessionId: id, + provider: metadata.provider, + launch: { command: planned.command, args: planned.args, env: visible, cwd: planned.cwd }, + secretEnvNames, + takenEnv: new Set(Object.keys(planned.launchEnvironment)), + path: launchSearchPath(planned.env) + }); + if (!live()) { + return "superseded"; + } + if (!wrapped.ok) { + return refuse(wrapped.reason); + } + this.addLaunchSecrets(session, wrapped.secrets); + spawn = { command: wrapped.command, args: wrapped.args, cwd: wrapped.cwd, + env: { ...planned.env, ...(wrapped.cwd !== planned.cwd ? { PWD: wrapped.cwd } : {}), ...wrapped.env } }; } - this.addLaunchSecrets(session, wrapped.secrets); - spawn = { command: wrapped.command, args: wrapped.args, cwd: wrapped.cwd, - env: { ...planned.env, ...(wrapped.cwd !== planned.cwd ? { PWD: wrapped.cwd } : {}), ...wrapped.env } }; - } - if (decision.apply) { + if (decision.apply) { + try { + spawn = { ...this.wrapIsolated(id, metadata.provider, decision.profile, spawn), cwd: spawn.cwd }; + } catch (error) { + return refuse(error instanceof Error ? error.message : String(error)); + } + } + let process: IPty; try { - spawn = { ...this.wrapIsolated(id, metadata.provider, decision.profile, spawn), cwd: spawn.cwd }; + process = this.spawnPty(spawn.command, spawn.args, { + name: "xterm-256color", cols: session.cols, rows: session.rows, cwd: spawn.cwd, env: spawn.env + }); } catch (error) { - abandon(); - return refuse(error instanceof Error ? error.message : String(error)); + this.releaseIsolation(id); + metadata.failureDetails = this.redactSecrets(error instanceof Error ? error.message : String(error)); + return "failed"; + } + session.process = process; + if (planned.launchEnvironment.CANVASTTY_CODEX_KEYBOARD) { + metadata.nativeEditor = JSON.parse(planned.launchEnvironment.CANVASTTY_CODEX_KEYBOARD); + } else { + delete metadata.nativeEditor; + } + this.launchContexts.set(id, { cwd: spawn.cwd, configDir: spawn.env.CLAUDE_CONFIG_DIR ?? null }); + session.agentBrowser = planned.agentBrowser; + session.agentRuntime = planned.agentRuntime; + session.agentOrchestration = planned.agentOrchestration; + session.launchCleanup = contribution?.cleanup ?? null; + adopted = true; + metadata.status = initialSessionStatus(metadata.provider); + metadata.exitCode = null; + metadata.failureDetails = null; + this.bindProcess(id, session, process); + const runtimeStatus = this.agentRuntime?.currentStatus(id); + if (runtimeStatus) metadata.status = runtimeStatus; + if (environment) this.describeEnvironment(id, session, environment); + return "launched"; + } finally { + // Unadopted resources remain owned by this tracked launch, including throws during plugin wrapping. + if (!adopted) { + try { plannedCleanup?.(); } + finally { await contribution?.cleanup().catch(() => undefined); } } } - let process: IPty; - try { - process = this.spawnPty(spawn.command, spawn.args, { - name: "xterm-256color", cols: session.cols, rows: session.rows, cwd: spawn.cwd, env: spawn.env - }); - } catch (error) { - abandon(); - this.releaseIsolation(id); - metadata.failureDetails = this.redactSecrets(error instanceof Error ? error.message : String(error)); - return "failed"; - } - session.process = process; - if (planned.launchEnvironment.CANVASTTY_CODEX_KEYBOARD) { - metadata.nativeEditor = JSON.parse(planned.launchEnvironment.CANVASTTY_CODEX_KEYBOARD); - } else { - delete metadata.nativeEditor; - } - this.launchContexts.set(id, { cwd: spawn.cwd, configDir: spawn.env.CLAUDE_CONFIG_DIR ?? null }); - session.agentBrowser = planned.agentBrowser; - session.agentRuntime = planned.agentRuntime; - session.agentOrchestration = planned.agentOrchestration; - session.launchCleanup = contribution?.cleanup ?? null; - metadata.status = initialSessionStatus(metadata.provider); - metadata.exitCode = null; - metadata.failureDetails = null; - this.bindProcess(id, session, process); - const runtimeStatus = this.agentRuntime?.currentStatus(id); - if (runtimeStatus) metadata.status = runtimeStatus; - if (environment) this.describeEnvironment(id, session, environment); - return "launched"; } private addLaunchSecrets(session: ManagedSession, secrets: readonly string[]): void { @@ -2147,8 +2195,7 @@ export class TerminalManager { current.agentRuntime = null; current.agentOrchestration?.cleanup(); current.agentOrchestration = null; - void current.launchCleanup?.().catch(() => undefined); - current.launchCleanup = null; + void this.cleanupLaunchFiles(current).catch(() => undefined); this.emitSession(current.metadata); // Recorded at the moment of exit, so a finished agent is never relaunched. this.schedulePersistence(); diff --git a/tests/launch-cleanup-order.test.mjs b/tests/launch-cleanup-order.test.mjs new file mode 100644 index 00000000..f18e5c11 --- /dev/null +++ b/tests/launch-cleanup-order.test.mjs @@ -0,0 +1,211 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { LaunchPipeline } from "../src/main/services/LaunchPipeline.ts"; +import { TerminalManager } from "../src/main/services/TerminalManager.ts"; +import { availableRegistry, fakeSpawner } from "./helpers/terminal.mjs"; + +function deferred() { + let resolve; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} + +function cleanupPipeline(runs, serviceCalls = []) { + return new LaunchPipeline({ + contributors: () => [{ + pluginId: "fixture.cleanup", + pluginName: "Cleanup fixture", + serviceId: "launch", + secrets: false, + launch: { fields: [{ key: "enabled", label: "Enabled", kind: "boolean", default: true }] } + }], + call: async (...args) => { + serviceCalls.push(args); + return { env: { FIXTURE_FILE: "{launchFiles}/run.txt" }, files: [{ relPath: "run.txt", content: "temporary launch data" }] }; + }, + secret: async () => null, + runsRoot: runs, + timeoutMs: 2000 + }); +} + +const createCard = (terminals, cwd) => terminals.create({ + provider: "codex", + profile: "normal", + cwd, + position: { x: 0, y: 0 }, + launchOptions: { "fixture.cleanup": { enabled: true } } +}); + +/** + * One card with a launch plugin. Each run's cleanup is held until the test releases it, and every step of the + * child (run folder) and parent (card folder) removals is recorded in `events`. + */ +async function fixture(t, { rejectChild = false } = {}) { + const root = await mkdtemp(join(tmpdir(), "ctty-cleanup-order-")); + const runs = join(root, "runs"); + const rows = []; + const calls = []; + const events = []; + const spawned = [deferred(), deferred()]; + const forgotten = deferred(); + const pipeline = cleanupPipeline(runs); + const prepare = pipeline.prepare.bind(pipeline); + const forget = pipeline.forgetSession.bind(pipeline); + pipeline.prepare = async (context) => { + const contribution = await prepare(context); + assert.equal(contribution.ok, true); + const cleanup = contribution.cleanup; + const index = rows.length; + const row = { started: deferred(), release: deferred(), calls: 0 }; + rows.push(row); + contribution.cleanup = async () => { + row.calls++; + events.push(`child${index}:start`); + row.started.resolve(); + try { + await row.release.promise; + if (rejectChild) throw new Error("fixture child cleanup failure"); + await cleanup(); + } finally { + events.push(`child${index}:settled`); + } + }; + return contribution; + }; + let forgetCalls = 0; + pipeline.forgetSession = (id, runDirectories) => { + forgetCalls++; + events.push("parent:start"); + const pending = forget(id, runDirectories).then(() => { events.push("parent:done"); }); + forgotten.resolve(pending); + return pending; + }; + const fake = fakeSpawner(calls); + const terminals = new TerminalManager(() => undefined, availableRegistry(), undefined, undefined, true, (...args) => { + const pty = fake(...args); + spawned[calls.length - 1]?.resolve(); + return pty; + }); + terminals.configureLaunchPipeline(pipeline); + t.after(async () => { + for (const row of rows) row.release.resolve(); + terminals.disposeAll(); + await forgotten.promise; + await rm(root, { recursive: true, force: true }); + }); + const session = createCard(terminals, root); + await spawned[0].promise; + return { root, runs, rows, calls, events, spawned, forgotten, terminals, session, forgetCalls: () => forgetCalls }; +} + +for (const exited of [false, true]) { + for (const rejectChild of [false, true]) { + test(`parent launch cleanup waits for ${exited ? "previous exit" : "active disposal"} cleanup (${rejectChild ? "rejection" : "success"})`, { timeout: 5000 }, async (t) => { + const f = await fixture(t, { rejectChild }); + if (exited) f.calls[0].process.emitExit(1); + f.terminals.dispose(f.session.id); + f.terminals.dispose(f.session.id); + await f.rows[0].started.promise; + assert.equal(f.forgetCalls(), 0, "parent removal must not overlap a held child removal"); + assert.deepEqual(f.events, ["child0:start"]); + + f.rows[0].release.resolve(); + await f.forgotten.promise; + assert.deepEqual(f.events, ["child0:start", "child0:settled", "parent:start", "parent:done"]); + assert.equal(f.rows[0].calls, 1); + assert.equal(f.forgetCalls(), 1, "disposal remains idempotent"); + await assert.rejects(stat(join(f.runs, f.session.id)), { code: "ENOENT" }, "parent still cleaned after child failure"); + }); + } +} + +test("restart retains every pending run cleanup and invokes each captured callback once before forgetting the card", { timeout: 5000 }, async (t) => { + const f = await fixture(t); + f.calls[0].process.emitExit(1); + await f.rows[0].started.promise; + f.terminals.restart(f.session.id); + await f.spawned[1].promise; + assert.equal(f.rows.length, 2); + + f.terminals.dispose(f.session.id); + assert.equal(f.rows[1].calls, 0, "the newer cleanup queues behind the old launch callback"); + assert.equal(f.forgetCalls(), 0); + // A late notification from the old PTY must neither consume nor overwrite the new launch cleanup. + f.calls[0].process.emitExit(1); + f.rows[0].release.resolve(); + await f.rows[1].started.promise; + assert.equal(f.forgetCalls(), 0, "parent waits for both run directories"); + + f.rows[1].release.resolve(); + await f.forgotten.promise; + assert.deepEqual(f.events, ["child0:start", "child0:settled", "child1:start", "child1:settled", "parent:start", "parent:done"]); + assert.deepEqual(f.rows.map((row) => row.calls), [1, 1]); + assert.equal(f.forgetCalls(), 1); + await assert.rejects(stat(join(f.runs, f.session.id)), { code: "ENOENT" }); +}); + +test("a launch for a card closed in the same tick never asks its plugins or spawns", { timeout: 5000 }, async (t) => { + const root = await mkdtemp(join(tmpdir(), "ctty-cleanup-order-")); + const serviceCalls = []; + const calls = []; + const pipeline = cleanupPipeline(join(root, "runs"), serviceCalls); + const forgotten = []; + const forget = pipeline.forgetSession.bind(pipeline); + pipeline.forgetSession = (...args) => { + const pending = forget(...args); + forgotten.push(pending); + return pending; + }; + const terminals = new TerminalManager(() => undefined, availableRegistry(), undefined, undefined, true, fakeSpawner(calls)); + terminals.configureLaunchPipeline(pipeline); + t.after(async () => { + terminals.disposeAll(); + await rm(root, { recursive: true, force: true }); + }); + + const session = createCard(terminals, root); + const internal = terminals.sessions.get(session.id); + const tasks = [...internal.launchTasks]; + terminals.dispose(session.id); + await Promise.allSettled(tasks); + await Promise.allSettled(forgotten); + + assert.equal(serviceCalls.length, 0, "no plugin prepares a launch for a card that is already closed"); + assert.equal(calls.length, 0); + await assert.rejects(stat(join(root, "runs", session.id)), { code: "ENOENT" }); +}); + +test("a failed launch-file removal at close is reported with the card id", { timeout: 5000 }, async (t) => { + const root = await mkdtemp(join(tmpdir(), "ctty-cleanup-order-")); + const calls = []; + const pipeline = cleanupPipeline(join(root, "runs")); + const failure = new Error("fixture forget failure"); + const forgotten = deferred(); + pipeline.forgetSession = async () => { + forgotten.resolve(); + throw failure; + }; + const warnings = []; + t.mock.method(console, "warn", (...args) => { warnings.push(args); }); + const terminals = new TerminalManager(() => undefined, availableRegistry(), undefined, undefined, true, fakeSpawner(calls)); + terminals.configureLaunchPipeline(pipeline); + t.after(async () => { + terminals.disposeAll(); + await rm(root, { recursive: true, force: true }); + }); + + const session = createCard(terminals, root); + await Promise.allSettled([...terminals.sessions.get(session.id).launchTasks]); + terminals.dispose(session.id); + await forgotten.promise; + for (let i = 0; i < 20 && !warnings.length; i++) await new Promise((done) => setImmediate(done)); + + const warning = warnings.find(([message]) => String(message).includes(session.id)); + assert.ok(warning, "the close reports which card's launch files stayed behind"); + assert.ok(warning[1] instanceof AggregateError); + assert.deepEqual(warning[1].errors, [failure]); +}); diff --git a/tests/launch-file-lifecycle.test.mjs b/tests/launch-file-lifecycle.test.mjs new file mode 100644 index 00000000..7131f2ff --- /dev/null +++ b/tests/launch-file-lifecycle.test.mjs @@ -0,0 +1,282 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readdir, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import test from "node:test"; +import { EnvironmentRegistry } from "../src/main/services/EnvironmentRegistry.ts"; +import { LaunchPipeline } from "../src/main/services/LaunchPipeline.ts"; +import { TerminalManager } from "../src/main/services/TerminalManager.ts"; +import { persistedTerminalSession } from "../src/main/services/TerminalSessionStore.ts"; +import { availableRegistry, fakeSpawner } from "./helpers/terminal.mjs"; + +function deferred() { + let resolve; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} + +async function fixture(t, { spawnFailure = false } = {}) { + const root = await mkdtemp(join(tmpdir(), "ctty-launch-files-")); + const runs = join(root, "runs"); + const calls = []; + const pipelines = []; + const gates = []; + const disposals = []; + const fake = fakeSpawner(calls); + const terminals = new TerminalManager(() => undefined, availableRegistry(), undefined, undefined, true, (...args) => { + if (spawnFailure) throw new Error("fixture PTY spawn failure"); + return fake(...args); + }); + // Closing a card starts its run-file cleanup in the background; the tests await that same promise. + const forget = terminals.forgetLaunchFiles.bind(terminals); + terminals.forgetLaunchFiles = (session) => { + const pending = forget(session); + disposals.push(pending); + return pending; + }; + const gate = () => { + const result = deferred(); + gates.push(result); + return result; + }; + + function pipeline({ holdPrepare = false, holdForget = false, failCleanup = false } = {}) { + const started = deferred(); + const release = gate(); + const forgetStarted = deferred(); + const forgetRelease = gate(); + const events = []; + const rows = []; + if (!holdPrepare) release.resolve(); + if (!holdForget) forgetRelease.resolve(); + const value = new LaunchPipeline({ + contributors: () => [{ + pluginId: "fixture.files", + pluginName: "Files", + serviceId: "launch", + secrets: false, + launch: { fields: [{ key: "enabled", label: "Enabled", kind: "boolean" }] } + }], + call: async () => { + started.resolve(); + await release.promise; + return { env: { FIXTURE_FILE: "{launchFiles}/run.txt" }, files: [{ relPath: "run.txt", content: "temporary" }] }; + }, + secret: async () => null, + runsRoot: runs, + timeoutMs: 4000 + }); + const prepare = value.prepare.bind(value); + const remove = value.forgetSession.bind(value); + value.prepare = async (context) => { + const result = await prepare(context); + assert.equal(result.ok, true); + const row = { file: result.env.FIXTURE_FILE, runDirectory: result.runDirectory, cleanupCalls: 0 }; + const cleanup = result.cleanup; + rows.push(row); + result.cleanup = async () => { + row.cleanupCalls++; + events.push("child"); + if (failCleanup) throw new Error("fixture cleanup failure"); + await cleanup(); + }; + return result; + }; + value.forgetSession = async (id, runDirectories) => { + events.push("parent:start"); + forgetStarted.resolve(); + await forgetRelease.promise; + await remove(id, runDirectories); + events.push("parent:done"); + }; + const result = { value, started, release, forgetStarted, forgetRelease, events, rows }; + pipelines.push(result); + return result; + } + + const create = (extra = {}) => terminals.create({ + provider: "codex", + profile: "normal", + cwd: root, + position: { x: 0, y: 0 }, + launchOptions: { "fixture.files": { enabled: true } }, + ...extra + }); + const drain = async () => { await Promise.allSettled(disposals); }; + t.after(async () => { + for (const item of gates) item.resolve(); + terminals.disposeAll(); + await drain(); + await rm(root, { recursive: true, force: true }); + }); + return { root, runs, calls, terminals, pipeline, create, gate, drain, disposals }; +} + +for (const failCleanup of [false, true]) { + test(`closing during preparation drains its late files before parent deletion (${failCleanup ? "failed" : "successful"} child cleanup)`, { timeout: 5000 }, async (t) => { + const f = await fixture(t); + const p = f.pipeline({ holdPrepare: true, failCleanup }); + f.terminals.configureLaunchPipeline(p.value); + const session = f.create(); + await p.started.promise; + f.terminals.dispose(session.id); + assert.deepEqual(p.events, []); + + p.release.resolve(); + await f.drain(); + assert.deepEqual(p.events, ["child", "parent:start", "parent:done"]); + assert.equal(p.rows[0].cleanupCalls, 1); + assert.equal(f.calls.length, 0); + // A failed child cleanup is retried by the parent step, which still removes only this card's own run folder. + await assert.rejects(stat(join(f.runs, session.id)), { code: "ENOENT" }); + }); +} + +test("restart tracks both pending preparations and sequentially forgets their original pipelines after reconfiguration", { timeout: 5000 }, async (t) => { + const f = await fixture(t); + const a = f.pipeline({ holdPrepare: true, holdForget: true }); + const b = f.pipeline({ holdPrepare: true }); + const c = f.pipeline(); + f.terminals.configureLaunchPipeline(a.value); + const session = f.create(); + await a.started.promise; + // Force a restart while its original plugin response is still in flight, as restoration can do. + const internal = f.terminals.sessions.get(session.id); + internal.metadata.status = "failed"; + internal.metadata.exitCode = 1; + f.terminals.configureLaunchPipeline(b.value); + f.terminals.restart(session.id); + await b.started.promise; + f.terminals.configureLaunchPipeline(c.value); + f.terminals.dispose(session.id); + + a.release.resolve(); + await Promise.resolve(); + assert.deepEqual(a.events, [], "parent cannot precede pending preparation"); + b.release.resolve(); + await a.forgetStarted.promise; + assert.deepEqual(a.events, ["child", "parent:start"]); + assert.deepEqual(b.events, ["child"], "same parent cannot be removed concurrently by another pipeline"); + assert.deepEqual(c.events, [], "current configuration is not the owner"); + + a.forgetRelease.resolve(); + await f.drain(); + assert.deepEqual(b.events, ["child", "parent:start", "parent:done"]); + assert.deepEqual([a.rows[0].cleanupCalls, b.rows[0].cleanupCalls], [1, 1]); + assert.equal(f.calls.length, 0); + await assert.rejects(stat(join(f.runs, session.id)), { code: "ENOENT" }); +}); + +for (const outcome of ["success", "refusal", "throw"]) { + test(`close while environment wrapping ${outcome} releases planned capability and files once`, { timeout: 5000 }, async (t) => { + const f = await fixture(t); + const p = f.pipeline(); + f.terminals.configureLaunchPipeline(p.value); + const wrapping = deferred(); + const release = f.gate(); + let plannedCleanups = 0; + const plan = f.terminals.planSpawn.bind(f.terminals); + f.terminals.planSpawn = (...args) => { + const result = plan(...args); + assert.ok(!("failure" in result)); + const cleanup = result.cleanup; + result.cleanup = () => { + plannedCleanups++; + cleanup(); + }; + return result; + }; + const registry = new EnvironmentRegistry({ + providers: () => [{ + pluginId: "fixture.env", + pluginName: "Environment", + serviceId: "env", + secrets: false, + kinds: [{ kind: "test", label: "Test", fields: [] }] + }], + call: async (_plugin, _service, method) => method.endsWith(".prepare") ? { ref: { id: "one" }, label: "Test" } : {}, + secret: async () => null + }); + registry.wrap = async (_environment, input) => { + wrapping.resolve(); + await release.promise; + if (outcome === "throw") throw new Error("fixture wrapper threw"); + if (outcome === "refusal") return { ok: false, reason: "fixture refused" }; + return { ok: true, ...input.launch, secrets: [] }; + }; + f.terminals.configureEnvironments(registry); + const session = f.create({ environment: { pluginId: "fixture.env", kind: "test" } }); + await wrapping.promise; + await stat(p.rows[0].file); + f.terminals.dispose(session.id); + assert.deepEqual(p.events, []); + + release.resolve(); + await f.drain(); + assert.equal(plannedCleanups, 1); + assert.equal(p.rows[0].cleanupCalls, 1); + assert.equal(f.calls.length, 0); + assert.deepEqual(p.events, ["child", "parent:start", "parent:done"]); + await assert.rejects(stat(join(f.runs, session.id)), { code: "ENOENT" }); + }); +} + +test("spawn refusal releases unadopted files and capabilities before eventual parent cleanup", { timeout: 5000 }, async (t) => { + const f = await fixture(t, { spawnFailure: true }); + const p = f.pipeline(); + f.terminals.configureLaunchPipeline(p.value); + let cleanups = 0; + const plan = f.terminals.planSpawn.bind(f.terminals); + f.terminals.planSpawn = (...args) => { + const result = plan(...args); + const cleanup = result.cleanup; + result.cleanup = () => { + cleanups++; + cleanup(); + }; + return result; + }; + const session = f.create(); + const internal = f.terminals.sessions.get(session.id); + await Promise.all([...internal.launchTasks]); + assert.equal(internal.metadata.status, "failed"); + assert.equal(cleanups, 1); + assert.equal(p.rows[0].cleanupCalls, 1); + assert.deepEqual(p.events, ["child"]); + + f.terminals.dispose(session.id); + await f.drain(); + assert.deepEqual(p.events, ["child", "parent:start", "parent:done"]); + await assert.rejects(stat(join(f.runs, session.id)), { code: "ENOENT" }); +}); + +test("a delayed close keeps the run files of a card brought back under the same id", { timeout: 5000 }, async (t) => { + const f = await fixture(t); + const p = f.pipeline({ holdForget: true }); + f.terminals.configureLaunchPipeline(p.value); + const session = f.create(); + const first = f.terminals.sessions.get(session.id); + await Promise.all([...first.launchTasks]); + assert.equal(f.calls.length, 1); + const record = persistedTerminalSession(first.metadata, first.threadId, first.extras); + const firstRun = p.rows[0].runDirectory; + + // Close the card; its folder removal is held, as a slow disk or a busy Windows file would hold it. + f.terminals.dispose(session.id); + await p.forgetStarted.promise; + + // The same card comes back (restore under its saved id) and prepares a new run before the old removal finishes. + await f.terminals.restoreSessionRecords([{ ...record, lastState: "running" }], "continue"); + const second = f.terminals.sessions.get(session.id); + assert.ok(second && second !== first); + await Promise.all([...(second.launchTasks ?? [])]); + assert.equal(p.rows.length, 2); + const secondRun = p.rows[1].runDirectory; + assert.notEqual(secondRun, firstRun); + + p.forgetRelease.resolve(); + await f.drain(); + await assert.rejects(stat(firstRun), { code: "ENOENT" }, "the closed card's own run folder is removed"); + await stat(p.rows[1].file); + assert.deepEqual(await readdir(join(f.runs, session.id)), [basename(secondRun)], "the newer card's run folder is kept"); +}); From b286d26d7861c1b5cf40d36768b89f62dbbecbe3 Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:34:00 +0000 Subject: [PATCH 02/28] fix(main): run every shutdown step even when one fails If closing the Even G2 companion threw, quitting skipped terminalManager.shutdown(), so PTYs were not hung up and sessions were not saved. Shutdown now runs its steps in order through runShutdownSteps, which logs a failed step and continues. --- src/main/index.ts | 39 ++++++++------- src/main/services/shutdownSteps.ts | 13 +++++ tests/shutdown-steps.test.mjs | 76 ++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 16 deletions(-) create mode 100644 src/main/services/shutdownSteps.ts create mode 100644 tests/shutdown-steps.test.mjs diff --git a/src/main/index.ts b/src/main/index.ts index b9832dce..da506ee4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -36,6 +36,7 @@ import { AgentChatHistoryService } from "./services/AgentChatHistoryService"; import { LimitsService, withoutAccountScope } from "./services/LimitsService"; import { UsageHistoryService } from "./services/UsageHistoryService"; import { LocalUsageCollector } from "./services/LocalUsageCollector"; +import { runShutdownSteps } from "./services/shutdownSteps"; import { createProviderCliRegistry, providerCliAvailability, @@ -1275,30 +1276,36 @@ void IPC.terminalData; async function shutdownServices(): Promise { diagnostics.record("info", "application", "shutdown.started"); - mascotManager?.dispose(); - agentChatHistory?.dispose(); - if (agentControl) await Promise.allSettled([agentControl.close()]); if (updateTimer) clearTimeout(updateTimer); if (updateInterval) clearInterval(updateInterval); for (const request of browserRequests.values()) { clearTimeout(request.timer); request.reject(new Error("App closing")); } browserRequests.clear(); - await evenG2?.close(); - if (terminalManager) await terminalManager.shutdown(); + // Each step runs even when an earlier one fails, so the terminals are always hung up and saved. + await runShutdownSteps([ + { name: "mascots", run: () => mascotManager?.dispose() }, + { name: "agent chat history", run: () => agentChatHistory?.dispose() }, + { name: "agent control", run: () => agentControl?.close() }, + { name: "Even G2", run: () => evenG2?.close() }, + { name: "terminal processes", run: async () => { if (terminalManager) await terminalManager.shutdown(); } } + ]); // The hung-up PTYs exit while the other services close; quitting waits for them (see waitForProcessExits). const ptyExits = terminalManager?.waitForProcessExits().then((left) => { if (left > 0) console.warn(`CanvasTTY quit with ${left} terminal process(es) that did not exit after SIGKILL.`); }); - // Stop the timer first, then abort in-flight limit requests so the final save is prompt. - const usageHistoryClosed = usageHistory?.dispose(); - limitsService?.dispose(); - await usageHistoryClosed; - if (agentGateway) await Promise.allSettled([agentGateway.close()]); - if (runtimeGateway) await Promise.allSettled([runtimeGateway.close()]); - if (browserService) await Promise.allSettled([browserService.dispose()]); - if (pluginServices) await Promise.allSettled([pluginServices.dispose()]); - if (pluginManager) await Promise.allSettled([pluginManager.dispose()]); - await handoffService?.dispose(); - if (materialService) await Promise.allSettled([materialService.dispose()]); + // Stop the usage-history timer first, then abort in-flight limit requests so its final save is prompt. + let usageHistoryClosed: Promise | undefined; + await runShutdownSteps([ + { name: "usage history", run: () => { usageHistoryClosed = usageHistory?.dispose(); } }, + { name: "limits", run: () => limitsService?.dispose() }, + { name: "usage history save", run: () => usageHistoryClosed }, + { name: "agent gateway", run: () => agentGateway?.close() }, + { name: "runtime gateway", run: () => runtimeGateway?.close() }, + { name: "browser", run: () => browserService?.dispose() }, + { name: "plugin services", run: () => pluginServices?.dispose() }, + { name: "plugins", run: () => pluginManager?.dispose() }, + { name: "handoff", run: () => handoffService?.dispose() }, + { name: "materials", run: () => materialService?.dispose() } + ]); await ptyExits; diagnostics.record("info", "application", "shutdown.completed"); await diagnostics.flush(); diff --git a/src/main/services/shutdownSteps.ts b/src/main/services/shutdownSteps.ts new file mode 100644 index 00000000..67024971 --- /dev/null +++ b/src/main/services/shutdownSteps.ts @@ -0,0 +1,13 @@ +export interface ShutdownStep { + name: string; + run(): void | Promise; +} + +/** Cleanup remains ordered, but one failed save must not skip independent services. */ +export async function runShutdownSteps(steps: readonly ShutdownStep[], + warn: (message: string, error: unknown) => void = console.warn): Promise { + for (const step of steps) { + try { await step.run(); } + catch (error) { warn(`CanvasTTY shutdown: ${step.name} failed.`, error); } + } +} diff --git a/tests/shutdown-steps.test.mjs b/tests/shutdown-steps.test.mjs new file mode 100644 index 00000000..fd0c46dd --- /dev/null +++ b/tests/shutdown-steps.test.mjs @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { runShutdownSteps } from "../src/main/services/shutdownSteps.ts"; + +test("a failed shutdown step does not skip the steps after it", async () => { + const calls = [], warnings = []; + const failure = new Error("fixture close failed"); + await assert.doesNotReject(runShutdownSteps([ + { name: "Even G2", run: async () => { calls.push("Even G2"); throw failure; } }, + { name: "sync step", run: () => { calls.push("sync step"); throw failure; } }, + ...["terminal processes", "browser", "plugin services", "plugins", "materials"].map(name => ({ name, run: async () => { calls.push(name); } })) + ], (message, error) => warnings.push({ message, error }))); + assert.deepEqual(calls, ["Even G2", "sync step", "terminal processes", "browser", "plugin services", "plugins", "materials"]); + assert.equal(warnings.length, 2); + assert.equal(warnings[0].error, failure); + assert.match(warnings[0].message, /Even G2 failed/u); +}); + +test("shutdown steps run in order and each waits for the previous one", async () => { + const events = []; + let release; + const held = new Promise(resolve => { release = resolve; }); + const done = runShutdownSteps([ + { name: "first", run: async () => { events.push("first:start"); await held; events.push("first:done"); } }, + { name: "second", run: () => { events.push("second"); } }, + { name: "absent service", run: () => undefined } + ], () => assert.fail("no step failed")); + await Promise.resolve(); + assert.deepEqual(events, ["first:start"]); + release(); + await done; + assert.deepEqual(events, ["first:start", "first:done", "second"]); +}); + +test("quitting still hangs up the terminals when the Even G2 companion fails to close", async () => { + const { readFile } = await import("node:fs/promises"); + const { stripTypeScriptTypes } = await import("node:module"); + const { runInNewContext } = await import("node:vm"); + const main = (await readFile(new URL("../src/main/index.ts", import.meta.url), "utf8")).replaceAll("\r\n", "\n"); + const start = main.indexOf("async function shutdownServices"); + const source = main.slice(start, main.indexOf("\n}\n", start) + 2); + const calls = []; + // The usage-history save finishes only after the limits service aborts its requests. + let limitsAborted; + const aborted = new Promise(resolve => { limitsAborted = resolve; }); + const service = (name, method, fail = false) => ({ [method]: async () => { calls.push(name); if (fail) throw new Error(`fixture ${name} failed`); } }); + const warn = console.warn; + console.warn = () => undefined; + try { + const shutdownServices = runInNewContext(`${stripTypeScriptTypes(source)}; shutdownServices`, { + runShutdownSteps, console, clearTimeout, clearInterval, Promise, Error, + diagnostics: { record: () => undefined, flush: async () => { calls.push("diagnostics"); } }, + mascotManager: { dispose: () => { calls.push("mascots"); } }, + agentChatHistory: { dispose: () => { calls.push("chat history"); } }, + agentControl: service("agent control", "close"), + updateTimer: null, updateInterval: null, browserRequests: new Map(), + evenG2: service("Even G2", "close", true), + terminalManager: { shutdown: async () => { calls.push("terminals"); }, waitForProcessExits: async () => 0 }, + usageHistory: { dispose: () => { calls.push("usage history"); return aborted.then(() => { calls.push("usage history saved"); }); } }, + limitsService: { dispose: () => { calls.push("limits"); limitsAborted(); } }, + agentGateway: service("agent gateway", "close"), + runtimeGateway: service("runtime gateway", "close"), + browserService: service("browser", "dispose"), + pluginServices: service("plugin services", "dispose"), + pluginManager: service("plugins", "dispose"), + handoffService: service("handoff", "dispose"), + materialService: service("materials", "dispose") + }); + await shutdownServices(); + } finally { + console.warn = warn; + } + assert.deepEqual(calls, ["mascots", "chat history", "agent control", "Even G2", "terminals", "usage history", "limits", + "usage history saved", "agent gateway", "runtime gateway", "browser", "plugin services", "plugins", "handoff", "materials", + "diagnostics"]); +}); From 9572d22827dc6451a8d168f5d78c4215eb1a1a8d Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:51:06 +0000 Subject: [PATCH 03/28] fix: report an agent killed by a signal as failed with exit 128+n node-pty reports a process killed by a signal as exitCode 0 plus the signal. The signal was dropped, so an agent killed with SIGKILL (OOM, kill -9) was recorded as "done, exit 0": wait_for_agent returned done, get_agent_result said done, and the saved session restored as exited. Record such an exit the way a shell does, as 128+signal (SIGKILL -> 137), mark the session failed, and put the signal name first in the failure details. Every reader of the exit (wait_for_agent, get_agent_result, the session store, the HOME failure summary) already derives its state from the exit code, so they now agree. Exits CanvasTTY causes itself (closing a card, quitting) are unaffected: those sessions are removed before the kill, and the exit handler ignores them. --- src/main/services/TerminalManager.ts | 22 ++++- tests/subagent-signal-exit.test.mjs | 118 +++++++++++++++++++++++++++ tests/terminal-lifecycle.test.mjs | 1 - 3 files changed, 136 insertions(+), 5 deletions(-) create mode 100644 tests/subagent-signal-exit.test.mjs diff --git a/src/main/services/TerminalManager.ts b/src/main/services/TerminalManager.ts index 3f310247..af27d88c 100644 --- a/src/main/services/TerminalManager.ts +++ b/src/main/services/TerminalManager.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { constants as osConstants } from "node:os"; import { realpathSync, statSync } from "node:fs"; import { basename, dirname } from "node:path"; import { isPathInside } from "../../agent-runtime/path-inside.mjs"; @@ -2166,29 +2167,36 @@ export class TerminalManager { let exited!: () => void; this.liveProcesses.set(process, new Promise((resolve) => { exited = resolve; })); - process.onExit(({ exitCode }) => { + process.onExit(({ exitCode, signal }) => { this.liveProcesses.delete(process); exited(); const current = this.sessions.get(id); if (!current || current !== session || current.process !== process) return; // node-pty calls this from a native callback that aborts the whole app when JavaScript throws in it. try { - this.recordExit(id, current, exitCode); + this.recordExit(id, current, exitCode, signal); } catch (error) { console.warn(`PTY ${id} exit could not be recorded.`, error); } }); } - private recordExit(id: string, current: ManagedSession, exitCode: number): void { + private recordExit(id: string, current: ManagedSession, reportedExitCode: number, signal?: number): void { this.flushOutput(id, current); this.releaseIsolation(id); if (current.extras.gitAuditSince !== undefined) void this.auditGit(id, current); + // node-pty reports a process killed by a signal as exitCode 0 plus the signal. That is a crash, not a clean finish: + // record it the way a shell does, as 128+signal, so the card, wait_for_agent and the saved session all say failed. + const killedBy = typeof signal === "number" && signal > 0 ? signal : 0; + const exitCode = killedBy ? 128 + killedBy : reportedExitCode; current.metadata.exitCode = exitCode; current.metadata.status = exitCode === 0 ? "done" : "failed"; - current.metadata.failureDetails = exitCode === 0 + const details = exitCode === 0 ? null : terminalFailureDetails(this.redactSecrets(current.bufferChunks.slice(current.bufferStart).join(""))); + current.metadata.failureDetails = killedBy + ? `${signalExitText(killedBy)}${details ? `\n${details}` : ""}`.slice(0, 8_000) + : details; current.agentBrowser?.cleanup(); current.agentBrowser = null; current.agentRuntime?.cleanup(); @@ -2521,3 +2529,9 @@ function appendScrollback(session: ManagedSession, data: string): void { session.bufferStart = 0; } } + +/** "The process was killed by signal SIGKILL (9)." from node-pty's signal number. */ +function signalExitText(signal: number): string { + const name = Object.entries(osConstants.signals).find(([, value]) => value === signal)?.[0]; + return `The process was killed by signal ${name ? `${name} (${signal})` : signal}.`; +} diff --git a/tests/subagent-signal-exit.test.mjs b/tests/subagent-signal-exit.test.mjs new file mode 100644 index 00000000..69229b14 --- /dev/null +++ b/tests/subagent-signal-exit.test.mjs @@ -0,0 +1,118 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import test from "node:test"; +import pty from "node-pty"; +import { AgentControlService } from "../src/main/services/AgentControlService.ts"; +import { TerminalManager } from "../src/main/services/TerminalManager.ts"; +import { persistedTerminalSession } from "../src/main/services/TerminalSessionStore.ts"; +import { failureSummary } from "../src/renderer/src/features/home/failureSummary.ts"; +import { availableRegistry } from "./helpers/terminal.mjs"; + +// A subagent whose CLI was killed with SIGKILL showed "done, exit 0", so wait_for_agent reported a crash as success: +// node-pty reports a signal death as { exitCode: 0, signal: 9 }. These tests start real processes (inside sandbox-exec on macOS, as the +// isolation layer wraps an agent there) and kill them. + +const at = { x: 0, y: 0 }; +const SANDBOX = "/usr/bin/sandbox-exec"; +const wrapped = process.platform === "darwin" && existsSync(SANDBOX); +const skip = process.platform === "win32" ? "POSIX signals only" : false; + +/** Every launch becomes the given real command tree, wrapped in sandbox-exec where the isolation layer would. */ +function realSpawner(argv, spawned) { + return (_command, _args, options) => { + const [command, ...args] = wrapped ? [SANDBOX, "-p", "(version 1)(allow default)", ...argv] : argv; + const child = pty.spawn(command, args, { name: "xterm-256color", cols: 80, rows: 24, cwd: options.cwd, env: { PATH: "/usr/bin:/bin", HOME: options.cwd } }); + spawned.push(child); + return child; + }; +} + +async function setup(t, argv) { + const root = await mkdtemp(join(tmpdir(), "ctty-signal-exit-")); + const spawned = []; + const terminals = new TerminalManager(() => undefined, availableRegistry(), undefined, undefined, true, realSpawner(argv, spawned)); + t.after(async () => { + terminals.disposeAll(); + for (const child of spawned) { try { process.kill(child.pid, "SIGKILL"); } catch { /* exited */ } } + await rm(root, { recursive: true, force: true }); + }); + const control = new AgentControlService(terminals); + const orchestrator = terminals.create({ provider: "opencode", profile: "normal", cwd: root, position: at, role: "orchestrator" }); + return { root, spawned, terminals, control, orchestrator }; +} + +async function until(check, timeoutMs = 5_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const value = check(); + if (value) return value; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error("timed out"); +} + +const descendants = (pid) => { + try { return execFileSync("/usr/bin/pgrep", ["-P", String(pid)], { encoding: "utf8" }).trim().split("\n").filter(Boolean).map(Number); } catch { return []; } +}; + +test("a subagent whose CLI is killed by SIGKILL is failed (128+9) with the signal named, everywhere its exit is read", { skip }, async (t) => { + const s = await setup(t, ["/bin/sleep", "30"]); + const child = await s.control.spawn({ parentSessionId: s.orchestrator.id, provider: "opencode", cwd: s.root }); + const live = s.spawned.at(-1); + // sandbox-exec execs the CLI: the PTY's own process is the CLI, not a wrapper that could exit 0 after it. + // (node-pty's spawn-helper and then sandbox-exec exec in place first.) + // macOS reports an executable path; Linux ps reports only the command name. + const command = await until(() => { const comm = execFileSync("/bin/ps", ["-o", "comm=", "-p", String(live.pid)], { encoding: "utf8" }).trim(); return basename(comm) === "sleep" ? comm : null; }); + assert.equal(basename(command), "sleep"); + assert.deepEqual(descendants(live.pid), [], "no process below the CLI"); + process.kill(live.pid, "SIGKILL"); + const ended = await until(() => { const m = s.terminals.getMetadata(child.id); return m.exitCode !== null ? m : null; }); + assert.equal(ended.status, "failed"); + assert.equal(ended.exitCode, 137); + assert.match(ended.failureDetails, /killed by signal SIGKILL \(9\)/u); + // wait_for_agent and get_agent_result read the same exit. + const waited = await s.control.waitFor(child.id, { timeoutMs: 1_000 }); + assert.equal(waited.reason, "failed"); + assert.equal(waited.status, "failed"); + assert.equal(waited.exitCode, 137); + const result = s.control.result(child.id); + assert.equal(result.state, "failed"); + assert.equal(result.exitCode, 137); + // The saved session restores as failed, and HOME's failure summary shows the exit code with the signal line. + const saved = persistedTerminalSession(ended); + assert.equal(saved.lastState, "failed"); + assert.equal(saved.exitCode, 137); + const summary = failureSummary(ended, "en"); + assert.match(summary.headline, /137/u); + assert.match(summary.diagnostics, /^The process was killed by signal SIGKILL \(9\)\./u); +}); + +test("a wrapper shell whose CLI child is killed reports the signal as a non-zero exit: the subagent is failed", { skip }, async (t) => { + const s = await setup(t, ["/bin/sh", "-c", "/bin/sleep 30; exit $?"]); + const child = await s.control.spawn({ parentSessionId: s.orchestrator.id, provider: "opencode", cwd: s.root }); + const shell = s.spawned.at(-1); + const cli = await until(() => descendants(shell.pid)[0]); + process.kill(cli, "SIGKILL"); + const ended = await until(() => { const m = s.terminals.getMetadata(child.id); return m.exitCode !== null ? m : null; }); + assert.equal(ended.status, "failed"); + assert.equal(ended.exitCode, 137); + // The shell exited normally with 137: no signal reached node-pty, so nothing is named. + assert.doesNotMatch(ended.failureDetails ?? "", /killed by signal/u); + assert.equal((await s.control.waitFor(child.id, { timeoutMs: 1_000 })).reason, "failed"); +}); + +test("a clean exit stays done", { skip }, async (t) => { + const s = await setup(t, ["/usr/bin/true"]); + const child = await s.control.spawn({ parentSessionId: s.orchestrator.id, provider: "opencode", cwd: s.root }); + const ended = await until(() => { const m = s.terminals.getMetadata(child.id); return m.exitCode !== null ? m : null; }); + assert.equal(ended.status, "done"); + assert.equal(ended.exitCode, 0); + assert.equal(ended.failureDetails, null); + assert.equal((await s.control.waitFor(child.id, { timeoutMs: 1_000 })).reason, "done"); + assert.equal(s.control.result(child.id).state, "done"); + assert.equal(persistedTerminalSession(ended).lastState, "exited"); +}); diff --git a/tests/terminal-lifecycle.test.mjs b/tests/terminal-lifecycle.test.mjs index 8ca7f9c1..c1e7c124 100644 --- a/tests/terminal-lifecycle.test.mjs +++ b/tests/terminal-lifecycle.test.mjs @@ -377,7 +377,6 @@ test("failed PTYs preserve their final sanitized output as failure details", asy // Masked whole before the last lines are chosen (a cut inside a secret would leave its tail readable). assert.match(source, /terminalFailureDetails\(this\.redactSecrets\(current\.bufferChunks\.slice\(current\.bufferStart\)\.join\(""\)\)\)/); - assert.match(source, /current\.metadata\.failureDetails = exitCode === 0/); }); function effectDependenciesContaining(source, marker) { From dfb9a2a9d84f9971ada48467f83c0ed6c7a13336 Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:01:34 +0000 Subject: [PATCH 04/28] fix(orchestration): keep the orchestrator's model choice for subagents A subagent spawned without launch options ran on its CLI's own default model and sign-in (for OpenCode the hosted OpenCode Zen model), even when its orchestrator ran on a model the person picked through a plugin's launch options. A plugin the spawn does not name now keeps the orchestrator's own values for a subagent of the same CLI; repeating the person's choice needs no delegable declaration, and then the normalized values must equal the orchestrator's exactly (missing keys come from the orchestrator, never from the plugin's defaults). A subagent of another CLI must name a plugin whose contribution set the orchestrator's model, and spawn_agent reports servedBy (CLI, requested model, inherited or explicit options). - Recognise every form a CLI takes for its model: --model and --model=, -m and -mX for OpenCode, Codex, Qwen, Kimi and Grok, and for Codex also --profile/-p and -c/--config setting model, model_provider or profile. - Refuse a second model (an explicit model next to a plugin that sets it, or two plugins that both set it; OpenCode crashes at start on two --model values) only for a subagent's launch. A person's own launches and restores start as before. - Inherit an empty choice (a plugin without fields) as chosen. Leave out an inherited plugin that was turned off since, unless it set the model (then refuse); refuse with a clear reason when its fields changed. - Reset modelSetBy at the start of each launch. - Describe inheritance, servedBy and the refusals in the orchestrator skill and docs. The core stays plugin-agnostic: it reads the plugin from the launch options and the contribution, never from a fixed plugin id. --- agent/orchestrator/SKILL.md | 2 +- docs/agent-orchestration.md | 4 +- docs/plugins.md | 2 +- native/canvastty-helper/catalog.json | 2 +- src/agent-browser/orchestration-catalog.mjs | 2 +- src/main/services/AgentControlService.ts | 53 +++ src/main/services/LaunchPipeline.ts | 163 +++++++-- src/main/services/TerminalManager.ts | 34 +- .../agent-browser/OrchestrationTools.ts | 16 +- tests/orchestration-gateway.test.mjs | 1 + tests/plugin-launch-choices.test.mjs | 4 +- tests/subagent-model-account.test.mjs | 312 ++++++++++++++++++ 12 files changed, 555 insertions(+), 40 deletions(-) create mode 100644 tests/subagent-model-account.test.mjs diff --git a/agent/orchestrator/SKILL.md b/agent/orchestrator/SKILL.md index 1b68bdb2..bf67517c 100644 --- a/agent/orchestrator/SKILL.md +++ b/agent/orchestrator/SKILL.md @@ -10,7 +10,7 @@ description: Create and coordinate native agent sessions (Codex, Claude, Qwen, K A session started from CanvasTTY's launcher with the **Orchestrator** role has the `canvastty_agents` MCP tools. Use them in this order: 1. `list_providers`: which agents CanvasTTY can launch here. Each entry's `id` is the exact `spawn_agent.provider` value; prefer entries that are `available` with `signIn: "ok"` (`unknown` only means CanvasTTY has not checked yet). When plugins offer launch options (for example an accounts plugin's `list_routes` tool), the answer names that tool. -2. `spawn_agent` once per part of the task: a known `provider` id, an absolute `cwd` and a self-contained prompt. An unknown provider is refused with a pointer back to `list_providers`. **If the person names a model, pass it as `model`** in the format `list_providers` gives for that provider (`model.format`; OpenCode takes `provider/model`, for example `zai-coding-plan/glm-5.3-flash`, and `model.known` lists what `opencode models` reported); without `model` the CLI uses its own default. `effort` sets the reasoning effort where the CLI has one (`efforts`). Without `profile` the subagent gets your own launch profile, or the next lower one its CLI has (never more than yours, never YOLO); `list_providers` shows each provider's `profiles`. `cwd` must be this project's folder or a folder inside it. A refusal says why (a folder outside the project, a profile above yours, a limit the person set): change the request, do not retry it. +2. `spawn_agent` once per part of the task: a known `provider` id, an absolute `cwd` and a self-contained prompt. An unknown provider is refused with a pointer back to `list_providers`. **If the person names a model, pass it as `model`** in the format `list_providers` gives for that provider (`model.format`; OpenCode takes `provider/model`, for example `zai-coding-plan/glm-5.3-flash`, and `model.known` lists what `opencode models` reported); without `model` a subagent of your own CLI keeps your plugin launch options (for example your model account), and any other CLI uses its own default. A plugin you do not name in `launchOptions` keeps your own values; passing `model` next to inherited options that already set the model is refused, and a subagent of another CLI is refused while your model comes from a plugin's options (spawn one of your own CLI instead). The answer's `servedBy` names the CLI, the model asked for, and whether each plugin's options were `inherited` or `explicit`. `effort` sets the reasoning effort where the CLI has one (`efforts`). Without `profile` the subagent gets your own launch profile, or the next lower one its CLI has (never more than yours, never YOLO); `list_providers` shows each provider's `profiles`. `cwd` must be this project's folder or a folder inside it. A refusal says why (a folder outside the project, a profile above yours, a limit the person set): change the request, do not retry it. 3. `wait_for_agent` for each subagent (`timeoutSeconds` up to 600; call it again after `timeout`) instead of polling. `needs_approval` means the person must answer a prompt in that card: never answer it yourself. 4. `get_agent_result` to read what the subagent produced; check the actual files before reporting success. diff --git a/docs/agent-orchestration.md b/docs/agent-orchestration.md index 0e8b6e3f..3b380d2c 100644 --- a/docs/agent-orchestration.md +++ b/docs/agent-orchestration.md @@ -9,7 +9,7 @@ A session launched from the desktop with the **Orchestrator** role gets the `can | Tool | What it does | | --- | --- | | `list_providers` | The agent providers CanvasTTY can launch as subagents: `id` (the exact `spawn_agent.provider` value), `name`, `installed`/`available` (from the provider CLI registry resolved at startup or on a recheck), `signIn` (`ok`, `signed_out`, `expired` or `unknown`, from the last usage-limits read; it never starts a read and never reads credentials), `subagent` and `orchestrator` support, `model` (whether the CLI takes one, its format, and for OpenCode the models a cached `opencode models` listing reported: run in the background with a 5 s timeout, refreshed after 10 minutes, never awaited) and `efforts`, and plugin launch options (`launchOptions`, plus `launchOptionTools` such as an accounts plugin's `list_routes`). Fast: cached state only, no network calls. | -| `spawn_agent` | Launches a subagent (profile: see below). `provider` must be a `list_providers` id (the schema lists them); an unknown one is refused with `INVALID_REQUEST` naming `list_providers`. Optional `model` goes to the CLI's own `--model` for that run (OpenCode `provider/model`; Codex, Claude, Qwen, Kimi alias, Grok, OMP, Pi, Cursor; Hermes, MiniMax, Devin and Antigravity take none) and optional `effort` to its reasoning effort (Codex `-c model_reasoning_effort`, Claude `--effort`, Grok `--reasoning-effort`). Both are checked per provider, kept on restart and restore, and never written to the CLI's configuration. If the person names a model, the orchestrator passes it as `model`. An OpenCode model its own `opencode models` list does not contain is refused before launch with up to five closest listed ids (OpenCode would otherwise stop with only "Unexpected server error"); when that list cannot be read, the model is passed as given. A subagent that exits anyway reports the last lines of its screen as `exitLines` in `wait_for_agent`, `observe_agent` and `get_agent_result`. | +| `spawn_agent` | Launches a subagent (profile: see below). `provider` must be a `list_providers` id (the schema lists them); an unknown one is refused with `INVALID_REQUEST` naming `list_providers`. Optional `model` goes to the CLI's own `--model` for that run (OpenCode `provider/model`; Codex, Claude, Qwen, Kimi alias, Grok, OMP, Pi, Cursor; Hermes, MiniMax, Devin and Antigravity take none) and optional `effort` to its reasoning effort (Codex `-c model_reasoning_effort`, Claude `--effort`, Grok `--reasoning-effort`). Both are checked per provider, kept on restart and restore, and never written to the CLI's configuration. If the person names a model, the orchestrator passes it as `model`. Plugin launch options the call does not name are inherited from the orchestrator for a subagent of the same CLI (see Plugin launch options below); `model` next to inherited options that set the model is refused, and so is a subagent of another CLI while the orchestrator's model comes from a plugin's options. The answer's `servedBy` is `{ provider, model, launchOptions: { : "inherited" | "explicit" } }`. An OpenCode model its own `opencode models` list does not contain is refused before launch with up to five closest listed ids (OpenCode would otherwise stop with only "Unexpected server error"); when that list cannot be read, the model is passed as given. A subagent that exits anyway reports the last lines of its screen as `exitLines` in `wait_for_agent`, `observe_agent` and `get_agent_result`. | | `wait_for_agent` | `{ sessionId, timeoutSeconds ≤ 600 }` (default 55). Returns when the subagent is `idle` (after a prompt: only once a turn that started after the latest delivered prompt has ended, so the CLI's startup idle does not count), `needs_approval`, `done`/`failed` (exited), `quiet` (reports no status and its screen stopped changing), `closed`, or on `timeout`, with `status`, `exitCode`, `waitedMs` and the masked terminal tail (masked before it is cut). Only this orchestrator's own subagents; it stops at once when the call is canceled or the orchestrator disconnects. | | `get_agent_result`, `observe_agent` | `get_agent_result`: `answer`, the final reply of the subagent's last turn as the agent itself reported it (Codex through its Stop hook, OpenCode through CanvasTTY's plugin reading the session's last assistant message at `session.idle`; at most 4,096 characters with the end kept, masked, in memory only and cleared when the next turn starts), plus `status`, the exit state and the masked terminal tail. Other providers have no `answer`: their tail is raw screen output. `observe_agent`: the current status and tail. | | `send_to_agent`, `cancel_agent`, `list_agents` | Follow-up prompts, disposing a subagent, listing this session's subagents. | @@ -45,7 +45,7 @@ The core applies these to every way an agent can start another one: `spawn_agent | Profile ceiling | A subagent's profile is at most its orchestrator's, never YOLO. A saved subagent whose record says more is restored with its orchestrator's ceiling. | | Project folder | A subagent's `cwd` must be the folder the person chose for the agent it descends from, or a folder inside it, compared as real paths in both Unicode spellings (NFC and NFD name the same folder on macOS). `/`, the home folder or another project is refused: "A subagent works only inside this project's folder …". A relative `cwd` is taken from the orchestrator's folder. | | Limits | At most `orchestrationMaxDepth` levels of subagents below the agent the person started (2 by default) and `orchestrationMaxSubagents` live subagents in one orchestration, all levels together (8 by default). Only the person sets them, in Settings → Agents. Exited subagents do not count. | -| Plugin launch options | `launchOptions` from an orchestrator reach only plugins that declared `launch.delegable: true`; everything a plugin contributes still passes the core-owned flag and configuration checks (no permission, sandbox, hook or bypass flags, no OpenCode/Kimi configuration that sets approvals). | +| Plugin launch options | `launchOptions` from an orchestrator reach only plugins that declared `launch.delegable: true` (a plugin the spawn does not name keeps the orchestrator's own values for a subagent of the same CLI; repeating them needs no declaration; a plugin turned off since is left out, or refused when it set the model; changed plugin fields refuse with that reason); everything a plugin contributes still passes the core-owned flag and configuration checks (no permission, sandbox, hook or bypass flags, no OpenCode/Kimi configuration that sets approvals). | | YOLO | Enforced in the main process: only for a CLI the person acknowledged YOLO for in the launcher, never for a subagent, never from an orchestrator's control connection; a plugin with `sessions:launch` needs the same acknowledgement. | | Isolation | Every subagent and every agent a plugin starts runs inside agent isolation (below) when it is on; where there is no layer, a subagent runs in normal (it asks) and its card says why. | | No settings | The `canvastty_agents` tools and an orchestrator's control connection cannot change settings, base protection, profiles, plugin trust or isolation. | diff --git a/docs/plugins.md b/docs/plugins.md index 0047ad17..a93611a0 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -196,7 +196,7 @@ Up to 8 fields; `kind` is `boolean`, `select` (1–16 options) or `text` (at mos A `select` with `"optionsFrom": "service"` also lists choices the service offers, such as its own accounts. When the launcher opens, CanvasTTY asks the service `canvastty.launch.options` `{ provider, fields: [keys] }` and waits at most 3 s; the answer `{ "": [{ value, label }] }` adds up to 64 choices per field after the declared ones (which stay required and are all the launcher shows when the service does not answer). Because such a list can change after a card was saved, its value is accepted as any text up to 200 characters without control characters, and `canvastty.launch.prepare` must check it and refuse a value it no longer knows. -Orchestrators pass the same values to `spawn_agent` as `launchOptions` (`{ "": { "": value } }`), checked exactly like the launcher's, but only to a plugin whose `launch` declares `"delegable": true` (its options are safe for an agent to choose; without it only the person chooses them, and `list_providers` does not list them); a plugin tool can hand them out (for example the account it picked). Whatever a contribution adds is checked the same way for every launch: no permission, sandbox, hook or bypass flags, and no configuration the CLI reads that sets approvals (an OpenCode `OPENCODE_CONFIG` file or `OPENCODE_CONFIG_DIR`, `OPENCODE_PERMISSION`, Kimi's `--config`/`--config-file`); a file CanvasTTY cannot read and check is refused. While a child's launch waits for its plugins (launch options, a launch policy, an environment), `spawn_agent` answers only after its `prompt` reached the started agent, and `send_to_agent` waits the same way. A refused, failed or cancelled launch fails the call with the reason and the session id (the card stays); the text is dropped, never kept for a later restart. The control CLI answers `NOT_READY` for such a card. +Orchestrators pass the same values to `spawn_agent` as `launchOptions` (`{ "": { "": value } }`), checked exactly like the launcher's, but only to a plugin whose `launch` declares `"delegable": true` (its options are safe for an agent to choose; without it only the person chooses them, and `list_providers` does not list them); a plugin tool can hand them out (for example the account it picked). A plugin the spawn does not name keeps the orchestrator's own values for a subagent of the same CLI (the person chose them; this needs no `delegable`), so a subagent never falls back silently to its CLI's default model; a subagent of another CLI is refused while a plugin's contribution sets the orchestrator's model (its CLI model argument or `thirdPartyModel`) unless the spawn names that plugin, and `spawn_agent` answers with `servedBy` (CLI, requested model, and whether each plugin's options were inherited or explicit). An inherited plugin that was turned off since the orchestrator started is left out, unless it set the model (then the spawn is refused); one whose fields changed refuses the spawn with that reason. For a subagent's launch, a contribution that chooses the model (`--model`/`--model=`, `-m` for OpenCode, Codex, Qwen, Kimi and Grok, and for Codex also `--profile`/`-p` and `-c`/`--config` setting `model`, `model_provider` or `profile`) refuses a launch that also asks for a model, and two contributions that both set the model are refused, instead of starting the CLI with two. A person's own launches and restores start as before. Whatever a contribution adds is checked the same way for every launch: no permission, sandbox, hook or bypass flags, and no configuration the CLI reads that sets approvals (an OpenCode `OPENCODE_CONFIG` file or `OPENCODE_CONFIG_DIR`, `OPENCODE_PERMISSION`, Kimi's `--config`/`--config-file`); a file CanvasTTY cannot read and check is refused. While a child's launch waits for its plugins (launch options, a launch policy, an environment), `spawn_agent` answers only after its `prompt` reached the started agent, and `send_to_agent` waits the same way. A refused, failed or cancelled launch fails the call with the reason and the session id (the card stays); the text is dropped, never kept for a later restart. The control CLI answers `NOT_READY` for such a card. Before the agent starts, the host sends the service a `canvastty.launch.prepare` request, which surfaces cannot send: diff --git a/native/canvastty-helper/catalog.json b/native/canvastty-helper/catalog.json index d3ec39a2..4622e41c 100644 --- a/native/canvastty-helper/catalog.json +++ b/native/canvastty-helper/catalog.json @@ -964,7 +964,7 @@ }, { "name": "spawn_agent", - "description": "Launch another provider's agent as a CanvasTTY subagent of this session and optionally deliver a first prompt. Returns the new session id. provider must be an id from list_providers (known ids: codex, claude, qwen, kimi, opencode, hermes, grok, omp, pi, cursor, minimax, devin, antigravity); call list_providers first to see which are installed and signed in. Give each subagent one self-contained part of the task and an absolute cwd. If the person names a model, pass it as model in the format list_providers gives for that provider (OpenCode: provider/model); effort sets the reasoning effort where that CLI has one (list_providers shows its efforts). An unsupported model or effort is refused with the reason. profile: without it the subagent gets this session's launch profile, or the next lower one its CLI has; a subagent never gets more than this session (plan < normal < acceptEdits < auto) and never YOLO. \"auto\" lets it work without asking the person for each edit or command inside the project; CanvasTTY's outer layers (agent isolation, base protection) still stop writes outside the project, secrets and system changes. The answer says the profile it got. cwd must be this project's folder or a folder inside it. launchOptions passes plugin launch options exactly as a plugin tool gives them (for example the account a plugin picked), for plugins that allow an orchestrator to choose them. Refusals say why (a limit the person set, a folder outside the project, a profile above this session's): change the request instead of retrying it. Then call wait_for_agent and get_agent_result.", + "description": "Launch another provider's agent as a CanvasTTY subagent of this session and optionally deliver a first prompt. Returns the new session id. provider must be an id from list_providers (known ids: codex, claude, qwen, kimi, opencode, hermes, grok, omp, pi, cursor, minimax, devin, antigravity); call list_providers first to see which are installed and signed in. Give each subagent one self-contained part of the task and an absolute cwd. If the person names a model, pass it as model in the format list_providers gives for that provider (OpenCode: provider/model); effort sets the reasoning effort where that CLI has one (list_providers shows its efforts). An unsupported model or effort is refused with the reason. profile: without it the subagent gets this session's launch profile, or the next lower one its CLI has; a subagent never gets more than this session (plan < normal < acceptEdits < auto) and never YOLO. \"auto\" lets it work without asking the person for each edit or command inside the project; CanvasTTY's outer layers (agent isolation, base protection) still stop writes outside the project, secrets and system changes. The answer says the profile it got. cwd must be this project's folder or a folder inside it. launchOptions passes plugin launch options exactly as a plugin tool gives them (for example the account a plugin picked), for plugins that allow an orchestrator to choose them. A plugin this call does not name keeps this session's own launch options (for example its model account), so the subagent never falls back silently to its CLI's default model; a subagent of another CLI is refused while this session's model comes from a plugin's options (spawn one of this CLI instead), and model next to inherited options that set the model is refused. The answer's servedBy says which CLI and requested model serve it and where each plugin's options came from. Refusals say why (a limit the person set, a folder outside the project, a profile above this session's): change the request instead of retrying it. Then call wait_for_agent and get_agent_result.", "inputSchema": { "type": "object", "properties": { diff --git a/src/agent-browser/orchestration-catalog.mjs b/src/agent-browser/orchestration-catalog.mjs index ad7b53d2..e48ea3de 100644 --- a/src/agent-browser/orchestration-catalog.mjs +++ b/src/agent-browser/orchestration-catalog.mjs @@ -58,7 +58,7 @@ export const ORCHESTRATION_TOOL_DEFINITIONS = Object.freeze([ ), tool( "spawn_agent", - `Launch another provider's agent as a CanvasTTY subagent of this session and optionally deliver a first prompt. Returns the new session id. provider must be an id from list_providers (known ids: ${AGENT_PROVIDER_IDS.join(", ")}); call list_providers first to see which are installed and signed in. Give each subagent one self-contained part of the task and an absolute cwd. If the person names a model, pass it as model in the format list_providers gives for that provider (OpenCode: provider/model); effort sets the reasoning effort where that CLI has one (list_providers shows its efforts). An unsupported model or effort is refused with the reason. profile: without it the subagent gets this session's launch profile, or the next lower one its CLI has; a subagent never gets more than this session (plan < normal < acceptEdits < auto) and never YOLO. "auto" lets it work without asking the person for each edit or command inside the project; CanvasTTY's outer layers (agent isolation, base protection) still stop writes outside the project, secrets and system changes. The answer says the profile it got. cwd must be this project's folder or a folder inside it. launchOptions passes plugin launch options exactly as a plugin tool gives them (for example the account a plugin picked), for plugins that allow an orchestrator to choose them. Refusals say why (a limit the person set, a folder outside the project, a profile above this session's): change the request instead of retrying it. Then call wait_for_agent and get_agent_result.`, + `Launch another provider's agent as a CanvasTTY subagent of this session and optionally deliver a first prompt. Returns the new session id. provider must be an id from list_providers (known ids: ${AGENT_PROVIDER_IDS.join(", ")}); call list_providers first to see which are installed and signed in. Give each subagent one self-contained part of the task and an absolute cwd. If the person names a model, pass it as model in the format list_providers gives for that provider (OpenCode: provider/model); effort sets the reasoning effort where that CLI has one (list_providers shows its efforts). An unsupported model or effort is refused with the reason. profile: without it the subagent gets this session's launch profile, or the next lower one its CLI has; a subagent never gets more than this session (plan < normal < acceptEdits < auto) and never YOLO. "auto" lets it work without asking the person for each edit or command inside the project; CanvasTTY's outer layers (agent isolation, base protection) still stop writes outside the project, secrets and system changes. The answer says the profile it got. cwd must be this project's folder or a folder inside it. launchOptions passes plugin launch options exactly as a plugin tool gives them (for example the account a plugin picked), for plugins that allow an orchestrator to choose them. A plugin this call does not name keeps this session's own launch options (for example its model account), so the subagent never falls back silently to its CLI's default model; a subagent of another CLI is refused while this session's model comes from a plugin's options (spawn one of this CLI instead), and model next to inherited options that set the model is refused. The answer's servedBy says which CLI and requested model serve it and where each plugin's options came from. Refusals say why (a limit the person set, a folder outside the project, a profile above this session's): change the request instead of retrying it. Then call wait_for_agent and get_agent_result.`, { provider, cwd: string({ minLength: 1, maxLength: 4_096 }), diff --git a/src/main/services/AgentControlService.ts b/src/main/services/AgentControlService.ts index 3b35fa78..c44bbc4e 100644 --- a/src/main/services/AgentControlService.ts +++ b/src/main/services/AgentControlService.ts @@ -151,6 +151,8 @@ export class AgentControlService { const capabilities = PROVIDER_CAPABILITIES[request.provider]; if (!capabilities) throw new Error("Unknown agent provider."); if (!capabilities.send) throw new Error(`${request.provider} cannot receive prompts.`); + const inheritance = this.subagentLaunchOptions(parent.id, request.provider, request.launchOptions, request.model); + if (inheritance.launchOptions !== request.launchOptions) request = { ...request, launchOptions: inheritance.launchOptions }; const children = this.children(parent.id); if (children.length >= MAX_CHILDREN_PER_PARENT) { @@ -200,6 +202,57 @@ export class AgentControlService { }); } + /** + * The plugin launch options a subagent of this parent starts with. A plugin the spawn does not name keeps the + * orchestrator's own values, which the person chose: a subagent never falls back silently to its CLI's default model + * and sign-in (for OpenCode that is OpenCode Zen, a third-party service) while its orchestrator runs on a model the + * person picked through a plugin. Naming a plugin is an explicit choice (checked by the launch: another value needs + * the plugin's delegable declaration). Another CLI cannot inherit a plugin choice that set the orchestrator's model, + * and is refused unless the spawn names that plugin. An explicit model next to an inherited choice that sets the model is refused. + */ + subagentLaunchOptions( + parentSessionId: string, + provider: AgentProviderId, + requested: SpawnAgentRequest["launchOptions"], + model?: string + ): { launchOptions: SpawnAgentRequest["launchOptions"]; sources: Record } { + const parent = this.requireSession(parentSessionId); + const sources: Record = {}; + for (const pluginId of Object.keys(requested ?? {})) sources[pluginId] = "explicit"; + const parentOptions = this.terminals.launchOptionsOf(parent.id); + const unnamed = Object.keys(parentOptions ?? {}).filter((pluginId) => !Object.hasOwn(sources, pluginId)); + if (!parentOptions || unnamed.length === 0) return { launchOptions: requested, sources }; + const modelSetBy = new Set(this.terminals.modelSetByOf(parent.id)); + const modelPlugins = unnamed.filter((pluginId) => modelSetBy.has(pluginId)); + if (parent.provider !== provider) { + if (modelPlugins.length === 0) return { launchOptions: requested, sources }; + const named = modelPlugins.map((pluginId) => pluginId.slice(0, 80)).join(", "); + // The person chose those options for this CLI; another CLI would run on its own default model and sign-in. + throw new DelegationRefusal(`This orchestrator's model is set by its ${named} launch options, which the person chose for ${parent.provider}; ` + + `a ${provider} subagent would not get that model. Spawn a ${parent.provider} subagent instead.`); + } + if (model !== undefined && modelPlugins.length > 0) { + const named = modelPlugins.map((pluginId) => pluginId.slice(0, 80)).join(", "); + throw new DelegationRefusal(`This subagent inherits its orchestrator's ${named} launch options, which set its model; it cannot also take model ${model.slice(0, 80)}. ` + + `Omit model, or pass launchOptions for ${named} that choose the model you want.`); + } + // A plugin turned off since the orchestrator started is left out, unless it set the model: then the subagent would + // run on its CLI's own default model and sign-in, so it is refused instead. + const gone = new Set(this.terminals.unavailableLaunchPlugins(Object.fromEntries(unnamed.map((pluginId) => [pluginId, parentOptions[pluginId]])))); + const goneModel = modelPlugins.filter((pluginId) => gone.has(pluginId)); + if (goneModel.length > 0) { + const named = goneModel.map((pluginId) => pluginId.slice(0, 80)).join(", "); + throw new DelegationRefusal(`This orchestrator's model is set by its ${named} launch options, and ${named} is now disabled, removed or not trusted; ` + + "a subagent would run on its CLI's own default model instead. The person can turn the plugin back on, or start the orchestrator again."); + } + const launchOptions: NonNullable = { ...(requested ?? {}) }; + for (const pluginId of unnamed.filter((id) => !gone.has(id))) { + launchOptions[pluginId] = parentOptions[pluginId]!; + sources[pluginId] = "inherited"; + } + return { launchOptions, sources }; + } + /** The profile a subagent of this parent gets for this request (what spawn will use), or why it gets none. */ profileFor(parentSessionId: string, provider: AgentProviderId, requested?: unknown): { profile: LaunchProfile; inherited: boolean } | { error: string } { return subagentProfile(this.requireSession(parentSessionId).profile, provider, requested, this.containment()); diff --git a/src/main/services/LaunchPipeline.ts b/src/main/services/LaunchPipeline.ts index f6adc53e..b992e729 100644 --- a/src/main/services/LaunchPipeline.ts +++ b/src/main/services/LaunchPipeline.ts @@ -73,6 +73,8 @@ export type PreparedLaunch = envSources: Record; /** A contributor runs the agent on another model than its vendor's: "auto" becomes accept-edits. */ thirdPartyModel: boolean; + /** Plugin ids whose contribution decides the model: it passes the CLI's model flag or runs a third-party model. */ + modelSetBy: string[]; /** This run's own folder below the card's folder; `cleanup` removes it. */ runDirectory: string; cleanup(): Promise; @@ -163,45 +165,45 @@ export class LaunchPipeline { * Checks launcher values against each plugin's declared fields and fills defaults. * Throws with a person-readable reason; returns undefined when nothing was chosen. */ - normalizeOptions(provider: ProviderId, candidate: unknown, context?: { delegated?: boolean }): Record | undefined { + normalizeOptions(provider: ProviderId, candidate: unknown, context?: { + delegated?: boolean; + /** The orchestrator's own options: repeating them for its subagent is the person's choice, not the orchestrator's. */ + inherited?: Readonly>>; + }): Record | undefined { if (candidate === undefined) return undefined; if (!isRecord(candidate) || Object.keys(candidate).length > MAX_OPTION_PLUGINS) throw new Error("Launch options are invalid."); if (Object.keys(candidate).length === 0) return undefined; if (provider === "terminal") throw new Error("A plain terminal takes no launch options."); const contributors = new Map(this.dependencies.contributors().map((contributor) => [contributor.pluginId, contributor])); const options: Record = {}; - for (const [pluginId, raw] of Object.entries(candidate)) { + for (const [pluginId, requested] of Object.entries(candidate)) { + let raw = requested; const contributor = contributors.get(pluginId); if (!contributor) throw new Error(unavailableReason(pluginId)); // An orchestrator picks options for its subagents only where the plugin said that is safe. + const notDelegable = `${contributor.pluginName} has not declared its launch options safe for an orchestrator to choose; only the person chooses them, in the launcher.`; + // Without the declaration a subagent may only repeat the orchestrator's own values: the keys it leaves out are + // the inherited ones, never the plugin's defaults, and the result is checked against them below. + const inherited = context?.delegated && contributor.launch.delegable !== true ? context.inherited?.[pluginId] : undefined; if (context?.delegated && contributor.launch.delegable !== true) { - throw new LaunchRefusal(`${contributor.pluginName} has not declared its launch options safe for an orchestrator to choose; only the person chooses them, in the launcher.`); + if (!inherited || !sameAsInherited(raw, inherited)) throw new LaunchRefusal(notDelegable); + raw = { ...inherited, ...(raw as Record) }; } - const { appliesTo, fields } = contributor.launch; - if (appliesTo && !appliesTo.includes(provider as never)) { - throw new Error(`${contributor.pluginName} launch options do not apply to ${provider}.`); + if (!inherited) { + options[pluginId] = launchValues(contributor, provider, raw); + continue; } - if (!isRecord(raw)) throw new Error(`${contributor.pluginName} launch options are invalid.`); - const known = new Set(fields.map((field) => field.key)); - const unknown = Object.keys(raw).find((key) => !known.has(key)); - if (unknown) throw new Error(`${contributor.pluginName} has no launch option ${unknown.slice(0, 40)}.`); - const values: PluginLaunchValues = {}; - for (const field of fields) { - const value = raw[field.key] ?? field.default - ?? (field.kind === "boolean" ? false : field.kind === "select" ? field.options?.[0]?.value ?? "" : ""); - const plainText = (limit: number): boolean => typeof value === "string" && value.length <= limit && !/[\u0000-\u001f\u007f]/.test(value); - // A service-provided choice may have changed since the launcher showed it: the service checks it when it prepares. - const valid = field.kind === "boolean" - ? typeof value === "boolean" - : field.kind === "select" - ? typeof value === "string" && (Boolean(field.options?.some((option) => option.value === value)) || (field.optionsFrom === "service" && plainText(200))) - : plainText(field.maxLength ?? 200); - if (!valid) throw new Error(`${contributor.pluginName} launch option ${field.label} is invalid.`); - values[field.key] = value as boolean | string; - } - if (Buffer.byteLength(JSON.stringify(values), "utf8") > MAX_PLUGIN_SLOT_BYTES) { - throw new Error(`${contributor.pluginName} launch options exceed 4 KB.`); + // The person's earlier choice no longer fits the plugin (it changed its fields or what it applies to): a subagent + // never gets other values than its orchestrator's, so it is refused with that reason. + const changed = `${contributor.pluginName} launch options changed since this orchestrator started, so its subagents cannot repeat the person's choice. ` + + "The person can start the orchestrator again with the new options."; + let values: PluginLaunchValues; + try { + values = launchValues(contributor, provider, raw); + } catch { + throw new LaunchRefusal(changed); } + if (!sameValues(values, inherited)) throw new LaunchRefusal(changed); options[pluginId] = values; } return options; @@ -257,8 +259,10 @@ export class LaunchPipeline { * refuses the whole launch with a reason; nothing is ever launched without a contribution the person selected, * or past a policy that did not answer. */ - async prepare(context: LaunchSessionContext): Promise { + async prepare(context: LaunchSessionContext, launch: { model?: string } = {}): Promise { const contributors = new Map(this.dependencies.contributors().map((contributor) => [contributor.pluginId, contributor])); + // Only a subagent's launch is refused for a second model; it was chosen for it, not by the person. + const delegated = context.role === "subagent"; const selected = Object.keys(context.options).sort(); for (const pluginId of selected) { if (!contributors.has(pluginId)) return { ok: false, reason: unavailableReason(pluginId) }; @@ -285,6 +289,7 @@ export class LaunchPipeline { const args: string[] = []; const secrets: string[] = []; let thirdPartyModel = false; + const modelSetBy: string[] = []; for (const answer of answers) { if ("refuse" in answer) return refuse(answer.refuse); } @@ -303,6 +308,19 @@ export class LaunchPipeline { } const forbidden = contributedArgs.find((argument) => coreOwnedLaunchArgument(context.provider, argument)); if (forbidden) return refuse(`${name} added ${forbidden.slice(0, 60)}, which only CanvasTTY may pass.`); + const modelFlag = modelSettingArgument(context.provider, contributedArgs); + if (modelFlag !== undefined || contribution.thirdPartyModel === true) { + // A subagent's CLI gets one model: a second model flag next to the launch's own made OpenCode crash at start. + // A person's own launches (and their restores) start as they always did; the person chose both. + if (delegated && modelFlag !== undefined && launch.model !== undefined) { + return refuse(`${name} sets the model of this launch, and the launch also asks for model ${launch.model.slice(0, 80)}. Omit the model, or choose ${name} options for the model you want.`); + } + if (delegated && modelSetBy.length > 0) { + const first = contributors.get(modelSetBy[0]!)?.pluginName ?? modelSetBy[0]!; + return refuse(`${first} and ${name} both set the model of this launch; choose launch options from only one of them.`); + } + modelSetBy.push(contributor.pluginId); + } // A configuration a CLI reads from the environment or an argument may not decide its approvals either. const widening = permissionConfigProblem(context.provider, contribution, context.cwd); if (widening) return refuse(`${name} ${widening}`); @@ -345,7 +363,7 @@ export class LaunchPipeline { args.push(...contributedArgs.map(expand)); thirdPartyModel ||= contribution.thirdPartyModel === true; } - return { ok: true, env, args, secrets, envSources, thirdPartyModel, runDirectory, cleanup }; + return { ok: true, env, args, secrets, envSources, thirdPartyModel, modelSetBy, runDirectory, cleanup }; } private async ask( @@ -604,3 +622,92 @@ export function errorText(error: unknown): string { export function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } + +/** A plugin's launch values checked against its declared fields, with defaults filled in. Throws with a person-readable reason. */ +function launchValues(contributor: LaunchContributor, provider: ProviderId, raw: unknown): PluginLaunchValues { + const { appliesTo, fields } = contributor.launch; + if (appliesTo && !appliesTo.includes(provider as never)) { + throw new Error(`${contributor.pluginName} launch options do not apply to ${provider}.`); + } + if (!isRecord(raw)) throw new Error(`${contributor.pluginName} launch options are invalid.`); + const known = new Set(fields.map((field) => field.key)); + const unknown = Object.keys(raw).find((key) => !known.has(key)); + if (unknown) throw new Error(`${contributor.pluginName} has no launch option ${unknown.slice(0, 40)}.`); + const values: PluginLaunchValues = {}; + for (const field of fields) { + const value = raw[field.key] ?? field.default + ?? (field.kind === "boolean" ? false : field.kind === "select" ? field.options?.[0]?.value ?? "" : ""); + const plainText = (limit: number): boolean => typeof value === "string" && value.length <= limit && !/[\u0000-\u001f\u007f]/.test(value); + // A service-provided choice may have changed since the launcher showed it: the service checks it when it prepares. + const valid = field.kind === "boolean" + ? typeof value === "boolean" + : field.kind === "select" + ? typeof value === "string" && (Boolean(field.options?.some((option) => option.value === value)) || (field.optionsFrom === "service" && plainText(200))) + : plainText(field.maxLength ?? 200); + if (!valid) throw new Error(`${contributor.pluginName} launch option ${field.label} is invalid.`); + values[field.key] = value as boolean | string; + } + if (Buffer.byteLength(JSON.stringify(values), "utf8") > MAX_PLUGIN_SLOT_BYTES) { + throw new Error(`${contributor.pluginName} launch options exceed 4 KB.`); + } + return values; +} + +/** The orchestrator's own choice repeated for its subagent: only keys it chose, each with the same value. */ +function sameAsInherited(raw: unknown, inherited: Readonly | undefined): boolean { + if (!inherited || !isRecord(raw)) return false; + const keys = Object.keys(raw); + // An empty choice repeats only an empty one (a plugin without fields, chosen in the launcher). + if (keys.length === 0) return Object.keys(inherited).length === 0; + return keys.every((key) => Object.hasOwn(inherited, key) && inherited[key] === raw[key]); +} + +/** Exactly the same keys and values. */ +function sameValues(values: Readonly, inherited: Readonly): boolean { + const keys = Object.keys(values); + return keys.length === Object.keys(inherited).length + && keys.every((key) => Object.hasOwn(inherited, key) && inherited[key] === values[key]); +} + +/** + * How each CLI's arguments choose its model. Every CLI here takes `--model`; `short` are its short model flags, + * `profile` flags that select a configuration profile (which may name a model), and `config` flags that set a + * configuration key, with the keys that decide the model. + */ +const MODEL_ARGUMENTS: Readonly>> = { + codex: { short: ["-m"], profile: ["--profile", "-p"], config: { flags: ["-c", "--config"], keys: ["model", "model_provider", "profile"] } }, + opencode: { short: ["-m"] }, + qwen: { short: ["-m"] }, + kimi: { short: ["-m"] }, + grok: { short: ["-m"] } +}; + +/** + * The first argument that chooses the CLI's model, in any form: `--model x`, `--model=x`, a short `-m x` or `-mx`, + * and for Codex `--profile`/`-p` and `-c`/`--config` setting `model`, `model_provider` or `profile`. Undefined when none does. + */ +export function modelSettingArgument(provider: ProviderId, args: readonly string[]): string | undefined { + const table = MODEL_ARGUMENTS[provider] ?? {}; + const valueFlags = ["--model", ...(table.short ?? []), ...(table.profile ?? [])]; + const configKeys = new Set(table.config?.keys ?? []); + const configSetsModel = (value: string | undefined): boolean => { + if (value === undefined) return false; + const key = value.split("=", 1)[0]!.trim().replace(/^["']|["']$/gu, ""); + return value.includes("=") && configKeys.has(key); + }; + for (let index = 0; index < args.length; index++) { + const argument = args[index]!; + if (valueFlags.some((flag) => argument === flag || attachedValue(argument, flag) !== undefined)) return argument; + for (const flag of table.config?.flags ?? []) { + const value = argument === flag ? args[index + 1] : attachedValue(argument, flag); + if (configSetsModel(value)) return argument === flag ? `${argument} ${value}` : argument; + } + } + return undefined; +} + +/** The value written into the flag's own argument: `--flag=value`, or `-fvalue` for a short flag. */ +function attachedValue(argument: string, flag: string): string | undefined { + if (flag.startsWith("--")) return argument.startsWith(`${flag}=`) ? argument.slice(flag.length + 1) : undefined; + return argument.length > flag.length && argument.startsWith(flag) ? argument.slice(flag.length) : undefined; +} diff --git a/src/main/services/TerminalManager.ts b/src/main/services/TerminalManager.ts index af27d88c..d9e196ec 100644 --- a/src/main/services/TerminalManager.ts +++ b/src/main/services/TerminalManager.ts @@ -9,6 +9,7 @@ import type { CreateSessionRequest, GitRiskReport, Point, + PluginLaunchValues, ProviderId, SessionBounds, SessionEnvironmentChoice, @@ -149,6 +150,8 @@ interface ManagedSession { restoringLaunch: boolean; /** The environment was prepared or resumed in this run of the app, so it can be wrapped now. */ environmentReady: boolean; + /** Plugins whose launch contribution decided this run's model (LaunchPipeline.prepare), once prepared. */ + modelSetBy?: string[]; /** Bumped by every launch the person or the app asks for (create, restart, restore); input waits for one. */ launchEpoch: number; /** Input waiting for this launch to start (deliverInput): woken whenever the launch moves on. */ @@ -664,8 +667,15 @@ export class TerminalManager { } const delegated = origin === "subagent" || control.ownerPluginId !== undefined; + // The orchestrator's own options are the person's choice: its subagent may repeat them even where the plugin did + // not declare its options delegable. Any other choice needs the plugin's delegable declaration. + // Only a subagent of the same CLI: the person chose them for that CLI (as AgentControlService.subagentLaunchOptions). + const parent = origin === "subagent" && request.parentSessionId !== undefined ? this.sessions.get(request.parentSessionId) : undefined; + const parentOptions = parent && parent.metadata.provider === request.provider + ? parent.extras.options as Record | undefined : undefined; const launchOptions = this.launchPipeline - ? this.launchPipeline.normalizeOptions(request.provider, request.launchOptions, origin === "subagent" ? { delegated: true } : undefined) + ? this.launchPipeline.normalizeOptions(request.provider, request.launchOptions, origin === "subagent" + ? { delegated: true, ...(parentOptions ? { inherited: parentOptions } : {}) } : undefined) : request.launchOptions === undefined ? undefined : failWith("Plugin launch options are not available."); // The isolation layer decides before anything starts: a subagent without it runs in normal, a launch that // needs it and cannot have it is refused. @@ -1845,6 +1855,20 @@ export class TerminalManager { } } + /** + * The plugin launch options this card was started with (a copy), or undefined without any. A subagent inherits them + * unless its spawn names the plugin (AgentControlService.subagentLaunchOptions). + */ + launchOptionsOf(id: string): Record | undefined { + const options = this.sessions.get(id)?.extras.options; + return options ? structuredClone(options) as Record : undefined; + } + + /** Plugins whose launch contribution decided this card's model in its current run; empty until it is prepared. */ + modelSetByOf(id: string): string[] { + return [...(this.sessions.get(id)?.modelSetBy ?? [])]; + } + /** The model and effort this card's launches ask the CLI for. */ private launchModelOf(id: string): LaunchModelChoice { const starting = this.startingModels.get(id); @@ -1895,7 +1919,8 @@ export class TerminalManager { return this.environments?.available(environment) ?? false; } - private unavailableLaunchPlugins(options: Record | undefined): string[] { + /** The plugins among these launch options that are disabled, removed or not trusted now. */ + unavailableLaunchPlugins(options: Record | undefined): string[] { if (!options) return []; return this.launchPipeline ? this.launchPipeline.unavailable(options) : Object.keys(options).sort(); } @@ -1915,6 +1940,8 @@ export class TerminalManager { ): void { const token = ++session.launchToken; const { metadata } = session; + // Each launch decides its model again; until it is prepared no plugin has. + session.modelSetBy = []; const tasks = session.launchTasks ??= new Set(); // Register before invoking plugin code, which may synchronously close or restart its card. const task = Promise.resolve().then(() => this.runContributedLaunch(id, session, token, resume, restoring, answerCaptureGrantExpiresAt)) @@ -2019,13 +2046,14 @@ export class TerminalManager { options: structuredClone(session.extras.options ?? {}) as Record>, environment: placedIn ? { pluginId: placedIn.pluginId, kind: placedIn.kind } : null, ...(trustedFolder ? { trustedFolder } : {}) - }); + }, { ...(this.launchModelOf(id).model !== undefined ? { model: this.launchModelOf(id).model } : {}) }); if (prepared.ok) { contribution = prepared; ownFolders.add(prepared.runDirectory); } if (!live()) return "superseded"; if (!prepared.ok) return refuse(prepared.reason); + session.modelSetBy = prepared.modelSetBy; this.addLaunchSecrets(session, prepared.secrets); } diff --git a/src/main/services/agent-browser/OrchestrationTools.ts b/src/main/services/agent-browser/OrchestrationTools.ts index ba6afebf..f5df4f31 100644 --- a/src/main/services/agent-browser/OrchestrationTools.ts +++ b/src/main/services/agent-browser/OrchestrationTools.ts @@ -146,13 +146,22 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { if (unknown) throw orchestrationBridgeError("INVALID_REQUEST", unknown, false); } if (signal?.aborted) throw canceledError(); + // A plugin the spawn does not name keeps this session's own options (never silently the CLI's default model). + const requested = args.launchOptions as SpawnAgentRequest["launchOptions"]; + let inheritance: ReturnType; + try { + inheritance = this.control.subagentLaunchOptions(orchestratorId, provider, requested, args.model as string | undefined); + } catch (error) { + throw orchestrationBridgeError("INVALID_REQUEST", error instanceof Error ? error.message : String(error), false); + } + const launchOptions = inheritance.launchOptions; const created = await this.control.spawn({ parentSessionId: orchestratorId, provider: args.provider as never, cwd: args.cwd as string, ...(args.title !== undefined ? { title: args.title as string } : {}), ...(args.prompt !== undefined ? { initialPrompt: args.prompt as string } : {}), - ...(args.launchOptions !== undefined ? { launchOptions: args.launchOptions as SpawnAgentRequest["launchOptions"] } : {}), + ...(launchOptions !== undefined ? { launchOptions } : {}), ...(args.model !== undefined ? { model: args.model as string } : {}), ...(args.effort !== undefined ? { effort: args.effort as SpawnAgentRequest["effort"] } : {}), profile: profile.profile @@ -177,7 +186,10 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { profile: created.profile, ...(profile.inherited ? { profileInherited: true } : {}), ...(created.model !== undefined ? { model: created.model } : {}), - ...(created.effort !== undefined ? { effort: created.effort } : {}) + ...(created.effort !== undefined ? { effort: created.effort } : {}), + // Which CLI serves it, the model it was asked for (null: its launch options or the CLI's own default decide), + // and where each plugin's launch options came from. + servedBy: { provider: created.provider, model: created.model ?? null, launchOptions: inheritance.sources } }; } diff --git a/tests/orchestration-gateway.test.mjs b/tests/orchestration-gateway.test.mjs index e2f0a524..ace1c16c 100644 --- a/tests/orchestration-gateway.test.mjs +++ b/tests/orchestration-gateway.test.mjs @@ -370,6 +370,7 @@ test("a spawn_agent canceled while it was starting closes the agent it created", const control = { status: () => ({ role: "orchestrator", provider: "codex" }), profileFor: () => ({ profile: "normal", inherited: true }), + subagentLaunchOptions: (_parent, _provider, launchOptions) => ({ launchOptions, sources: {} }), spawn: async () => { controller.abort(); return { id: "child-1", provider: "codex", status: "running", title: "worker" }; diff --git a/tests/plugin-launch-choices.test.mjs b/tests/plugin-launch-choices.test.mjs index 884ea5bb..1f024e90 100644 --- a/tests/plugin-launch-choices.test.mjs +++ b/tests/plugin-launch-choices.test.mjs @@ -158,7 +158,9 @@ test("spawn_agent takes plugin launch options and hands them to the launch", () get: (id) => (id === "orch" ? parent : undefined), getMetadata: (id) => (id === "orch" ? parent : null), listMetadata: () => [parent], - create: (request) => { created.push(request); return { id: "child", ...request, status: "starting", title: "c" }; } + create: (request) => { created.push(request); return { id: "child", ...request, status: "starting", title: "c" }; }, + launchOptionsOf: () => undefined, + modelSetByOf: () => [] }; const control = new AgentControlService(terminals); control.spawn({ parentSessionId: "orch", provider: "codex", cwd: process.cwd(), launchOptions: { "canvastty-accounts": { account: "ollama" } } }); diff --git a/tests/subagent-model-account.test.mjs b/tests/subagent-model-account.test.mjs new file mode 100644 index 00000000..a10150fb --- /dev/null +++ b/tests/subagent-model-account.test.mjs @@ -0,0 +1,312 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { AgentControlService } from "../src/main/services/AgentControlService.ts"; +import { ScopedOrchestrationHandler } from "../src/main/services/agent-browser/OrchestrationTools.ts"; +import { LaunchPipeline, modelSettingArgument } from "../src/main/services/LaunchPipeline.ts"; +import { TerminalManager } from "../src/main/services/TerminalManager.ts"; +import { TerminalSessionStore } from "../src/main/services/TerminalSessionStore.ts"; +import { availableRegistry, fakeSpawner } from "./helpers/terminal.mjs"; + +// A subagent spawned without launch options once ran on its CLI's own default model (for OpenCode the hosted OpenCode +// Zen model), while its orchestrator ran on a model the person picked through a plugin (a model account). It now keeps +// the orchestrator's options for every plugin the spawn does not name; naming a plugin is an explicit choice, and a +// second model flag next to a plugin's own is refused instead of starting a CLI that crashes. + +const at = { x: 0, y: 0 }; +// Any plugin id: the core knows no plugin by name. +const MODELS = "example-model-accounts"; +const EXTRAS = "example-extras"; +const OTHER = "example-other-models"; +const BARE = "example-no-fields"; + +async function setup(t, { delegable = true, store } = {}) { + const root = await mkdtemp(join(tmpdir(), "ctty-sub-account-")); + t.after(() => rm(root, { recursive: true, force: true })); + const calls = []; + const prepared = []; + // What the person can change while the orchestrator runs: turn a plugin off, or a plugin update changes its fields. + const state = { gone: new Set(), extrasFields: [{ key: "flavor", label: "Flavor", kind: "text" }] }; + const terminals = new TerminalManager(() => undefined, availableRegistry(), undefined, undefined, true, fakeSpawner(calls)); + t.after(() => store ? terminals.shutdown() : terminals.disposeAll()); + terminals.configureLaunchPipeline(new LaunchPipeline({ + contributors: () => [ + { pluginId: MODELS, pluginName: "Model accounts", serviceId: "accounts", secrets: false, + launch: { fields: [{ key: "account", label: "Model account", kind: "text" }], ...(delegable ? { delegable: true } : {}) } }, + // Options that do not decide the model. + { pluginId: EXTRAS, pluginName: "Extras", serviceId: "extras", secrets: false, + launch: { fields: state.extrasFields } }, + // A plugin without fields: choosing it in the launcher stores {}. + { pluginId: BARE, pluginName: "No fields", serviceId: "bare", secrets: false, launch: { fields: [] } }, + // A second plugin that also sets the model. + { pluginId: OTHER, pluginName: "Other models", serviceId: "other", secrets: false, + launch: { fields: [{ key: "pick", label: "Model", kind: "text" }], delegable: true } } + ].filter((contributor) => !state.gone.has(contributor.pluginId)), + call: async (pluginId, _service, method, params) => { + if (method !== "canvastty.launch.prepare") return null; + prepared.push({ pluginId, sessionId: params.sessionId, chosen: params.chosen, options: params.options }); + if (pluginId === OTHER && params.chosen) return { env: {}, secretEnv: {}, args: [`--model=other/${params.options.pick}`], files: [] }; + return pluginId === MODELS && params.chosen && params.options.account && params.options.account !== "none" + ? { env: {}, secretEnv: {}, args: ["--model", `acct_${params.options.account}/glm-5.3-flash`], files: [] } + : { env: {}, secretEnv: {}, args: [], files: [] }; + }, + secret: async () => null, runsRoot: join(root, "runs"), timeoutMs: 2000 + })); + if (store) { + terminals.configureSessionPersistence(new TerminalSessionStore(store), "continue"); + await terminals.restorePersistedSessions(); + } + const control = new AgentControlService(terminals); + const handler = new ScopedOrchestrationHandler(control, null, + { cli: () => "available", limits: () => null, models: () => ({ models: ["zai/glm-5.3"], checkedAt: Date.now() }), checkModel: async () => null }, + {}); + const orchestrator = (launchOptions, provider = "opencode") => terminals.create({ provider, profile: "normal", cwd: root, position: at, role: "orchestrator", + ...(launchOptions ? { launchOptions } : {}) }); + const spawn = (parent, args) => handler.execute(parent.id, { id: `c-${Math.random()}`, tool: "spawn_agent", arguments: { provider: "opencode", cwd: root, prompt: "Small task", ...args } }); + const settle = () => new Promise((resolve) => setTimeout(resolve, 50)); + return { root, calls, prepared, state, terminals, control, handler, orchestrator, spawn, settle }; +} + +test("a subagent spawned without launch options runs on its orchestrator's model account, never on the CLI default", async (t) => { + const s = await setup(t); + const parent = s.orchestrator({ [MODELS]: { account: "glm-flash" } }); + await s.settle(); + assert.deepEqual(s.terminals.modelSetByOf(parent.id), [MODELS]); + const child = await s.spawn(parent, {}); + await s.settle(); + assert.deepEqual(child.servedBy, { provider: "opencode", model: null, launchOptions: { [MODELS]: "inherited" } }); + const childPrepare = s.prepared.find((entry) => entry.sessionId === child.sessionId); + assert.deepEqual(childPrepare, { pluginId: MODELS, sessionId: child.sessionId, chosen: true, options: { account: "glm-flash" } }); + const launched = s.calls.at(-1).args; + assert.ok(launched.includes("acct_glm-flash/glm-5.3-flash"), launched.join(" ")); + assert.equal(launched.filter((arg) => arg === "--model").length, 1); + assert.deepEqual(s.terminals.launchOptionsOf(child.sessionId), { [MODELS]: { account: "glm-flash" } }); +}); + +test("naming the plugin is an explicit choice; without any options the CLI's default serves the subagent", async (t) => { + const s = await setup(t); + const parent = s.orchestrator({ [MODELS]: { account: "glm-flash" } }); + await s.settle(); + const own = await s.spawn(parent, { launchOptions: { [MODELS]: { account: "none" } } }); + await s.settle(); + assert.deepEqual(own.servedBy.launchOptions, { [MODELS]: "explicit" }); + assert.deepEqual(s.terminals.launchOptionsOf(own.sessionId), { [MODELS]: { account: "none" } }); + assert.ok(!s.calls.at(-1).args.some((arg) => arg.startsWith("acct_")), "explicit none: no account model"); + assert.deepEqual(s.terminals.modelSetByOf(own.sessionId), []); + + const bare = await setup(t); + const plain = bare.orchestrator(); + const child = await bare.spawn(plain, {}); + await bare.settle(); + assert.deepEqual(child.servedBy, { provider: "opencode", model: null, launchOptions: {} }); + assert.equal(bare.terminals.launchOptionsOf(child.sessionId), undefined); +}); + +test("another value needs the plugin's delegable declaration; the orchestrator's own values do not", async (t) => { + const s = await setup(t, { delegable: false }); + const parent = s.orchestrator({ [MODELS]: { account: "glm-flash" }, [EXTRAS]: { flavor: "mild" } }); + await s.settle(); + const inherited = await s.spawn(parent, {}); + assert.deepEqual(inherited.servedBy.launchOptions, { [MODELS]: "inherited", [EXTRAS]: "inherited" }); + assert.deepEqual(s.terminals.launchOptionsOf(inherited.sessionId), { [MODELS]: { account: "glm-flash" }, [EXTRAS]: { flavor: "mild" } }); + const same = await s.spawn(parent, { launchOptions: { [MODELS]: { account: "glm-flash" } } }); + assert.deepEqual(same.servedBy.launchOptions, { [MODELS]: "explicit", [EXTRAS]: "inherited" }); + await assert.rejects(s.spawn(parent, { launchOptions: { [MODELS]: { account: "other-paid" } } }), + /Model accounts has not declared its launch options safe for an orchestrator to choose/u); +}); + +test("a subagent of another CLI cannot silently drop the options that set the orchestrator's model", async (t) => { + const s = await setup(t); + const parent = s.orchestrator({ [MODELS]: { account: "glm-flash" }, [EXTRAS]: { flavor: "mild" } }); + await s.settle(); + const refused = new RegExp(`model is set by its ${MODELS} launch options, which the person chose for opencode; a codex subagent would not get that model\\. Spawn a opencode subagent instead\\.`, "u"); + await assert.rejects(s.spawn(parent, { provider: "codex" }), refused); + assert.throws(() => s.control.spawn({ parentSessionId: parent.id, provider: "codex", cwd: s.root }), refused); + // Naming the plugin for the other CLI is explicit; options that do not decide the model stay with their CLI. + const explicit = await s.spawn(parent, { provider: "codex", launchOptions: { [MODELS]: { account: "none" } } }); + assert.deepEqual(explicit.servedBy.launchOptions, { [MODELS]: "explicit" }); + assert.deepEqual(s.terminals.launchOptionsOf(explicit.sessionId), { [MODELS]: { account: "none" } }); +}); + +test("AgentControlService.spawn applies the same inheritance for every caller", async (t) => { + const s = await setup(t); + const parent = s.orchestrator({ [MODELS]: { account: "glm-flash" } }); + await s.settle(); + const child = await s.control.spawn({ parentSessionId: parent.id, provider: "opencode", cwd: s.root }); + await s.settle(); + assert.deepEqual(s.terminals.launchOptionsOf(child.id), { [MODELS]: { account: "glm-flash" } }); + assert.deepEqual(s.terminals.modelSetByOf(child.id), [MODELS]); +}); + +test("a subagent with an explicit model next to launch options that set the model is refused instead of a crashing double --model", async (t) => { + const s = await setup(t); + const parent = s.orchestrator({ [EXTRAS]: { flavor: "mild" } }); + await s.settle(); + const spawned = s.calls.length; + const child = await s.control.spawn({ parentSessionId: parent.id, provider: "opencode", cwd: s.root, model: "zai/glm-5.3", + launchOptions: { [MODELS]: { account: "glm-flash" } } }); + await s.settle(); + const failed = s.terminals.getMetadata(child.id); + assert.equal(failed.status, "failed"); + assert.match(failed.failureDetails, /Model accounts sets the model of this launch, and the launch also asks for model zai\/glm-5\.3/u); + assert.equal(s.calls.length, spawned, "no CLI was started with two --model flags"); + + // Options that do not set the model take an explicit model as before. + const plain = await s.control.spawn({ parentSessionId: parent.id, provider: "opencode", cwd: s.root, model: "zai/glm-5.3" }); + await s.settle(); + assert.notEqual(s.terminals.getMetadata(plain.id).status, "failed"); + assert.equal(s.calls.at(-1).args.filter((arg) => arg === "--model").length, 1); + + // A subagent that would inherit them is refused before anything starts. + const accounts = s.orchestrator({ [MODELS]: { account: "glm-flash" } }); + await s.settle(); + await assert.rejects(s.spawn(accounts, { model: "zai/glm-5.3" }), + new RegExp(`inherits its orchestrator's ${MODELS} launch options, which set its model; it cannot also take model zai/glm-5\\.3`, "u")); +}); + +test("a subagent whose launch options come from two plugins that both set the model is refused", async (t) => { + const s = await setup(t); + const parent = s.orchestrator(); + await s.settle(); + const spawned = s.calls.length; + const child = await s.control.spawn({ parentSessionId: parent.id, provider: "opencode", cwd: s.root, + launchOptions: { [MODELS]: { account: "glm-flash" }, [OTHER]: { pick: "x" } } }); + await s.settle(); + const failed = s.terminals.getMetadata(child.id); + assert.equal(failed.status, "failed"); + assert.match(failed.failureDetails, /Model accounts and Other models both set the model of this launch/u); + assert.equal(s.calls.length, spawned); +}); + +test("a person's own card keeps starting with a model and a plugin that sets the model, also when it is restored", async (t) => { + const s = await setup(t); + const card = s.terminals.create({ provider: "opencode", profile: "normal", cwd: s.root, position: at, model: "zai/glm-5.3", + launchOptions: { [MODELS]: { account: "glm-flash" } } }); + await s.settle(); + assert.notEqual(s.terminals.getMetadata(card.id).status, "failed"); + assert.ok(s.calls.at(-1).args.includes("acct_glm-flash/glm-5.3-flash")); + + const store = await mkdtemp(join(tmpdir(), "ctty-sub-account-store-")); + t.after(() => rm(store, { recursive: true, force: true })); + await new TerminalSessionStore(store).replace([{ + id: "person-card", provider: "opencode", profile: "normal", role: "agent", title: "Agent", titleCustomized: false, + cwd: s.root, position: at, size: { width: 700, height: 430 }, lastState: "running", restore: true, + model: "zai/glm-5.3", options: { [MODELS]: { account: "glm-flash" } } + }]); + const restored = await setup(t, { store }); + await restored.settle(); + const metadata = restored.terminals.getMetadata("person-card"); + assert.notEqual(metadata.status, "failed", metadata.failureDetails ?? ""); + assert.equal(restored.calls.length, 1); + assert.ok(restored.calls[0].args.includes("acct_glm-flash/glm-5.3-flash")); + assert.deepEqual(restored.terminals.modelSetByOf("person-card"), [MODELS]); +}); + +test("a subagent of another CLI is never checked against options the person chose for its orchestrator's CLI", async (t) => { + const s = await setup(t, { delegable: false }); + // EXTRAS is not delegable and does not set the model: an opencode orchestrator's choice is not a codex subagent's. + const parent = s.orchestrator({ [EXTRAS]: { flavor: "mild" } }); + await s.settle(); + const child = await s.spawn(parent, { provider: "codex" }); + assert.deepEqual(child.servedBy.launchOptions, {}); + assert.equal(s.terminals.launchOptionsOf(child.sessionId), undefined); + // Naming it for codex is the orchestrator's own choice, which this plugin does not allow. + await assert.rejects(s.spawn(parent, { provider: "codex", launchOptions: { [EXTRAS]: { flavor: "mild" } } }), + /Extras has not declared its launch options safe for an orchestrator to choose/u); +}); + +test("model-setting arguments are recognised in every form each CLI takes", () => { + const sets = (provider, ...args) => modelSettingArgument(provider, args); + for (const provider of ["opencode", "codex", "qwen", "kimi", "grok", "claude", "cursor"]) { + assert.equal(sets(provider, "--model", "x"), "--model", provider); + assert.equal(sets(provider, "--model=x"), "--model=x", provider); + } + for (const provider of ["opencode", "codex", "qwen", "kimi", "grok"]) { + assert.equal(sets(provider, "-m", "x"), "-m", provider); + assert.equal(sets(provider, "-mx"), "-mx", provider); + } + // Claude has no -m. + assert.equal(sets("claude", "-m", "x"), undefined); + // Codex: a profile, and configuration keys that pick the model or its provider. + assert.equal(sets("codex", "--profile", "work"), "--profile"); + assert.equal(sets("codex", "--profile=work"), "--profile=work"); + assert.equal(sets("codex", "-p", "work"), "-p"); + assert.equal(sets("codex", "-pwork"), "-pwork"); + assert.equal(sets("codex", "-c", "model=\"o3\""), "-c model=\"o3\""); + assert.equal(sets("codex", "-c", " model = o3"), "-c model = o3"); + assert.equal(sets("codex", "--config", "model_provider=acct"), "--config model_provider=acct"); + assert.equal(sets("codex", "--config=profile=work"), "--config=profile=work"); + assert.equal(sets("codex", "-cmodel=o3"), "-cmodel=o3"); + assert.equal(sets("codex", "-c", "\"model\"=o3"), "-c \"model\"=o3"); + // Other configuration keys and flags do not choose the model. + assert.equal(sets("codex", "-c", "model_reasoning_effort=high"), undefined); + assert.equal(sets("codex", "-c", "model"), undefined); + assert.equal(sets("codex", "--search", "-c", "tools.web_search=true"), undefined); + assert.equal(sets("opencode", "--profile", "x", "-p", "y"), undefined); + assert.equal(sets("opencode", "--models"), undefined); +}); + +test("without the delegable declaration a subagent gets exactly the inherited values, never the plugin's defaults", () => { + const pipeline = new LaunchPipeline({ + contributors: () => [{ pluginId: EXTRAS, pluginName: "Extras", serviceId: "extras", secrets: false, + launch: { fields: [{ key: "a", label: "A", kind: "text", default: "da" }, { key: "b", label: "B", kind: "text", default: "db" }, + { key: "c", label: "C", kind: "text" }] } }], + call: async () => null, secret: async () => null, runsRoot: join(tmpdir(), "ctty-unused-runs") + }); + const inherited = { [EXTRAS]: { a: "1", b: "2", c: "" } }; + const delegated = { delegated: true, inherited }; + // A subset is completed from the orchestrator's values, not from the defaults. + assert.deepEqual(pipeline.normalizeOptions("opencode", { [EXTRAS]: { a: "1" } }, delegated), { [EXTRAS]: { a: "1", b: "2", c: "" } }); + assert.deepEqual(pipeline.normalizeOptions("opencode", { [EXTRAS]: { a: "1", b: "2", c: "" } }, delegated), inherited); + const refused = /Extras has not declared its launch options safe for an orchestrator to choose/u; + assert.throws(() => pipeline.normalizeOptions("opencode", { [EXTRAS]: { a: "1", b: "x" } }, delegated), refused); + assert.throws(() => pipeline.normalizeOptions("opencode", { [EXTRAS]: { c: "set" } }, delegated), refused); + assert.throws(() => pipeline.normalizeOptions("opencode", { [EXTRAS]: { a: "1", extra: "1" } }, delegated), refused); + assert.throws(() => pipeline.normalizeOptions("opencode", { [EXTRAS]: {} }, delegated), refused); + // An orchestrator whose own values the plugin no longer accepts as they were is refused, not given defaults. + assert.throws(() => pipeline.normalizeOptions("opencode", { [EXTRAS]: { a: "1" } }, { delegated: true, inherited: { [EXTRAS]: { a: "1", b: "2" } } }), + /Extras launch options changed since this orchestrator started/u); + assert.throws(() => pipeline.normalizeOptions("opencode", { [EXTRAS]: { a: "1" } }, { delegated: true }), refused); +}); + +test("a plugin without fields chosen for the orchestrator is inherited as chosen, not refused", async (t) => { + const s = await setup(t, { delegable: false }); + const parent = s.orchestrator({ [BARE]: {} }); + await s.settle(); + assert.notEqual(s.terminals.getMetadata(parent.id).status, "failed"); + const child = await s.spawn(parent, {}); + await s.settle(); + assert.deepEqual(child.servedBy.launchOptions, { [BARE]: "inherited" }); + assert.deepEqual(s.terminals.launchOptionsOf(child.sessionId), { [BARE]: {} }); + assert.notEqual(s.terminals.getMetadata(child.sessionId).status, "failed"); +}); + +test("a plugin turned off after the orchestrator started: left out unless it set the model, then refused", async (t) => { + const s = await setup(t, { delegable: false }); + const parent = s.orchestrator({ [MODELS]: { account: "glm-flash" }, [EXTRAS]: { flavor: "mild" } }); + await s.settle(); + // Options that do not decide the model: the subagent starts without them. + s.state.gone.add(EXTRAS); + const child = await s.spawn(parent, {}); + await s.settle(); + assert.deepEqual(child.servedBy.launchOptions, { [MODELS]: "inherited" }); + assert.deepEqual(s.terminals.launchOptionsOf(child.sessionId), { [MODELS]: { account: "glm-flash" } }); + // The plugin that set the model: never a silent fall back to the CLI's default model. + s.state.gone.add(MODELS); + const spawned = s.calls.length; + await assert.rejects(s.spawn(parent, {}), + new RegExp(`model is set by its ${MODELS} launch options, and ${MODELS} is now disabled, removed or not trusted; a subagent would run on its CLI's own default model instead`, "u")); + assert.equal(s.calls.length, spawned); +}); + +test("a plugin whose fields changed after the orchestrator started refuses its subagents with that reason", async (t) => { + const s = await setup(t, { delegable: false }); + const parent = s.orchestrator({ [EXTRAS]: { flavor: "mild" } }); + await s.settle(); + s.state.extrasFields.splice(0, 1, { key: "taste", label: "Taste", kind: "text" }); + await assert.rejects(s.spawn(parent, {}), /Extras launch options changed since this orchestrator started, so its subagents cannot repeat the person's choice/u); + s.state.extrasFields.splice(0, 1, { key: "flavor", label: "Flavor", kind: "text" }, { key: "extra", label: "Extra", kind: "text", default: "on" }); + await assert.rejects(s.spawn(parent, {}), /Extras launch options changed since this orchestrator started/u); +}); From 8a59c8224a2bfce67ed3e99a22c2290079d8e637 Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:59:41 +0000 Subject: [PATCH 05/28] fix(agents): type OpenCode prompts only once its input field is up OpenCode drops every key that arrives while its full-screen view starts (a few seconds). CanvasTTY typed spawn_agent's first prompt, send_to_agent and plugin sessions.send as soon as the PTY existed, so an OpenCode subagent sat at an empty prompt until the orchestrator gave up. deliverInput now waits, within the same bounded wait it already had for the launch, until OpenCode can take input: the terminal control sequence that shows its text cursor on the alternate screen once its prompt has focus, or a lifecycle hook from its plugin, counted only 2 s after the launch's first one (the plugin can report before the prompt takes keys). Only DEC private mode sequences are read; human-readable screen text is never parsed and no status changes. Other CLIs keep getting input when the PTY exists. - The wait is used on macOS and Linux. Windows' pseudo console may not pass those sequences, so there input is typed when the PTY exists, as before. - spawn_agent (checks, launch and readiness together) and send_to_agent wait at most 45 s, so the orchestrator gets the reason and the session id, not an MCP client timeout. Nothing is typed then. - A handoff waits up to 15 s for the CLI to take input instead of failing at once; a caller that does not wait is told the state. - Sends to one card are delivered in the order they were made, a queued send never reaches a later launch, and a send cancelled while it waits behind another settles at once and types nothing. --- docs/agent-orchestration.md | 2 +- src/main/services/AgentControlService.ts | 15 +- src/main/services/TerminalManager.ts | 153 ++++++++++- .../agent-browser/OrchestrationTools.ts | 13 +- src/main/services/materials/HandoffService.ts | 8 +- tests/helpers/terminal.mjs | 22 ++ tests/launch-readiness.test.mjs | 237 +++++++++++++++++- tests/material-handoff-service.test.mjs | 16 ++ tests/opencode-result.test.mjs | 9 +- tests/orchestration-providers-wait.test.mjs | 6 +- tests/subagent-model-account.test.mjs | 4 +- 11 files changed, 450 insertions(+), 35 deletions(-) diff --git a/docs/agent-orchestration.md b/docs/agent-orchestration.md index 3b380d2c..a34f2d6f 100644 --- a/docs/agent-orchestration.md +++ b/docs/agent-orchestration.md @@ -12,7 +12,7 @@ A session launched from the desktop with the **Orchestrator** role gets the `can | `spawn_agent` | Launches a subagent (profile: see below). `provider` must be a `list_providers` id (the schema lists them); an unknown one is refused with `INVALID_REQUEST` naming `list_providers`. Optional `model` goes to the CLI's own `--model` for that run (OpenCode `provider/model`; Codex, Claude, Qwen, Kimi alias, Grok, OMP, Pi, Cursor; Hermes, MiniMax, Devin and Antigravity take none) and optional `effort` to its reasoning effort (Codex `-c model_reasoning_effort`, Claude `--effort`, Grok `--reasoning-effort`). Both are checked per provider, kept on restart and restore, and never written to the CLI's configuration. If the person names a model, the orchestrator passes it as `model`. Plugin launch options the call does not name are inherited from the orchestrator for a subagent of the same CLI (see Plugin launch options below); `model` next to inherited options that set the model is refused, and so is a subagent of another CLI while the orchestrator's model comes from a plugin's options. The answer's `servedBy` is `{ provider, model, launchOptions: { : "inherited" | "explicit" } }`. An OpenCode model its own `opencode models` list does not contain is refused before launch with up to five closest listed ids (OpenCode would otherwise stop with only "Unexpected server error"); when that list cannot be read, the model is passed as given. A subagent that exits anyway reports the last lines of its screen as `exitLines` in `wait_for_agent`, `observe_agent` and `get_agent_result`. | | `wait_for_agent` | `{ sessionId, timeoutSeconds ≤ 600 }` (default 55). Returns when the subagent is `idle` (after a prompt: only once a turn that started after the latest delivered prompt has ended, so the CLI's startup idle does not count), `needs_approval`, `done`/`failed` (exited), `quiet` (reports no status and its screen stopped changing), `closed`, or on `timeout`, with `status`, `exitCode`, `waitedMs` and the masked terminal tail (masked before it is cut). Only this orchestrator's own subagents; it stops at once when the call is canceled or the orchestrator disconnects. | | `get_agent_result`, `observe_agent` | `get_agent_result`: `answer`, the final reply of the subagent's last turn as the agent itself reported it (Codex through its Stop hook, OpenCode through CanvasTTY's plugin reading the session's last assistant message at `session.idle`; at most 4,096 characters with the end kept, masked, in memory only and cleared when the next turn starts), plus `status`, the exit state and the masked terminal tail. Other providers have no `answer`: their tail is raw screen output. `observe_agent`: the current status and tail. | -| `send_to_agent`, `cancel_agent`, `list_agents` | Follow-up prompts, disposing a subagent, listing this session's subagents. | +| `send_to_agent`, `cancel_agent`, `list_agents` | Follow-up prompts, disposing a subagent, listing this session's subagents. Prompts to one subagent arrive in the order they were sent. OpenCode drops keys typed while it starts, so a prompt for it (also `spawn_agent`'s first one) is typed only once its input field is up: the terminal control sequence that shows its text cursor on its full screen, or a lifecycle hook only 2 s after its first one (OpenCode's plugin can report shortly before the field takes keys). This wait is used on macOS and Linux; on Windows, whose pseudo console may not pass those sequences, OpenCode gets input as soon as its PTY exists, as before. `spawn_agent` (its checks, the launch and this wait together) and `send_to_agent` wait at most 45 s, under the MCP client's tool deadline; then the call fails with the reason and the session id, and nothing is typed. A send cancelled while it waits returns at once. | ### Subagent profiles diff --git a/src/main/services/AgentControlService.ts b/src/main/services/AgentControlService.ts index c44bbc4e..67094d00 100644 --- a/src/main/services/AgentControlService.ts +++ b/src/main/services/AgentControlService.ts @@ -141,9 +141,10 @@ export class AgentControlService { /** * Creates the subagent card. With an initial prompt it resolves only once the prompt reached the agent's PTY * (after an asynchronous launch has started) and rejects with PromptNotDeliveredError when that launch did not - * start; the card stays, so the caller can inspect or cancel it. + * start; the card stays, so the caller can inspect or cancel it. `promptWaitMs` bounds that wait (the launch and + * the CLI's readiness together; LAUNCH_INPUT_WAIT_MS without it). */ - spawn(request: SpawnAgentRequest, signal?: AbortSignal): Promise { + spawn(request: SpawnAgentRequest, signal?: AbortSignal, promptWaitMs?: number): Promise { if (!request || typeof request.parentSessionId !== "string") { throw new Error("A parent session id is required."); } @@ -193,7 +194,7 @@ export class AgentControlService { ...(request.effort !== undefined ? { effort: request.effort } : {}) }, { ...(RESULT_CAPTURE_PROVIDERS.has(request.provider) ? { captureResult: true } : {}), origin: "subagent" }); if (request.initialPrompt === undefined || request.initialPrompt.length === 0) return Promise.resolve(created); - return this.deliver(created.id, `${request.initialPrompt}\r`, "prompt", signal) + return this.deliver(created.id, `${request.initialPrompt}\r`, "prompt", signal, promptWaitMs) .then(() => this.terminals.getMetadata(created.id) ?? created, (error: unknown) => { // Cancelled while it started: nobody receives its id, so the card is closed instead of left running. if (!signal?.aborted) throw error; @@ -259,14 +260,14 @@ export class AgentControlService { } /** Validates at once (throws); resolves once the text reached the agent, and rejects when it did not. */ - send(sessionId: string, text: string, submit = true, signal?: AbortSignal): Promise { + send(sessionId: string, text: string, submit = true, signal?: AbortSignal, waitMs?: number): Promise { const session = this.requireSession(sessionId); if (session.provider === "terminal") throw new Error("Plain terminals are not agents."); const capabilities = PROVIDER_CAPABILITIES[session.provider as AgentProviderId]; if (!capabilities.send) throw new Error(`${session.provider} cannot receive prompts.`); if (typeof text !== "string" || text.length === 0) throw new Error("Prompt text is required."); if (session.exitCode !== null) throw new Error("Agent session has already exited."); - return this.deliver(sessionId, submit ? `${text}\r` : text, "text", signal); + return this.deliver(sessionId, submit ? `${text}\r` : text, "text", signal, waitMs); } status(sessionId: string): SessionMetadata { @@ -434,8 +435,8 @@ export class AgentControlService { } /** Through the terminal manager's one delivery rule: exactly once, into the launch that is starting now. */ - private async deliver(sessionId: string, data: string, what: "prompt" | "text", signal?: AbortSignal): Promise { - const delivery = await this.terminals.deliverInput(sessionId, data, undefined, signal); + private async deliver(sessionId: string, data: string, what: "prompt" | "text", signal?: AbortSignal, waitMs?: number): Promise { + const delivery = await this.terminals.deliverInput(sessionId, data, waitMs, signal); if (!delivery.delivered) { throw new PromptNotDeliveredError(sessionId, `The ${what} for agent ${sessionId} was not delivered: ${delivery.reason}`); } diff --git a/src/main/services/TerminalManager.ts b/src/main/services/TerminalManager.ts index d9e196ec..c0f00ea7 100644 --- a/src/main/services/TerminalManager.ts +++ b/src/main/services/TerminalManager.ts @@ -164,6 +164,17 @@ interface ManagedSession { hookSignals?: number; /** Set after the person answered a hooked Claude prompt; see settleAnsweredPrompt. */ answeredPromptTimer?: ReturnType; + /** The last deliverInput of this card: the next one waits for it, so sends arrive in order. */ + inputQueue?: Promise; + /** This launch's CLI turned its text cursor on in its full-screen view: its input field is up (watchInputReadiness). */ + inputReady?: boolean; + /** While watching for inputReady: this launch's CLI is on its alternate (full-screen) buffer. */ + alternateScreen?: boolean; + /** While watching for inputReady: an escape sequence cut off at the end of the last output chunk. */ + readinessCarry?: string; + /** When this launch's first lifecycle hook stops waiting for inputReady (INPUT_READY_HOOK_GRACE_MS after it). */ + hookReadyAt?: number; + hookReadyTimer?: ReturnType; } type EnvironmentService = Pick = new Set(["opencode"]); + +/** + * Whether input for this CLI waits for its input field. Only on macOS and Linux: Windows' pseudo console (ConPTY, + * winpty) may not pass the alternate-screen and cursor sequences through, so there the gate could never open, and + * input is typed as soon as the PTY exists, as before. + */ +function waitsForInputReady(provider: ProviderId): boolean { + return (process.platform === "darwin" || process.platform === "linux") && WAITS_FOR_INPUT_READY.has(provider); +} + +/** + * A lifecycle hook alone is not proof that the input field is up: OpenCode's plugin reports (for a resumed session, + * idle on session.updated) about 0.6 s before typed keys reach its prompt. The cursor signal decides; a hook counts + * only this long after the launch's first one, for a CLI whose cursor sequence never arrives. + */ +export const INPUT_READY_HOOK_GRACE_MS = 2_000; + /** What happened to input handed to deliverInput. */ export type InputDelivery = { delivered: true } | { delivered: false; reason: string }; @@ -916,29 +951,59 @@ export class TerminalManager { * The one delivery rule for text another agent, a plugin or a controller sends to a card (spawn_agent's first * prompt, send_to_agent, plugin sessions.send). A running card gets it at once. A card whose launch plugins are * still preparing (launch options, a launch policy, an environment) or that waits for its grid gets it exactly - * once, when that launch has started. A launch that is refused, fails, is cancelled or superseded (closed, - * restarted), or does not start within LAUNCH_INPUT_WAIT_MS delivers nothing, says why, and drops the text: - * it never reaches a later launch of the card. An aborted `signal` (the sender cancelled) delivers nothing either. + * once, when that launch has started. A CLI that drops keys typed while it starts (WAITS_FOR_INPUT_READY) gets it + * only once its input field is up. A launch that is refused, fails, is cancelled or superseded (closed, restarted), + * or does not start or become ready within `waitMs` delivers nothing, says why, and drops the text: it never reaches + * a later launch of the card. An aborted `signal` (the sender cancelled) delivers nothing either. Sends to one card + * are delivered in the order they were made; the wait of each counts from its own call. */ - async deliverInput(id: string, data: string, waitMs = LAUNCH_INPUT_WAIT_MS, signal?: AbortSignal): Promise { - const canceled: InputDelivery = { delivered: false, reason: "The delivery was cancelled." }; - if (signal?.aborted) return canceled; + deliverInput(id: string, data: string, waitMs = LAUNCH_INPUT_WAIT_MS, signal?: AbortSignal): Promise { const session = this.sessions.get(id); - if (!session) return { delivered: false, reason: "The session does not exist." }; + if (!session) return Promise.resolve({ delivered: false, reason: "The session does not exist." }); + // The launch this call was made for: a send queued behind another never reaches a later launch either. const epoch = session.launchEpoch; const deadline = Date.now() + waitMs; - const waiting = (): boolean => this.sessions.get(id) === session && session.launchEpoch === epoch - && session.metadata.exitCode === null && !session.process; - while (waiting() && !signal?.aborted) { + const previous = session.inputQueue; + const run = (): Promise => this.deliverQueuedInput(id, session, epoch, data, waitMs, deadline, signal); + const pending = previous ? previous.then(run, run) : run(); + session.inputQueue = pending; + void pending.finally(() => { if (session.inputQueue === pending) delete session.inputQueue; }).catch(() => undefined); + if (!signal || !previous) return pending; + // A send cancelled while it waits behind another settles at once; its turn in the queue then types nothing. + const canceled: InputDelivery = { delivered: false, reason: "The delivery was cancelled." }; + if (signal.aborted) return Promise.resolve(canceled); + return new Promise((resolve) => { + const abort = (): void => resolve(canceled); + signal.addEventListener("abort", abort, { once: true }); + void pending.then(resolve, () => resolve(canceled)).finally(() => signal.removeEventListener("abort", abort)); + }); + } + + private async deliverQueuedInput( + id: string, session: ManagedSession, epoch: number, data: string, waitMs: number, deadline: number, signal?: AbortSignal + ): Promise { + const canceled: InputDelivery = { delivered: false, reason: "The delivery was cancelled." }; + if (signal?.aborted) return canceled; + // What the text still waits for: the launch to start, or (once its PTY exists) the CLI's input field. + const awaiting = (): "launch" | "input" | null => { + if (this.sessions.get(id) !== session || session.launchEpoch !== epoch || session.metadata.exitCode !== null) return null; + if (!session.process) return "launch"; + return cliAcceptsInput(session) ? null : "input"; + }; + for (let stage = awaiting(); stage && !signal?.aborted; stage = awaiting()) { const remaining = deadline - Date.now(); if (remaining <= 0) { - return { delivered: false, reason: `The session did not start within ${Math.round(waitMs / 1000)} s.` }; + // A caller that does not wait (0) is told the state, not a wait it never made. + const within = waitMs <= 0 ? "yet" : waitMs >= 1_000 ? `within ${Math.round(waitMs / 1_000)} s` : `within ${waitMs} ms`; + return { delivered: false, reason: stage === "launch" + ? `The session did not start ${within}.` + : `The agent's CLI did not show its input field ${within}; nothing was typed. Look at the card, then send the prompt again.` }; } await new Promise((resolve) => { const timer = setTimeout(wake, remaining); function wake(): void { clearTimeout(timer); - session!.launchWaiters.delete(wake); + session.launchWaiters.delete(wake); signal?.removeEventListener("abort", wake); resolve(); } @@ -1045,7 +1110,19 @@ export class TerminalManager { applyProviderSignal(id: string, signal: ProviderLifecycleSignal, source: "hook" | "title" = "hook"): void { const session = this.sessions.get(id); if (!this.lifecycleHooksEnabled || !session || session.metadata.status === "done" || session.metadata.status === "failed") return; - if (source === "hook") session.hookSignals = (session.hookSignals ?? 0) + 1; + if (source === "hook") { + session.hookSignals = (session.hookSignals ?? 0) + 1; + // The CLI runs, but its input field may not be up yet: input waiting for it (deliverInput) goes on the cursor + // signal, or once the grace after this first hook has passed. + if (session.hookSignals === 1 && waitsForInputReady(session.metadata.provider) && !session.inputReady) { + session.hookReadyAt = Date.now() + INPUT_READY_HOOK_GRACE_MS; + session.hookReadyTimer = setTimeout(() => { + session.hookReadyTimer = undefined; + this.wakeLaunchWaiters(session); + }, INPUT_READY_HOOK_GRACE_MS); + session.hookReadyTimer.unref?.(); + } + } const threadId = normalizeThreadId(session.metadata.provider, signal.threadId); const threadChanged = Boolean(threadId && threadId !== session.threadId); @@ -2182,6 +2259,9 @@ export class TerminalManager { const current = this.sessions.get(id); if (!current || current !== session || current.process !== process) return; + if (!current.inputReady && waitsForInputReady(current.metadata.provider) && watchInputReadiness(current, data)) { + this.wakeLaunchWaiters(current); + } const lifecycleState = current.lifecycle?.push(data); if (lifecycleState) current.titleState = lifecycleState; if (lifecycleState && !titleDefersToHooks(current, lifecycleState)) { @@ -2233,6 +2313,8 @@ export class TerminalManager { current.agentOrchestration = null; void this.cleanupLaunchFiles(current).catch(() => undefined); this.emitSession(current.metadata); + // Input waiting for this CLI to become ready learns at once that it never will. + this.wakeLaunchWaiters(current); // Recorded at the moment of exit, so a finished agent is never relaunched. this.schedulePersistence(); } @@ -2335,10 +2417,55 @@ function resetLaunchSignals(session: ManagedSession): void { delete session.promptTurnMark; delete session.hookSignals; delete session.titleState; + delete session.inputReady; + delete session.alternateScreen; + delete session.readinessCarry; + delete session.hookReadyAt; + if (session.hookReadyTimer) clearTimeout(session.hookReadyTimer); + session.hookReadyTimer = undefined; if (session.answeredPromptTimer) clearTimeout(session.answeredPromptTimer); session.answeredPromptTimer = undefined; } +/** Whether typed text reaches this launch's CLI now (see WAITS_FOR_INPUT_READY). */ +function cliAcceptsInput(session: ManagedSession): boolean { + return !waitsForInputReady(session.metadata.provider) || session.inputReady === true + || (session.hookReadyAt !== undefined && Date.now() >= session.hookReadyAt); +} + +/** DEC private modes set or reset (CSI ? Pm h / l): terminal control sequences, not text the CLI shows. */ +const DEC_PRIVATE_MODES = /\x1b\[\?([\d;]+)([hl])/gu; +/** The longest DEC private mode sequence kept from the end of one chunk for the next. */ +const READINESS_CARRY_CHARS = 32; + +/** + * Reads only terminal control sequences of one output chunk: the alternate screen (modes 1049, 1047, 47) and the text + * cursor (DECTCEM, mode 25). A full-screen CLI hides its cursor while it draws its start-up and shows it once its + * input field has focus; that moment is when typed keys start to reach that field. True once that happened; a + * sequence cut at the chunk's end is completed by the next chunk. + */ +function watchInputReadiness(session: ManagedSession, data: string): boolean { + const text = (session.readinessCarry ?? "") + data; + let consumed = 0; + for (const match of text.matchAll(DEC_PRIVATE_MODES)) { + consumed = match.index + match[0].length; + const set = match[2] === "h"; + for (const mode of match[1]!.split(";")) { + if (mode === "1049" || mode === "1047" || mode === "47") session.alternateScreen = set; + else if (mode === "25" && set && session.alternateScreen) session.inputReady = true; + } + if (session.inputReady) break; + } + if (session.inputReady) { + delete session.readinessCarry; + delete session.alternateScreen; + return true; + } + const escape = text.lastIndexOf("\x1b"); + session.readinessCarry = escape >= consumed && text.length - escape < READINESS_CARRY_CHARS ? text.slice(escape) : ""; + return false; +} + function titleDefersToHooks(session: ManagedSession, state: "idle" | "working" | "needs_approval"): boolean { return state !== "working" && session.metadata.provider === "claude" && session.hookSignals !== undefined; } diff --git a/src/main/services/agent-browser/OrchestrationTools.ts b/src/main/services/agent-browser/OrchestrationTools.ts index f5df4f31..3052d95b 100644 --- a/src/main/services/agent-browser/OrchestrationTools.ts +++ b/src/main/services/agent-browser/OrchestrationTools.ts @@ -22,6 +22,13 @@ import type { McpToolDefinition } from "../../../agent-browser/orchestration-cat * a foreign session id is a protocol error, never a filtered result, so an * orchestrator cannot probe sessions it does not own. */ +/** + * How long spawn_agent (its checks, the launch and the CLI's readiness together) and send_to_agent wait for a prompt + * to reach the agent: under the about 60 s after which most MCP clients end a tool call, so the orchestrator gets + * the reason (and the session id) instead of a client timeout. + */ +export const AGENT_TOOL_DELIVERY_MS = 45_000; + export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { private readonly control: AgentControlService; private readonly plugins: Pick | null; @@ -131,6 +138,7 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { } private async spawn(orchestratorId: string, args: Record, signal?: AbortSignal): Promise> { + const deadline = Date.now() + AGENT_TOOL_DELIVERY_MS; if (!(AGENT_PROVIDERS as readonly unknown[]).includes(args.provider)) { throw orchestrationBridgeError("INVALID_REQUEST", unknownProviderMessage(args.provider), false); } @@ -165,7 +173,7 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { ...(args.model !== undefined ? { model: args.model as string } : {}), ...(args.effort !== undefined ? { effort: args.effort as SpawnAgentRequest["effort"] } : {}), profile: profile.profile - }, signal).catch((error: unknown) => { + }, signal, Math.max(0, deadline - Date.now())).catch((error: unknown) => { if (signal?.aborted) throw canceledError(); throw error; }); @@ -200,7 +208,8 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { args.sessionId as string, args.prompt as string, args.submit === undefined ? true : Boolean(args.submit), - signal + signal, + AGENT_TOOL_DELIVERY_MS ); } catch (error) { if (signal?.aborted) throw canceledError(); diff --git a/src/main/services/materials/HandoffService.ts b/src/main/services/materials/HandoffService.ts index 85abce3f..dfafee2d 100644 --- a/src/main/services/materials/HandoffService.ts +++ b/src/main/services/materials/HandoffService.ts @@ -63,6 +63,8 @@ export interface HandoffImageOps { } interface HandoffTiming { + /** How long a paste waits for the agent's CLI to take input (OpenCode drops keys until its input field is up). */ + readyMs: number; attachMs: number; pasteMs: number; pollMs: number; @@ -114,7 +116,7 @@ type Checked = { ok: true; plan: HandoffPlan } | { ok: false; reason: HandoffBlo type Reserved = { ok: true; plan: HandoffPlan; screen: SessionScreen | null } | { ok: false; reason: HandoffBlock | MaterialFailure }; -const DEFAULT_TIMING: HandoffTiming = { attachMs: 8_000, pasteMs: 6_000, pollMs: 120 }; +const DEFAULT_TIMING: HandoffTiming = { readyMs: 15_000, attachMs: 8_000, pasteMs: 6_000, pollMs: 120 }; export class HandoffService { private readonly options: HandoffServiceOptions; @@ -508,7 +510,7 @@ export class HandoffService { const paste = async (data: string): Promise => { const error = this.deliveryError(plan, screen); if (error) return error; - const written = await this.options.terminals.deliverInput(id, `${PASTE_START}${data}${PASTE_END}`, 0); + const written = await this.options.terminals.deliverInput(id, `${PASTE_START}${data}${PASTE_END}`, this.timing.readyMs); return written.delivered ? null : written.reason; }; if (!screen) { @@ -553,7 +555,7 @@ export class HandoffService { ? failed(error) : { state: "pasted", note: "enter-failed", sentAt: this.now(), imagesAttached: attached, error }; this.submitting.set(plan.id, { sessionId: id, turnStartedAt: null }); - const submitted = await this.options.terminals.deliverInput(id, "\r", 0); + const submitted = await this.options.terminals.deliverInput(id, "\r", this.timing.readyMs); return submitted.delivered ? { state: "submitted", sentAt: this.now(), imagesAttached: attached } : { state: "pasted", note: "enter-failed", sentAt: this.now(), imagesAttached: attached, error: submitted.reason }; diff --git a/tests/helpers/terminal.mjs b/tests/helpers/terminal.mjs index c36d9d62..2747eb98 100644 --- a/tests/helpers/terminal.mjs +++ b/tests/helpers/terminal.mjs @@ -25,6 +25,18 @@ export function availableRegistry() { * returns a fake PTY: `emitData`/`emitExit` drive its listeners, `lastResize` * holds the last size, and `onWrite(data, options)` sees what was written. */ +/** + * What OpenCode prints once its prompt has focus: the alternate screen, its hidden cursor while it draws, then the + * cursor shown in the input field (TerminalManager types queued input only after that for OpenCode). + */ +export const OPENCODE_INPUT_READY = "\x1b[?1049h\x1b[?2004h\x1b[?25l Ask anything\x1b[?25h"; + +/** Shows OpenCode's input field on the fake PTY OpenCode was spawned on, once that PTY exists. */ +export async function openCodeInputReady(calls, index = calls.length) { + for (let i = 0; i < 200 && calls.length <= index; i++) await new Promise((resolve) => setTimeout(resolve, 5)); + calls[index].process.emitData(OPENCODE_INPUT_READY); +} + export function fakeSpawner(calls, { pidBase = 20_000, onWrite } = {}) { return (command, args, options) => { let dataListener = () => undefined; @@ -53,3 +65,13 @@ export function fakeSpawner(calls, { pidBase = 20_000, onWrite } = {}) { return process; }; } + +/** fakeSpawner whose PTYs show OpenCode's input field shortly after they start (input waits for it for OpenCode). */ +export function inputReadySpawner(calls, options) { + const spawner = fakeSpawner(calls, options); + return (command, args, spawnOptions) => { + const process = spawner(command, args, spawnOptions); + setTimeout(() => process.emitData(OPENCODE_INPUT_READY), 10); + return process; + }; +} diff --git a/tests/launch-readiness.test.mjs b/tests/launch-readiness.test.mjs index b1aa6465..00716d50 100644 --- a/tests/launch-readiness.test.mjs +++ b/tests/launch-readiness.test.mjs @@ -14,9 +14,9 @@ import { controlRequest } from "../scripts/canvastty-control.mjs"; import { AgentControlService } from "../src/main/services/AgentControlService.ts"; import { LaunchPipeline } from "../src/main/services/LaunchPipeline.ts"; import { PluginSessions } from "../src/main/services/PluginSessions.ts"; -import { TerminalManager } from "../src/main/services/TerminalManager.ts"; +import { INPUT_READY_HOOK_GRACE_MS, TerminalManager } from "../src/main/services/TerminalManager.ts"; import { AgentControlGateway } from "../src/main/services/agent-control/AgentControlGateway.ts"; -import { ScopedOrchestrationHandler } from "../src/main/services/agent-browser/OrchestrationTools.ts"; +import { AGENT_TOOL_DELIVERY_MS, ScopedOrchestrationHandler } from "../src/main/services/agent-browser/OrchestrationTools.ts"; const at = { x: 0, y: 0 }; const cwd = process.cwd(); @@ -209,3 +209,236 @@ test("the control CLI says a starting session is not ready instead of claiming t assert.equal(f.calls.length, 2); assert.deepEqual(f.calls[1].written, [], "the refused send was not queued"); }); + +// OpenCode drops every key that arrives while its full-screen view starts, so a prompt typed when its PTY exists is +// lost and the agent sits at an empty prompt. Its prompt goes in only once its input field is up: a lifecycle hook, +// or the control sequence that shows its text cursor on the alternate screen (never its human-readable screen text). + +const ALT_SCREEN_HIDDEN_CURSOR = "\x1b[?1049h\x1b[?2004h\x1b[?25l"; +const CURSOR_SHOWN = "\x1b[1 q\x1b[?25h"; + +/** + * A slow-starting OpenCode stand-in: it draws its start-up for `startupMs` with the cursor hidden and drops every key + * written meanwhile (as OpenCode does), then shows its input field. `accepted` holds what reached that field. + */ +function slowOpenCode({ startupMs = 120, ready = true } = {}) { + const runs = []; + const spawn = () => { + let emit = () => undefined; + const exits = []; + const run = { written: [], accepted: [], dropped: [], ready: false, exit: (code) => exits.forEach((listener) => listener({ exitCode: code })) }; + runs.push(run); + setTimeout(() => emit(ALT_SCREEN_HIDDEN_CURSOR), 5); + setTimeout(() => emit(" Ask anything… \"Fix broken tests\" tab agents ctrl+p commands"), startupMs / 2); + if (ready) setTimeout(() => { run.ready = true; emit(CURSOR_SHOWN); }, startupMs); + return { + pid: 70_000 + runs.length, process: "opencode", resize() {}, kill() {}, pause() {}, resume() {}, + write(text) { run.written.push(text); (run.ready ? run.accepted : run.dropped).push(text); }, + onData(listener) { emit = listener; return { dispose() {} }; }, + onExit(listener) { exits.push(listener); return { dispose() {} }; } + }; + }; + return { runs, spawn }; +} + +function slowOpenCodeOrchestration(t, options) { + const cli = slowOpenCode(options); + let orchestratorStarted = false; + const terminals = new TerminalManager(() => {}, clis(), undefined, undefined, true, (...args) => { + if (!orchestratorStarted) { orchestratorStarted = true; return spawner([])(...args); } + return cli.spawn(...args); + }); + t.after(() => terminals.disposeAll()); + const control = new AgentControlService(terminals); + const handler = new ScopedOrchestrationHandler(control); + const orchestrator = terminals.create({ provider: "codex", profile: "normal", cwd, position: at, role: "orchestrator" }); + return { cli, terminals, control, execute: (tool, args) => handler.execute(orchestrator.id, { tool, arguments: args }) }; +} + +test("spawn_agent: a slow-starting OpenCode gets its first prompt once its input field is up, exactly once", async (t) => { + const f = slowOpenCodeOrchestration(t, { startupMs: 150 }); + const spawned = f.execute("spawn_agent", { provider: "opencode", cwd, prompt: "do part A" }); + await new Promise((resolve) => setTimeout(resolve, 100)); + assert.equal(await state(spawned), "pending", "the PTY exists and the home screen text is drawn, but the field is not up"); + assert.deepEqual(f.cli.runs[0].written, []); + const result = await spawned; + assert.equal(typeof result.sessionId, "string"); + assert.deepEqual(f.cli.runs[0].accepted, ["do part A\r"], "the prompt reached the input field"); + assert.deepEqual(f.cli.runs[0].dropped, [], "nothing was typed while OpenCode started"); + + // send_to_agent to the running card goes in at once. + assert.deepEqual(await f.execute("send_to_agent", { sessionId: result.sessionId, prompt: "now part B" }), { sessionId: result.sessionId, sent: true }); + assert.deepEqual(f.cli.runs[0].accepted, ["do part A\r", "now part B\r"]); +}); + +test("spawn_agent: an OpenCode that exits before its input field is up fails the call with the reason", async (t) => { + const f = slowOpenCodeOrchestration(t, { ready: false }); + const spawned = f.execute("spawn_agent", { provider: "opencode", cwd, prompt: "never typed" }); + await tick(); + f.cli.runs[0].exit(1); + await assert.rejects(spawned, (error) => { + assert.match(error.bridgeError?.message ?? "", /was not delivered: The session/u); + return true; + }); + assert.deepEqual(f.cli.runs[0].written, []); +}); + +test("OpenCode that never shows its input field gets no prompt and a bounded reason", async (t) => { + const cli = slowOpenCode({ ready: false }); + const terminals = new TerminalManager(() => {}, clis(), undefined, undefined, true, cli.spawn); + t.after(() => terminals.disposeAll()); + const session = terminals.create({ provider: "opencode", profile: "normal", cwd, position: at }); + const started = Date.now(); + const result = await terminals.deliverInput(session.id, "never\r", 200); + assert.equal(result.delivered, false); + assert.match(result.reason, /did not show its input field within 200 ms; nothing was typed/u); + assert.ok(Date.now() - started < 1_000); + assert.deepEqual(cli.runs[0].written, [], "its home screen text alone is not readiness"); +}); + +test("OpenCode readiness: a cursor shown before the alternate screen does not count; one cut across chunks does", async (t) => { + const calls = []; + const terminals = new TerminalManager(() => {}, clis(), undefined, undefined, true, (command, args, options) => { + const pty = spawner(calls)(command, args, options); + return { ...pty, onData(listener) { calls.at(-1).emit = listener; return { dispose() {} }; } }; + }); + t.after(() => terminals.disposeAll()); + const session = terminals.create({ provider: "opencode", profile: "normal", cwd, position: at }); + const pending = terminals.deliverInput(session.id, "split\r", 2_000); + calls[0].emit("\x1b[?25h"); + calls[0].emit("\x1b[?104"); + calls[0].emit("9h\x1b[?25l drawing"); + await tick(); + assert.deepEqual(calls[0].written, []); + calls[0].emit("\x1b[?2"); + await tick(); + assert.deepEqual(calls[0].written, []); + calls[0].emit("5h"); + assert.deepEqual(await pending, { delivered: true }); + assert.deepEqual(calls[0].written, ["split\r"]); +}); + +test("OpenCode readiness: a lifecycle hook counts only after a grace, and sends made while it starts arrive in order", async (t) => { + const calls = []; + const terminals = new TerminalManager(() => {}, clis(), undefined, undefined, true, spawner(calls)); + t.after(() => terminals.disposeAll()); + const session = terminals.create({ provider: "opencode", profile: "normal", cwd, position: at }); + const first = terminals.deliverInput(session.id, "first\r", INPUT_READY_HOOK_GRACE_MS + 2_000); + const second = terminals.deliverInput(session.id, "second\r", INPUT_READY_HOOK_GRACE_MS + 2_000); + await tick(); + assert.deepEqual(calls[0].written, []); + const hookAt = Date.now(); + terminals.applyProviderSignal(session.id, { kind: "lifecycle", state: "idle", event: "session.created" }); + await new Promise((resolve) => setTimeout(resolve, 200)); + assert.deepEqual(calls[0].written, [], "a hook alone is not the input field"); + assert.deepEqual([await first, await second], [{ delivered: true }, { delivered: true }]); + assert.ok(Date.now() - hookAt >= INPUT_READY_HOOK_GRACE_MS - 20, "typed only after the grace"); + assert.deepEqual(calls[0].written, ["first\r", "second\r"]); +}); + +test("OpenCode readiness: a resumed card whose plugin reports before its input field is up gets the text on the cursor signal", async (t) => { + const calls = []; + const terminals = new TerminalManager(() => {}, clis(), undefined, undefined, true, (command, args, options) => { + const pty = spawner(calls)(command, args, options); + return { ...pty, onData(listener) { calls.at(-1).emit = listener; return { dispose() {} }; } }; + }); + t.after(() => terminals.disposeAll()); + const session = terminals.create({ provider: "opencode", profile: "normal", cwd, position: at }); + calls[0].emit("\x1b[?1049h\x1b[?25l"); + // The plugin reports idle on session.updated about 0.6 s before typed keys reach the prompt. + terminals.applyProviderSignal(session.id, { kind: "lifecycle", state: "idle", event: "session.updated" }); + const sent = terminals.deliverInput(session.id, "resumed\r", 5_000); + await new Promise((resolve) => setTimeout(resolve, 300)); + assert.deepEqual(calls[0].written, [], "not typed on the hook"); + const shownAt = Date.now(); + calls[0].emit("\x1b[?25h"); + assert.deepEqual(await sent, { delivered: true }); + assert.ok(Date.now() - shownAt < INPUT_READY_HOOK_GRACE_MS / 2, "typed on the cursor signal, not after the grace"); + assert.deepEqual(calls[0].written, ["resumed\r"]); +}); + +test("OpenCode readiness: a send cancelled while it waits behind another settles at once and is never typed", async (t) => { + const calls = []; + const terminals = new TerminalManager(() => {}, clis(), undefined, undefined, true, (command, args, options) => { + const pty = spawner(calls)(command, args, options); + return { ...pty, onData(listener) { calls.at(-1).emit = listener; return { dispose() {} }; } }; + }); + t.after(() => terminals.disposeAll()); + const session = terminals.create({ provider: "opencode", profile: "normal", cwd, position: at }); + const first = terminals.deliverInput(session.id, "first\r", 5_000); + const controller = new AbortController(); + const queued = terminals.deliverInput(session.id, "queued\r", 5_000, controller.signal); + const third = terminals.deliverInput(session.id, "third\r", 5_000); + await tick(); + const abortedAt = Date.now(); + controller.abort(); + assert.deepEqual(await queued, { delivered: false, reason: "The delivery was cancelled." }); + assert.ok(Date.now() - abortedAt < 200, "settled without waiting for the send ahead of it"); + calls[0].emit("\x1b[?1049h\x1b[?25h"); + assert.deepEqual([await first, await third], [{ delivered: true }, { delivered: true }]); + assert.deepEqual(calls[0].written, ["first\r", "third\r"]); +}); + +test("OpenCode readiness is not awaited on Windows, whose pseudo console may not pass the cursor sequences", async (t) => { + const platform = Object.getOwnPropertyDescriptor(process, "platform"); + const calls = []; + const terminals = new TerminalManager(() => {}, clis(), undefined, undefined, true, spawner(calls)); + t.after(() => terminals.disposeAll()); + const session = terminals.create({ provider: "opencode", profile: "normal", cwd, position: at }); + Object.defineProperty(process, "platform", { ...platform, value: "win32" }); + try { + assert.deepEqual(await terminals.deliverInput(session.id, "windows\r", 200), { delivered: true }); + } finally { + Object.defineProperty(process, "platform", platform); + } + assert.deepEqual(calls[0].written, ["windows\r"]); +}); + +test("spawn_agent and send_to_agent wait for delivery within a budget under the MCP client's deadline", async (t) => { + assert.ok(AGENT_TOOL_DELIVERY_MS <= 45_000); + const f = slowOpenCodeOrchestration(t, { ready: false }); + const waits = []; + const { control } = f; + const spawn = control.spawn.bind(control); + control.spawn = (request, signal, promptWaitMs) => { waits.push(["spawn", promptWaitMs]); return spawn(request, signal, 150); }; + const send = control.send.bind(control); + control.send = (id, text, submit, signal, waitMs) => { waits.push(["send", waitMs]); return send(id, text, submit, signal, 150); }; + await assert.rejects(f.execute("spawn_agent", { provider: "opencode", cwd, prompt: "late" }), (error) => { + assert.match(error.bridgeError?.message ?? "", /did not show its input field within 150 ms; nothing was typed/u); + return true; + }); + const [child] = f.terminals.listMetadata().filter((session) => session.role === "subagent"); + await assert.rejects(f.execute("send_to_agent", { sessionId: child.id, prompt: "again" })); + assert.equal(waits[0][0], "spawn"); + assert.ok(waits[0][1] > 0 && waits[0][1] <= AGENT_TOOL_DELIVERY_MS, String(waits[0][1])); + assert.deepEqual(waits[1], ["send", AGENT_TOOL_DELIVERY_MS]); +}); + +test("OpenCode readiness: cancellation and a restart while it starts never deliver the queued text", async (t) => { + const cli = slowOpenCode({ startupMs: 80 }); + const terminals = new TerminalManager(() => {}, clis(), undefined, undefined, true, cli.spawn); + t.after(() => terminals.disposeAll()); + const session = terminals.create({ provider: "opencode", profile: "normal", cwd, position: at }); + const controller = new AbortController(); + const canceled = terminals.deliverInput(session.id, "cancel\r", 2_000, controller.signal); + const replaced = terminals.deliverInput(session.id, "old launch\r", 2_000); + controller.abort(); + assert.deepEqual(await canceled, { delivered: false, reason: "The delivery was cancelled." }); + cli.runs[0].exit(0); + terminals.restart(session.id); + assert.equal((await replaced).delivered, false); + await new Promise((resolve) => setTimeout(resolve, 150)); + assert.ok(cli.runs[1].ready, "the new launch became ready"); + assert.ok(cli.runs.every((run) => run.written.length === 0), "neither launch got the text"); +}); + +test("Claude and Codex still get input as soon as their PTY exists", async (t) => { + const calls = []; + const terminals = new TerminalManager(() => {}, clis(), undefined, undefined, true, spawner(calls)); + t.after(() => terminals.disposeAll()); + for (const provider of ["claude", "codex"]) { + const session = terminals.create({ provider, profile: "normal", cwd, position: at }); + assert.deepEqual(await terminals.deliverInput(session.id, `${provider} task\r`, 200), { delivered: true }); + } + assert.deepEqual(calls.map((call) => call.written), [["claude task\r"], ["codex task\r"]]); +}); diff --git a/tests/material-handoff-service.test.mjs b/tests/material-handoff-service.test.mjs index 141e452a..8ce26e6e 100644 --- a/tests/material-handoff-service.test.mjs +++ b/tests/material-handoff-service.test.mjs @@ -979,3 +979,19 @@ test("a delivered handoff says when the outcome could not be saved", async () => await rm(blocked, { recursive: true }); }); }); + +test("a handoff waits for the agent's CLI to take input instead of failing at once", async () => { + await withHandoffs(async ({ handoffs, terminal, draft }) => { + terminal.add({ provider: "claude", id: "s1" }); + terminal.tui = claudeTui(terminal); + const waits = []; + const deliverInput = terminal.deliverInput.bind(terminal); + terminal.deliverInput = async (id, data, waitMs) => { + waits.push(waitMs); + return deliverInput(id, data); + }; + assert.equal((await handoffs.send(draft("s1"))).handoff.delivery.state, "submitted"); + assert.ok(waits.length >= 2, "the paste and its Enter"); + assert.ok(waits.every((waitMs) => waitMs >= 10_000), `waits: ${waits.join(", ")}`); + }); +}); diff --git a/tests/opencode-result.test.mjs b/tests/opencode-result.test.mjs index 1355f842..9689b60a 100644 --- a/tests/opencode-result.test.mjs +++ b/tests/opencode-result.test.mjs @@ -10,7 +10,7 @@ import { RuntimeGateway } from "../src/main/services/agent-runtime/RuntimeGatewa import { TerminalManager } from "../src/main/services/TerminalManager.ts"; import { AgentControlService } from "../src/main/services/AgentControlService.ts"; import { ScopedOrchestrationHandler } from "../src/main/services/agent-browser/OrchestrationTools.ts"; -import { availableRegistry, fakeSpawner } from "./helpers/terminal.mjs"; +import { availableRegistry, fakeSpawner, openCodeInputReady } from "./helpers/terminal.mjs"; const POSIX = { skip: process.platform === "win32" ? "Unix socket test; the Windows pipe transport has its own suite." : false }; @@ -147,11 +147,14 @@ test("an ordinary OpenCode card (not a subagent) never keeps an answer", async ( test("wait_for_agent ignores an idle before the prompt's turn and returns that turn's answer", async (t) => { const root = await realpath(await mkdtemp(join(tmpdir(), "ctty-ocr-"))); t.after(() => rm(root, { recursive: true, force: true })); - const terminals = new TerminalManager(() => undefined, availableRegistry(), undefined, undefined, true, fakeSpawner([])); + const calls = []; + const terminals = new TerminalManager(() => undefined, availableRegistry(), undefined, undefined, true, fakeSpawner(calls)); t.after(() => terminals.disposeAll()); const orchestrator = terminals.create({ provider: "codex", cwd: root, profile: "normal", position: { x: 0, y: 0 }, role: "orchestrator" }); const handler = new ScopedOrchestrationHandler(new AgentControlService(terminals, { waitTiming: { checkMs: 5, settleMs: 10, quietMs: 60_000 } })); - const child = await handler.execute(orchestrator.id, { id: "1", tool: "spawn_agent", arguments: { provider: "opencode", cwd: root, prompt: "do part A" } }); + const spawning = handler.execute(orchestrator.id, { id: "1", tool: "spawn_agent", arguments: { provider: "opencode", cwd: root, prompt: "do part A" } }); + await openCodeInputReady(calls, 1); + const child = await spawning; const wait = (timeoutSeconds) => handler.execute(orchestrator.id, { id: "w", tool: "wait_for_agent", arguments: { sessionId: child.sessionId, timeoutSeconds } }); // OpenCode's session.created reports idle before the prompt's turn starts. terminals.applyProviderSignal(child.sessionId, { kind: "lifecycle", state: "idle", event: "session.created" }); diff --git a/tests/orchestration-providers-wait.test.mjs b/tests/orchestration-providers-wait.test.mjs index ebe18914..7bfc7357 100644 --- a/tests/orchestration-providers-wait.test.mjs +++ b/tests/orchestration-providers-wait.test.mjs @@ -261,7 +261,7 @@ test("after a prompt, an idle before the turn starts does not end wait_for_agent const { tmpdir } = await import("node:os"); const { join } = await import("node:path"); const { TerminalManager } = await import("../src/main/services/TerminalManager.ts"); - const { availableRegistry, fakeSpawner } = await import("./helpers/terminal.mjs"); + const { availableRegistry, fakeSpawner, openCodeInputReady } = await import("./helpers/terminal.mjs"); const root = await realpath(await mkdtemp(join(tmpdir(), "ctty-turn-"))); t.after(() => rm(root, { recursive: true, force: true })); const calls = []; @@ -277,7 +277,9 @@ test("after a prompt, an idle before the turn starts does not end wait_for_agent terminals.applyProviderSignal(idleChild.sessionId, { kind: "lifecycle", state: "idle" }); assert.equal((await handler.execute(orchestrator.id, { id: "i", tool: "wait_for_agent", arguments: { sessionId: idleChild.sessionId } })).reason, "idle"); - const child = await handler.execute(orchestrator.id, { id: "1", tool: "spawn_agent", arguments: { provider: "opencode", cwd: root, prompt: "do part A" } }); + const spawning = handler.execute(orchestrator.id, { id: "1", tool: "spawn_agent", arguments: { provider: "opencode", cwd: root, prompt: "do part A" } }); + await openCodeInputReady(calls, 2); + const child = await spawning; // The CLI's startup idle arrives after the prompt was typed but before its turn starts. terminals.applyProviderSignal(child.sessionId, { kind: "lifecycle", state: "idle" }); const early = await wait(1); diff --git a/tests/subagent-model-account.test.mjs b/tests/subagent-model-account.test.mjs index a10150fb..d6e91c98 100644 --- a/tests/subagent-model-account.test.mjs +++ b/tests/subagent-model-account.test.mjs @@ -8,7 +8,7 @@ import { ScopedOrchestrationHandler } from "../src/main/services/agent-browser/O import { LaunchPipeline, modelSettingArgument } from "../src/main/services/LaunchPipeline.ts"; import { TerminalManager } from "../src/main/services/TerminalManager.ts"; import { TerminalSessionStore } from "../src/main/services/TerminalSessionStore.ts"; -import { availableRegistry, fakeSpawner } from "./helpers/terminal.mjs"; +import { availableRegistry, inputReadySpawner } from "./helpers/terminal.mjs"; // A subagent spawned without launch options once ran on its CLI's own default model (for OpenCode the hosted OpenCode // Zen model), while its orchestrator ran on a model the person picked through a plugin (a model account). It now keeps @@ -29,7 +29,7 @@ async function setup(t, { delegable = true, store } = {}) { const prepared = []; // What the person can change while the orchestrator runs: turn a plugin off, or a plugin update changes its fields. const state = { gone: new Set(), extrasFields: [{ key: "flavor", label: "Flavor", kind: "text" }] }; - const terminals = new TerminalManager(() => undefined, availableRegistry(), undefined, undefined, true, fakeSpawner(calls)); + const terminals = new TerminalManager(() => undefined, availableRegistry(), undefined, undefined, true, inputReadySpawner(calls)); t.after(() => store ? terminals.shutdown() : terminals.disposeAll()); terminals.configureLaunchPipeline(new LaunchPipeline({ contributors: () => [ From 1cb4ba99c183a76e1530246a73a792b441720b73 Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:52:05 +0000 Subject: [PATCH 06/28] fix(orchestration): return wait_for_agent before the MCP client gives up wait_for_agent accepted up to 600 seconds, but the MCP clients of several supported CLIs (OpenCode among them) end a tool call after about 60 seconds, so an orchestrator that asked for a long wait got a failed call instead of a result. A call now asks for at most 100 seconds (default 50). Unless the orchestrator's CLI is known to let a tool call run longer, a call returns after at most 50 seconds. A timeout carries the status and the masked output as progress plus a message to call again, and the tool description in both catalogs, the orchestrator skill and the docs say that a timeout means the subagent is still running. --- agent/orchestrator/SKILL.md | 2 +- docs/agent-orchestration.md | 2 +- native/canvastty-helper/catalog.json | 6 ++-- src/agent-browser/orchestration-catalog.d.mts | 1 + src/agent-browser/orchestration-catalog.mjs | 13 ++++--- .../agent-browser/OrchestrationTools.ts | 8 +++-- tests/orchestration-providers-wait.test.mjs | 36 ++++++++++++++++--- 7 files changed, 51 insertions(+), 17 deletions(-) diff --git a/agent/orchestrator/SKILL.md b/agent/orchestrator/SKILL.md index bf67517c..fbcc2fbf 100644 --- a/agent/orchestrator/SKILL.md +++ b/agent/orchestrator/SKILL.md @@ -11,7 +11,7 @@ A session started from CanvasTTY's launcher with the **Orchestrator** role has t 1. `list_providers`: which agents CanvasTTY can launch here. Each entry's `id` is the exact `spawn_agent.provider` value; prefer entries that are `available` with `signIn: "ok"` (`unknown` only means CanvasTTY has not checked yet). When plugins offer launch options (for example an accounts plugin's `list_routes` tool), the answer names that tool. 2. `spawn_agent` once per part of the task: a known `provider` id, an absolute `cwd` and a self-contained prompt. An unknown provider is refused with a pointer back to `list_providers`. **If the person names a model, pass it as `model`** in the format `list_providers` gives for that provider (`model.format`; OpenCode takes `provider/model`, for example `zai-coding-plan/glm-5.3-flash`, and `model.known` lists what `opencode models` reported); without `model` a subagent of your own CLI keeps your plugin launch options (for example your model account), and any other CLI uses its own default. A plugin you do not name in `launchOptions` keeps your own values; passing `model` next to inherited options that already set the model is refused, and a subagent of another CLI is refused while your model comes from a plugin's options (spawn one of your own CLI instead). The answer's `servedBy` names the CLI, the model asked for, and whether each plugin's options were `inherited` or `explicit`. `effort` sets the reasoning effort where the CLI has one (`efforts`). Without `profile` the subagent gets your own launch profile, or the next lower one its CLI has (never more than yours, never YOLO); `list_providers` shows each provider's `profiles`. `cwd` must be this project's folder or a folder inside it. A refusal says why (a folder outside the project, a profile above yours, a limit the person set): change the request, do not retry it. -3. `wait_for_agent` for each subagent (`timeoutSeconds` up to 600; call it again after `timeout`) instead of polling. `needs_approval` means the person must answer a prompt in that card: never answer it yourself. +3. `wait_for_agent` for each subagent (`timeoutSeconds` up to 100; a call returns within 50 seconds unless you are Claude Code; `timeout` means it is still running, so read `output` as progress and call it again) instead of polling. `needs_approval` means the person must answer a prompt in that card: never answer it yourself. 4. `get_agent_result` to read what the subagent produced; check the actual files before reporting success. Do not explore the filesystem, `PATH` or config folders (for example `~/.local/bin` or `~/.config/`) looking for agents or their configuration: `list_providers` is what CanvasTTY can launch, and delegation goes through `spawn_agent`. diff --git a/docs/agent-orchestration.md b/docs/agent-orchestration.md index a34f2d6f..c6373cd9 100644 --- a/docs/agent-orchestration.md +++ b/docs/agent-orchestration.md @@ -10,7 +10,7 @@ A session launched from the desktop with the **Orchestrator** role gets the `can | --- | --- | | `list_providers` | The agent providers CanvasTTY can launch as subagents: `id` (the exact `spawn_agent.provider` value), `name`, `installed`/`available` (from the provider CLI registry resolved at startup or on a recheck), `signIn` (`ok`, `signed_out`, `expired` or `unknown`, from the last usage-limits read; it never starts a read and never reads credentials), `subagent` and `orchestrator` support, `model` (whether the CLI takes one, its format, and for OpenCode the models a cached `opencode models` listing reported: run in the background with a 5 s timeout, refreshed after 10 minutes, never awaited) and `efforts`, and plugin launch options (`launchOptions`, plus `launchOptionTools` such as an accounts plugin's `list_routes`). Fast: cached state only, no network calls. | | `spawn_agent` | Launches a subagent (profile: see below). `provider` must be a `list_providers` id (the schema lists them); an unknown one is refused with `INVALID_REQUEST` naming `list_providers`. Optional `model` goes to the CLI's own `--model` for that run (OpenCode `provider/model`; Codex, Claude, Qwen, Kimi alias, Grok, OMP, Pi, Cursor; Hermes, MiniMax, Devin and Antigravity take none) and optional `effort` to its reasoning effort (Codex `-c model_reasoning_effort`, Claude `--effort`, Grok `--reasoning-effort`). Both are checked per provider, kept on restart and restore, and never written to the CLI's configuration. If the person names a model, the orchestrator passes it as `model`. Plugin launch options the call does not name are inherited from the orchestrator for a subagent of the same CLI (see Plugin launch options below); `model` next to inherited options that set the model is refused, and so is a subagent of another CLI while the orchestrator's model comes from a plugin's options. The answer's `servedBy` is `{ provider, model, launchOptions: { : "inherited" | "explicit" } }`. An OpenCode model its own `opencode models` list does not contain is refused before launch with up to five closest listed ids (OpenCode would otherwise stop with only "Unexpected server error"); when that list cannot be read, the model is passed as given. A subagent that exits anyway reports the last lines of its screen as `exitLines` in `wait_for_agent`, `observe_agent` and `get_agent_result`. | -| `wait_for_agent` | `{ sessionId, timeoutSeconds ≤ 600 }` (default 55). Returns when the subagent is `idle` (after a prompt: only once a turn that started after the latest delivered prompt has ended, so the CLI's startup idle does not count), `needs_approval`, `done`/`failed` (exited), `quiet` (reports no status and its screen stopped changing), `closed`, or on `timeout`, with `status`, `exitCode`, `waitedMs` and the masked terminal tail (masked before it is cut). Only this orchestrator's own subagents; it stops at once when the call is canceled or the orchestrator disconnects. | +| `wait_for_agent` | `{ sessionId, timeoutSeconds ≤ 100 }` (default 50). For an orchestrator other than Claude Code a call returns after at most 50 seconds, because its MCP client (Codex, OpenCode and others) ends a tool call after about 60 seconds; `timeout` then means the subagent is still running, with its progress in `output` and a `message` to call again. Returns when the subagent is `idle` (after a prompt: only once a turn that started after the latest delivered prompt has ended, so the CLI's startup idle does not count), `needs_approval`, `done`/`failed` (exited), `quiet` (reports no status and its screen stopped changing), `closed`, or on `timeout`, with `status`, `exitCode`, `waitedMs` and the masked terminal tail (masked before it is cut). Only this orchestrator's own subagents; it stops at once when the call is canceled or the orchestrator disconnects. | | `get_agent_result`, `observe_agent` | `get_agent_result`: `answer`, the final reply of the subagent's last turn as the agent itself reported it (Codex through its Stop hook, OpenCode through CanvasTTY's plugin reading the session's last assistant message at `session.idle`; at most 4,096 characters with the end kept, masked, in memory only and cleared when the next turn starts), plus `status`, the exit state and the masked terminal tail. Other providers have no `answer`: their tail is raw screen output. `observe_agent`: the current status and tail. | | `send_to_agent`, `cancel_agent`, `list_agents` | Follow-up prompts, disposing a subagent, listing this session's subagents. Prompts to one subagent arrive in the order they were sent. OpenCode drops keys typed while it starts, so a prompt for it (also `spawn_agent`'s first one) is typed only once its input field is up: the terminal control sequence that shows its text cursor on its full screen, or a lifecycle hook only 2 s after its first one (OpenCode's plugin can report shortly before the field takes keys). This wait is used on macOS and Linux; on Windows, whose pseudo console may not pass those sequences, OpenCode gets input as soon as its PTY exists, as before. `spawn_agent` (its checks, the launch and this wait together) and `send_to_agent` wait at most 45 s, under the MCP client's tool deadline; then the call fails with the reason and the session id, and nothing is typed. A send cancelled while it waits returns at once. | diff --git a/native/canvastty-helper/catalog.json b/native/canvastty-helper/catalog.json index 4622e41c..faf93a3c 100644 --- a/native/canvastty-helper/catalog.json +++ b/native/canvastty-helper/catalog.json @@ -1095,7 +1095,7 @@ }, { "name": "wait_for_agent", - "description": "Wait until one of this session's subagents stops working, instead of polling observe_agent or get_agent_result; nothing is sent to it while it waits. Returns reason \"idle\" (the turn that answers your latest prompt ended and it waits for input; an idle before that turn started does not count), \"needs_approval\" (its card shows a prompt only the person may answer; never answer it yourself), \"done\" or \"failed\" (its process exited), \"quiet\" (it reports no status or no turn start and its screen stopped changing, so judge from output), \"closed\" (its card was closed) or \"timeout\" after timeoutSeconds (default 55, at most 600), with status, exitCode, waitedMs, the masked terminal tail as output, and answer (the final reply of the turn that ended, for Codex and OpenCode subagents); when the subagent's process exited (for example at once, on a model its CLI does not know), exitLines holds the last lines of its screen as plain text, which say why. After a timeout, call it again. Then read get_agent_result.", + "description": "Wait until one of this session's subagents stops working, instead of polling observe_agent or get_agent_result; nothing is sent to it while it waits. Returns reason \"idle\" (the turn that answers your latest prompt ended and it waits for input; an idle before that turn started does not count), \"needs_approval\" (its card shows a prompt only the person may answer; never answer it yourself), \"done\" or \"failed\" (its process exited), \"quiet\" (it reports no status or no turn start and its screen stopped changing, so judge from output), \"closed\" (its card was closed) or \"timeout\" after timeoutSeconds (default 50, at most 100; unless this session is Claude Code, a call returns after at most 50 seconds, before its MCP client would end the call), with status, exitCode, waitedMs, the masked terminal tail as output, and answer (the final reply of the turn that ended, for Codex and OpenCode subagents); when the subagent's process exited (for example at once, on a model its CLI does not know), exitLines holds the last lines of its screen as plain text, which say why. A timeout means it is still running: read output as its progress and call wait_for_agent again. Then read get_agent_result.", "inputSchema": { "type": "object", "properties": { @@ -1107,7 +1107,7 @@ "timeoutSeconds": { "type": "integer", "minimum": 1, - "maximum": 600 + "maximum": 100 } }, "required": [ @@ -1178,6 +1178,6 @@ ], "maxPluginToolNameLength": 64, "maxLaunchOptionsBytes": 16384, - "defaultAgentWaitSeconds": 55 + "defaultAgentWaitSeconds": 50 } } diff --git a/src/agent-browser/orchestration-catalog.d.mts b/src/agent-browser/orchestration-catalog.d.mts index 48685eb7..1af1e815 100644 --- a/src/agent-browser/orchestration-catalog.d.mts +++ b/src/agent-browser/orchestration-catalog.d.mts @@ -3,6 +3,7 @@ export const MAX_ORCHESTRATION_PAYLOAD_BYTES: number; export const AGENT_PROVIDER_IDS: readonly string[]; export const MAX_AGENT_WAIT_SECONDS: number; export const DEFAULT_AGENT_WAIT_SECONDS: number; +export const CLIENT_SAFE_AGENT_WAIT_SECONDS: number; export const REASONING_EFFORT_IDS: readonly string[]; export function unknownProviderMessage(value: unknown): string; diff --git a/src/agent-browser/orchestration-catalog.mjs b/src/agent-browser/orchestration-catalog.mjs index e48ea3de..c57119e8 100644 --- a/src/agent-browser/orchestration-catalog.mjs +++ b/src/agent-browser/orchestration-catalog.mjs @@ -28,10 +28,13 @@ const prompt = string({ minLength: 1, maxLength: 65_536 }); export const AGENT_PROVIDER_IDS = Object.freeze([ "codex", "claude", "qwen", "kimi", "opencode", "hermes", "grok", "omp", "pi", "cursor", "minimax", "devin", "antigravity" ]); -/** wait_for_agent: the longest wait one call may ask for, and the wait without timeoutSeconds (some MCP clients - * end a tool call after 60 seconds). */ -export const MAX_AGENT_WAIT_SECONDS = 600; -export const DEFAULT_AGENT_WAIT_SECONDS = 55; +/** wait_for_agent: the longest wait one call may ask for, and the wait without timeoutSeconds. Most MCP clients end a + * tool call after 60 seconds (Codex's default tool_timeout_sec, OpenCode's client), so a call from any orchestrator + * but Claude Code (whose client waits far longer) returns after at most CLIENT_SAFE_AGENT_WAIT_SECONDS: a "timeout" + * that says the subagent still runs, never a call its client cut off. */ +export const MAX_AGENT_WAIT_SECONDS = 100; +export const DEFAULT_AGENT_WAIT_SECONDS = 50; +export const CLIENT_SAFE_AGENT_WAIT_SECONDS = 50; /** spawn_agent.effort: every level some CLI takes (src/shared/launchModel.ts REASONING_EFFORTS; a test keeps them equal). */ export const REASONING_EFFORT_IDS = Object.freeze(["minimal", "low", "medium", "high", "xhigh", "max"]); const provider = string({ minLength: 1, maxLength: 32, enum: [...AGENT_PROVIDER_IDS] }); @@ -85,7 +88,7 @@ export const ORCHESTRATION_TOOL_DEFINITIONS = Object.freeze([ ), tool( "wait_for_agent", - `Wait until one of this session's subagents stops working, instead of polling observe_agent or get_agent_result; nothing is sent to it while it waits. Returns reason "idle" (the turn that answers your latest prompt ended and it waits for input; an idle before that turn started does not count), "needs_approval" (its card shows a prompt only the person may answer; never answer it yourself), "done" or "failed" (its process exited), "quiet" (it reports no status or no turn start and its screen stopped changing, so judge from output), "closed" (its card was closed) or "timeout" after timeoutSeconds (default ${DEFAULT_AGENT_WAIT_SECONDS}, at most ${MAX_AGENT_WAIT_SECONDS}), with status, exitCode, waitedMs, the masked terminal tail as output, and answer (the final reply of the turn that ended, for Codex and OpenCode subagents); when the subagent's process exited (for example at once, on a model its CLI does not know), exitLines holds the last lines of its screen as plain text, which say why. After a timeout, call it again. Then read get_agent_result.`, + `Wait until one of this session's subagents stops working, instead of polling observe_agent or get_agent_result; nothing is sent to it while it waits. Returns reason "idle" (the turn that answers your latest prompt ended and it waits for input; an idle before that turn started does not count), "needs_approval" (its card shows a prompt only the person may answer; never answer it yourself), "done" or "failed" (its process exited), "quiet" (it reports no status or no turn start and its screen stopped changing, so judge from output), "closed" (its card was closed) or "timeout" after timeoutSeconds (default ${DEFAULT_AGENT_WAIT_SECONDS}, at most ${MAX_AGENT_WAIT_SECONDS}; unless this session is Claude Code, a call returns after at most ${CLIENT_SAFE_AGENT_WAIT_SECONDS} seconds, before its MCP client would end the call), with status, exitCode, waitedMs, the masked terminal tail as output, and answer (the final reply of the turn that ended, for Codex and OpenCode subagents); when the subagent's process exited (for example at once, on a model its CLI does not know), exitLines holds the last lines of its screen as plain text, which say why. A timeout means it is still running: read output as its progress and call wait_for_agent again. Then read get_agent_result.`, { sessionId, timeoutSeconds: integer({ minimum: 1, maximum: MAX_AGENT_WAIT_SECONDS }) }, ["sessionId"] ), diff --git a/src/main/services/agent-browser/OrchestrationTools.ts b/src/main/services/agent-browser/OrchestrationTools.ts index 3052d95b..71c8cb54 100644 --- a/src/main/services/agent-browser/OrchestrationTools.ts +++ b/src/main/services/agent-browser/OrchestrationTools.ts @@ -6,6 +6,7 @@ import { PromptNotDeliveredError, type AgentControlService, type SpawnAgentReque import { LaunchRefusal } from "../launchRefusal.ts"; import type { PluginAgentTools } from "../PluginAgentTools.ts"; import { + CLIENT_SAFE_AGENT_WAIT_SECONDS, DEFAULT_AGENT_WAIT_SECONDS, MAX_AGENT_WAIT_SECONDS, ORCHESTRATION_TOOL_DEFINITIONS, @@ -125,12 +126,15 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { } this.requireOwned(orchestratorId, target); const seconds = typeof args.timeoutSeconds === "number" ? args.timeoutSeconds : DEFAULT_AGENT_WAIT_SECONDS; + // Shorter than the orchestrator's MCP client lets a tool call run, so it gets "timeout" and progress, not an error. + const limit = this.control.status(orchestratorId).provider === "claude" ? MAX_AGENT_WAIT_SECONDS : CLIENT_SAFE_AGENT_WAIT_SECONDS; try { const result = await this.control.waitFor(target, { - timeoutMs: Math.min(MAX_AGENT_WAIT_SECONDS, Math.max(1, seconds)) * 1_000, + timeoutMs: Math.min(limit, Math.max(1, seconds)) * 1_000, ...(signal ? { signal } : {}) }); - return { ...result }; + return { ...result, ...(result.reason === "timeout" + ? { message: "Still running. Read output for its progress and call wait_for_agent again." } : {}) }; } catch (error) { if (signal?.aborted || (error instanceof Error && error.name === "AbortError")) throw canceledError(); throw error; diff --git a/tests/orchestration-providers-wait.test.mjs b/tests/orchestration-providers-wait.test.mjs index 7bfc7357..bd7c6380 100644 --- a/tests/orchestration-providers-wait.test.mjs +++ b/tests/orchestration-providers-wait.test.mjs @@ -2,6 +2,8 @@ import assert from "node:assert/strict"; import test from "node:test"; import { AGENT_PROVIDER_IDS, + CLIENT_SAFE_AGENT_WAIT_SECONDS, + DEFAULT_AGENT_WAIT_SECONDS, MAX_AGENT_WAIT_SECONDS, ORCHESTRATION_TOOL_DEFINITIONS, ORCHESTRATION_TOOL_NAMES, @@ -22,10 +24,14 @@ test("the catalog lists list_providers first and wait_for_agent with a bounded t assert.deepEqual(definition("list_providers").inputSchema, { type: "object", properties: {}, required: [], additionalProperties: false }); const wait = definition("wait_for_agent").inputSchema; assert.deepEqual(wait.required, ["sessionId"]); - assert.equal(wait.properties.timeoutSeconds.maximum, 600); - assert.equal(MAX_AGENT_WAIT_SECONDS, 600); - assert.equal(validateOrchestrationArguments("wait_for_agent", { sessionId: "a", timeoutSeconds: 600 }).ok, true); - assert.match(validateOrchestrationArguments("wait_for_agent", { sessionId: "a", timeoutSeconds: 601 }).error, /above the maximum/u); + assert.equal(wait.properties.timeoutSeconds.maximum, 100); + assert.equal(MAX_AGENT_WAIT_SECONDS, 100); + assert.ok(DEFAULT_AGENT_WAIT_SECONDS <= CLIENT_SAFE_AGENT_WAIT_SECONDS && CLIENT_SAFE_AGENT_WAIT_SECONDS < 60, + "a call without timeoutSeconds, and every call from a 60-second MCP client, ends before that client gives up"); + assert.equal(validateOrchestrationArguments("wait_for_agent", { sessionId: "a", timeoutSeconds: 100 }).ok, true); + assert.match(validateOrchestrationArguments("wait_for_agent", { sessionId: "a", timeoutSeconds: 101 }).error, /above the maximum/u); + assert.match(definition("wait_for_agent").description, /at most 100; unless this session is Claude Code, a call returns after at most 50 seconds/u); + assert.match(definition("wait_for_agent").description, /A timeout means it is still running: read output as its progress and call wait_for_agent again/u); assert.equal(validateOrchestrationArguments("list_providers", {}).ok, true); assert.match(validateOrchestrationArguments("list_providers", { x: 1 }).error, /Unexpected argument/u); assert.match(definition("list_providers").description, /Never search the filesystem/u); @@ -217,7 +223,7 @@ test("wait_for_agent stops at once when the call is canceled", async () => { const controller = new AbortController(); const started = Date.now(); setTimeout(() => controller.abort(), 20); - await assert.rejects(call(handler, "wait_for_agent", { sessionId: "child", timeoutSeconds: 600 }, controller.signal), + await assert.rejects(call(handler, "wait_for_agent", { sessionId: "child", timeoutSeconds: 100 }, controller.signal), (error) => error.bridgeError?.code === "CANCELED"); assert.ok(Date.now() - started < 1_000); const aborted = new AbortController(); @@ -305,3 +311,23 @@ test("the skill tells the orchestrator to pass a model the person names", async const skill = await readFile(new URL("../agent/orchestrator/SKILL.md", import.meta.url), "utf8"); assert.match(skill, /If the person names a model, pass it as `model`/u); }); + +test("wait_for_agent returns before the orchestrator's MCP client gives up, as a timeout that says the agent still runs", async () => { + for (const [provider, requested, expectedMs] of [ + ["opencode", 100, 50_000], ["codex", 100, 50_000], ["opencode", undefined, 50_000], ["opencode", 20, 20_000], ["claude", 100, 100_000] + ]) { + const { terminals, control, handler } = setup(); + terminals.sessions.get("orch").provider = provider; + let received; + const real = control.waitFor.bind(control); + control.waitFor = (id, request) => { received = request.timeoutMs; return real(id, { ...request, timeoutMs: 30 }); }; + terminals.sessions.get("child").buffer = "step 3 of 5"; + const result = await call(handler, "wait_for_agent", { sessionId: "child", ...(requested ? { timeoutSeconds: requested } : {}) }); + assert.equal(received, expectedMs, `${provider} asking for ${requested ?? "the default"}`); + assert.deepEqual([result.reason, result.status, result.output], ["timeout", "working", "step 3 of 5"]); + assert.match(result.message, /Still running\. Read output for its progress and call wait_for_agent again\./u); + } + const { terminals, handler } = setup(); + terminals.sessions.get("child").status = "idle"; + assert.equal((await call(handler, "wait_for_agent", { sessionId: "child" })).message, undefined, "only a timeout says to call again"); +}); From 93b5347960848f94748bf2e56c8d7c8ee4505efa Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:10:28 +0000 Subject: [PATCH 07/28] fix(agents): tie subagent answers to their turn and say when hooks are off A Codex or OpenCode subagent's final answer could belong to an earlier turn: OpenCode reused its session id as the turn id and reported idle before its asynchronous answer read finished, and recordAnswer accepted any answer. OpenCode now names each turn, reports no idle before the answer is read and drops a read a newer turn superseded; retry counts as a turn start. A submitted input (not an approval answer, not an empty Enter in an idle card) starts a new task, and only an answer of the turn that answers it is kept. A signal of that turn which predates the input still moves the status, so wait_for_agent ends instead of hanging, but brings no answer. An answer reported as the agent exits still counts. main's signal glue is now applyRuntimeSignal, shared with the tests. With CanvasTTY status hooks off a launch gets no hooks, so no answer can be captured. Whether a launch has them is decided when it is planned (deferred plugin launches included). wait_for_agent and get_agent_result then return answerUnavailable { reason: "status_hooks_off", message } so the orchestrator reads the output instead. Every write of person or agent input now goes through one method, writeInput, which does this bookkeeping; input() and deliverInput call it, and a later writer that holds input and replays it must call it too, so a replayed Enter counts as a submitted prompt. It writes nothing into a process the card no longer runs. --- docs/ARCHITECTURE.md | 2 +- docs/ARCHITECTURE.ru.md | 2 +- docs/ARCHITECTURE.zh-CN.md | 2 +- docs/agent-orchestration.md | 4 +- native/canvastty-helper/catalog.json | 4 +- src/agent-browser/orchestration-catalog.mjs | 4 +- src/agent-runtime/opencode-plugin.mjs | 39 +- src/main/index.ts | 11 +- src/main/services/AgentControlService.ts | 26 +- src/main/services/TerminalManager.ts | 131 ++++++- .../agent-browser/OrchestrationTools.ts | 3 +- .../services/agent-runtime/RuntimeGateway.ts | 3 +- .../agent-runtime/applyRuntimeSignal.ts | 22 ++ tests/agent-runtime-provider-launch.test.mjs | 22 ++ tests/opencode-result.test.mjs | 365 +++++++++++++++++- tests/runtime-result-transport.test.mjs | 129 +++++++ tests/terminal-lifecycle.test.mjs | 35 +- 17 files changed, 729 insertions(+), 75 deletions(-) create mode 100644 src/main/services/agent-runtime/applyRuntimeSignal.ts create mode 100644 tests/runtime-result-transport.test.mjs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index aa004a20..218c3514 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -70,7 +70,7 @@ Electron main process - `src/main/services/BrowserService.ts` is the only owner of the built-in browser's `WebContentsView` tabs and shared persistent partition. Remote pages have no preload or Node access, keep context isolation and sandbox enabled, and cannot request hardware, location, notification, clipboard-read, certificate-bypass, or external-protocol capabilities. HTTP(S) popups are adopted as internal tabs; other schemes are rejected. - `src/main/services/browser/` contains the browser kernel. `BrowserStore` atomically persists only tab order, active tab, and safe restore URLs. `BrowserPolicyService` centralizes URL, permission, download, and upload rules; validated uploads are copied through an already-open no-follow file descriptor into private staging before Chromium sees them. `BrowserAutomationService` attaches Electron's internal debugger to the existing live tab without a remote-debugging port. `BrowserCommandDispatcher` adds revisions, revision-bound refs, mutation request deduplication, per-tab FIFO mutation lanes, bounded concurrency, typed errors, and redacted fail-closed audit for agent mutations. - `src/main/services/agent-browser/` exposes the kernel only through an authenticated user-local Unix socket (`0600`) or Windows named pipe. The Windows pipe is created by the bundled native host with a protected DACL containing only the exact current-user SID and rejects remote clients. Each agent PTY receives a one-use bootstrap capability through its child environment. A successful authentication rotates it to a session-scoped reconnect capability held only in helper memory; duplicate bootstrap authentication is accepted only while the same `connectionId` is already live, and every capability is revoked when the PTY ends. The bundled stdio MCP helper is the only protocol adapter; no TCP listener, cookie/storage endpoint, arbitrary evaluation tool, or raw CDP surface exists. -- `src/main/services/agent-runtime/` is a separate lifecycle boundary and is not controlled by the Browser access switch. When CanvasTTY status hooks are enabled, every agent PTY receives a distinct capability for a protected user-local socket/pipe. Provider command hooks and the OpenCode event plugin may report only the fixed status enum, bounded event name, and optional opaque turn/prompt ID; prompt text, responses, tool input, and arbitrary telemetry are rejected by the exact gateway schema. On macOS and Linux the helpers are the native `canvastty-helper` (`native/canvastty-helper`, Go, standard library only; subcommands `mcp-browser`, `mcp-orchestration`, `permission-gate`, `hook`), which speaks exactly the wire protocols of the `.mjs` helpers (`tests/native-helper-*-parity.test.mjs` run both) without an Electron-as-Node process per agent and per tool call; `src/main/services/agentHelpers.ts` falls back to the `.mjs` helpers when the binary is absent, on Windows, or with `CANVASTTY_HELPERS=node`. Electron helper commands carry `ELECTRON_RUN_AS_NODE=1` inside the exact hook command only; the provider PTY never inherits that process-mode flag, so a provider cannot accidentally launch a second CanvasTTY GUI instance. The user can revoke this capability from Agents settings, immediately returning live agent status to `unavailable`; re-enabling requires a new/restarted PTY. Explicitly trusted plugin hooks use a separate process runner which re-checks the private PluginManager registry on every invocation and strips CanvasTTY internal capabilities before passing the provider payload to third-party code. Provider-native review remains an independent gate; CanvasTTY does not bypass Codex hook trust globally. +- `src/main/services/agent-runtime/` is a separate lifecycle boundary and is not controlled by the Browser access switch. When CanvasTTY status hooks are enabled, every agent PTY receives a distinct capability for a protected user-local socket/pipe. Provider command hooks and the OpenCode event plugin may report only the fixed status enum, bounded event name, and optional opaque turn/prompt ID; prompt text, responses, tool input, and arbitrary telemetry are rejected by the exact gateway schema. On macOS and Linux the helpers are the native `canvastty-helper` (`native/canvastty-helper`, Go, standard library only; subcommands `mcp-browser`, `mcp-orchestration`, `permission-gate`, `hook`), which speaks exactly the wire protocols of the `.mjs` helpers (`tests/native-helper-*-parity.test.mjs` run both) without an Electron-as-Node process per agent and per tool call; `src/main/services/agentHelpers.ts` falls back to the `.mjs` helpers when the binary is absent, on Windows, or with `CANVASTTY_HELPERS=node`. Electron helper commands carry `ELECTRON_RUN_AS_NODE=1` inside the exact hook command only; the provider PTY never inherits that process-mode flag, so a provider cannot accidentally launch a second CanvasTTY GUI instance. The user can revoke this capability from Agents settings, immediately returning live agent status to `unavailable`; re-enabling requires a new/restarted PTY. Explicitly trusted plugin hooks use a separate process runner which re-checks the private PluginManager registry on every invocation and strips CanvasTTY internal capabilities before passing the provider payload to third-party code. Provider-native review remains an independent gate; CanvasTTY does not bypass Codex hook trust globally. A Codex or OpenCode subagent's final answer (Codex `Stop` hook, the OpenCode plugin at `session.idle`) travels on the same hooks, so it exists only while status hooks are on for that launch: whether a launch has them is decided when it is planned (a deferred plugin launch included), and a launch planned with them off has no lease and no lifecycle hooks at all. `wait_for_agent` and `get_agent_result` then return `answerUnavailable: { reason: "status_hooks_off", message }` and the orchestrator reads the output instead. An answer is kept only for its own turn (`src/main/services/agent-runtime/applyRuntimeSignal.ts`, the glue main runs for each signal): OpenCode names each turn, a submitted input other than an approval answer or an empty Enter in an idle card starts a new one, and a signal of the current turn that predates that input still ends the wait but brings no answer. - Lifecycle adapters use launch-only settings for Claude, Codex, Qwen, and OpenCode. Kimi, Hermes, and Grok, whose hook discovery is home-config based, receive ownership-checked temporary entries shared across live CanvasTTY sessions. Kimi and Hermes keep recovery journals and exact backups; Grok uses a dedicated owned hook file. Cleanup restores exact original bytes when no concurrent edit occurred and otherwise removes only CanvasTTY-owned entries. - `TerminalManager` injects the MCP helper per launch without leaving permanent provider configuration. Claude Code, Codex, and Qwen Code receive CLI arguments; Qwen gets one inline `--mcp-config` entry that overrides only the CanvasTTY server name and leaves unrelated user servers available. OpenCode receives a merged launch-only `OPENCODE_CONFIG_CONTENT` entry plus one scoped browser-tool permission; Kimi uses its per-run MCP configuration when supported. Older Kimi versions receive a compare-and-swap temporary CanvasTTY entry and one exact permission rule with an atomic recovery journal. Hermes receives a temporary `mcp_servers.canvastty_browser` entry in `HERMES_HOME/config.yaml` (defaulting to `~/.hermes/config.yaml` on POSIX or `%LOCALAPPDATA%\hermes\config.yaml` on Windows); sensitive capability values stay as child-environment placeholders. Temporary Kimi and Hermes configuration remains until the final owning PTY session ends, then exact original bytes are restored when safe. A journal repairs an interrupted Hermes launch at the next CanvasTTY startup, while compare-and-swap checks preserve concurrent user edits. Unrelated MCP entries, credentials, and file/shell permissions are preserved. Qwen, OpenCode, and Hermes YOLO remain launch-only and do not change persistent permission settings. - `src/main/services/providerCliRegistry.ts` is the single owner of provider CLI discovery. During main-process startup it creates a shared snapshot for every provider in `PROVIDER_CLI_DEFINITIONS` — each definition declares the executable command names it may install as (which may differ from the provider ID, e.g. a provider shipping as `mcode`) and optional known home-relative or Windows LOCALAPPDATA-relative directories — by checking smoke-only overrides, the inherited `PATH`, platform defaults, and those known directories in that order. Available entries retain an absolute executable, launcher kind, and supplemented child `PATH`; POSIX entries must be executable files and Windows entries must be supported native or batch launchers. `TerminalManager`, `LimitsService`, agent-browser probes, and provider smoke tests consume that same snapshot and never repeat command lookup. Missing entries produce a failed session with copyable checked-path diagnostics before PTY or temporary browser configuration creation, while the limit adapter reports `cli-not-found`; its HOME row is hidden until explicitly selected after CLI detection. Launchers keep a provider without a local CLI visible when an account bound to a remote computer or a saved container profile provides another route. CanvasTTY never reads shell startup scripts. Agents settings can recheck candidate paths, atomically replace the registry snapshot, reconcile saved launcher and limit selections, and refresh CLI-bound adapters without restarting the app. Existing sessions keep running; a newly found CLI remains disabled until selected. diff --git a/docs/ARCHITECTURE.ru.md b/docs/ARCHITECTURE.ru.md index ae66a88d..d7e0054a 100644 --- a/docs/ARCHITECTURE.ru.md +++ b/docs/ARCHITECTURE.ru.md @@ -48,7 +48,7 @@ Electron main process - `src/main/services/PluginSecretsService.ts` сериализует запись секретов каждого плагина, шифрует весь ограниченный payload через Electron `safeStorage`, отклоняет plaintext-only backend и удаляет зашифрованный файл при uninstall. `ProviderSecretsService.ts` применяет ту же архитектуру к API-ключам провайдеров для CLI с BYOK: значения остаются в main-процессе, а renderer-контракт раскрывает только флаги `configured` и операции set/clear. Записи настроек `ApiProfile` именуют model-бэкенды (протокол, HTTPS base URL, ссылка на секрет) для тех же BYOK-рантаймов; это не agent providers, а normalizer настроек отбрасывает невалидные профили вместо «ремонта». - `src/main/services/PluginMediaService.ts` сохраняет разрешения только после нативного выбора папки, скрывает абсолютные пути, пропускает symlinks и отдаёт аудио с HTTP Range. Чтение плейлистов остаётся внутри разрешённых библиотек; ограниченная атомарная запись разрешена только в `Playlists/`. - `src/main/services/BrowserService.ts` владеет вкладками встроенного браузера в `WebContentsView`. Удалённые страницы используют отдельный persistent partition с выключенным Node, включёнными context isolation/sandbox и отклонением website permissions по умолчанию. Это core service, а не возможность runtime-плагина. -- `src/main/services/agent-runtime/` — отдельная всегда включённая lifecycle-граница, не зависящая от переключателя Browser access. Каждый agent PTY получает собственный capability для защищённого user-local socket/pipe. Provider command hooks и OpenCode event plugin могут передать только фиксированный status enum, ограниченное имя события и необязательный opaque turn/prompt ID; точная schema Gateway отклоняет prompt text, ответы, tool input и произвольную telemetry. При завершении PTY capability и временные файлы отзываются. +- `src/main/services/agent-runtime/` — отдельная всегда включённая lifecycle-граница, не зависящая от переключателя Browser access. Каждый agent PTY получает собственный capability для защищённого user-local socket/pipe. Provider command hooks и OpenCode event plugin могут передать только фиксированный status enum, ограниченное имя события и необязательный opaque turn/prompt ID; точная schema Gateway отклоняет prompt text, ответы, tool input и произвольную telemetry. При завершении PTY capability и временные файлы отзываются. Финальный ответ субагента Codex или OpenCode (хук Codex `Stop`, плагин OpenCode на `session.idle`) идёт через те же хуки, поэтому он есть только пока status hooks включены для этого запуска: это решается при планировании запуска (включая отложенный запуск с плагинами), и запуск, спланированный с выключенными хуками, не получает ни lease, ни lifecycle-хуков. Тогда `wait_for_agent` и `get_agent_result` возвращают `answerUnavailable: { reason: "status_hooks_off", message }`, и оркестратор читает вывод. Ответ хранится только для своего хода (`applyRuntimeSignal.ts`): OpenCode именует каждый ход, отправленный ввод (кроме ответа на запрос подтверждения и пустого Enter в idle-карточке) начинает новый, а сигнал текущего хода, начатого до этого ввода, завершает ожидание, но без ответа. - Claude, Codex, Qwen и OpenCode получают lifecycle hooks только на текущий запуск. Для Kimi, Hermes и Grok, которые ищут hooks в home-конфигурации, используются ownership-checked временные записи с совместным владением живых сессий. Kimi и Hermes используют recovery journals и точные backups, Grok — отдельный owned hook file; cleanup восстанавливает исходные байты или удаляет только записи CanvasTTY при конкурентных изменениях. - `TerminalManager` подмешивает MCP helper, не оставляя постоянных изменений в provider-конфигах. Claude Code, Codex и Qwen Code получают CLI arguments; Qwen получает одну inline-запись `--mcp-config`, которая переопределяет только имя сервера CanvasTTY и не скрывает сторонние user servers. OpenCode — объединённый launch-only `OPENCODE_CONFIG_CONTENT` с одной scoped browser-tool permission, Kimi — per-run MCP config или временную запись с compare-and-swap и recovery journal для старых версий. Hermes получает временную запись `mcp_servers.canvastty_browser` в `HERMES_HOME/config.yaml` (по умолчанию `~/.hermes/config.yaml` в POSIX или `%LOCALAPPDATA%\hermes\config.yaml` в Windows); чувствительные capability-значения остаются ссылками на окружение дочернего процесса. Временная конфигурация Kimi и Hermes живёт до завершения последней владеющей PTY-сессии, после чего исходные байты точно восстанавливаются, если файл не менялся параллельно. Journal восстанавливает Hermes после прерванного запуска при следующем старте CanvasTTY, а compare-and-swap сохраняет одновременные пользовательские изменения. Сторонние MCP-записи, credentials и file/shell permissions не затрагиваются. Qwen, OpenCode и Hermes YOLO остаются launch-only и не меняют постоянные permission-настройки. - `src/main/services/providerCliRegistry.ts` — единственный владелец обнаружения provider CLI. При запуске main-процесса он создаёт общий snapshot для каждого провайдера из `PROVIDER_CLI_DEFINITIONS` — каждое определение объявляет имена executable-команд, под которыми провайдер может устанавливаться (они могут отличаться от ID провайдера, например провайдер с командой `mcode`), и опциональные известные каталоги (относительно home или Windows LOCALAPPDATA), — последовательно проверяя smoke-only overrides, унаследованный `PATH`, системные каталоги платформы и эти известные каталоги. Доступная запись хранит абсолютный executable, тип launcher-а и дополненный дочерний `PATH`; POSIX-кандидат обязан быть исполняемым файлом, а Windows-кандидат — поддерживаемым native или batch launcher-ом. `TerminalManager`, `LimitsService`, agent-browser probes и provider smoke используют один и тот же snapshot и не повторяют поиск команды. Недоступный CLI создаёт failed-сессию с копируемой диагностикой проверенных путей до создания PTY или временной browser-конфигурации, а адаптер лимитов сообщает `cli-not-found`; строка HOME скрыта до ручного выбора после обнаружения CLI. Провайдер без локального CLI остаётся в панелях запуска, если для него есть аккаунт на удалённом компьютере или сохранённый контейнерный профиль. CanvasTTY не читает shell startup scripts. Настройки агентов позволяют повторно проверить пути, атомарно заменить snapshot registry, согласовать сохранённые списки запуска и лимитов и обновить зависящие от CLI адаптеры без перезапуска. Работающие сессии продолжаются; найденный позже CLI остаётся выключенным до ручного выбора. diff --git a/docs/ARCHITECTURE.zh-CN.md b/docs/ARCHITECTURE.zh-CN.md index 34dcc04e..1f7d0b66 100644 --- a/docs/ARCHITECTURE.zh-CN.md +++ b/docs/ARCHITECTURE.zh-CN.md @@ -49,7 +49,7 @@ Electron main process - `src/main/services/PluginSecretsService.ts` 串行化每个插件的机密写入,通过 Electron `safeStorage` 加密完整的有界 payload,拒绝 plaintext-only backend,并在卸载时删除加密文件。`ProviderSecretsService.ts` 将同一架构应用于面向 BYOK CLI 的服务商 API key:值只留在 main 进程,renderer 契约只暴露每个 key 的 `configured` 标志与 set/clear 操作。`ApiProfile` 设置项为同一批 BYOK 运行时命名 model 后端(协议、HTTPS base URL、secret 引用);它们不是 agent provider,且 settings normalizer 会丢弃而非修复无效 profile。 - `src/main/services/PluginMediaService.ts` 仅在原生目录选择后保存授权,隐藏绝对路径,跳过 symlink,并以 HTTP Range 提供音频。Playlist 读取限制在授权媒体库内;写入受大小限制,并且只能原子写入 `Playlists/`。 - `src/main/services/BrowserService.ts` 管理内置浏览器的 `WebContentsView` tab。远程页面使用独立 persistent partition,禁用 Node,启用 context isolation/sandbox,并默认拒绝网站权限。这是 core service,不是 runtime 插件能力。 -- `src/main/services/agent-runtime/` 是独立且始终启用的 lifecycle 边界,不受 Browser access 开关控制。每个 agent PTY 都为受保护的 user-local socket/pipe 获得独立 capability。Provider command hook 与 OpenCode event plugin 只能提交固定 status enum、受限 event 名称和可选 opaque turn/prompt ID;Gateway 的精确 schema 会拒绝 prompt text、回复、tool input 与任意 telemetry。PTY 退出时 capability 与临时文件都会被撤销。 +- `src/main/services/agent-runtime/` 是独立且始终启用的 lifecycle 边界,不受 Browser access 开关控制。每个 agent PTY 都为受保护的 user-local socket/pipe 获得独立 capability。Provider command hook 与 OpenCode event plugin 只能提交固定 status enum、受限 event 名称和可选 opaque turn/prompt ID;Gateway 的精确 schema 会拒绝 prompt text、回复、tool input 与任意 telemetry。PTY 退出时 capability 与临时文件都会被撤销。 Codex 或 OpenCode 子 agent 的最终回答(Codex `Stop` hook、OpenCode 插件在 `session.idle` 时)经由同一组 hook 传递,因此只有该次启动开启了 status hooks 时才存在:是否开启在规划启动时决定(包括延迟的插件启动),在关闭状态下规划的启动既没有 lease 也没有任何 lifecycle hook。此时 `wait_for_agent` 与 `get_agent_result` 返回 `answerUnavailable: { reason: "status_hooks_off", message }`,编排者改为读取输出。回答只归属于它自己的 turn(`applyRuntimeSignal.ts`):OpenCode 为每个 turn 命名;除审批回答和空闲卡片中的空 Enter 外,提交的输入会开始新 turn;在该输入之前开始的当前 turn 的信号仍会结束等待,但不带回答。 - Claude、Codex、Qwen 与 OpenCode 使用仅本次启动有效的 lifecycle hook。Kimi、Hermes 与 Grok 只能从 home 配置发现 hook,因此使用由实时 CanvasTTY 会话共享、带 ownership 检查的临时条目。Kimi 与 Hermes 使用 recovery journal 和精确 backup;Grok 使用独立 owned hook 文件。Cleanup 在无并发编辑时逐字恢复原文件,否则只移除 CanvasTTY 自己的条目。 - `TerminalManager` 注入 MCP helper 时不会留下永久的服务商配置变更。Claude Code、Codex 与 Qwen Code 使用 CLI 参数;Qwen 使用一个 inline `--mcp-config`,只覆盖 CanvasTTY 服务名,不隐藏无关用户服务。OpenCode 使用合并后的、仅本次启动有效的 `OPENCODE_CONFIG_CONTENT` 和一条 scoped browser-tool 权限;Kimi 使用 per-run MCP 配置,旧版本则使用带 compare-and-swap 与 recovery journal 的临时配置。Hermes 会在 `HERMES_HOME/config.yaml` 中获得临时 `mcp_servers.canvastty_browser` 配置项(POSIX 默认路径为 `~/.hermes/config.yaml`,Windows 默认路径为 `%LOCALAPPDATA%\hermes\config.yaml`),敏感 capability 值仍以子进程环境变量占位符保存。Kimi 与 Hermes 的临时配置会保留到最后一个所属 PTY 会话结束;若文件未被并发修改,则精确恢复原始字节。若 Hermes 启动意外中断,journal 会在 CanvasTTY 下次启动时修复配置,compare-and-swap 则保留用户的并发修改。其他 MCP 配置项、凭据和文件/shell 权限不会受影响。Qwen、OpenCode 与 Hermes 的 YOLO 都不修改持久权限设置。 - `src/main/services/providerCliRegistry.ts` 是服务商 CLI 发现的唯一职责边界。main 进程启动时,它按 smoke-only override、继承的 `PATH`、平台默认目录、已知用户/服务商目录的顺序,为 `PROVIDER_CLI_DEFINITIONS` 中的每个服务商创建一个共享快照——每个定义声明该服务商可能安装的 executable 命令名(可以与服务商 ID 不同,例如命令为 `mcode` 的服务商),以及可选的已知目录(相对 home 或 Windows LOCALAPPDATA)。可用条目保存绝对 executable、launcher 类型以及补充后的子进程 `PATH`;POSIX 候选必须是可执行文件,Windows 候选必须是受支持的 native 或 batch launcher。`TerminalManager`、`LimitsService`、agent-browser probe 与 provider smoke 共用该快照,不再各自查找命令。CLI 不可用时,系统会在创建 PTY 或临时 browser 配置之前生成 failed session,并提供可复制的已检查路径诊断;限额适配器报告 `cli-not-found`;HOME 行保持隐藏,直到检测到 CLI 后由用户手动选择。若某服务商绑定了远程计算机上的账户或已保存的容器配置,即使本地没有 CLI,启动器仍会显示它。CanvasTTY 不读取 shell startup script。Agents 设置可重新检查候选路径、原子替换 registry 快照、调整已保存的启动器和限额选择,并在无需重启的情况下刷新依赖 CLI 的适配器。运行中的 session 保持不变;新检测到的 CLI 需手动启用。 diff --git a/docs/agent-orchestration.md b/docs/agent-orchestration.md index c6373cd9..1211afb4 100644 --- a/docs/agent-orchestration.md +++ b/docs/agent-orchestration.md @@ -10,8 +10,8 @@ A session launched from the desktop with the **Orchestrator** role gets the `can | --- | --- | | `list_providers` | The agent providers CanvasTTY can launch as subagents: `id` (the exact `spawn_agent.provider` value), `name`, `installed`/`available` (from the provider CLI registry resolved at startup or on a recheck), `signIn` (`ok`, `signed_out`, `expired` or `unknown`, from the last usage-limits read; it never starts a read and never reads credentials), `subagent` and `orchestrator` support, `model` (whether the CLI takes one, its format, and for OpenCode the models a cached `opencode models` listing reported: run in the background with a 5 s timeout, refreshed after 10 minutes, never awaited) and `efforts`, and plugin launch options (`launchOptions`, plus `launchOptionTools` such as an accounts plugin's `list_routes`). Fast: cached state only, no network calls. | | `spawn_agent` | Launches a subagent (profile: see below). `provider` must be a `list_providers` id (the schema lists them); an unknown one is refused with `INVALID_REQUEST` naming `list_providers`. Optional `model` goes to the CLI's own `--model` for that run (OpenCode `provider/model`; Codex, Claude, Qwen, Kimi alias, Grok, OMP, Pi, Cursor; Hermes, MiniMax, Devin and Antigravity take none) and optional `effort` to its reasoning effort (Codex `-c model_reasoning_effort`, Claude `--effort`, Grok `--reasoning-effort`). Both are checked per provider, kept on restart and restore, and never written to the CLI's configuration. If the person names a model, the orchestrator passes it as `model`. Plugin launch options the call does not name are inherited from the orchestrator for a subagent of the same CLI (see Plugin launch options below); `model` next to inherited options that set the model is refused, and so is a subagent of another CLI while the orchestrator's model comes from a plugin's options. The answer's `servedBy` is `{ provider, model, launchOptions: { : "inherited" | "explicit" } }`. An OpenCode model its own `opencode models` list does not contain is refused before launch with up to five closest listed ids (OpenCode would otherwise stop with only "Unexpected server error"); when that list cannot be read, the model is passed as given. A subagent that exits anyway reports the last lines of its screen as `exitLines` in `wait_for_agent`, `observe_agent` and `get_agent_result`. | -| `wait_for_agent` | `{ sessionId, timeoutSeconds ≤ 100 }` (default 50). For an orchestrator other than Claude Code a call returns after at most 50 seconds, because its MCP client (Codex, OpenCode and others) ends a tool call after about 60 seconds; `timeout` then means the subagent is still running, with its progress in `output` and a `message` to call again. Returns when the subagent is `idle` (after a prompt: only once a turn that started after the latest delivered prompt has ended, so the CLI's startup idle does not count), `needs_approval`, `done`/`failed` (exited), `quiet` (reports no status and its screen stopped changing), `closed`, or on `timeout`, with `status`, `exitCode`, `waitedMs` and the masked terminal tail (masked before it is cut). Only this orchestrator's own subagents; it stops at once when the call is canceled or the orchestrator disconnects. | -| `get_agent_result`, `observe_agent` | `get_agent_result`: `answer`, the final reply of the subagent's last turn as the agent itself reported it (Codex through its Stop hook, OpenCode through CanvasTTY's plugin reading the session's last assistant message at `session.idle`; at most 4,096 characters with the end kept, masked, in memory only and cleared when the next turn starts), plus `status`, the exit state and the masked terminal tail. Other providers have no `answer`: their tail is raw screen output. `observe_agent`: the current status and tail. | +| `wait_for_agent` | `{ sessionId, timeoutSeconds ≤ 100 }` (default 50). For an orchestrator other than Claude Code a call returns after at most 50 seconds, because its MCP client (Codex, OpenCode and others) ends a tool call after about 60 seconds; `timeout` then means the subagent is still running, with its progress in `output` and a `message` to call again. Returns when the subagent is `idle` (after a prompt: only once a turn that started after the latest delivered prompt has ended, so the CLI's startup idle does not count), `needs_approval`, `done`/`failed` (exited), `quiet` (reports no status and its screen stopped changing), `closed`, or on `timeout`, with `status`, `exitCode`, `waitedMs`, the masked terminal tail (masked before it is cut) and, for Codex and OpenCode subagents, `answer` (see `get_agent_result`). A turn that the person interrupted by submitting their own input in the subagent's card still ends the wait, without an `answer`: that answer is no longer for the orchestrator's prompt. With CanvasTTY status hooks off (Settings → Agents) a subagent has no hooks at all, so no `answer` can be captured: the result carries `answerUnavailable: { reason: "status_hooks_off", message }` instead and the wait ends `quiet`; read `output` (or `observe_agent`). Turning status hooks on applies to subagents started or restarted afterwards. Only this orchestrator's own subagents; it stops at once when the call is canceled or the orchestrator disconnects. | +| `get_agent_result`, `observe_agent` | `get_agent_result`: `answer`, the final reply of the subagent's last turn as the agent itself reported it (Codex through its Stop hook, OpenCode through CanvasTTY's plugin reading the session's last assistant message at `session.idle`; at most 4,096 characters with the end kept, masked, in memory only, kept only for the turn it ended and cleared when the next input is submitted or the next turn starts; an Enter with nothing typed in an idle card keeps it; an answer reported as the subagent exits still counts; `answerUnavailable` instead when status hooks are off), plus `status`, the exit state and the masked terminal tail. Other providers have no `answer`: their tail is raw screen output. `observe_agent`: the current status and tail. | | `send_to_agent`, `cancel_agent`, `list_agents` | Follow-up prompts, disposing a subagent, listing this session's subagents. Prompts to one subagent arrive in the order they were sent. OpenCode drops keys typed while it starts, so a prompt for it (also `spawn_agent`'s first one) is typed only once its input field is up: the terminal control sequence that shows its text cursor on its full screen, or a lifecycle hook only 2 s after its first one (OpenCode's plugin can report shortly before the field takes keys). This wait is used on macOS and Linux; on Windows, whose pseudo console may not pass those sequences, OpenCode gets input as soon as its PTY exists, as before. `spawn_agent` (its checks, the launch and this wait together) and `send_to_agent` wait at most 45 s, under the MCP client's tool deadline; then the call fails with the reason and the session id, and nothing is typed. A send cancelled while it waits returns at once. | ### Subagent profiles diff --git a/native/canvastty-helper/catalog.json b/native/canvastty-helper/catalog.json index faf93a3c..b273493b 100644 --- a/native/canvastty-helper/catalog.json +++ b/native/canvastty-helper/catalog.json @@ -1095,7 +1095,7 @@ }, { "name": "wait_for_agent", - "description": "Wait until one of this session's subagents stops working, instead of polling observe_agent or get_agent_result; nothing is sent to it while it waits. Returns reason \"idle\" (the turn that answers your latest prompt ended and it waits for input; an idle before that turn started does not count), \"needs_approval\" (its card shows a prompt only the person may answer; never answer it yourself), \"done\" or \"failed\" (its process exited), \"quiet\" (it reports no status or no turn start and its screen stopped changing, so judge from output), \"closed\" (its card was closed) or \"timeout\" after timeoutSeconds (default 50, at most 100; unless this session is Claude Code, a call returns after at most 50 seconds, before its MCP client would end the call), with status, exitCode, waitedMs, the masked terminal tail as output, and answer (the final reply of the turn that ended, for Codex and OpenCode subagents); when the subagent's process exited (for example at once, on a model its CLI does not know), exitLines holds the last lines of its screen as plain text, which say why. A timeout means it is still running: read output as its progress and call wait_for_agent again. Then read get_agent_result.", + "description": "Wait until one of this session's subagents stops working, instead of polling observe_agent or get_agent_result; nothing is sent to it while it waits. Returns reason \"idle\" (the turn that answers your latest prompt ended and it waits for input; an idle before that turn started does not count), \"needs_approval\" (its card shows a prompt only the person may answer; never answer it yourself), \"done\" or \"failed\" (its process exited), \"quiet\" (it reports no status or no turn start and its screen stopped changing, so judge from output), \"closed\" (its card was closed) or \"timeout\" after timeoutSeconds (default 50, at most 100; unless this session is Claude Code, a call returns after at most 50 seconds, before its MCP client would end the call), with status, exitCode, waitedMs, the masked terminal tail as output, and answer (the final reply of the turn that ended, for Codex and OpenCode subagents; when CanvasTTY status hooks are off it cannot be captured and answerUnavailable says so instead: then read output); when the subagent's process exited (for example at once, on a model its CLI does not know), exitLines holds the last lines of its screen as plain text, which say why. A timeout means it is still running: read output as its progress and call wait_for_agent again. Then read get_agent_result.", "inputSchema": { "type": "object", "properties": { @@ -1118,7 +1118,7 @@ }, { "name": "get_agent_result", - "description": "Get one of this session's subagents' result: answer (its last turn's final reply as the agent reported it, for Codex and OpenCode subagents; truncated:true when only the end was kept), status (idle once its turn ended), the exit state (running | done | failed; an interactive CLI stays running after a task) and the masked terminal tail as output. Prefer answer; the terminal tail is raw screen output.", + "description": "Get one of this session's subagents' result: answer (its last turn's final reply as the agent reported it, for Codex and OpenCode subagents; truncated:true when only the end was kept), status (idle once its turn ended), the exit state (running | done | failed; an interactive CLI stays running after a task) and the masked terminal tail as output. Prefer answer; the terminal tail is raw screen output. answerUnavailable (reason status_hooks_off) means no answer can be captured because CanvasTTY status hooks are off: read output instead.", "inputSchema": { "type": "object", "properties": { diff --git a/src/agent-browser/orchestration-catalog.mjs b/src/agent-browser/orchestration-catalog.mjs index c57119e8..64b0f1d1 100644 --- a/src/agent-browser/orchestration-catalog.mjs +++ b/src/agent-browser/orchestration-catalog.mjs @@ -88,13 +88,13 @@ export const ORCHESTRATION_TOOL_DEFINITIONS = Object.freeze([ ), tool( "wait_for_agent", - `Wait until one of this session's subagents stops working, instead of polling observe_agent or get_agent_result; nothing is sent to it while it waits. Returns reason "idle" (the turn that answers your latest prompt ended and it waits for input; an idle before that turn started does not count), "needs_approval" (its card shows a prompt only the person may answer; never answer it yourself), "done" or "failed" (its process exited), "quiet" (it reports no status or no turn start and its screen stopped changing, so judge from output), "closed" (its card was closed) or "timeout" after timeoutSeconds (default ${DEFAULT_AGENT_WAIT_SECONDS}, at most ${MAX_AGENT_WAIT_SECONDS}; unless this session is Claude Code, a call returns after at most ${CLIENT_SAFE_AGENT_WAIT_SECONDS} seconds, before its MCP client would end the call), with status, exitCode, waitedMs, the masked terminal tail as output, and answer (the final reply of the turn that ended, for Codex and OpenCode subagents); when the subagent's process exited (for example at once, on a model its CLI does not know), exitLines holds the last lines of its screen as plain text, which say why. A timeout means it is still running: read output as its progress and call wait_for_agent again. Then read get_agent_result.`, + `Wait until one of this session's subagents stops working, instead of polling observe_agent or get_agent_result; nothing is sent to it while it waits. Returns reason "idle" (the turn that answers your latest prompt ended and it waits for input; an idle before that turn started does not count), "needs_approval" (its card shows a prompt only the person may answer; never answer it yourself), "done" or "failed" (its process exited), "quiet" (it reports no status or no turn start and its screen stopped changing, so judge from output), "closed" (its card was closed) or "timeout" after timeoutSeconds (default ${DEFAULT_AGENT_WAIT_SECONDS}, at most ${MAX_AGENT_WAIT_SECONDS}; unless this session is Claude Code, a call returns after at most ${CLIENT_SAFE_AGENT_WAIT_SECONDS} seconds, before its MCP client would end the call), with status, exitCode, waitedMs, the masked terminal tail as output, and answer (the final reply of the turn that ended, for Codex and OpenCode subagents; when CanvasTTY status hooks are off it cannot be captured and answerUnavailable says so instead: then read output); when the subagent's process exited (for example at once, on a model its CLI does not know), exitLines holds the last lines of its screen as plain text, which say why. A timeout means it is still running: read output as its progress and call wait_for_agent again. Then read get_agent_result.`, { sessionId, timeoutSeconds: integer({ minimum: 1, maximum: MAX_AGENT_WAIT_SECONDS }) }, ["sessionId"] ), tool( "get_agent_result", - "Get one of this session's subagents' result: answer (its last turn's final reply as the agent reported it, for Codex and OpenCode subagents; truncated:true when only the end was kept), status (idle once its turn ended), the exit state (running | done | failed; an interactive CLI stays running after a task) and the masked terminal tail as output. Prefer answer; the terminal tail is raw screen output.", + "Get one of this session's subagents' result: answer (its last turn's final reply as the agent reported it, for Codex and OpenCode subagents; truncated:true when only the end was kept), status (idle once its turn ended), the exit state (running | done | failed; an interactive CLI stays running after a task) and the masked terminal tail as output. Prefer answer; the terminal tail is raw screen output. answerUnavailable (reason status_hooks_off) means no answer can be captured because CanvasTTY status hooks are off: read output instead.", { sessionId }, ["sessionId"] ), diff --git a/src/agent-runtime/opencode-plugin.mjs b/src/agent-runtime/opencode-plugin.mjs index bd8151b8..69ae5f00 100644 --- a/src/agent-runtime/opencode-plugin.mjs +++ b/src/agent-runtime/opencode-plugin.mjs @@ -3,10 +3,13 @@ import { CAPTURE_RESULT_ENV } from "./runtime-protocol.mjs"; import { finalAnswer } from "./opencode-final-answer.mjs"; import { createOpenCodeDecisions } from "./opencode-decisions.mjs"; import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; import { HOOK_TIMEOUT_MS, preparePluginHook } from "./plugin-hook-dispatch.mjs"; let rootSessionId = null; let rootWorking = false; +// One id per root turn (not the session id), so a late answer of an earlier turn is never taken for the current one. +let rootTurnId = null; const lifecycleEnabled = process.env.CANVASTTY_LIFECYCLE_HOOKS_ENABLED !== "0"; const pluginHookRegistry = process.env.CANVASTTY_PLUGIN_HOOK_REGISTRY ?? ""; const pluginHookRunnerCommand = process.env.CANVASTTY_PLUGIN_HOOK_RUNNER_COMMAND ?? ""; @@ -45,6 +48,7 @@ async function lifecycleEvent(event, decisions, client) { if (session.parentID || session.parentId) return; rootSessionId = stringField(session.id, sessionId); rootWorking = false; + rootTurnId = null; if (!rootSessionId) return; if (lifecycleEnabled) { await reportLifecycle({ state: "idle", event: event.type, threadId: rootSessionId }); @@ -63,44 +67,55 @@ async function lifecycleEvent(event, decisions, client) { ? statusValue.type : null; if (status === "busy" || status === "retry") { + const startsTurn = status === "busy" && !rootWorking; + if (startsTurn || !rootTurnId) rootTurnId = randomUUID(); + rootWorking = true; if (lifecycleEnabled) { - await reportLifecycle({ state: "working", event: `session.status:${status}`, turnId: rootSessionId }); + await reportLifecycle({ state: "working", event: `session.status:${status}`, turnId: rootTurnId }); } - if (status === "busy" && !rootWorking) { + if (startsTurn) { runPluginHooks("prompt-submit", `session.status:${status}`, event); } - rootWorking = true; } else if (status === "idle") { rootWorking = false; - if (lifecycleEnabled) await reportLifecycle({ state: "idle", event: "session.status:idle", turnId: rootSessionId }); + // A result-capturing turn ends with session.idle, once its final answer was read: an idle reported here would + // let a waiting orchestrator take the turn as finished before its answer arrived. + if (lifecycleEnabled && !captureResult) await reportLifecycle({ state: "idle", event: "session.status:idle", turnId: rootTurnId }); } return; } if (event.type === "session.idle") { rootWorking = false; - const result = captureResult ? await finalAnswer(client, rootSessionId) : undefined; - if (lifecycleEnabled || result) { - await reportLifecycle({ state: "idle", event: event.type, turnId: rootSessionId, ...(result ? { result } : {}) }); + const endingSessionId = rootSessionId, endingTurnId = rootTurnId; + // With CanvasTTY status hooks off nothing is reported, the answer included: it is never read. + const result = lifecycleEnabled && captureResult && endingTurnId ? await finalAnswer(client, endingSessionId) : undefined; + // The read is asynchronous: when the next turn started meanwhile, this answer and this idle are no longer current. + const superseded = endingSessionId !== rootSessionId || endingTurnId !== rootTurnId || rootWorking; + if (lifecycleEnabled && !superseded) { + await reportLifecycle({ state: "idle", event: event.type, turnId: endingTurnId, ...(result ? { result } : {}) }); } runPluginHooks("stop", event.type, event); } else if (event.type === "permission.asked") { - if (lifecycleEnabled) await reportLifecycle({ state: "needs_approval", event: event.type, turnId: rootSessionId }); + if (lifecycleEnabled) await reportLifecycle({ state: "needs_approval", event: event.type, turnId: rootTurnId }); runPluginHooks("permission-request", event.type, event); } else if (event.type === "permission.replied") { - if (lifecycleEnabled) await reportLifecycle({ state: "working", event: event.type, turnId: rootSessionId }); + rootWorking = true; + if (lifecycleEnabled) await reportLifecycle({ state: "working", event: event.type, turnId: rootTurnId }); runPluginHooks("permission-result", event.type, event); } else if (event.type === "question.asked") { - if (lifecycleEnabled) await reportLifecycle({ state: "needs_approval", event: event.type, turnId: rootSessionId }); + if (lifecycleEnabled) await reportLifecycle({ state: "needs_approval", event: event.type, turnId: rootTurnId }); } else if (event.type === "question.replied" || event.type === "question.rejected") { - if (lifecycleEnabled) await reportLifecycle({ state: "working", event: event.type, turnId: rootSessionId }); + rootWorking = true; + if (lifecycleEnabled) await reportLifecycle({ state: "working", event: event.type, turnId: rootTurnId }); } else if (event.type === "session.error") { rootWorking = false; - if (lifecycleEnabled) await reportLifecycle({ state: "idle", event: event.type, turnId: rootSessionId }); + if (lifecycleEnabled) await reportLifecycle({ state: "idle", event: event.type, turnId: rootTurnId }); runPluginHooks("stop", event.type, event); } else if (event.type === "session.deleted") { runPluginHooks("session-end", event.type, event); rootWorking = false; rootSessionId = null; + rootTurnId = null; } else if (event.type === "tool.execute.after") { runPluginHooks("after-tool", event.type, event); } diff --git a/src/main/index.ts b/src/main/index.ts index da506ee4..7de30338 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -92,6 +92,7 @@ import { RuntimeGateway } from "./services/agent-runtime"; import type { RuntimeHookHelperLaunch } from "./services/agent-runtime/ProviderRuntimeLaunch"; +import { applyRuntimeSignal } from "./services/agent-runtime/applyRuntimeSignal"; import { agentHelperLaunches } from "./services/agentHelpers"; import { recoverHermesConfigurationOnStartup, @@ -530,15 +531,7 @@ async function initializeServices(ipc: IpcRegistrar): Promise { runtimeDirectory: lifecycleRuntimeDirectory, windowsHostPath, onSignal: (terminalSessionId, signal) => { - terminalManager?.applyProviderSignal(terminalSessionId, { - kind: "lifecycle", - state: signal.state, - event: signal.event, - ...(signal.turnId ? { requestId: signal.turnId } : {}), - ...(signal.threadId ? { threadId: signal.threadId } : {}) - }); - // A subagent's final answer (Codex Stop hook, OpenCode plugin) for get_agent_result and wait_for_agent. - if (signal.result) terminalManager?.recordAnswer(terminalSessionId, signal.result); + if (terminalManager) applyRuntimeSignal(terminalManager, terminalSessionId, signal); agentControl?.onSignal(terminalSessionId, signal); if (signal.lastAssistantMessage !== undefined && signal.answerCaptureGrantExpiresAt !== undefined) { evenG2?.answer( diff --git a/src/main/services/AgentControlService.ts b/src/main/services/AgentControlService.ts index 67094d00..2b587ca9 100644 --- a/src/main/services/AgentControlService.ts +++ b/src/main/services/AgentControlService.ts @@ -75,6 +75,8 @@ export interface AgentWaitResult { exitLines?: string; /** The final answer of the turn that ended (Codex, OpenCode subagents), masked. */ answer?: AgentAnswer; + /** Why a Codex or OpenCode subagent has no answer and never will in this launch (read its output instead). */ + answerUnavailable?: AgentAnswerUnavailable; } export interface AgentWaitTiming { @@ -110,8 +112,20 @@ export interface AgentResult { exitLines?: string; /** The last turn's final answer as the agent reported it (Codex, OpenCode subagents), masked; absent otherwise. */ answer?: AgentAnswer; + /** Why a Codex or OpenCode subagent has no answer and never will in this launch (read its output instead). */ + answerUnavailable?: AgentAnswerUnavailable; } +export interface AgentAnswerUnavailable { + /** "status_hooks_off": CanvasTTY status hooks were off for its launch, so it has no hooks to report an answer. */ + reason: "status_hooks_off"; + message: string; +} + +const STATUS_HOOKS_OFF_MESSAGE = "This subagent's final answer cannot be captured because CanvasTTY status hooks are off " + + "(Settings → Agents; turning them on applies to agents started or restarted afterwards). Read its output instead: " + + "the output field here, or observe_agent."; + export interface AgentAnswer { text: string; /** Only the end of a longer answer was kept. */ @@ -376,7 +390,7 @@ export class AgentControlService { exitCode: session.exitCode, output: this.redactTail(buffer, MAX_OBSERVE_CHARS), ...(session.exitCode !== null && this.exitLines(buffer) ? { exitLines: this.exitLines(buffer)! } : {}), - ...(answer ? { answer } : {}) + ...(answer ? { answer } : this.answerUnavailable(sessionId)) }; } @@ -401,7 +415,7 @@ export class AgentControlService { try { observation = this.observe(sessionId); } catch { observation = null; } const answer = reason === "timeout" || reason === "needs_approval" ? null : this.answer(sessionId); return { sessionId, reason, status: session.status, exitCode: session.exitCode, waitedMs, output: observation?.output ?? "", - ...(observation?.exitLines ? { exitLines: observation.exitLines } : {}), ...(answer ? { answer } : {}) }; + ...(observation?.exitLines ? { exitLines: observation.exitLines } : {}), ...(answer ? { answer } : this.answerUnavailable(sessionId)) }; }; let offset = this.outputOffset(sessionId); let changedAt = started; @@ -459,6 +473,14 @@ export class AgentControlService { try { return typeof this.terminals.turnProgress === "function" ? this.terminals.turnProgress(sessionId) : null; } catch { return null; } } + /** Says so when no answer can be captured for this agent, so an orchestrator reads its output instead of waiting for one. */ + private answerUnavailable(sessionId: string): { answerUnavailable?: AgentAnswerUnavailable } { + try { + const reason = typeof this.terminals.answerUnavailable === "function" ? this.terminals.answerUnavailable(sessionId) : null; + return reason === "status_hooks_off" ? { answerUnavailable: { reason, message: STATUS_HOOKS_OFF_MESSAGE } } : {}; + } catch { return {}; } + } + private answer(sessionId: string): AgentAnswer | null { try { const answer = typeof this.terminals.answer === "function" ? this.terminals.answer(sessionId) : null; diff --git a/src/main/services/TerminalManager.ts b/src/main/services/TerminalManager.ts index c0f00ea7..74fde97a 100644 --- a/src/main/services/TerminalManager.ts +++ b/src/main/services/TerminalManager.ts @@ -128,6 +128,20 @@ interface ManagedSession { promptTurnMark?: number; /** The last turn's final answer its hook or plugin reported (captureResult only); cleared when a turn starts. */ answer?: { text: string; truncated: boolean; at: number }; + /** + * Status hooks were off when this launch was planned, or were turned off since: it has no lifecycle hooks, so its + * card stays unavailable and no answer is captured until a restart with status hooks on. + */ + statusHooksOff?: boolean; + /** Something other than Enter was typed since the last submitted input (an Enter alone then submits nothing). */ + typedSinceSubmit?: boolean; + /** Bumped by every submitted input (except an approval answer) and every launch: the turn an answer must belong to. */ + inputGeneration?: number; + /** inputGeneration when the current turn started. */ + answerTurnGeneration?: number; + /** OpenCode's current turn id and the inputGeneration each recent turn id started in. */ + providerTurnId?: string; + providerTurnGenerations?: Map; /** * Plugin options, environment ref (or, until the plugin has prepared it, the launcher's environment choice) * and owning plugin carried into the saved record. @@ -1026,6 +1040,23 @@ export class TerminalManager { return { delivered: true }; } + /** + * Submitted text starts a new task (an approval answer continues the current turn), so an earlier answer is not its. + * An Enter with nothing typed before it in an idle card submits nothing: the stored answer stays. + */ + private noteSubmittedInput(session: ManagedSession, data: string): void { + const typed = /[^\r\n]/u.test(data); + if (!data.endsWith("\r")) { + if (typed) session.typedSinceSubmit = true; + return; + } + const empty = !typed && !session.typedSinceSubmit; + session.typedSinceSubmit = false; + if (empty && session.metadata.status === "idle") return; + if (session.metadata.status !== "needs_approval") session.inputGeneration = (session.inputGeneration ?? 0) + 1; + delete session.answer; + } + private wakeLaunchWaiters(session: ManagedSession): void { for (const wake of [...session.launchWaiters]) wake(); } @@ -1034,8 +1065,18 @@ export class TerminalManager { if (typeof data !== "string" || data.length === 0) return false; const session = this.sessions.get(id); if (!session || session.metadata.exitCode !== null || !session.process) return false; - const process = session.process; + return this.writeInput(id, session, session.process, data); + } + + /** + * The one write of person or agent input to a card's CLI, and what it means for turns and answers. Everything that + * types into a card goes through here: input(), deliverInput, and any writer that holds input and replays it later, + * so a replayed Enter is a submitted prompt like a typed one. Nothing is written to a process the card no longer runs. + */ + private writeInput(id: string, session: ManagedSession, process: IPty, data: string): boolean { + if (this.sessions.get(id) !== session || session.process !== process || session.metadata.exitCode !== null) return false; const written = tryPtyOperation(() => process.write(data)); + if (written) this.noteSubmittedInput(session, data); if (written && ANSWERS_PROMPT.test(data)) this.settleAnsweredPrompt(id, session); return written; } @@ -1106,10 +1147,40 @@ export class TerminalManager { return publicSessionMetadata(session); } - /** `source` "hook" is the agent's own lifecycle hook (through the runtime gateway); "title" is its terminal title. */ - applyProviderSignal(id: string, signal: ProviderLifecycleSignal, source: "hook" | "title" = "hook"): void { + /** + * `source` "hook" is the agent's own lifecycle hook (through the runtime gateway); "title" is its terminal title. + * Returns whether the signal belongs to the current turn and the last submitted input, so the answer it carries may + * be kept (applyRuntimeSignal). With status hooks off for this launch nothing applies and no answer is kept. + */ + applyProviderSignal(id: string, signal: ProviderLifecycleSignal, source: "hook" | "title" = "hook"): boolean { const session = this.sessions.get(id); - if (!this.lifecycleHooksEnabled || !session || session.metadata.status === "done" || session.metadata.status === "failed") return; + if (!this.lifecycleHooksEnabled || !session || session.statusHooksOff) return false; + // A result-capturing OpenCode names each turn. A signal of an earlier turn is stale and changes nothing; a signal + // of the current turn that started before the last submitted input still moves the status (so a wait ends), but + // is not that input's turn: it neither counts as a turn start nor brings an answer. + const exited = session.metadata.status === "done" || session.metadata.status === "failed"; + let current = true; + let startsProviderTurn = false; + if (source === "hook" && session.captureResult && session.metadata.provider === "opencode" && signal.requestId) { + const turns = session.providerTurnGenerations ??= new Map(); + const generation = session.inputGeneration ?? 0; + const known = turns.get(signal.requestId); + if (known === undefined) { + // A turn id not seen yet opens the next turn, also when its start was lost (as the gateway counts it); after + // the exit no turn opens. + if (exited) return false; + startsProviderTurn = true; + turns.set(signal.requestId, generation); + session.providerTurnId = signal.requestId; + while (turns.size > 64) turns.delete(turns.keys().next().value!); + } else if (session.providerTurnId !== signal.requestId) { + return false; + } else { + current = known === generation; + } + } + // The process already exited: its status stays, but the answer its last turn reported as it ended still counts. + if (exited) return current; if (source === "hook") { session.hookSignals = (session.hookSignals ?? 0) + 1; // The CLI runs, but its input field may not be up yet: input waiting for it (deliverInput) goes on the cursor @@ -1133,24 +1204,44 @@ export class TerminalManager { const nextStatus = signal.state; // A turn starts when the agent moves to working; wait_for_agent compares it with the last delivered prompt. - if (nextStatus === "working" && session.metadata.status !== "working") session.turnStarts = (session.turnStarts ?? 0) + 1; - // A new turn: the previous answer is no longer this turn's. - if (nextStatus === "working") session.answer = undefined; + if (current && nextStatus === "working") { + if (startsProviderTurn || session.metadata.status !== "working") session.turnStarts = (session.turnStarts ?? 0) + 1; + // A new turn: the previous answer is no longer this turn's. + session.answer = undefined; + session.answerTurnGeneration = session.inputGeneration ?? 0; + } const completed = nextStatus === "idle" && ["Stop", "StopFailure", "StopCancelled"].includes(signal.event ?? ""); const nextTurnCompleted = nextStatus === "working" ? false : completed || Boolean(session.metadata.turnCompleted); - if (!threadChanged && session.metadata.status === nextStatus && Boolean(session.metadata.turnCompleted) === nextTurnCompleted) return; + if (!threadChanged && session.metadata.status === nextStatus && Boolean(session.metadata.turnCompleted) === nextTurnCompleted) return current; session.metadata.status = nextStatus; session.metadata.turnCompleted = nextTurnCompleted; this.emitSession(session.metadata); + return current; + } + + /** + * Why a result-capturing session's final answer cannot be captured, or null when it can (or the session does not + * capture one). With CanvasTTY status hooks off for its launch it gets no hooks, so nothing reports the answer. + */ + answerUnavailable(id: string): "status_hooks_off" | null { + const session = this.sessions.get(id); + if (!session?.captureResult) return null; + return !this.lifecycleHooksEnabled || session.statusHooksOff ? "status_hooks_off" : null; } /** * Keeps the final answer a result-capturing session reported with its turn's end (the Codex Stop hook, the OpenCode - * plugin's session.idle); read back masked by answer(). In memory only, never saved. + * plugin's session.idle), only while it belongs to the current turn and the last submitted input; read back masked + * by answer(). OpenCode passes its turn id. In memory only, never saved. */ - recordAnswer(id: string, result: { text: string; truncated: boolean }): void { + recordAnswer(id: string, result: { text: string; truncated: boolean }, correlation?: { turnId?: string | null }): void { const session = this.sessions.get(id); if (!session?.captureResult || typeof result?.text !== "string") return; + const generation = session.inputGeneration ?? 0; + if (session.metadata.provider === "opencode" && correlation && "turnId" in correlation) { + if (!correlation.turnId || correlation.turnId !== session.providerTurnId + || session.providerTurnGenerations?.get(correlation.turnId) !== generation) return; + } else if (session.promptTurnMark !== undefined && session.answerTurnGeneration !== generation) return; session.answer = { text: result.text, truncated: result.truncated === true, at: Date.now() }; } @@ -1168,6 +1259,7 @@ export class TerminalManager { if (next) return; for (const session of this.sessions.values()) { session.lifecycle = null; + session.statusHooksOff = true; if ( session.metadata.provider === "terminal" || session.metadata.status === "done" @@ -2151,6 +2243,8 @@ export class TerminalManager { // 5. The host spawns the PTY; an environment only rewrites what is spawned, and the isolation layer wraps that. let planned: PlannedSpawn | { failure: LaunchFailure }; + // Whether this launch gets lifecycle hooks is decided as it is planned; the wrap below may take a while. + const statusHooks = this.lifecycleHooksEnabled; try { planned = this.planSpawn(id, metadata.provider, decision.profile, metadata.cwd, resume, session.captureResult, metadata.role, answerCaptureGrantExpiresAt, contribution, trustedFolder, Boolean(environment), decision.apply); @@ -2224,7 +2318,7 @@ export class TerminalManager { metadata.status = initialSessionStatus(metadata.provider); metadata.exitCode = null; metadata.failureDetails = null; - this.bindProcess(id, session, process); + this.bindProcess(id, session, process, statusHooks); const runtimeStatus = this.agentRuntime?.currentStatus(id); if (runtimeStatus) metadata.status = runtimeStatus; if (environment) this.describeEnvironment(id, session, environment); @@ -2253,7 +2347,13 @@ export class TerminalManager { }).catch(() => undefined); } - private bindProcess(id: string, session: ManagedSession, process: IPty): void { + /** + * `statusHooks`: status hooks were on when this launch was planned (and so got its lifecycle hooks). A launch planned + * and spawned in one step passes nothing; a contributed launch passes what held before its asynchronous steps. + */ + private bindProcess(id: string, session: ManagedSession, process: IPty, statusHooks = this.lifecycleHooksEnabled): void { + if (statusHooks && this.lifecycleHooksEnabled) delete session.statusHooksOff; + else session.statusHooksOff = true; session.agentBrowser?.retainUntilExit?.(); process.onData((data) => { const current = this.sessions.get(id); @@ -2415,6 +2515,13 @@ function resetLaunchSignals(session: ManagedSession): void { delete session.answer; delete session.turnStarts; delete session.promptTurnMark; + // A late answer of the ended launch never belongs to the new one. + session.inputGeneration = (session.inputGeneration ?? 0) + 1; + delete session.statusHooksOff; + delete session.typedSinceSubmit; + delete session.answerTurnGeneration; + delete session.providerTurnId; + delete session.providerTurnGenerations; delete session.hookSignals; delete session.titleState; delete session.inputReady; diff --git a/src/main/services/agent-browser/OrchestrationTools.ts b/src/main/services/agent-browser/OrchestrationTools.ts index 71c8cb54..d1273825 100644 --- a/src/main/services/agent-browser/OrchestrationTools.ts +++ b/src/main/services/agent-browser/OrchestrationTools.ts @@ -248,7 +248,8 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { exitCode: result.exitCode, output: result.output, ...(result.exitLines ? { exitLines: result.exitLines } : {}), - ...(result.answer ? { answer: result.answer } : {}) + ...(result.answer ? { answer: result.answer } : {}), + ...(result.answerUnavailable ? { answerUnavailable: result.answerUnavailable } : {}) }; } diff --git a/src/main/services/agent-runtime/RuntimeGateway.ts b/src/main/services/agent-runtime/RuntimeGateway.ts index 7ad4f9d4..97015bf6 100644 --- a/src/main/services/agent-runtime/RuntimeGateway.ts +++ b/src/main/services/agent-runtime/RuntimeGateway.ts @@ -877,7 +877,8 @@ function isTurnStart(event: string): boolean { return event === "UserPromptSubmit" || event === "TurnStarted" || event === "pre_llm_call" - || event === "session.status:busy"; + || event === "session.status:busy" + || event === "session.status:retry"; } function isRecord(value: unknown): value is Record { diff --git a/src/main/services/agent-runtime/applyRuntimeSignal.ts b/src/main/services/agent-runtime/applyRuntimeSignal.ts new file mode 100644 index 00000000..47b800df --- /dev/null +++ b/src/main/services/agent-runtime/applyRuntimeSignal.ts @@ -0,0 +1,22 @@ +import type { RuntimeLifecycleSignal } from "./RuntimeGateway.ts"; +import type { TerminalManager } from "../TerminalManager.ts"; + +/** + * What main does with one runtime gateway signal: the card's status first, then the final answer it carries (Codex + * Stop hook, OpenCode plugin) for get_agent_result and wait_for_agent, kept only when the signal belongs to the + * current turn and the last submitted input. + */ +export function applyRuntimeSignal( + terminals: Pick, + terminalSessionId: string, + signal: RuntimeLifecycleSignal +): void { + const current = terminals.applyProviderSignal(terminalSessionId, { + kind: "lifecycle", + state: signal.state, + event: signal.event, + ...(signal.turnId ? { requestId: signal.turnId } : {}), + ...(signal.threadId ? { threadId: signal.threadId } : {}) + }); + if (current && signal.result) terminals.recordAnswer(terminalSessionId, signal.result, { turnId: signal.turnId }); +} diff --git a/tests/agent-runtime-provider-launch.test.mjs b/tests/agent-runtime-provider-launch.test.mjs index c56a310b..c1183d43 100644 --- a/tests/agent-runtime-provider-launch.test.mjs +++ b/tests/agent-runtime-provider-launch.test.mjs @@ -497,3 +497,25 @@ function runtimeOptionsFor(root, qwenSystemSettingsPath, pluginHooks) { environment: {} }; } + +test("with status hooks off a result-capturing subagent gets no lease and no lifecycle hooks", async (t) => { + const root = await fixture(t); + const registrations = []; + const gateway = { + registerSession(id, provider, capture, _grant, decisions) { + registrations.push({ id, capture, decisions }); + return { address: join(root, "runtime.sock"), terminalSessionId: id, provider, capabilityToken: "token" }; + }, + revokeTerminalSession() {}, + currentStatus: () => null + }; + const bridge = new AgentRuntimeBridge(gateway, { ...runtimeOptionsFor(root), coreHooksEnabled: false }); + for (const provider of ["codex", "opencode"]) { + const launch = bridge.prepareLaunch({ terminalSessionId: provider, provider, cwd: root, captureResult: true, decisions: false }); + assert.equal(launch.environment[AGENT_RUNTIME_ENV.capabilityToken], undefined, `${provider}: turning status hooks off means no hooks`); + assert.deepEqual(launch.args.filter((arg) => /^hooks\./u.test(arg)), []); + assert.equal(launch.environment.OPENCODE_CONFIG_CONTENT, undefined); + launch.cleanup(); + } + assert.deepEqual(registrations, []); +}); diff --git a/tests/opencode-result.test.mjs b/tests/opencode-result.test.mjs index 9689b60a..a6f77225 100644 --- a/tests/opencode-result.test.mjs +++ b/tests/opencode-result.test.mjs @@ -7,10 +7,12 @@ import test from "node:test"; import { AGENT_RUNTIME_ENV, CAPTURE_RESULT_ENV, MAX_RESULT_CHARS } from "../src/agent-runtime/runtime-protocol.mjs"; import { finalAnswer } from "../src/agent-runtime/opencode-final-answer.mjs"; import { RuntimeGateway } from "../src/main/services/agent-runtime/RuntimeGateway.ts"; +import { applyRuntimeSignal } from "../src/main/services/agent-runtime/applyRuntimeSignal.ts"; +import { LaunchPipeline } from "../src/main/services/LaunchPipeline.ts"; import { TerminalManager } from "../src/main/services/TerminalManager.ts"; import { AgentControlService } from "../src/main/services/AgentControlService.ts"; import { ScopedOrchestrationHandler } from "../src/main/services/agent-browser/OrchestrationTools.ts"; -import { availableRegistry, fakeSpawner, openCodeInputReady } from "./helpers/terminal.mjs"; +import { availableRegistry, fakeSpawner, inputReadySpawner, openCodeInputReady } from "./helpers/terminal.mjs"; const POSIX = { skip: process.platform === "win32" ? "Unix socket test; the Windows pipe transport has its own suite." : false }; @@ -55,9 +57,9 @@ await hooks.event({ event: { type: "session.status", properties: { sessionID: "s await hooks.event({ event: { type: "session.idle", properties: { sessionID: "ses_root" } } }); `; -function runFakeOpenCode(capability, answer, captureResult) { +function runFakeOpenCode(capability, answer, captureResult, script = FAKE_OPENCODE, extraEnv = {}, onCapture = null) { const env = { - ...process.env, + ...process.env, ...extraEnv, PLUGIN_URL: new URL("../src/agent-runtime/opencode-plugin.mjs", import.meta.url).href, FAKE_ANSWER: JSON.stringify(answer), [AGENT_RUNTIME_ENV.address]: capability.address, @@ -67,35 +69,43 @@ function runFakeOpenCode(capability, answer, captureResult) { }; if (captureResult) env[CAPTURE_RESULT_ENV] = "1"; else delete env[CAPTURE_RESULT_ENV]; - const child = spawn(process.execPath, ["--input-type=module", "-e", FAKE_OPENCODE], { env, stdio: ["ignore", "ignore", "pipe"] }); + const child = spawn(process.execPath, ["--input-type=module", "-e", script], { env, stdio: ["pipe", "pipe", "pipe"] }); + const timeout = setTimeout(() => child.kill(), 5_000); let stderr = ""; child.stderr.on("data", (chunk) => { stderr += chunk; }); - return new Promise((resolve) => child.on("close", (code) => resolve({ code, stderr }))); + return new Promise((resolve, reject) => { + // The script writes to stdout when its SDK read starts and waits on stdin until onCapture has run. + if (onCapture) child.stdout.once("data", () => { + Promise.resolve().then(onCapture).then(() => child.stdin.end("continue"), (error) => { child.kill(); reject(error); }); + }); + child.on("error", (error) => { clearTimeout(timeout); reject(error); }); + child.on("close", (code) => { clearTimeout(timeout); resolve({ code, stderr }); }); + }); } -async function setup(t) { +async function setupTerminals(t) { const root = await realpath(await mkdtemp(join(tmpdir(), "ctty-ocr-"))); t.after(() => rm(root, { recursive: true, force: true })); const calls = []; - const terminals = new TerminalManager(() => undefined, availableRegistry(), undefined, undefined, true, fakeSpawner(calls)); + const terminals = new TerminalManager(() => undefined, availableRegistry(), undefined, undefined, true, inputReadySpawner(calls)); t.after(() => terminals.disposeAll()); - const runtimeDirectory = await mkdtemp(join(tmpdir(), "ctty-rt-")); - t.after(() => rm(runtimeDirectory, { recursive: true, force: true })); - // What main/index.ts does with each runtime signal. - const gateway = new RuntimeGateway({ - runtimeDirectory, - onSignal: (id, signal) => { - terminals.applyProviderSignal(id, { kind: "lifecycle", state: signal.state }); - if (signal.result) terminals.recordAnswer(id, signal.result); - } - }); - await gateway.start(); - t.after(() => gateway.close()); const orchestrator = terminals.create({ provider: "codex", cwd: root, profile: "normal", position: { x: 0, y: 0 }, role: "orchestrator" }); const control = new AgentControlService(terminals, { waitTiming: { checkMs: 5, settleMs: 300, quietMs: 2_000 } }); const handler = new ScopedOrchestrationHandler(control); const child = await handler.execute(orchestrator.id, { id: "1", tool: "spawn_agent", arguments: { provider: "opencode", cwd: root } }); - return { root, calls, terminals, gateway, handler, orchestrator, childId: child.sessionId }; + return { root, calls, terminals, control, handler, orchestrator, childId: child.sessionId }; +} + +async function setup(t) { + const fixture = await setupTerminals(t); + const { terminals } = fixture; + const runtimeDirectory = await mkdtemp(join(tmpdir(), "ctty-rt-")); + t.after(() => rm(runtimeDirectory, { recursive: true, force: true })); + // The same glue main/index.ts runs for each runtime signal. + const gateway = new RuntimeGateway({ runtimeDirectory, onSignal: (id, signal) => applyRuntimeSignal(terminals, id, signal) }); + await gateway.start(); + t.after(() => gateway.close()); + return { ...fixture, gateway }; } test("an OpenCode subagent's final reply reaches wait_for_agent and get_agent_result, masked", POSIX, async (t) => { @@ -170,3 +180,318 @@ test("wait_for_agent ignores an idle before the prompt's turn and returns that t assert.equal(done.reason, "idle"); assert.deepEqual(done.answer, { text: "part A done", truncated: false }); }); + +test("OpenCode turn correlation rejects preceding answers and continuations across submitted prompts", async (t) => { + const { terminals, childId } = await setupTerminals(t); + const apply = (state, event, turnId, answer) => { + const current = terminals.applyProviderSignal(childId, { kind: "lifecycle", state, event, requestId: turnId }); + if (current && answer) terminals.recordAnswer(childId, { text: answer, truncated: false }, { turnId }); + return current; + }; + assert.equal(apply("working", "session.status:busy", "turn1"), true); + assert.equal(apply("idle", "session.idle", "turn1", "old answer"), true); + assert.equal(terminals.answer(childId).text, "old answer"); + assert.deepEqual(await terminals.deliverInput(childId, "new task\r"), { delivered: true }); + assert.equal(terminals.answer(childId), null, "a submitted prompt drops the previous answer"); + assert.equal(apply("idle", "session.idle", "turn1", "delayed old answer"), false); + assert.equal(apply("working", "permission.replied", "turn1"), false, "an old continuation cannot acknowledge the new prompt"); + assert.equal(terminals.getMetadata(childId).status, "working", "but it still moves the status of the turn that runs"); + terminals.recordAnswer(childId, { text: "uncorrelated old answer", truncated: false }); + assert.equal(terminals.answer(childId), null); + assert.equal(terminals.turnProgress(childId).turnStartedSincePrompt, false); + assert.equal(apply("working", "session.status:busy", "turn2"), true); + assert.equal(apply("idle", "session.idle", "turn1", "late old after new start"), false); + assert.equal(terminals.getMetadata(childId).status, "working", "a late signal of an earlier turn changes nothing"); + assert.equal(apply("needs_approval", "permission.asked", "turn2"), true); + const generation = terminals.sessions.get(childId).inputGeneration ?? 0; + terminals.input(childId, "y\r"); + assert.equal(terminals.sessions.get(childId).inputGeneration ?? 0, generation, "an approval answer is not a new task"); + assert.equal(apply("working", "permission.replied", "turn2"), true); + assert.equal(apply("working", "session.status:retry", "turn2"), true); + assert.equal(apply("idle", "session.idle", "turn2", "new answer"), true); + assert.equal(terminals.answer(childId).text, "new answer"); + apply("working", "session.status:busy", "turn3"); + await terminals.deliverInput(childId, "interrupt current work\r"); + const starts = terminals.sessions.get(childId).turnStarts; + assert.equal(apply("working", "session.status:busy", "turn4"), true); + assert.equal(terminals.sessions.get(childId).turnStarts, starts + 1, "a new turn id is a new turn even while the previous one was working"); + assert.equal(terminals.turnProgress(childId).turnStartedSincePrompt, true); + assert.equal(apply("idle", "session.idle", "turn3", "interrupted answer"), false); + assert.equal(terminals.answer(childId), null); +}); + +test("with status hooks off the plugin reports nothing and the wait says the answer cannot be captured", POSIX, async (t) => { + const { terminals, gateway, handler, orchestrator, childId } = await setup(t); + terminals.setLifecycleHooksEnabled(false); + const signals = []; + const apply = terminals.applyProviderSignal.bind(terminals); + terminals.applyProviderSignal = (id, signal, ...args) => { signals.push(signal); return apply(id, signal, ...args); }; + // A lease that exists for another reason (the decision hook) still carries no lifecycle report and no answer. + const capability = gateway.registerSession(childId, "opencode", true); + const run = await runFakeOpenCode(capability, "never captured", true, FAKE_OPENCODE, { CANVASTTY_LIFECYCLE_HOOKS_ENABLED: "0" }); + assert.equal(run.code, 0, run.stderr); + assert.deepEqual(signals, []); + assert.equal(terminals.getMetadata(childId).status, "unavailable"); + assert.equal(terminals.answer(childId), null); + const waited = await handler.execute(orchestrator.id, { id: "w", tool: "wait_for_agent", arguments: { sessionId: childId, timeoutSeconds: 5 } }); + assert.equal(waited.reason, "quiet"); + assert.equal(waited.answer, undefined); + assert.equal(waited.answerUnavailable.reason, "status_hooks_off"); + assert.match(waited.answerUnavailable.message, /status hooks are off[\s\S]*Read its output/u); + const result = await handler.execute(orchestrator.id, { id: "r", tool: "get_agent_result", arguments: { sessionId: childId } }); + assert.equal(result.answer, undefined); + assert.deepEqual(result.answerUnavailable, waited.answerUnavailable); + // Turning them on again applies to agents started or restarted afterwards, not to this launch. + terminals.setLifecycleHooksEnabled(true); + assert.equal(terminals.applyProviderSignal(childId, { kind: "lifecycle", state: "idle", event: "session.idle", requestId: "t" }), false); + const later = await handler.execute(orchestrator.id, { id: "r2", tool: "get_agent_result", arguments: { sessionId: childId } }); + assert.equal(later.answerUnavailable?.reason, "status_hooks_off"); +}); + +test("OpenCode async final answer keeps its ending turn and drops a read superseded by a new turn", POSIX, async (t) => { + const { terminals, gateway, childId } = await setup(t); + const capability = gateway.registerSession(childId, "opencode", true); + const signals = []; + const original = terminals.applyProviderSignal.bind(terminals); + terminals.applyProviderSignal = (id, signal, ...args) => { signals.push(signal); return original(id, signal, ...args); }; + const script = ` + const { CanvasTTYLifecycle } = await import(process.env.PLUGIN_URL); + let resolveOld, started; const capturing = new Promise((done) => started = done); + let reads = 0; + const client = { session: { messages: async () => { + if (reads++ === 0) { started(); return new Promise((done) => resolveOld = done); } + return { data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "second answer" }] }] }; + } } }; + const hooks = await CanvasTTYLifecycle({ client }); + const event = (type, rest = {}) => hooks.event({ event: { type, properties: { sessionID: "root", ...rest } } }); + await event("session.created", { info: { id: "root" } }); + await event("session.status", { status: { type: "busy" } }); + await event("session.status", { status: { type: "idle" } }); + const old = event("session.idle"); await capturing; + await event("session.status", { status: { type: "busy" } }); + resolveOld({ data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "old delayed answer" }] }] }); + await old; + await event("permission.asked"); await event("permission.replied"); + await event("session.status", { status: { type: "retry" } }); + await event("session.status", { status: { type: "idle" } }); + await event("session.idle"); + `; + const run = await runFakeOpenCode(capability, "unused", true, script); + assert.equal(run.code, 0, run.stderr); + const starts = signals.filter((signal) => signal.event === "session.status:busy"); + assert.equal(starts.length, 2); + assert.notEqual(starts[0].requestId, starts[1].requestId, "each turn has its own id"); + assert.equal(signals.filter((signal) => signal.event === "session.status:idle").length, 0, "no idle before the answer was read"); + const ends = signals.filter((signal) => signal.event === "session.idle"); + assert.equal(ends.length, 1, "a superseded answer read reports neither idle nor an answer"); + assert.equal(ends[0].requestId, starts[1].requestId); + assert.equal(signals.find((signal) => signal.event === "permission.replied").requestId, starts[1].requestId); + assert.equal(signals.find((signal) => signal.event === "session.status:retry").requestId, starts[1].requestId); + assert.equal(terminals.answer(childId).text, "second answer"); +}); + +test("an answer read that finishes after the next prompt was submitted is not that prompt's answer", POSIX, async (t) => { + const { terminals, gateway, handler, orchestrator, childId } = await setup(t); + const capability = gateway.registerSession(childId, "opencode", true); + const script = ` + const { CanvasTTYLifecycle } = await import(process.env.PLUGIN_URL); + const client = { session: { messages: async () => { + process.stdout.write("capturing"); + await new Promise((done) => process.stdin.once("data", done)); + return { data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "late preceding answer" }] }] }; + } } }; + const hooks = await CanvasTTYLifecycle({ client }); + const event = (type, rest = {}) => hooks.event({ event: { type, properties: { sessionID: "root", ...rest } } }); + await event("session.created", { info: { id: "root" } }); + await event("session.status", { status: { type: "busy" } }); + await event("session.idle"); + `; + const run = await runFakeOpenCode(capability, "unused", true, script, {}, () => terminals.deliverInput(childId, "next task\r")); + assert.equal(run.code, 0, run.stderr); + const result = await handler.execute(orchestrator.id, { id: "poll", tool: "get_agent_result", arguments: { sessionId: childId } }); + assert.equal(result.answer, undefined); + assert.equal(terminals.turnProgress(childId).turnStartedSincePrompt, false, "the late idle does not acknowledge the next prompt"); +}); + +test("approval continuations and repeated working events stay in the same turn", async (t) => { + const { terminals, childId } = await setupTerminals(t); + const apply = (state, event, turnId) => terminals.applyProviderSignal(childId, { kind: "lifecycle", state, event, requestId: turnId }); + assert.equal(apply("working", "session.status:busy", "turn1"), true); + const starts = terminals.sessions.get(childId).turnStarts; + assert.equal(apply("working", "session.status:busy", "turn1"), true); + assert.equal(apply("working", "session.status:retry", "turn1"), true); + assert.equal(terminals.sessions.get(childId).turnStarts, starts, "a repeated working or retry is not another start"); + assert.equal(apply("needs_approval", "permission.asked", "turn1"), true); + assert.equal(terminals.getMetadata(childId).status, "needs_approval"); + const generation = terminals.sessions.get(childId).inputGeneration ?? 0; + await terminals.deliverInput(childId, "y\r"); + assert.equal(terminals.sessions.get(childId).inputGeneration ?? 0, generation, "an approval answer keeps the current turn"); + assert.equal(apply("working", "permission.replied", "turn1"), true); + assert.equal(terminals.turnProgress(childId).turnStartedSincePrompt, true, "the continuation acknowledges the approval"); + const continued = terminals.sessions.get(childId).turnStarts; + assert.equal(apply("working", "session.status:retry", "turn1"), true); + assert.equal(terminals.sessions.get(childId).turnStarts, continued); + assert.equal(apply("idle", "session.idle", "turn1"), true); + terminals.recordAnswer(childId, { text: "answer after approval", truncated: false }, { turnId: "turn1" }); + assert.equal(terminals.answer(childId).text, "answer after approval"); + assert.equal(terminals.getMetadata(childId).status, "idle"); + terminals.input(childId, "next task\r"); + assert.equal(terminals.sessions.get(childId).inputGeneration, generation + 1); + assert.equal(apply("working", "permission.replied", "turn1"), false); +}); + +test("a result-capturing OpenCode does not report idle while its final answer is still being read", POSIX, async (t) => { + const { terminals, gateway, handler, orchestrator, childId } = await setup(t); + const capability = gateway.registerSession(childId, "opencode", true); + const script = ` + const { CanvasTTYLifecycle } = await import(process.env.PLUGIN_URL); + const client = { session: { messages: async () => { + process.stdout.write("capturing"); await new Promise((done) => process.stdin.once("data", done)); + return { data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "captured after delay" }] }] }; + } } }; + const hooks = await CanvasTTYLifecycle({ client }); + const event = (type, rest = {}) => hooks.event({ event: { type, properties: { sessionID: "root", ...rest } } }); + await event("session.created", { info: { id: "root" } }); + await event("session.status", { status: { type: "busy" } }); + await event("session.status", { status: { type: "idle" } }); + await event("session.idle"); + `; + const run = await runFakeOpenCode(capability, "unused", true, script, {}, () => { + assert.equal(terminals.getMetadata(childId).status, "working"); + assert.equal(terminals.answer(childId), null); + }); + assert.equal(run.code, 0, run.stderr); + const waited = await handler.execute(orchestrator.id, { id: "w", tool: "wait_for_agent", arguments: { sessionId: childId, timeoutSeconds: 5 } }); + assert.equal(waited.reason, "idle"); + assert.equal(waited.answer.text, "captured after delay"); +}); + +for (const failure of ["error", "timeout"]) test(`a captured OpenCode turn still ends, without an answer, after an SDK read ${failure}`, POSIX, async (t) => { + const { terminals, gateway, childId } = await setup(t); + const accepted = []; + const apply = terminals.applyProviderSignal.bind(terminals); + terminals.applyProviderSignal = (id, signal, ...args) => { accepted.push(signal); return apply(id, signal, ...args); }; + const capability = gateway.registerSession(childId, "opencode", true); + const script = ` + const { CanvasTTYLifecycle } = await import(process.env.PLUGIN_URL); + const client = { session: { messages: async () => { ${failure === "error" ? 'throw new Error("fixture failed");' : "return new Promise(() => {});"} } } }; + const hooks = await CanvasTTYLifecycle({ client }); + const event = (type, rest = {}) => hooks.event({ event: { type, properties: { sessionID: "root", ...rest } } }); + await event("session.created", { info: { id: "root" } }); await event("session.status", { status: { type: "busy" } }); + await event("session.status", { status: { type: "idle" } }); await event("session.idle"); + `; + const run = await runFakeOpenCode(capability, "unused", true, script); + assert.equal(run.code, 0, run.stderr); + assert.equal(accepted.filter((signal) => signal.event === "session.status:idle").length, 0); + assert.equal(accepted.filter((signal) => signal.event === "session.idle").length, 1, "the turn end is still reported"); + assert.equal(terminals.answer(childId), null); + assert.equal(terminals.getMetadata(childId).status, "idle"); +}); + +/** Feeds one runtime signal through the glue main uses, as the gateway would hand it over. */ +const signalFrom = (terminals, id) => (state, event, turnId, answer) => applyRuntimeSignal(terminals, id, + { state, event, turnId, ...(answer ? { result: { text: answer, truncated: false } } : {}) }); + +test("a person's Enter in the middle of a turn still lets that turn end the wait, without an answer", async (t) => { + const { terminals, handler, orchestrator, childId } = await setupTerminals(t); + const signal = signalFrom(terminals, childId); + assert.deepEqual(await terminals.deliverInput(childId, "orchestrator task\r"), { delivered: true }); + signal("working", "session.status:busy", "turn1"); + // The person presses Enter in the subagent's card while it works: the turn's answer is no longer for that prompt. + terminals.input(childId, "\r"); + const waiting = handler.execute(orchestrator.id, { id: "w", tool: "wait_for_agent", arguments: { sessionId: childId, timeoutSeconds: 5 } }); + setTimeout(() => signal("idle", "session.idle", "turn1", "answer to the earlier prompt"), 20); + const waited = await waiting; + assert.equal(waited.reason, "idle", "the wait does not hang until its timeout"); + assert.equal(waited.status, "idle"); + assert.equal(waited.answer, undefined); + assert.equal(waited.answerUnavailable, undefined, "status hooks are on: nothing says the answer cannot be captured"); +}); + +test("an answer that arrives as the agent exits is kept when it belongs to the current turn", async (t) => { + const { calls, terminals, handler, orchestrator, childId } = await setupTerminals(t); + const signal = signalFrom(terminals, childId); + await terminals.deliverInput(childId, "last task\r"); + signal("working", "session.status:busy", "turn1"); + calls.at(-1).process.emitExit(0); + assert.equal(terminals.getMetadata(childId).status, "done"); + signal("idle", "session.idle", "turn1", "final words"); + assert.equal(terminals.getMetadata(childId).status, "done", "the exit stays"); + const result = await handler.execute(orchestrator.id, { id: "r", tool: "get_agent_result", arguments: { sessionId: childId } }); + assert.equal(result.state, "done"); + assert.equal(result.answer?.text, "final words"); + // A late answer of an earlier turn still does not count after the exit. + signal("idle", "session.idle", "turn0", "stale"); + assert.equal(terminals.answer(childId)?.text, "final words"); +}); + +test("an Enter with nothing typed in an idle card keeps the stored answer", async (t) => { + const { terminals, childId } = await setupTerminals(t); + const signal = signalFrom(terminals, childId); + await terminals.deliverInput(childId, "task\r"); + signal("working", "session.status:busy", "turn1"); + signal("idle", "session.idle", "turn1", "kept answer"); + const generation = terminals.sessions.get(childId).inputGeneration; + terminals.input(childId, "\r"); + assert.equal(terminals.answer(childId)?.text, "kept answer", "an empty Enter submits nothing"); + assert.equal(terminals.sessions.get(childId).inputGeneration, generation); + // Typed key by key, as a person types: the Enter after it submits a new task. + for (const key of ["n", "e", "x", "t"]) terminals.input(childId, key); + terminals.input(childId, "\r"); + assert.equal(terminals.answer(childId), null); + assert.equal(terminals.sessions.get(childId).inputGeneration, generation + 1); +}); + +test("every input write goes through writeInput, which keeps the turn and answer bookkeeping", async (t) => { + const { terminals, childId } = await setupTerminals(t); + const signal = signalFrom(terminals, childId); + const writes = []; + const writeInput = terminals.writeInput; + assert.equal(typeof writeInput, "function", "one write path for person and agent input"); + terminals.writeInput = function (...args) { writes.push(args[3]); return writeInput.apply(this, args); }; + await terminals.deliverInput(childId, "task\r"); + terminals.input(childId, "x"); + assert.deepEqual(writes, ["task\r", "x"], "input() and deliverInput write through it"); + terminals.writeInput = writeInput; + signal("working", "session.status:busy", "turn1"); + signal("idle", "session.idle", "turn1", "kept answer"); + // A writer that held input and replays it later (outside input()) uses the same method: its Enter submits a task. + const session = terminals.sessions.get(childId); + const generation = session.inputGeneration; + for (const key of ["n", "e", "x", "t", "\r"]) assert.equal(terminals.writeInput(childId, session, session.process, key), true); + assert.equal(terminals.answer(childId), null, "the replayed prompt drops the earlier answer"); + assert.equal(session.inputGeneration, generation + 1); + // Never into a process the card no longer runs. + assert.equal(terminals.writeInput(childId, session, { write: () => assert.fail("stale process") }, "late\r"), false); + assert.equal(session.inputGeneration, generation + 1); +}); + +for (const [atCreate, beforeSpawn] of [[false, true], [true, false]]) { + test(`a deferred launch takes status hooks as they are when it is planned (${atCreate ? "on" : "off"} at create, ${beforeSpawn ? "on" : "off"} at launch)`, async (t) => { + const root = await realpath(await mkdtemp(join(tmpdir(), "ctty-ocr-deferred-"))); + t.after(() => rm(root, { recursive: true, force: true })); + let release; + const prepared = new Promise((resolve) => { release = resolve; }); + const pipeline = new LaunchPipeline({ + contributors: () => [{ pluginId: "fixture.hold", pluginName: "Hold", serviceId: "launch", secrets: false, + launch: { fields: [{ key: "on", label: "On", kind: "boolean", default: true }] } }], + call: async () => { await prepared; return {}; }, + secret: async () => null, + runsRoot: join(root, "runs"), + timeoutMs: 4_000 + }); + const calls = []; + const terminals = new TerminalManager(() => undefined, availableRegistry(), undefined, undefined, atCreate, fakeSpawner(calls)); + t.after(() => { release(); terminals.disposeAll(); }); + terminals.configureLaunchPipeline(pipeline); + const card = terminals.create({ provider: "opencode", cwd: root, profile: "normal", position: { x: 0, y: 0 }, + launchOptions: { "fixture.hold": { on: true } } }, { captureResult: true }); + terminals.setLifecycleHooksEnabled(beforeSpawn); + release(); + const deadline = Date.now() + 4_000; + while (calls.length === 0 && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 5)); + assert.equal(calls.length, 1, "the launch spawned"); + assert.equal(terminals.answerUnavailable(card.id), beforeSpawn ? null : "status_hooks_off"); + assert.equal(terminals.applyProviderSignal(card.id, { kind: "lifecycle", state: "working", event: "session.status:busy", requestId: "t1" }), beforeSpawn); + }); +} diff --git a/tests/runtime-result-transport.test.mjs b/tests/runtime-result-transport.test.mjs new file mode 100644 index 00000000..af2be39e --- /dev/null +++ b/tests/runtime-result-transport.test.mjs @@ -0,0 +1,129 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { realpathSync } from "node:fs"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { AGENT_RUNTIME_ENV, CAPTURE_RESULT_ENV } from "../src/agent-runtime/runtime-protocol.mjs"; +import { AgentControlService } from "../src/main/services/AgentControlService.ts"; +import { AgentRuntimeBridge } from "../src/main/services/agent-runtime/AgentRuntimeBridge.ts"; +import { RuntimeGateway } from "../src/main/services/agent-runtime/RuntimeGateway.ts"; +import { applyRuntimeSignal } from "../src/main/services/agent-runtime/applyRuntimeSignal.ts"; +import { TerminalManager } from "../src/main/services/TerminalManager.ts"; +import { availableRegistry, inputReadySpawner } from "./helpers/terminal.mjs"; + +const POSIX = { timeout: 15_000, skip: process.platform === "win32" ? "Real Unix socket roundtrip; the Windows pipe transport has its own suite." : false }; +const helper = fileURLToPath(new URL("../src/agent-runtime/hook-helper.mjs", import.meta.url)); +const plugin = fileURLToPath(new URL("../src/agent-runtime/opencode-plugin.mjs", import.meta.url)); + +/** Runs a hook or plugin process the way the agent CLI would, with the launch's environment. */ +function run(args, env, input = "") { + const child = spawn(process.execPath, args, { env: { ...process.env, ...env }, stdio: ["pipe", "ignore", "pipe"] }); + const timeout = setTimeout(() => child.kill(), 5_000); + let stderr = ""; + child.stderr.on("data", (chunk) => { stderr += chunk; }); + child.stdin.end(input); + return new Promise((resolve, reject) => { + child.on("error", reject); + child.on("close", (code) => { clearTimeout(timeout); code === 0 ? resolve() : reject(new Error(stderr || `exit ${code}`)); }); + }); +} + +const openCodeTurn = (answer) => ` + const { CanvasTTYLifecycle } = await import(${JSON.stringify(new URL("../src/agent-runtime/opencode-plugin.mjs", import.meta.url).href)}); + const hooks = await CanvasTTYLifecycle({ client: { session: { messages: async () => ({ data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: ${JSON.stringify(answer)} }] }] }) } } }); + const event = (type, rest = {}) => hooks.event({ event: { type, properties: { sessionID: "worker-root", ...rest } } }); + await event("session.created", { info: { id: "worker-root" } }); + await event("session.status", { status: { type: "busy" } }); + await event("session.status", { status: { type: "idle" } }); + await event("session.idle"); +`; + +/** A real gateway, bridge and hook helper/plugin, with fake CLI PTYs; signals go through main's own glue. */ +async function fixture(t, statusHooks) { + const root = realpathSync(await mkdtemp(join(tmpdir(), "ctty-rt-"))); + const calls = []; + let terminals; + const gateway = new RuntimeGateway({ runtimeDirectory: root, onSignal: (id, signal) => applyRuntimeSignal(terminals, id, signal) }); + await gateway.start(); + const bridge = new AgentRuntimeBridge(gateway, { + runtimeDirectory: join(root, "configs"), helper: { command: process.execPath, args: [helper] }, openCodePluginPath: plugin, + coreHooksEnabled: statusHooks, environment: {}, + kimiHomeDirectory: join(root, "kimi"), hermesHomeDirectory: join(root, "hermes"), grokHomeDirectory: join(root, "grok") + }); + terminals = new TerminalManager(() => undefined, availableRegistry(), undefined, bridge, statusHooks, inputReadySpawner(calls)); + t.after(async () => { + terminals.disposeAll(); + await gateway.close(); + await rm(root, { recursive: true, force: true }); + }); + const control = new AgentControlService(terminals, { waitTiming: { checkMs: 5, settleMs: 0, quietMs: 200 } }); + const parent = terminals.create({ provider: "codex", profile: "normal", cwd: root, role: "orchestrator", position: { x: 0, y: 0 } }); + // Status hooks are one switch: the bridge (hooks, leases) and the cards (status) follow it together, as in main. + const setStatusHooks = (enabled) => { + bridge.setCoreHooksEnabled(enabled); + terminals.setLifecycleHooksEnabled(enabled); + }; + return { root, calls, gateway, bridge, terminals, control, parent, setStatusHooks }; +} + +async function finishTurn(provider, env, answer) { + if (provider === "codex") { + await run([helper, "working", "UserPromptSubmit"], env, JSON.stringify({ turn_id: "fixture-turn" })); + await run([helper, "idle", "Stop"], env, JSON.stringify({ turn_id: "fixture-turn", last_assistant_message: answer })); + } else { + await run(["--input-type=module", "-e", openCodeTurn(answer)], env); + } +} + +for (const provider of ["codex", "opencode"]) { + test(`${provider} subagent's answer reaches wait_for_agent through its hooks while status hooks are on`, POSIX, async (t) => { + const f = await fixture(t, true); + const worker = await f.control.spawn({ parentSessionId: f.parent.id, provider, cwd: f.root }); + const env = f.calls.at(-1).options.env; + assert.equal(env[CAPTURE_RESULT_ENV], "1"); + assert.ok(env[AGENT_RUNTIME_ENV.capabilityToken]); + await f.terminals.deliverInput(worker.id, "worker task\r"); + await finishTurn(provider, env, "worker final answer"); + const waited = await f.control.waitFor(worker.id, { timeoutMs: 2_000 }); + assert.equal(waited.reason, "idle"); + assert.equal(waited.answer?.text, "worker final answer"); + assert.equal(waited.answerUnavailable, undefined); + + // Turning status hooks off revokes the lease: this launch has no hooks any more, and the result says so. + f.setStatusHooks(false); + assert.equal(f.gateway.currentStatus(worker.id), null); + assert.equal(f.control.result(worker.id).answer?.text, "worker final answer", "an answer captured earlier stays"); + await f.terminals.deliverInput(worker.id, "next task\r"); + const after = f.control.result(worker.id); + assert.equal(after.answer, undefined); + assert.equal(after.answerUnavailable?.reason, "status_hooks_off"); + }); + + test(`${provider} subagent started with status hooks off gets no hooks, and the wait says its answer cannot be captured`, POSIX, async (t) => { + const f = await fixture(t, false); + const worker = await f.control.spawn({ parentSessionId: f.parent.id, provider, cwd: f.root }); + const launch = f.calls.at(-1); + assert.equal(launch.options.env[AGENT_RUNTIME_ENV.capabilityToken], undefined, "no runtime lease"); + assert.deepEqual(launch.args.filter((arg) => typeof arg === "string" && /^hooks\./u.test(arg)), [], "no lifecycle hooks"); + await f.terminals.deliverInput(worker.id, "worker task\r"); + const waited = await f.control.waitFor(worker.id, { timeoutMs: 2_000 }); + assert.equal(waited.reason, "quiet"); + assert.equal(waited.status, "unavailable"); + assert.equal(waited.answer, undefined); + assert.equal(waited.answerUnavailable?.reason, "status_hooks_off"); + assert.match(waited.answerUnavailable.message, /status hooks are off/u); + + // Turning them on applies to agents started or restarted afterwards. + f.setStatusHooks(true); + assert.equal(f.control.result(worker.id).answerUnavailable?.reason, "status_hooks_off"); + launch.process.emitExit(0); + f.terminals.restart(worker.id); + const restarted = f.calls.at(-1); + assert.notEqual(restarted, launch); + assert.ok(restarted.options.env[AGENT_RUNTIME_ENV.capabilityToken], "the restarted launch has its hooks"); + assert.equal(f.control.result(worker.id).answerUnavailable, undefined); + }); +} diff --git a/tests/terminal-lifecycle.test.mjs b/tests/terminal-lifecycle.test.mjs index c1e7c124..fc1b3e54 100644 --- a/tests/terminal-lifecycle.test.mjs +++ b/tests/terminal-lifecycle.test.mjs @@ -290,15 +290,31 @@ test("session metadata revisions advance before lifecycle events cross IPC", asy assert.match(source, /metadata\.revision \+= 1;[\s\S]{0,120}?this\.emit\(IPC\.terminalSession/); }); -test("revoking lifecycle hooks makes live agent status unavailable until a restarted session gets a new parser", async () => { +test("revoking lifecycle hooks makes live agent status unavailable until a restarted session gets a new parser", async (t) => { const source = await readFile(terminalManagerPath, "utf8"); - - assert.match(source, /setLifecycleHooksEnabled\(enabled: boolean\): void/); - assert.match(source, /this\.lifecycleHooksEnabled = next;\s*if \(next\) return;/); - assert.match(source, /session\.lifecycle = null;/); - assert.match(source, /session\.metadata\.provider === "terminal"/); - assert.match(source, /session\.metadata\.status = "unavailable";\s*this\.emitSession\(session\.metadata\)/); - assert.match(source, /if \(!this\.lifecycleHooksEnabled \|\| !session/); + const { TerminalManager } = await import("../src/main/services/TerminalManager.ts"); + const { availableRegistry, fakeSpawner } = await import("./helpers/terminal.mjs"); + const manager = new TerminalManager(() => undefined, availableRegistry(), undefined, undefined, true, fakeSpawner([])); + t.after(() => manager.disposeAll()); + const session = manager.create({ provider: "codex", profile: "normal", cwd: process.cwd(), position: { x: 0, y: 0 } }); + const shell = manager.create({ provider: "terminal", profile: "normal", cwd: process.cwd(), position: { x: 0, y: 0 } }); + const shellStatus = manager.getMetadata(shell.id).status; + manager.applyProviderSignal(session.id, { kind: "lifecycle", state: "working" }); + assert.equal(manager.getMetadata(session.id).status, "working"); + manager.setLifecycleHooksEnabled(false); + assert.equal(manager.getMetadata(session.id).status, "unavailable"); + assert.equal(manager.sessions.get(session.id).lifecycle, null); + assert.equal(manager.getMetadata(shell.id).status, shellStatus, "a plain terminal has no agent status to revoke"); + for (const state of ["needs_approval", "working", "idle"]) { + manager.applyProviderSignal(session.id, { kind: "lifecycle", state }); + assert.equal(manager.getMetadata(session.id).status, "unavailable", "a hook signal never restores a disabled status"); + } + manager.setLifecycleHooksEnabled(true); + manager.applyProviderSignal(session.id, { kind: "lifecycle", state: "working" }); + assert.equal(manager.getMetadata(session.id).status, "unavailable", "re-enabling applies only to new or restarted sessions"); + const fresh = manager.create({ provider: "codex", profile: "normal", cwd: process.cwd(), position: { x: 0, y: 0 } }); + manager.applyProviderSignal(fresh.id, { kind: "lifecycle", state: "working" }); + assert.equal(manager.getMetadata(fresh.id).status, "working"); assert.match(source, /session\.lifecycle = this\.lifecycleHooksEnabled\s*\? createProviderLifecycleParser/); }); @@ -306,8 +322,9 @@ test("only explicit turn-stop hooks set completed art and a new turn clears it", const [card, manager, main] = await Promise.all([ readFile(terminalCardPath, "utf8"), readFile(terminalManagerPath, "utf8"), - readFile(new URL("../src/main/index.ts", import.meta.url), "utf8") + readFile(new URL("../src/main/services/agent-runtime/applyRuntimeSignal.ts", import.meta.url), "utf8") ]); + // main/index.ts hands every runtime signal to applyRuntimeSignal, which passes the hook's event on. assert.match(main, /state: signal\.state,\s*event: signal\.event/); assert.match(manager, /\["Stop", "StopFailure", "StopCancelled"\]\.includes\(signal\.event \?\? ""\)/); assert.match(manager, /nextStatus === "working" \? false : completed \|\| Boolean\(session\.metadata\.turnCompleted\)/); From cac98b6d21dc50e9d94e0fc2f05099cce2863368 Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:10:28 +0000 Subject: [PATCH 08/28] fix(runtime): keep a turn whose start hook was lost, and map PostToolUseFailure Once a turn id was active, the gateway dropped every non-start event with a new turn id. Each hook runs on its own connection and can time out, so when a turn's start hook was lost the whole turn, including its Stop, was ignored and the card kept the previous turn's status. The gateway now tracks whether a turn is open. After an idle, an unseen turn id opens the next turn; during an open turn, or for an id already seen, the event is still dropped as out of order. When a tool the person approved fails, the agent CLI runs PostToolUseFailure instead of PostToolUse. CanvasTTY did not register it, so the card stayed needs_approval until the next hook although the turn went on. It now maps to working. --- .../agent-runtime/ProviderRuntimeLaunch.ts | 2 ++ .../services/agent-runtime/RuntimeGateway.ts | 23 +++++++++++----- tests/agent-runtime-gateway.test.mjs | 26 +++++++++++++++++++ tests/agent-runtime-provider-launch.test.mjs | 1 + tests/claude-http-hooks.test.mjs | 2 +- 5 files changed, 46 insertions(+), 8 deletions(-) diff --git a/src/main/services/agent-runtime/ProviderRuntimeLaunch.ts b/src/main/services/agent-runtime/ProviderRuntimeLaunch.ts index a945496f..949d29ba 100644 --- a/src/main/services/agent-runtime/ProviderRuntimeLaunch.ts +++ b/src/main/services/agent-runtime/ProviderRuntimeLaunch.ts @@ -406,6 +406,8 @@ const CLAUDE_HOOKS: readonly HookMapping[] = [ { event: "UserPromptSubmit", state: "working" }, { event: "PermissionRequest", state: "needs_approval" }, { event: "PostToolUse", state: "working" }, + // An approved tool that fails runs PostToolUseFailure, not PostToolUse: the turn goes on after the approval. + { event: "PostToolUseFailure", state: "working" }, { event: "Stop", state: "idle" }, { event: "StopFailure", state: "idle" }, { event: "SessionEnd", state: "idle" }, diff --git a/src/main/services/agent-runtime/RuntimeGateway.ts b/src/main/services/agent-runtime/RuntimeGateway.ts index 97015bf6..42a27aad 100644 --- a/src/main/services/agent-runtime/RuntimeGateway.ts +++ b/src/main/services/agent-runtime/RuntimeGateway.ts @@ -79,6 +79,8 @@ interface RuntimeLease { provider: Exclude; tokenDigest: Buffer; activeTurnId: string | null; + /** A turn started and has not reported idle yet; between turns an unseen turn id may open the next one. */ + turnOpen: boolean; /** Turn ids this session already reported (bounded, oldest dropped): a late start of one of them is stale. */ seenTurnIds: Set; latest: RuntimeLifecycleSignal | null; @@ -276,6 +278,7 @@ export class RuntimeGateway { provider, tokenDigest: tokenDigest(capabilityToken), activeTurnId: null, + turnOpen: false, seenTurnIds: new Set(), latest: null, captureResult: captureResultOrGrantExpiresAt === true, @@ -469,21 +472,27 @@ export class RuntimeGateway { throw new Error("Answer capture is not authorized for this session."); } - if (message.turnId && isTurnStart(message.event)) { + const startsTurn = isTurnStart(message.event); + if (message.turnId && startsTurn) { // Each hook runs on its own connection, so a start of an earlier turn can arrive after the next turn began; // it must not make that newer turn's events look stale. if (message.turnId !== lease.activeTurnId && lease.seenTurnIds.has(message.turnId)) return null; lease.activeTurnId = message.turnId; + lease.turnOpen = true; + rememberTurn(lease, message.turnId); + } else if (message.turnId && message.turnId !== lease.activeTurnId) { + // A turn whose start hook was lost (it timed out, or the CLI skipped it) still counts: after an idle, an + // unseen id opens the next turn. During an open turn, or for an id already seen, the event is out of order. + if (lease.activeTurnId && (lease.turnOpen || lease.seenTurnIds.has(message.turnId))) return null; + lease.activeTurnId = message.turnId; + lease.turnOpen = true; rememberTurn(lease, message.turnId); - } else if ( - message.turnId - && lease.activeTurnId - && message.turnId !== lease.activeTurnId - ) { - return null; } else if (message.turnId) { rememberTurn(lease, message.turnId); + } else if (startsTurn) { + lease.turnOpen = true; } + if (message.state === "idle") lease.turnOpen = false; const signal: RuntimeLifecycleSignal = { state: message.state, event: message.event, diff --git a/tests/agent-runtime-gateway.test.mjs b/tests/agent-runtime-gateway.test.mjs index 61ec999d..67e8bc0d 100644 --- a/tests/agent-runtime-gateway.test.mjs +++ b/tests/agent-runtime-gateway.test.mjs @@ -158,6 +158,32 @@ test("RuntimeGateway: a late start of an earlier turn does not take over from th assert.equal(gateway.currentStatus("terminal-turns"), "idle"); }); +test("RuntimeGateway: a turn whose start hook was lost still counts after an idle, but not while another turn runs", POSIX_RUNTIME_GATEWAY_TEST, async (t) => { + const root = await fixture(t); + const signals = []; + const gateway = new RuntimeGateway({ runtimeDirectory: root, onSignal: (id, signal) => signals.push({ id, signal }) }); + await gateway.start(); + t.after(() => gateway.close()); + const capability = gateway.registerSession("terminal-lost-start", "codex"); + await send(capability.address, message(capability, "working", "UserPromptSubmit", "turn-1")); + // An event of another turn while turn 1 runs is out of order. + await send(capability.address, message(capability, "working", "PostToolUse", "turn-x")); + await send(capability.address, message(capability, "idle", "Stop", "turn-1")); + // Turn 2's UserPromptSubmit never arrived: its own events still report it. + await send(capability.address, message(capability, "working", "PostToolUse", "turn-2")); + assert.equal(gateway.currentStatus("terminal-lost-start"), "working"); + // A late event of the earlier turn stays stale. + await send(capability.address, message(capability, "idle", "Stop", "turn-1")); + await send(capability.address, message(capability, "idle", "Stop", "turn-2")); + // After that idle, an id already seen does not open a turn again. + await send(capability.address, message(capability, "working", "PostToolUse", "turn-1")); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(signals.map(({ signal }) => `${signal.state}:${signal.event}:${signal.turnId}`), [ + "working:UserPromptSubmit:turn-1", "idle:Stop:turn-1", "working:PostToolUse:turn-2", "idle:Stop:turn-2" + ]); + assert.equal(gateway.currentStatus("terminal-lost-start"), "idle"); +}); + test("RuntimeGateway propagates threadId for codex sessions with canonical UUID", POSIX_RUNTIME_GATEWAY_TEST, async (t) => { const root = await fixture(t); const signals = []; diff --git a/tests/agent-runtime-provider-launch.test.mjs b/tests/agent-runtime-provider-launch.test.mjs index c1183d43..c3966d87 100644 --- a/tests/agent-runtime-provider-launch.test.mjs +++ b/tests/agent-runtime-provider-launch.test.mjs @@ -46,6 +46,7 @@ test("Claude and Codex receive automatic lifecycle hooks without prompt or respo assert.equal(settings.showStatusInTerminalTab, true); assert.match(settings.hooks.UserPromptSubmit[0].hooks[0].command, /working.*UserPromptSubmit/u); assert.match(settings.hooks.PermissionRequest[0].hooks[0].command, /needs_approval.*PermissionRequest/u); + assert.match(settings.hooks.PostToolUseFailure[0].hooks[0].command, /working.*PostToolUseFailure/u); assert.match(settings.hooks.Stop[0].hooks[0].command, /idle.*Stop/u); assert.match(settings.hooks.Stop[0].hooks[0].command, /^ELECTRON_RUN_AS_NODE='1' /u); assert.equal(JSON.stringify(settings).includes('"prompt":'), false); diff --git a/tests/claude-http-hooks.test.mjs b/tests/claude-http-hooks.test.mjs index fb649ea8..94791bd4 100644 --- a/tests/claude-http-hooks.test.mjs +++ b/tests/claude-http-hooks.test.mjs @@ -262,7 +262,7 @@ test("Claude's lifecycle hooks become HTTP hooks except SessionStart; the decisi assert.equal(settings.hooks.PreToolUse[0].hooks[0].type, "command"); assert.match(settings.hooks.PreToolUse[0].hooks[0].command, /permission-gate\.mjs' 'pretool'/u); for (const [event, state] of [["UserPromptSubmit", "working"], ["PermissionRequest", "needs_approval"], ["PostToolUse", "working"], - ["Stop", "idle"], ["StopFailure", "idle"], ["SessionEnd", "idle"], ["Notification", "needs_approval"]]) { + ["PostToolUseFailure", "working"], ["Stop", "idle"], ["StopFailure", "idle"], ["SessionEnd", "idle"], ["Notification", "needs_approval"]]) { const hook = settings.hooks[event][0].hooks[0]; assert.deepEqual(hook, { type: "http", From e88f2a3e89680e2f359c4f3f940f38c94e90fc34 Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:00:00 +0000 Subject: [PATCH 09/28] fix(orchestration): return a subagent's screen as plain text wait_for_agent, get_agent_result and observe_agent returned the raw PTY tail, which for a full-screen TUI such as OpenCode is cursor moves, colours and spinner redraws, often starting mid-sequence. They now render the masked scrollback window (the last 65,536 characters) through the existing headless xterm at the session's size and return the visible text, masked again and capped as before (observe_agent within its maxChars). A cut window starts at its first escape or line break, so the rest of a cut sequence ("18;9H") is never printed as text. exitLines were the regex-stripped last 16 KiB, so a TUI that redraws only the cells that changed read as its redraws joined together, and a line drawn early and never redrawn (the model's status line) was lost. They are now rendered by the same headless terminal from the same window, masked before and after, and the three tools return the same exitLines. --- docs/agent-orchestration.md | 2 +- native/canvastty-helper/catalog.json | 6 +- src/agent-browser/orchestration-catalog.mjs | 6 +- src/main/services/AgentControlService.ts | 106 ++++++++++--- .../agent-browser/OrchestrationTools.ts | 12 +- src/main/services/terminalScreenText.ts | 35 +++++ tests/agent-control.test.mjs | 14 +- tests/launch-contributors.test.mjs | 4 +- tests/orchestration-providers-wait.test.mjs | 143 ++++++++++++++++++ tests/runtime-result-transport.test.mjs | 8 +- tests/secret-redaction.test.mjs | 15 +- tests/subagent-signal-exit.test.mjs | 4 +- 12 files changed, 297 insertions(+), 58 deletions(-) create mode 100644 src/main/services/terminalScreenText.ts diff --git a/docs/agent-orchestration.md b/docs/agent-orchestration.md index 1211afb4..1e5294d0 100644 --- a/docs/agent-orchestration.md +++ b/docs/agent-orchestration.md @@ -11,7 +11,7 @@ A session launched from the desktop with the **Orchestrator** role gets the `can | `list_providers` | The agent providers CanvasTTY can launch as subagents: `id` (the exact `spawn_agent.provider` value), `name`, `installed`/`available` (from the provider CLI registry resolved at startup or on a recheck), `signIn` (`ok`, `signed_out`, `expired` or `unknown`, from the last usage-limits read; it never starts a read and never reads credentials), `subagent` and `orchestrator` support, `model` (whether the CLI takes one, its format, and for OpenCode the models a cached `opencode models` listing reported: run in the background with a 5 s timeout, refreshed after 10 minutes, never awaited) and `efforts`, and plugin launch options (`launchOptions`, plus `launchOptionTools` such as an accounts plugin's `list_routes`). Fast: cached state only, no network calls. | | `spawn_agent` | Launches a subagent (profile: see below). `provider` must be a `list_providers` id (the schema lists them); an unknown one is refused with `INVALID_REQUEST` naming `list_providers`. Optional `model` goes to the CLI's own `--model` for that run (OpenCode `provider/model`; Codex, Claude, Qwen, Kimi alias, Grok, OMP, Pi, Cursor; Hermes, MiniMax, Devin and Antigravity take none) and optional `effort` to its reasoning effort (Codex `-c model_reasoning_effort`, Claude `--effort`, Grok `--reasoning-effort`). Both are checked per provider, kept on restart and restore, and never written to the CLI's configuration. If the person names a model, the orchestrator passes it as `model`. Plugin launch options the call does not name are inherited from the orchestrator for a subagent of the same CLI (see Plugin launch options below); `model` next to inherited options that set the model is refused, and so is a subagent of another CLI while the orchestrator's model comes from a plugin's options. The answer's `servedBy` is `{ provider, model, launchOptions: { : "inherited" | "explicit" } }`. An OpenCode model its own `opencode models` list does not contain is refused before launch with up to five closest listed ids (OpenCode would otherwise stop with only "Unexpected server error"); when that list cannot be read, the model is passed as given. A subagent that exits anyway reports the last lines of its screen as `exitLines` in `wait_for_agent`, `observe_agent` and `get_agent_result`. | | `wait_for_agent` | `{ sessionId, timeoutSeconds ≤ 100 }` (default 50). For an orchestrator other than Claude Code a call returns after at most 50 seconds, because its MCP client (Codex, OpenCode and others) ends a tool call after about 60 seconds; `timeout` then means the subagent is still running, with its progress in `output` and a `message` to call again. Returns when the subagent is `idle` (after a prompt: only once a turn that started after the latest delivered prompt has ended, so the CLI's startup idle does not count), `needs_approval`, `done`/`failed` (exited), `quiet` (reports no status and its screen stopped changing), `closed`, or on `timeout`, with `status`, `exitCode`, `waitedMs`, the masked terminal tail (masked before it is cut) and, for Codex and OpenCode subagents, `answer` (see `get_agent_result`). A turn that the person interrupted by submitting their own input in the subagent's card still ends the wait, without an `answer`: that answer is no longer for the orchestrator's prompt. With CanvasTTY status hooks off (Settings → Agents) a subagent has no hooks at all, so no `answer` can be captured: the result carries `answerUnavailable: { reason: "status_hooks_off", message }` instead and the wait ends `quiet`; read `output` (or `observe_agent`). Turning status hooks on applies to subagents started or restarted afterwards. Only this orchestrator's own subagents; it stops at once when the call is canceled or the orchestrator disconnects. | -| `get_agent_result`, `observe_agent` | `get_agent_result`: `answer`, the final reply of the subagent's last turn as the agent itself reported it (Codex through its Stop hook, OpenCode through CanvasTTY's plugin reading the session's last assistant message at `session.idle`; at most 4,096 characters with the end kept, masked, in memory only, kept only for the turn it ended and cleared when the next input is submitted or the next turn starts; an Enter with nothing typed in an idle card keeps it; an answer reported as the subagent exits still counts; `answerUnavailable` instead when status hooks are off), plus `status`, the exit state and the masked terminal tail. Other providers have no `answer`: their tail is raw screen output. `observe_agent`: the current status and tail. | +| `get_agent_result`, `observe_agent` | `get_agent_result`: `answer`, the final reply of the subagent's last turn as the agent itself reported it (Codex through its Stop hook, OpenCode through CanvasTTY's plugin reading the session's last assistant message at `session.idle`; at most 4,096 characters with the end kept, masked, in memory only, kept only for the turn it ended and cleared when the next input is submitted or the next turn starts; an Enter with nothing typed in an idle card keeps it; an answer reported as the subagent exits still counts; `answerUnavailable` instead when status hooks are off), plus `status`, the exit state and the end of the subagent's screen as masked plain text (a headless terminal renders the redraws of a full-screen TUI such as OpenCode, so no control sequences remain; `wait_for_agent` returns the same). Other providers have no `answer`: read that text. `observe_agent`: the current status and the end of the screen as masked plain text, rendered like `wait_for_agent`'s `output` (at most `maxChars`). | | `send_to_agent`, `cancel_agent`, `list_agents` | Follow-up prompts, disposing a subagent, listing this session's subagents. Prompts to one subagent arrive in the order they were sent. OpenCode drops keys typed while it starts, so a prompt for it (also `spawn_agent`'s first one) is typed only once its input field is up: the terminal control sequence that shows its text cursor on its full screen, or a lifecycle hook only 2 s after its first one (OpenCode's plugin can report shortly before the field takes keys). This wait is used on macOS and Linux; on Windows, whose pseudo console may not pass those sequences, OpenCode gets input as soon as its PTY exists, as before. `spawn_agent` (its checks, the launch and this wait together) and `send_to_agent` wait at most 45 s, under the MCP client's tool deadline; then the call fails with the reason and the session id, and nothing is typed. A send cancelled while it waits returns at once. | ### Subagent profiles diff --git a/native/canvastty-helper/catalog.json b/native/canvastty-helper/catalog.json index b273493b..ab71c425 100644 --- a/native/canvastty-helper/catalog.json +++ b/native/canvastty-helper/catalog.json @@ -1072,7 +1072,7 @@ }, { "name": "observe_agent", - "description": "Read the capped terminal tail and status of one of this session's subagents.", + "description": "Read the status of one of this session's subagents and the end of its screen as masked plain text (once its process exited, also exitLines).", "inputSchema": { "type": "object", "properties": { @@ -1095,7 +1095,7 @@ }, { "name": "wait_for_agent", - "description": "Wait until one of this session's subagents stops working, instead of polling observe_agent or get_agent_result; nothing is sent to it while it waits. Returns reason \"idle\" (the turn that answers your latest prompt ended and it waits for input; an idle before that turn started does not count), \"needs_approval\" (its card shows a prompt only the person may answer; never answer it yourself), \"done\" or \"failed\" (its process exited), \"quiet\" (it reports no status or no turn start and its screen stopped changing, so judge from output), \"closed\" (its card was closed) or \"timeout\" after timeoutSeconds (default 50, at most 100; unless this session is Claude Code, a call returns after at most 50 seconds, before its MCP client would end the call), with status, exitCode, waitedMs, the masked terminal tail as output, and answer (the final reply of the turn that ended, for Codex and OpenCode subagents; when CanvasTTY status hooks are off it cannot be captured and answerUnavailable says so instead: then read output); when the subagent's process exited (for example at once, on a model its CLI does not know), exitLines holds the last lines of its screen as plain text, which say why. A timeout means it is still running: read output as its progress and call wait_for_agent again. Then read get_agent_result.", + "description": "Wait until one of this session's subagents stops working, instead of polling observe_agent or get_agent_result; nothing is sent to it while it waits. Returns reason \"idle\" (the turn that answers your latest prompt ended and it waits for input; an idle before that turn started does not count), \"needs_approval\" (its card shows a prompt only the person may answer; never answer it yourself), \"done\" or \"failed\" (its process exited), \"quiet\" (it reports no status or no turn start and its screen stopped changing, so judge from output), \"closed\" (its card was closed) or \"timeout\" after timeoutSeconds (default 50, at most 100; unless this session is Claude Code, a call returns after at most 50 seconds, before its MCP client would end the call), with status, exitCode, waitedMs, the end of its screen as masked plain text as output, and answer (the final reply of the turn that ended, for Codex and OpenCode subagents; when CanvasTTY status hooks are off it cannot be captured and answerUnavailable says so instead: then read output); when the subagent's process exited (for example at once, on a model its CLI does not know), exitLines holds the last lines of its screen as plain text, which say why. A timeout means it is still running: read output as its progress and call wait_for_agent again. Then read get_agent_result.", "inputSchema": { "type": "object", "properties": { @@ -1118,7 +1118,7 @@ }, { "name": "get_agent_result", - "description": "Get one of this session's subagents' result: answer (its last turn's final reply as the agent reported it, for Codex and OpenCode subagents; truncated:true when only the end was kept), status (idle once its turn ended), the exit state (running | done | failed; an interactive CLI stays running after a task) and the masked terminal tail as output. Prefer answer; the terminal tail is raw screen output. answerUnavailable (reason status_hooks_off) means no answer can be captured because CanvasTTY status hooks are off: read output instead.", + "description": "Get one of this session's subagents' result: answer (its last turn's final reply as the agent reported it, for Codex and OpenCode subagents; truncated:true when only the end was kept), status (idle once its turn ended), the exit state (running | done | failed; an interactive CLI stays running after a task) and the end of its screen as masked plain text as output (what the terminal shows, without control sequences). Prefer answer. answerUnavailable (reason status_hooks_off) means no answer can be captured because CanvasTTY status hooks are off: read output instead.", "inputSchema": { "type": "object", "properties": { diff --git a/src/agent-browser/orchestration-catalog.mjs b/src/agent-browser/orchestration-catalog.mjs index 64b0f1d1..3829b3f6 100644 --- a/src/agent-browser/orchestration-catalog.mjs +++ b/src/agent-browser/orchestration-catalog.mjs @@ -82,19 +82,19 @@ export const ORCHESTRATION_TOOL_DEFINITIONS = Object.freeze([ ), tool( "observe_agent", - "Read the capped terminal tail and status of one of this session's subagents.", + "Read the status of one of this session's subagents and the end of its screen as masked plain text (once its process exited, also exitLines).", { sessionId, maxChars: integer({ minimum: 256, maximum: 8_192 }) }, ["sessionId"] ), tool( "wait_for_agent", - `Wait until one of this session's subagents stops working, instead of polling observe_agent or get_agent_result; nothing is sent to it while it waits. Returns reason "idle" (the turn that answers your latest prompt ended and it waits for input; an idle before that turn started does not count), "needs_approval" (its card shows a prompt only the person may answer; never answer it yourself), "done" or "failed" (its process exited), "quiet" (it reports no status or no turn start and its screen stopped changing, so judge from output), "closed" (its card was closed) or "timeout" after timeoutSeconds (default ${DEFAULT_AGENT_WAIT_SECONDS}, at most ${MAX_AGENT_WAIT_SECONDS}; unless this session is Claude Code, a call returns after at most ${CLIENT_SAFE_AGENT_WAIT_SECONDS} seconds, before its MCP client would end the call), with status, exitCode, waitedMs, the masked terminal tail as output, and answer (the final reply of the turn that ended, for Codex and OpenCode subagents; when CanvasTTY status hooks are off it cannot be captured and answerUnavailable says so instead: then read output); when the subagent's process exited (for example at once, on a model its CLI does not know), exitLines holds the last lines of its screen as plain text, which say why. A timeout means it is still running: read output as its progress and call wait_for_agent again. Then read get_agent_result.`, + `Wait until one of this session's subagents stops working, instead of polling observe_agent or get_agent_result; nothing is sent to it while it waits. Returns reason "idle" (the turn that answers your latest prompt ended and it waits for input; an idle before that turn started does not count), "needs_approval" (its card shows a prompt only the person may answer; never answer it yourself), "done" or "failed" (its process exited), "quiet" (it reports no status or no turn start and its screen stopped changing, so judge from output), "closed" (its card was closed) or "timeout" after timeoutSeconds (default ${DEFAULT_AGENT_WAIT_SECONDS}, at most ${MAX_AGENT_WAIT_SECONDS}; unless this session is Claude Code, a call returns after at most ${CLIENT_SAFE_AGENT_WAIT_SECONDS} seconds, before its MCP client would end the call), with status, exitCode, waitedMs, the end of its screen as masked plain text as output, and answer (the final reply of the turn that ended, for Codex and OpenCode subagents; when CanvasTTY status hooks are off it cannot be captured and answerUnavailable says so instead: then read output); when the subagent's process exited (for example at once, on a model its CLI does not know), exitLines holds the last lines of its screen as plain text, which say why. A timeout means it is still running: read output as its progress and call wait_for_agent again. Then read get_agent_result.`, { sessionId, timeoutSeconds: integer({ minimum: 1, maximum: MAX_AGENT_WAIT_SECONDS }) }, ["sessionId"] ), tool( "get_agent_result", - "Get one of this session's subagents' result: answer (its last turn's final reply as the agent reported it, for Codex and OpenCode subagents; truncated:true when only the end was kept), status (idle once its turn ended), the exit state (running | done | failed; an interactive CLI stays running after a task) and the masked terminal tail as output. Prefer answer; the terminal tail is raw screen output. answerUnavailable (reason status_hooks_off) means no answer can be captured because CanvasTTY status hooks are off: read output instead.", + "Get one of this session's subagents' result: answer (its last turn's final reply as the agent reported it, for Codex and OpenCode subagents; truncated:true when only the end was kept), status (idle once its turn ended), the exit state (running | done | failed; an interactive CLI stays running after a task) and the end of its screen as masked plain text as output (what the terminal shows, without control sequences). Prefer answer. answerUnavailable (reason status_hooks_off) means no answer can be captured because CanvasTTY status hooks are off: read output instead.", { sessionId }, ["sessionId"] ), diff --git a/src/main/services/AgentControlService.ts b/src/main/services/AgentControlService.ts index 2b587ca9..579ffefe 100644 --- a/src/main/services/AgentControlService.ts +++ b/src/main/services/AgentControlService.ts @@ -14,6 +14,7 @@ import type { TerminalManager } from "./TerminalManager.ts"; import { onDiskPath, otherSpellings } from "./onDiskPath.ts"; import { LaunchRefusal } from "./launchRefusal.ts"; import { terminalFailureDetails } from "./terminalFailureDetails.ts"; +import { terminalScreenText } from "./terminalScreenText.ts"; import { RESULT_CAPTURE_PROVIDERS } from "./resultCapture.ts"; // Cards one parent may have in total, live or exited (the live limit is the person's setting). @@ -29,8 +30,12 @@ export interface DelegationLimits { } const MAX_OBSERVE_CHARS = 8_192; // An exited agent's reason is in its last screen lines (OpenCode: "Error: Unexpected server error" for an unknown model). -const MAX_EXIT_WINDOW_CHARS = 16_384; const MAX_EXIT_LINES = 20; +/** + * How much of the scrollback (masked first) the plain-text output is rendered from: several full redraws of a + * full-screen TUI, and many screens of a line-based CLI, while each call still masks a bounded window. + */ +const MAX_SCREEN_SOURCE_CHARS = 65_536; const CHILD_POSITION_STEP = { x: 60, y: 60 }; export interface SpawnAgentRequest { @@ -51,7 +56,7 @@ export interface SpawnAgentRequest { export interface AgentObservation { sessionId: string; status: SessionSnapshot["status"]; - /** Raw terminal tail, capped; capabilities with result \"none\" see nothing. */ + /** The screen's text as a terminal shows it (no control sequences), masked, capped to its last characters. */ output: string; /** Once the process exited: its exit code and the last lines of its screen as plain text, masked. */ exitCode?: number | null; @@ -69,7 +74,7 @@ export interface AgentWaitResult { status?: SessionSnapshot["status"]; exitCode: number | null; waitedMs: number; - /** Masked terminal tail (masked before the cut). */ + /** The screen's text as a terminal shows it (no control sequences), masked, capped to its last characters. */ output: string; /** Once the process exited: the last lines of its screen as plain text, masked (why it stopped, e.g. a bad model). */ exitLines?: string; @@ -107,6 +112,7 @@ export interface AgentResult { /** The session's status (idle once its turn ended; state stays "running" while the CLI is open). */ status: SessionSnapshot["status"]; exitCode: number | null; + /** The screen's text as a terminal shows it (no control sequences), masked, capped to its last characters. */ output: string; /** Once the process exited: the last lines of its screen as plain text, masked. */ exitLines?: string; @@ -352,25 +358,28 @@ export class AgentControlService { return false; } - observe(sessionId: string, maxChars = MAX_OBSERVE_CHARS): AgentObservation { - const session = this.requireSession(sessionId); - if (session.provider === "terminal") throw new Error("Plain terminals are not agents."); - const capabilities = PROVIDER_CAPABILITIES[session.provider as AgentProviderId]; - if (!capabilities.observe) throw new Error(`${session.provider} cannot be observed.`); + async observe(sessionId: string, maxChars = MAX_OBSERVE_CHARS): Promise { + const session = this.observableSession(sessionId); const buffer = this.terminals.readBuffer(sessionId).buffer; - const exitLines = session.exitCode === null ? null : this.exitLines(buffer); + const exitLines = session.exitCode === null ? null : await this.screenExitLines(sessionId, buffer); return { sessionId: session.id, status: session.status, - // Masked before the cut (a cut inside a secret would leave a tail no pattern recognizes), over a window - // wider than any match rather than the whole scrollback. - output: this.redactTail(buffer, maxChars), + output: await this.screenText(sessionId, buffer, maxChars), ...(session.exitCode === null ? {} : { exitCode: session.exitCode }), ...(exitLines ? { exitLines } : {}) }; } - result(sessionId: string): AgentResult { + private observableSession(sessionId: string): SessionMetadata { + const session = this.requireSession(sessionId); + if (session.provider === "terminal") throw new Error("Plain terminals are not agents."); + const capabilities = PROVIDER_CAPABILITIES[session.provider as AgentProviderId]; + if (!capabilities.observe) throw new Error(`${session.provider} cannot be observed.`); + return session; + } + + async result(sessionId: string): Promise { const session = this.requireSession(sessionId); if (session.provider === "terminal") throw new Error("Plain terminals are not agents."); const capabilities = PROVIDER_CAPABILITIES[session.provider as AgentProviderId]; @@ -381,6 +390,7 @@ export class AgentControlService { const buffer = capabilities.result === "terminal" ? this.terminals.readBuffer(sessionId).buffer : ""; + const exitLines = session.exitCode === null ? null : await this.screenExitLines(sessionId, buffer); return { sessionId: session.id, state: session.exitCode === null @@ -388,8 +398,8 @@ export class AgentControlService { : session.exitCode === 0 ? "done" : "failed", status: session.status, exitCode: session.exitCode, - output: this.redactTail(buffer, MAX_OBSERVE_CHARS), - ...(session.exitCode !== null && this.exitLines(buffer) ? { exitLines: this.exitLines(buffer)! } : {}), + output: buffer ? await this.screenText(sessionId, buffer, MAX_OBSERVE_CHARS) : "", + ...(exitLines ? { exitLines } : {}), ...(answer ? { answer } : this.answerUnavailable(sessionId)) }; } @@ -408,14 +418,23 @@ export class AgentControlService { const timing = this.options.waitTiming ?? AGENT_WAIT_TIMING; const timeoutMs = Math.min(MAX_AGENT_WAIT_MS, Math.max(0, request.timeoutMs)); const started = Date.now(); - const answer = (session: SessionMetadata | null, reason: AgentWaitReason): AgentWaitResult => { + const answer = async (session: SessionMetadata | null, reason: AgentWaitReason): Promise => { const waitedMs = Date.now() - started; if (!session) return { sessionId, reason, exitCode: null, waitedMs, output: "" }; - let observation: AgentObservation | null = null; - try { observation = this.observe(sessionId); } catch { observation = null; } + let observable = false; + try { observable = Boolean(this.observableSession(sessionId)); } catch { observable = false; } + let output = ""; + let exitLines: string | null = null; + if (observable) { + try { + const buffer = this.terminals.readBuffer(sessionId).buffer; + output = await this.screenText(sessionId, buffer, MAX_OBSERVE_CHARS); + if (session.exitCode !== null) exitLines = await this.screenExitLines(sessionId, buffer); + } catch { output = ""; } + } const answer = reason === "timeout" || reason === "needs_approval" ? null : this.answer(sessionId); - return { sessionId, reason, status: session.status, exitCode: session.exitCode, waitedMs, output: observation?.output ?? "", - ...(observation?.exitLines ? { exitLines: observation.exitLines } : {}), ...(answer ? { answer } : this.answerUnavailable(sessionId)) }; + return { sessionId, reason, status: session.status, exitCode: session.exitCode, waitedMs, output, + ...(exitLines ? { exitLines } : {}), ...(answer ? { answer } : this.answerUnavailable(sessionId)) }; }; let offset = this.outputOffset(sessionId); let changedAt = started; @@ -456,13 +475,44 @@ export class AgentControlService { } } - /** The last lines of an exited agent's screen as plain text: masked over the whole tail window before it is cut. */ - private exitLines(buffer: string): string | null { - const text = terminalFailureDetails(this.redactTail(buffer, MAX_EXIT_WINDOW_CHARS)); + /** + * The last lines of the screen, rendered like `output` (a TUI that redraws only the cells that changed would + * otherwise read as its redraws joined together) from the same window as `output` (a line drawn early and never + * redrawn, such as the model's status line, is still on the screen), masked before it is cut and again after. + */ + private async screenExitLines(sessionId: string, buffer: string): Promise { + const source = fromSequenceBoundary(this.redactTail(buffer, MAX_SCREEN_SOURCE_CHARS), buffer.length > MAX_SCREEN_SOURCE_CHARS); + let screen: string; + try { + screen = this.redactTail(await terminalScreenText(source, this.geometry(sessionId)), MAX_SCREEN_SOURCE_CHARS); + } catch { + screen = source; + } + const text = terminalFailureDetails(screen); if (!text) return null; return text.split("\n").slice(-MAX_EXIT_LINES).join("\n"); } + /** + * The text the agent's terminal shows, as plain text: a TUI's redraws (cursor moves, erases, colors) rendered by a + * headless terminal instead of returned as escape sequences. Masked twice: the scrollback before it is rendered + * (a held value the TUI wrote whole is gone before any redraw can split it), and the rendered text before its + * last `maxChars` are cut (a value the screen shows across wrapped rows or box sides). Should the rendering fail, + * the masked raw tail is returned as before. + */ + private async screenText(sessionId: string, buffer: string, maxChars: number): Promise { + const source = fromSequenceBoundary(this.redactTail(buffer, MAX_SCREEN_SOURCE_CHARS), buffer.length > MAX_SCREEN_SOURCE_CHARS); + try { + return this.redactTail(await terminalScreenText(source, this.geometry(sessionId)), maxChars); + } catch { + return this.redactTail(buffer, maxChars); + } + } + + private geometry(sessionId: string): { cols: number; rows: number } | null { + try { return typeof this.terminals.geometry === "function" ? this.terminals.geometry(sessionId) : null; } catch { return null; } + } + /** Plugin launch secrets never reach another agent through observed output: the tail as masking the whole text leaves it. */ private redactTail(text: string, maxChars: number): string { if (typeof this.terminals.redactSecretsTail === "function") return this.terminals.redactSecretsTail(text, maxChars); @@ -597,3 +647,13 @@ function tail(text: string, maxChars: number): string { function spawnCanceled(): Error { return new DOMException("The spawn was canceled before its agent started.", "AbortError"); } + +/** + * A window cut from the end of a terminal stream may begin inside an escape sequence, whose rest (`18;9H`, `m`) a + * terminal would print as text. A cut window therefore starts at its first escape or line break instead. + */ +function fromSequenceBoundary(window: string, cut: boolean): string { + if (!cut) return window; + const starts = [window.indexOf("\u001b"), window.indexOf("\n")].filter((index) => index >= 0); + return starts.length > 0 ? window.slice(Math.min(...starts)) : window; +} diff --git a/src/main/services/agent-browser/OrchestrationTools.ts b/src/main/services/agent-browser/OrchestrationTools.ts index d1273825..28566b78 100644 --- a/src/main/services/agent-browser/OrchestrationTools.ts +++ b/src/main/services/agent-browser/OrchestrationTools.ts @@ -73,9 +73,9 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { case "send_to_agent": return await this.send(sessionId, request.arguments, signal); case "observe_agent": - return this.observe(sessionId, request.arguments); + return await this.observe(sessionId, request.arguments); case "get_agent_result": - return this.result(sessionId, request.arguments); + return await this.result(sessionId, request.arguments); case "cancel_agent": return this.cancel(sessionId, request.arguments); case "list_agents": @@ -223,9 +223,9 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { return { sessionId: args.sessionId as string, sent: true }; } - private observe(orchestratorId: string, args: Record): Record { + private async observe(orchestratorId: string, args: Record): Promise> { this.requireOwned(orchestratorId, args.sessionId as string); - const observation = this.control.observe( + const observation = await this.control.observe( args.sessionId as string, args.maxChars as number | undefined ); @@ -238,9 +238,9 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { }; } - private result(orchestratorId: string, args: Record): Record { + private async result(orchestratorId: string, args: Record): Promise> { this.requireOwned(orchestratorId, args.sessionId as string); - const result = this.control.result(args.sessionId as string); + const result = await this.control.result(args.sessionId as string); return { sessionId: result.sessionId, state: result.state, diff --git a/src/main/services/terminalScreenText.ts b/src/main/services/terminalScreenText.ts new file mode 100644 index 00000000..6adf8db5 --- /dev/null +++ b/src/main/services/terminalScreenText.ts @@ -0,0 +1,35 @@ +import { lazyRequire } from "../lazyRequire.ts"; + +const xterm = lazyRequire("@xterm/headless"); + +/** Rows of scrollback the rendering keeps above the screen: the tail an orchestrator reads is far shorter. */ +const SCROLLBACK_ROWS = 500; +const DEFAULT_GEOMETRY = { cols: 120, rows: 40 }; + +/** + * What a terminal shows for `data` as plain text: the scrollback rows and the screen (the alternate screen of a + * full-screen TUI such as OpenCode, which keeps no scrollback), as a headless xterm renders them, so cursor moves, + * redraws, erases and colors leave only the text a person sees. A row the terminal wrapped continues its line; + * trailing blanks and empty rows at the end are dropped. + */ +export async function terminalScreenText(data: string, geometry: { cols: number; rows: number } | null): Promise { + const { cols, rows } = geometry && geometry.cols > 0 && geometry.rows > 0 ? geometry : DEFAULT_GEOMETRY; + const terminal = new (xterm().Terminal)({ cols, rows, scrollback: SCROLLBACK_ROWS, allowProposedApi: true }); + try { + await new Promise((resolve) => terminal.write(data, resolve)); + const buffer = terminal.buffer.active; + const lines: string[] = []; + for (let row = 0; row < buffer.length; row++) { + const line = buffer.getLine(row); + if (!line) continue; + // Untrimmed, so a space the wrap fell on stays between the words; each line is trimmed once it is whole. + const text = line.translateToString(false); + if (line.isWrapped && lines.length > 0) lines[lines.length - 1] += text; + else lines.push(text); + } + while (lines.length > 0 && lines[lines.length - 1]!.trim() === "") lines.pop(); + return lines.map((line) => line.trimEnd()).join("\n"); + } finally { + terminal.dispose(); + } +} diff --git a/tests/agent-control.test.mjs b/tests/agent-control.test.mjs index 58ad664b..8b7ce971 100644 --- a/tests/agent-control.test.mjs +++ b/tests/agent-control.test.mjs @@ -709,7 +709,7 @@ test("send appends submit unless told otherwise and rejects exited sessions", as terminals.disposeAll(); }); -test("observe returns a capped terminal tail and result reflects exit state", () => { +test("observe returns a capped terminal tail and result reflects exit state", async () => { const { terminals, control } = serviceFixture(); const parent = terminals.create({ provider: "codex", @@ -717,11 +717,11 @@ test("observe returns a capped terminal tail and result reflects exit state", () profile: "normal", position: { x: 0, y: 0 } }); - const observation = control.observe(parent.id); + const observation = await control.observe(parent.id); assert.equal(observation.sessionId, parent.id); assert.equal(observation.output.length <= 8_192, true); - const running = control.result(parent.id); + const running = await control.result(parent.id); assert.equal(running.state, "running"); assert.equal(running.exitCode, null); terminals.disposeAll(); @@ -746,8 +746,8 @@ test("cancel disposes the subagent and plain terminals are not agents", async () assert.equal(terminals.list().some((session) => session.id === child.id), false); assert.throws(() => control.send(terminal.id, "text"), /not agents/u); - assert.throws(() => control.observe(terminal.id), /not agents/u); - assert.throws(() => control.result(terminal.id), /not agents/u); + await assert.rejects(control.observe(terminal.id), /not agents/u); + await assert.rejects(control.result(terminal.id), /not agents/u); terminals.disposeAll(); }); @@ -805,8 +805,8 @@ test("an orchestrator tool call looks its sessions up by id: no other card's scr assert.equal(control.status(child.id).id, child.id); assert.equal(control.isInSubtree(parent.id, child.id), true); assert.deepEqual(control.children(parent.id).map((session) => session.id), [child.id]); - control.observe(child.id); - control.result(child.id); + await control.observe(child.id); + await control.result(child.id); assert.throws(() => control.status("missing"), /does not exist/u); assert.deepEqual(copied, [child.id, child.id], "only the observed card's own scrollback is read, and no list() snapshot of every card"); diff --git a/tests/launch-contributors.test.mjs b/tests/launch-contributors.test.mjs index df2491a1..8a53e7ee 100644 --- a/tests/launch-contributors.test.mjs +++ b/tests/launch-contributors.test.mjs @@ -310,11 +310,11 @@ test("secret env comes from the plugin's own secrets, reaches the child, and is calls[0].print(`env: PROVIDER_API_KEY=${SECRET} PLAIN=visible\n`); const control = new AgentControlService(manager); - const observed = control.observe(created.id).output; + const observed = (await control.observe(created.id)).output; assert.match(observed, /PROVIDER_API_KEY= PLAIN=visible/u); assert.doesNotMatch(observed, new RegExp(SECRET, "u")); calls[0].exit(1); - assert.doesNotMatch(control.result(created.id).output, new RegExp(SECRET, "u")); + assert.doesNotMatch((await control.result(created.id)).output, new RegExp(SECRET, "u")); assert.doesNotMatch(card(manager, created.id).failureDetails ?? "", new RegExp(SECRET, "u")); await manager.shutdown(); diff --git a/tests/orchestration-providers-wait.test.mjs b/tests/orchestration-providers-wait.test.mjs index bd7c6380..2513b987 100644 --- a/tests/orchestration-providers-wait.test.mjs +++ b/tests/orchestration-providers-wait.test.mjs @@ -331,3 +331,146 @@ test("wait_for_agent returns before the orchestrator's MCP client gives up, as a terminals.sessions.get("child").status = "idle"; assert.equal((await call(handler, "wait_for_agent", { sessionId: "child" })).message, undefined, "only a timeout says to call again"); }); + +/** A recorded OpenCode-style session: alternate screen, absolute cursor moves, colors, a spinner and in-place redraws. */ +function openCodeRedraws() { + const e = "\u001b"; + const at = (row, column) => `${e}[${row};${column}H`; + const parts = [ + `${e}[?1049h${e}[?25l${e}[?2004h${e}]0;opencode${e}\\${e}[2J${e}[H`, + `${at(1, 1)}${e}[48;2;24;24;37m${e}[38;2;205;214;244m┃ opencode build gpt-5.5${e}[K${e}[0m`, + `${at(3, 3)}${e}[1mYou${e}[22m`, + `${at(4, 3)}${e}[38;5;252mFix the failing Button test${e}[39m`, + `${at(6, 3)}${e}[38;5;245mThinking${e}[0m` + ]; + // The spinner turns in place many times while the model works. + for (let turn = 0; turn < 120; turn++) parts.push(`${at(6, 12)}${"⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"[turn % 10]}${at(40, 1)}${e}[7m working ${turn}s ${e}[27m${e}[K`); + parts.push( + `${at(6, 1)}${e}[2K${at(6, 3)}${e}[38;2;166;227;161mThe Button test passes now.${e}[0m`, + `${at(7, 3)}Used key sk-secret-value for the request.`, + `${at(8, 3)}${e}[38;5;111m│${e}[0m src/Button.tsx ${e}[32m+3${e}[0m ${e}[31m-1${e}[0m`, + `${at(40, 1)}${e}[2K${e}[7m ctrl+p commands ${e}[27m` + ); + return parts.join(""); +} + +test("wait_for_agent and get_agent_result return a TUI's screen as plain text, not its escape sequences", async () => { + const { terminals, handler } = setup(); + const child = terminals.sessions.get("child"); + setTimeout(() => { + child.buffer = openCodeRedraws(); + child.outputOffset = child.buffer.length; + child.status = "idle"; + }, 20); + const waited = await call(handler, "wait_for_agent", { sessionId: "child", timeoutSeconds: 5 }); + const result = await call(handler, "get_agent_result", { sessionId: "child" }); + for (const output of [waited.output, result.output]) { + assert.equal(/[\u0000-\u0008\u000b-\u001f\u007f]/u.test(output), false, "no control characters or escape sequences"); + assert.deepEqual(output.split("\n"), [ + "┃ opencode build gpt-5.5", + "", + " You", + " Fix the failing Button test", + "", + " The Button test passes now.", + " Used key [redacted] for the request.", + " │ src/Button.tsx +3 -1", + ...Array.from({ length: 31 }, () => ""), + " ctrl+p commands" + ]); + assert.ok(!output.includes("sk-secret") && !output.includes("Thinking") && !output.includes("working"), "masked; only what the screen shows now"); + } +}); + +test("the plain-text output stays bounded and masked for a long line-based transcript", async () => { + const { terminals, control } = setup(); + const child = terminals.sessions.get("child"); + child.buffer = Array.from({ length: 4_000 }, (_value, index) => `\u001b[32mline ${index}\u001b[0m with key sk-secret-value\r\n`).join(""); + child.outputOffset = child.buffer.length; + child.status = "idle"; + const result = await control.result("child"); + assert.ok(result.output.length <= 8_192); + assert.ok(result.output.endsWith("line 3999 with key [redacted]"), result.output.slice(-80)); + assert.ok(!result.output.includes("\u001b") && !result.output.includes("sk-secret")); +}); + +/** Invisible SGR resets that pad a stream to an exact length: any length from 6 up is a sum of 3s and 4s. */ +function sgrPadding(length) { + const e = "\u001b"; + let fours = 0; + while ((length - 4 * fours) % 3 !== 0) fours++; + return `${e}[0m`.repeat(fours) + `${e}[m`.repeat((length - 4 * fours) / 3); +} + +test("a scrollback window that starts inside an escape sequence leaves no fragment of it on the screen", async () => { + const { terminals, handler } = setup(); + const child = terminals.sessions.get("child"); + const e = "\u001b"; + const at = (row, column) => `${e}[${row};${column}H`; + const start = "18;9Hof specialists who had internalized the machine."; + const redraws = []; + for (let turn = 0; turn < 900; turn++) redraws.push(`${at(40, 1)}${e}[7m working ${turn}s ${e}[27m${e}[K`); + const end = `${at(3, 3)}The first response was the assembler.`; + const body = start + redraws.join("") + end; + // The last 65,536 characters (the window the screen is rendered from) begin right after the ESC [ of a cursor move. + child.buffer = `${"earlier output\r\n".repeat(50)}${e}[` + body + sgrPadding(65_536 - body.length); + child.outputOffset = child.buffer.length; + child.status = "idle"; + const waited = await call(handler, "wait_for_agent", { sessionId: "child", timeoutSeconds: 5 }); + const result = await call(handler, "get_agent_result", { sessionId: "child" }); + for (const output of [waited.output, result.output]) { + assert.ok(!output.includes("18;9H"), output.slice(0, 80)); + assert.ok(output.includes(" The first response was the assembler."), output); + } +}); + +test("exitLines are the exited TUI's screen as plain text, not its redraws joined together", async () => { + const { terminals, handler } = setup(); + const child = terminals.sessions.get("child"); + const e = "\u001b"; + const at = (row, column) => `${e}[${row};${column}H`; + // Drawn once at the start and never again: still on the screen when the CLI exits, more than 16 KiB later. + const early = `${at(3, 3)}${e}[2mThinking${e}[0m${at(8, 3)}${e}[38;5;111m▣ Build · glm-5.3-flash${e}[0m`; + const turns = 1_300; + const progress = []; + for (let turn = 0; turn < turns; turn++) progress.push(`${at(2, 3)}${e}[38;2;166;227;161m${"■".repeat(turn % 7)}${"⬝".repeat(7 - (turn % 7))}${e}[0m`); + // A TUI redraws only the cells that changed: here a line written once, then its typos fixed in place. + const body = early + progress.join("") + + `${at(4, 3)}As explicit memoy address, and if a single instructon was` + + `${at(4, 19)}ry address, and if a single instruction was` + + `${at(6, 3)}${e}[31mError: the model stopped answering.${e}[0m`; + assert.ok(body.length > 16_384 && body.length < 65_000, String(body.length)); + // The rendered window (the last 65,536 characters) begins with the `m` of a color sequence whose start was cut off. + child.buffer = `${"x".repeat(100)}${e}[38;2;1;2;3` + "m" + body + sgrPadding(65_536 - 1 - body.length); + child.outputOffset = child.buffer.length; + child.exitCode = 1; + child.status = "failed"; + const waited = await call(handler, "wait_for_agent", { sessionId: "child", timeoutSeconds: 5 }); + const observed = await call(handler, "observe_agent", { sessionId: "child" }); + const result = await call(handler, "get_agent_result", { sessionId: "child" }); + assert.equal(waited.reason, "failed"); + const last = (turns - 1) % 7; + assert.deepEqual(waited.exitLines.split("\n"), [ + ` ${"■".repeat(last)}${"⬝".repeat(7 - last)}`, + " Thinking", + " As explicit memory address, and if a single instruction was", + " Error: the model stopped answering.", + " ▣ Build · glm-5.3-flash" + ]); + assert.equal(observed.exitLines, waited.exitLines); + assert.equal(result.exitLines, waited.exitLines); +}); + +test("observe_agent returns the screen as plain text too, within its maxChars", async () => { + const { terminals, handler } = setup(); + const child = terminals.sessions.get("child"); + child.buffer = openCodeRedraws(); + child.outputOffset = child.buffer.length; + child.status = "working"; + const observed = await call(handler, "observe_agent", { sessionId: "child" }); + assert.equal(/[\u0000-\u0008\u000b-\u001f\u007f]/u.test(observed.output), false, "no ESC or other control bytes"); + assert.ok(observed.output.includes(" The Button test passes now.") && observed.output.includes("[redacted]")); + assert.ok(!observed.output.includes("sk-secret") && !observed.output.includes("working")); + const short = await call(handler, "observe_agent", { sessionId: "child", maxChars: 256 }); + assert.ok(short.output.length <= 256 && short.output.endsWith(" ctrl+p commands"), short.output); +}); diff --git a/tests/runtime-result-transport.test.mjs b/tests/runtime-result-transport.test.mjs index af2be39e..010f5642 100644 --- a/tests/runtime-result-transport.test.mjs +++ b/tests/runtime-result-transport.test.mjs @@ -95,9 +95,9 @@ for (const provider of ["codex", "opencode"]) { // Turning status hooks off revokes the lease: this launch has no hooks any more, and the result says so. f.setStatusHooks(false); assert.equal(f.gateway.currentStatus(worker.id), null); - assert.equal(f.control.result(worker.id).answer?.text, "worker final answer", "an answer captured earlier stays"); + assert.equal((await f.control.result(worker.id)).answer?.text, "worker final answer", "an answer captured earlier stays"); await f.terminals.deliverInput(worker.id, "next task\r"); - const after = f.control.result(worker.id); + const after = await f.control.result(worker.id); assert.equal(after.answer, undefined); assert.equal(after.answerUnavailable?.reason, "status_hooks_off"); }); @@ -118,12 +118,12 @@ for (const provider of ["codex", "opencode"]) { // Turning them on applies to agents started or restarted afterwards. f.setStatusHooks(true); - assert.equal(f.control.result(worker.id).answerUnavailable?.reason, "status_hooks_off"); + assert.equal((await f.control.result(worker.id)).answerUnavailable?.reason, "status_hooks_off"); launch.process.emitExit(0); f.terminals.restart(worker.id); const restarted = f.calls.at(-1); assert.notEqual(restarted, launch); assert.ok(restarted.options.env[AGENT_RUNTIME_ENV.capabilityToken], "the restarted launch has its hooks"); - assert.equal(f.control.result(worker.id).answerUnavailable, undefined); + assert.equal((await f.control.result(worker.id)).answerUnavailable, undefined); }); } diff --git a/tests/secret-redaction.test.mjs b/tests/secret-redaction.test.mjs index f3b74d5c..63ca2ea3 100644 --- a/tests/secret-redaction.test.mjs +++ b/tests/secret-redaction.test.mjs @@ -163,7 +163,7 @@ test("the vault hands every value it reads or writes to the registry; agent-read readBuffer: () => ({ buffer: `env OPENAI=${value}\n${secrets.github}\n` }), redactSecrets: (text) => manager.redactSecrets(text) }); - const observed = control.observe("s1").output; + const observed = (await control.observe("s1")).output; assert.equal(observed.includes(value) || observed.includes(secrets.github), false); assert.match(observed, /env OPENAI=/u); }); @@ -204,17 +204,17 @@ test("observe_agent and get_agent_result mask the whole buffer before cutting th const control = new AgentControlService(f.terminals); // The 300-character observation starts 12 characters before the secret ends. f.print(`${"a".repeat(1_000)}${PLAIN_SECRET}${"b".repeat(288)}`); - const observed = control.observe(f.card.id, 300).output; + const observed = (await control.observe(f.card.id, 300)).output; assert.ok(observed.length <= 300, "the bound is kept"); assert.deepEqual(fragments(observed), []); // get_agent_result's 8 192-character tail, cut inside the secret the same way. f.print(`${PLAIN_SECRET}${"c".repeat(8_192 - 10)}`); - const result = control.result(f.card.id).output; + const result = (await control.result(f.card.id)).output; assert.ok(result.length <= 8_192); assert.deepEqual(fragments(result), []); // Wrapped secrets are still masked whole. f.print(`\r\n${PLAIN_SECRET.slice(0, 15)}\r\n${PLAIN_SECRET.slice(15)}\r\n`); - assert.deepEqual(fragments(control.observe(f.card.id, 8_192).output), []); + assert.deepEqual(fragments((await control.observe(f.card.id, 8_192)).output), []); }); test("plugin screen text and failure details mask the whole buffer before cutting", async (t) => { @@ -318,8 +318,8 @@ test("observe_agent, get_agent_result and the plugin screen mask a bounded windo f.print(scrollback(250_000)); f.print(`${PLAIN_SECRET}${"g".repeat(200)}`); const control = new AgentControlService(f.terminals); - assert.deepEqual(fragments(control.observe(f.card.id).output), []); - assert.deepEqual(fragments(control.result(f.card.id).output), []); + assert.deepEqual(fragments((await control.observe(f.card.id)).output), []); + assert.deepEqual(fragments((await control.result(f.card.id)).output), []); const { PluginSessions } = await import("../src/main/services/PluginSessions.ts"); const screens = []; const sessions = new PluginSessions({ terminals: f.terminals, notify: (_p, _s, _m, event) => { if (event.screen !== undefined) screens.push(event.screen); return true; } }); @@ -330,7 +330,8 @@ test("observe_agent, get_agent_result and the plugin screen mask a bounded windo await new Promise((resolve) => setTimeout(resolve, 20)); assert.ok(screens.length > 0); assert.deepEqual(fragments(screens.join("\n")), []); - assert.ok(masked.length >= 3 && Math.max(...masked) < 40_000, `masked ${masked} characters per call`); + // get_agent_result renders its plain text from a 64 KiB masked window (plus the tail margin); never the whole scrollback. + assert.ok(masked.length >= 3 && Math.max(...masked) < 90_000, `masked ${masked} characters per call`); }); /** diff --git a/tests/subagent-signal-exit.test.mjs b/tests/subagent-signal-exit.test.mjs index 69229b14..c434e05c 100644 --- a/tests/subagent-signal-exit.test.mjs +++ b/tests/subagent-signal-exit.test.mjs @@ -79,7 +79,7 @@ test("a subagent whose CLI is killed by SIGKILL is failed (128+9) with the signa assert.equal(waited.reason, "failed"); assert.equal(waited.status, "failed"); assert.equal(waited.exitCode, 137); - const result = s.control.result(child.id); + const result = await s.control.result(child.id); assert.equal(result.state, "failed"); assert.equal(result.exitCode, 137); // The saved session restores as failed, and HOME's failure summary shows the exit code with the signal line. @@ -113,6 +113,6 @@ test("a clean exit stays done", { skip }, async (t) => { assert.equal(ended.exitCode, 0); assert.equal(ended.failureDetails, null); assert.equal((await s.control.waitFor(child.id, { timeoutMs: 1_000 })).reason, "done"); - assert.equal(s.control.result(child.id).state, "done"); + assert.equal((await s.control.result(child.id)).state, "done"); assert.equal(persistedTerminalSession(ended).lastState, "exited"); }); From 9a4018f343264c3be2b64a552af8c415f1f218c7 Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:21:50 +0000 Subject: [PATCH 10/28] fix(isolation): hide plugin data from isolated agents An isolated agent could read every plugin's private data folder (/plugin-data), including every CLI home and token an accounts plugin keeps there, not only the account it runs on. plugin-data is now private app data for the isolation layer. The one folder reopened is the CLI home a chosen launch contributor started the agent with: the provider's own home variable set to an exact absolute folder strictly inside that contributor's host-assigned data folder. The host derives it from the contribution; a plugin cannot name a grant. AgentIsolation checks it again when the layer is built (real folders, no symbolic links from plugin-data down) and refuses the launch otherwise, giving the reason once. A project inside plugin data is reopened as that one folder. --- docs/agent-orchestration.md | 2 +- docs/installing-and-security.md | 2 +- docs/plugins.md | 1 + src/main/services/LaunchPipeline.ts | 13 +- src/main/services/PluginManager.ts | 1 + src/main/services/TerminalManager.ts | 9 +- src/main/services/isolation/AgentIsolation.ts | 9 +- .../isolation/accountHomeIsolation.ts | 68 ++++ src/main/services/isolation/isolationPaths.ts | 26 +- tests/account-home-isolation.test.mjs | 316 ++++++++++++++++++ 10 files changed, 435 insertions(+), 12 deletions(-) create mode 100644 src/main/services/isolation/accountHomeIsolation.ts create mode 100644 tests/account-home-isolation.test.mjs diff --git a/docs/agent-orchestration.md b/docs/agent-orchestration.md index 1e5294d0..c45d53af 100644 --- a/docs/agent-orchestration.md +++ b/docs/agent-orchestration.md @@ -55,7 +55,7 @@ The core applies these to every way an agent can start another one: `spawn_agent Settings → Agents → Agent isolation (on by default) wraps an agent's whole process tree (the CLI, its commands, its MCP servers and hooks) in an operating-system layer. It applies to every subagent, every agent a plugin starts, and every agent in a profile other than normal (manual), whoever launched it. Inside it: - files can be written only in the project folder (both spellings), the launch's own temporary folder (`TMPDIR`), the CLI's own state, configuration and cache folders, and the npm/bun caches; the project's git hooks and an existing repository's git config, and the CLI's own permission settings files (`~/.codex/config.toml`, `~/.claude/settings.json`, OpenCode's config) are not writable; -- SSH, cloud and package-registry credentials, other CLIs' folders (their credentials) and CanvasTTY's own tokens, secret stores, account homes and launch files are unreadable, except what this launch was handed (its own control connection, its account home); +- SSH, cloud and package-registry credentials, other CLIs' folders (their credentials) and CanvasTTY's own tokens, secret stores, account homes, launch files and plugins' private data are unreadable, except what this launch was handed (its own control connection, its account home, the one CLI home its launch plugin selected); - no other process can be signalled, no app opened through Launch Services, no Apple events sent (osascript driving Terminal), no preferences written through cfprefsd, no launchd job submitted, and Unix sockets reach only DNS, syslog, the launch's own folder and CanvasTTY's token-checked gateways (not Docker, tmux or an SSH agent); - network and the keychain's services work as before: the CLI reaches its provider and reads its own sign-in. diff --git a/docs/installing-and-security.md b/docs/installing-and-security.md index c8148025..58870261 100644 --- a/docs/installing-and-security.md +++ b/docs/installing-and-security.md @@ -91,7 +91,7 @@ Agents work in their own mode; CanvasTTY's layers sit outside it as a quiet safe | Delegation rules | A subagent never gets more than its orchestrator (never YOLO), works only in its orchestrator's project folder, within the depth and count limits the person set; orchestrators get a control connection of their own; no agent-facing tool changes settings, protection, profiles, trust or isolation; plugin launch options from an agent only where the plugin declared them delegable. | What a plugin the person trusted does with its own permissions. | | Agent isolation (OS layer) | On macOS (sandbox-exec) and Linux (bubblewrap), for subagents, plugin-started agents and every agent not in Manual: the whole process tree writes only in the project, its own temporary folder and its CLI's own folders; keys, other CLIs' credentials and CanvasTTY's tokens are unreadable; no other process, app, Apple event, preference write, launchd job or foreign Unix socket (Docker, tmux, SSH agent) is reachable. Fails closed. | Network is not restricted. Linux cannot filter Unix sockets, only hide the user runtime folder. The CLI's own sandbox cannot run inside the layer on macOS, so it is switched off there (approvals unchanged) and commands can use the network. A Claude launch may rewrite the login keychain file to save a refreshed sign-in, so it could also damage that file (other items stay behind their own access lists). Windows has no layer yet: subagents run in Manual there unless the person turns isolation off. | -Isolation keeps approval configuration read-only, including OpenCode's project and custom config files and its `agent`/`agents` directories. CLI state and caches remain writable. A moved CLI home is refused if its real path overlaps host credentials or private CanvasTTY data; a selected account folder below `account-homes` remains available. Credential exclusions use the host environment captured before launch contributions, even if the launch changes `HOME`. +Isolation keeps approval configuration read-only, including OpenCode's project and custom config files and its `agent`/`agents` directories. CLI state and caches remain writable. A moved CLI home is refused if its real path overlaps host credentials or private CanvasTTY data; a selected account folder below `account-homes` remains available. Plugins' private data (`plugin-data`) is unreadable as a whole; only the CLI home a chosen launch plugin started this agent with, an exact real folder inside that plugin's own data, is reopened, and a project inside plugin data (an environment's worktree) is reopened as that one folder. Credential exclusions use the host environment captured before launch contributions, even if the launch changes `HOME`. OpenCode Auto inspects only regular local config files of at most 1 MiB. An existing source that cannot be inspected adds no Auto allowances. A launch contributor's uninspectable config is refused; only absent optional directory candidates are skipped. JSONC comments and trailing commas are parsed without changing quoted strings. diff --git a/docs/plugins.md b/docs/plugins.md index a93611a0..5a3946a5 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -222,6 +222,7 @@ Rules the host enforces, none of which is ever skipped: - Claude Code applies only its last `--settings`, so a plugin's inline `--settings` JSON is merged into CanvasTTY's own (objects such as `env` key by key, hook lists appended); one that sets `permissions`, `hooks`, `disableAllHooks`, `sandbox`, `defaultMode` or `apiKeyHelper` is refused. Both `--settings ` and `--settings=` are checked; a settings file is accepted only as one of the contribution's own launch files (`{launchFiles}/…`), which CanvasTTY reads, checks the same way and passes inline; any other file path is refused. `--bare`, `--safe-mode`, `--allowedTools`, `--permission-prompt-tool` and `--permission-prompts` are CanvasTTY's too. - No answer within 5 s, an error, an invalid answer, a missing secret, or a plugin that is disabled, removed or no longer trusted refuses the launch with the reason on the card. The agent is never started without a contribution the person chose. A restored card whose plugin is unavailable comes back stopped with that reason and keeps its record until the plugin returns or the card is closed. - A plain terminal takes no launch options. +- An isolated agent cannot read any plugin's data folder (`/plugin-data`). If an `env` value sets the agent CLI's own home variable (`CODEX_HOME`, `CLAUDE_CONFIG_DIR`, `GROK_HOME`, …) to an exact absolute folder inside the plugin's own data folder (not the data folder itself), that one folder is reopened for this agent. It must be a real folder, with no symbolic link on the way. Nothing else in plugin data is reopened, and a plugin cannot name other folders to reopen. **Launch profiles.** `profile` is `normal` (the default), `yolo`, or `auto`. `auto` exists only for agents whose CLI has a native auto mode, checked against each CLI's `--help`: Codex `--approve-for-me` (its own reviewer, in its `workspace-write` sandbox), Claude Code `--permission-mode auto` with its sandbox (`{"sandbox":{"enabled":true,"autoAllowBashIfSandboxed":false}}` merged into the one `--settings`), and Grok `--permission-mode auto` (no Grok sandbox is relied on). Base protection and decision services keep answering in front of it (Codex, Claude Code). When any contributor answers `thirdPartyModel: true`, `auto` becomes the CLI's accept-edits mode in the same sandbox (Codex `--sandbox workspace-write --ask-for-approval on-request`, Claude Code and Grok `--permission-mode acceptEdits`): the native reviewer would be that same model, and a weak model's own classifier is not a safety boundary. diff --git a/src/main/services/LaunchPipeline.ts b/src/main/services/LaunchPipeline.ts index b992e729..7f6c0056 100644 --- a/src/main/services/LaunchPipeline.ts +++ b/src/main/services/LaunchPipeline.ts @@ -13,6 +13,7 @@ import { claudeCoreSettingsKey, coreOwnedLaunchArgument, parseInlineSettings } f import { MAX_PLUGIN_SLOT_BYTES } from "./TerminalSessionStore.ts"; import { LaunchRefusal } from "./launchRefusal.ts"; import { MAX_INSPECTED_CONFIG_BYTES, parseJsonc, readInspectedFile } from "./inspectedConfig.ts"; +import { contributedAccountHome } from "./isolation/accountHomeIsolation.ts"; /** A trusted plugin service that declared launch options (PluginManager.launchContributors). */ export interface LaunchContributor { @@ -20,6 +21,8 @@ export interface LaunchContributor { pluginName: string; serviceId: string; launch: PluginServiceLaunch; + /** The plugin's private data folder, set by the host; never sent to the plugin or taken from its answer. */ + dataDir?: string; /** The plugin holds the `secrets` permission, so `secretEnv` may name its secrets. */ secrets: boolean; } @@ -77,6 +80,12 @@ export type PreparedLaunch = modelSetBy: string[]; /** This run's own folder below the card's folder; `cleanup` removes it. */ runDirectory: string; + /** + * The CLI home a chosen contributor started this launch with inside its own data folder (an account it selected): + * the one folder of plugin data the isolation layer reopens for this agent. Derived by the host, never a grant a + * plugin can name. + */ + accountHome?: string; cleanup(): Promise; } | { ok: false; reason: string }; @@ -290,6 +299,7 @@ export class LaunchPipeline { const secrets: string[] = []; let thirdPartyModel = false; const modelSetBy: string[] = []; + let accountHome: string | undefined; for (const answer of answers) { if ("refuse" in answer) return refuse(answer.refuse); } @@ -349,6 +359,7 @@ export class LaunchPipeline { const conflict = claim(key); if (conflict) return refuse(conflict); env[key] = expand(value); + accountHome = contributedAccountHome(context.provider, contributor.dataDir, key, env[key]) ?? accountHome; } for (const [key, secretKey] of Object.entries(contribution.secretEnv)) { const conflict = claim(key); @@ -363,7 +374,7 @@ export class LaunchPipeline { args.push(...contributedArgs.map(expand)); thirdPartyModel ||= contribution.thirdPartyModel === true; } - return { ok: true, env, args, secrets, envSources, thirdPartyModel, modelSetBy, runDirectory, cleanup }; + return { ok: true, env, args, secrets, envSources, thirdPartyModel, modelSetBy, runDirectory, ...(accountHome ? { accountHome } : {}), cleanup }; } private async ask( diff --git a/src/main/services/PluginManager.ts b/src/main/services/PluginManager.ts index 0e86e719..bfedc269 100644 --- a/src/main/services/PluginManager.ts +++ b/src/main/services/PluginManager.ts @@ -654,6 +654,7 @@ export class PluginManager { pluginName: manifest.name, serviceId: service.id, launch: structuredClone(service.launch), + dataDir: join(this.dataRoot, plugin.manifest.id), secrets: manifest.permissions.includes("secrets") }); } diff --git a/src/main/services/TerminalManager.ts b/src/main/services/TerminalManager.ts index 74fde97a..ff057520 100644 --- a/src/main/services/TerminalManager.ts +++ b/src/main/services/TerminalManager.ts @@ -1791,7 +1791,7 @@ export class TerminalManager { let spawn: { command: string; args: string[] | string; env: Record } = planned; if (decision.apply) { try { - spawn = this.wrapIsolated(id, provider, profile, planned); + spawn = this.wrapIsolated(id, provider, profile, planned, contribution?.accountHome); } catch (error) { planned.cleanup(); return { ...none, failure: { diagnostic: `Launch refused: ${error instanceof Error ? error.message : String(error)}`, exitCode: 1 } }; @@ -1855,7 +1855,9 @@ export class TerminalManager { } /** Wraps a planned launch in the isolation layer (throws when the layer cannot start: the launch is refused). */ - private wrapIsolated(id: string, provider: ProviderId, profile: LaunchProfile, planned: { command: string; args: string[] | string; cwd: string; env: Record }): { command: string; args: string[]; env: Record } { + private wrapIsolated(id: string, provider: ProviderId, profile: LaunchProfile, + planned: { command: string; args: string[] | string; cwd: string; env: Record }, + accountHome?: string): { command: string; args: string[]; env: Record } { if (!this.isolation) throw new LaunchRefusal("agent isolation is not configured; the agent was not started without it."); if (typeof planned.args === "string") throw new LaunchRefusal("a Windows batch launcher cannot run inside agent isolation."); const grant = controlGrantFolder(planned.env); @@ -1867,6 +1869,7 @@ export class TerminalManager { args: planned.args, env: planned.env, profile, + ...(accountHome ? { accountHome } : {}), ...(grant ? { grantedPrivate: [grant] } : {}) }); this.releaseIsolation(id); @@ -2288,7 +2291,7 @@ export class TerminalManager { } if (decision.apply) { try { - spawn = { ...this.wrapIsolated(id, metadata.provider, decision.profile, spawn), cwd: spawn.cwd }; + spawn = { ...this.wrapIsolated(id, metadata.provider, decision.profile, spawn, contribution?.accountHome), cwd: spawn.cwd }; } catch (error) { return refuse(error instanceof Error ? error.message : String(error)); } diff --git a/src/main/services/isolation/AgentIsolation.ts b/src/main/services/isolation/AgentIsolation.ts index 5d648c29..74047d26 100644 --- a/src/main/services/isolation/AgentIsolation.ts +++ b/src/main/services/isolation/AgentIsolation.ts @@ -6,6 +6,7 @@ import type { ProviderId, SessionIsolation } from "../../../shared/contracts.ts" import { autoKind, PROFILE_RANK, type LaunchProfile } from "../../../shared/autoMode.ts"; import { LaunchRefusal } from "../launchRefusal.ts"; import { isolationPaths } from "./isolationPaths.ts"; +import { validateAccountHome } from "./accountHomeIsolation.ts"; import { seatbeltProfile } from "./seatbelt.ts"; import { bubblewrapArguments, projectHooks } from "./bubblewrap.ts"; import { LinuxHostPaths } from "./linuxHostPaths.ts"; @@ -75,6 +76,8 @@ export interface IsolationLaunch { env: Record; /** Folders under CanvasTTY's private data this launch was handed (its control grant, its account home). */ grantedPrivate?: readonly string[]; + /** The CLI home a launch contributor selected inside its plugin data (LaunchPipeline); checked again here. */ + accountHome?: string; /** The launch profile: in plan the project is not writable. */ profile?: LaunchProfile; } @@ -184,6 +187,10 @@ export class AgentIsolation { const temp = join(folder, "tmp"); mkdirSync(temp, { mode: 0o700 }); const cwd = realpathSync(launch.cwd); + const accountHome = launch.accountHome === undefined + ? undefined + : validateAccountHome(this.options.userDataPath, launch.provider, launch.accountHome, launch.env); + const granted = [...(launch.grantedPrivate ?? []), ...(accountHome ? [accountHome] : [])]; const paths = isolationPaths({ provider: launch.provider, cwd, @@ -192,7 +199,7 @@ export class AgentIsolation { hostEnvironment: this.hostEnvironment, userDataPath: this.options.userDataPath, sessionId: launch.sessionId, - ...(launch.grantedPrivate ? { grantedPrivate: launch.grantedPrivate } : {}), + ...(granted.length > 0 ? { grantedPrivate: granted } : {}), ...(launch.profile === "plan" ? { readOnlyProject: true } : {}) }); // `git init` and `git clone` copy git's template, sample hooks included: an empty one writes no hooks. diff --git a/src/main/services/isolation/accountHomeIsolation.ts b/src/main/services/isolation/accountHomeIsolation.ts new file mode 100644 index 00000000..6ed5e3e0 --- /dev/null +++ b/src/main/services/isolation/accountHomeIsolation.ts @@ -0,0 +1,68 @@ +import { lstatSync, realpathSync } from "node:fs"; +import { isAbsolute, join, relative, resolve, sep } from "node:path"; +import type { ProviderId } from "../../../shared/contracts.ts"; +import { PLUGIN_DATA_FOLDER, providerHomeVariables } from "./isolationPaths.ts"; + +/** The path segments of `path` below `root`, or null when it is not strictly inside. */ +function segmentsBelow(root: string, path: string): string[] | null { + const rest = relative(root, path); + if (rest === "" || rest.startsWith("..") || isAbsolute(rest)) return null; + return rest.split(sep); +} + +/** + * The CLI home a launch contributor handed this launch inside its own data folder: the value of the provider's own + * home variable (CODEX_HOME, CLAUDE_CONFIG_DIR, GROK_HOME, …) when it is an exact absolute path strictly below the + * contributor's data folder. `dataDir` comes from the host's contributor record, never from the plugin's answer, so a + * plugin can only point at a folder of its own data, and only the one the CLI is actually started with. + */ +export function contributedAccountHome( + provider: ProviderId, + dataDir: string | undefined, + key: string, + value: string +): string | undefined { + if (!dataDir || !isAbsolute(dataDir) || !providerHomeVariables(provider).includes(key)) return undefined; + if (!isAbsolute(value) || resolve(value) !== value) return undefined; + return segmentsBelow(resolve(dataDir), value) ? value : undefined; +} + +/** + * Checks a contributed account home again when the isolation layer is built. Nothing is reopened when the launch no + * longer starts the CLI with it (an environment replaced the variable). Otherwise it must be a real folder strictly + * inside one plugin's data folder, reached without a symbolic link at any step from `/plugin-data`: the + * path to reopen is returned, and anything else throws (the launch is refused). + */ +export function validateAccountHome( + userDataPath: string, + provider: ProviderId, + candidate: string, + env: Readonly> +): string | undefined { + if (!providerHomeVariables(provider).some((name) => env[name] === candidate)) return undefined; + if (!isAbsolute(candidate) || resolve(candidate) !== candidate) { + throw new Error("The selected account home must be an exact absolute folder."); + } + const pluginData = join(resolve(userDataPath), PLUGIN_DATA_FOLDER); + const segments = segmentsBelow(pluginData, candidate); + // /: never all of plugin data, never a whole plugin's data folder. + if (!segments || segments.length < 2) { + throw new Error("The selected account home must be a folder inside one plugin's data."); + } + try { + const canonicalRoot = join(realpathSync.native(resolve(userDataPath)), PLUGIN_DATA_FOLDER); + let path = pluginData; + let canonical = canonicalRoot; + for (const segment of ["", ...segments]) { + if (segment) { + path = join(path, segment); + canonical = join(canonical, segment); + } + const info = lstatSync(path); + if (!info.isDirectory() || info.isSymbolicLink() || realpathSync.native(path) !== canonical) throw new Error("not a real folder"); + } + } catch { + throw new Error("The selected account home is missing or crosses a symbolic link."); + } + return candidate; +} diff --git a/src/main/services/isolation/isolationPaths.ts b/src/main/services/isolation/isolationPaths.ts index 5cdcb9a5..ba8d1989 100644 --- a/src/main/services/isolation/isolationPaths.ts +++ b/src/main/services/isolation/isolationPaths.ts @@ -113,6 +113,11 @@ const HOME_VARIABLES: Partial> = { /** A CLI's configuration file named by a variable (it may hold keys): unreadable to other CLIs, never writable to its own. */ const CONFIG_FILE_VARIABLES: Partial> = { opencode: ["OPENCODE_CONFIG"] }; +/** The environment variables that name this provider's CLI home (empty when it has none). */ +export function providerHomeVariables(provider: ProviderId): readonly string[] { + return HOME_VARIABLES[provider] ?? []; +} + function movedProviderHomes(provider: ProviderId, env: IsolationPathInput["env"], variables = HOME_VARIABLES): string[] { return (variables[provider] ?? []) .map((name) => env[name]) @@ -138,13 +143,16 @@ function sensitiveHomeFolders(home: string, xdgConfig: string): string[] { ].map((name) => join(home, name)).concat([join(xdgConfig, "gh"), join(xdgConfig, "gcloud")]); } +/** Plugins' private data below userData (PluginManager's `/plugin-data/`). */ +export const PLUGIN_DATA_FOLDER = "plugin-data"; + /** - * CanvasTTY's own private data under userData (the same list base protection refuses): tokens, connection records, - * secret stores, account homes and prepared launch files. Its gateways' runtime folders stay readable: they hold - * token-authenticated sockets and per-run hook settings the CLI must read. + * CanvasTTY's own private data under userData: tokens, connection records, secret stores, account homes, prepared + * launch files and every plugin's private data (an accounts plugin keeps all of its CLI homes there). Its gateways' + * runtime folders stay readable: they hold token-authenticated sockets and per-run hook settings the CLI must read. */ export function privateAppData(userDataPath: string): string[] { - return ["agent-control", "provider-secrets.bin", "plugin-secrets", "account-homes", "github-oauth.json", "launch-runs"] + return ["agent-control", "provider-secrets.bin", "plugin-secrets", "account-homes", "github-oauth.json", "launch-runs", PLUGIN_DATA_FOLDER] .map((name) => join(userDataPath, name)); } @@ -212,8 +220,16 @@ export function isolationPaths(input: IsolationPathInput): IsolationPaths { // variable pointing at HOME or above the project would hide them, so it is not followed. .filter((folder) => !ownFolders.includes(folder) && !hides(folder)); const privateData = privateAppData(input.userDataPath); - const grants = [...(input.grantedPrivate ?? []), join(input.userDataPath, "launch-runs", safeSegment(input.sessionId))]; const project = input.cwd; + // A project inside plugin data (a plugin environment's worktree) stays this launch's project: that exact folder is + // reopened, never its siblings, its plugin's other data or all plugin data. + const pluginData = pathSpellings(join(input.userDataPath, PLUGIN_DATA_FOLDER)); + const projectDepth = Math.max(-1, ...pathSpellings(project).flatMap((candidate) => pluginData + .filter((root) => isWithin(candidate, root)) + .map((root) => relative(root, candidate).split(/[\\/]+/u).filter(Boolean).length))); + if (projectDepth >= 0 && projectDepth < 2) throw new Error(`The project ${project} is CanvasTTY's private plugin data.`); + const grants = [...(input.grantedPrivate ?? []), join(input.userDataPath, "launch-runs", safeSegment(input.sessionId)), + ...(projectDepth >= 2 ? [project] : [])]; const trustedOwn = providerFolders(input.provider, {}, hostHome); const trustedOthers = AGENT_PROVIDERS.filter((provider) => provider !== input.provider).flatMap((provider) => { const defaults = providerFolders(provider, {}, hostHome); diff --git a/tests/account-home-isolation.test.mjs b/tests/account-home-isolation.test.mjs new file mode 100644 index 00000000..cf5aa923 --- /dev/null +++ b/tests/account-home-isolation.test.mjs @@ -0,0 +1,316 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { LaunchPipeline } from "../src/main/services/LaunchPipeline.ts"; +import { AgentIsolation, SANDBOX_EXEC } from "../src/main/services/isolation/AgentIsolation.ts"; +import { isolationPaths } from "../src/main/services/isolation/isolationPaths.ts"; +import { bubblewrapArguments } from "../src/main/services/isolation/bubblewrap.ts"; +import { TerminalManager } from "../src/main/services/TerminalManager.ts"; + +// Any plugin id: the host treats every plugin's data the same way. +const PLUGIN = "com.example.accounts"; +const OTHER = "com.example.other"; +const mac = process.platform === "darwin" && existsSync(SANDBOX_EXEC); +const onMac = { skip: mac ? false : "macOS seatbelt (sandbox-exec) only" }; + +async function fixture(t) { + // Short: Unix socket paths are limited to ~104 bytes, so /tmp rather than macOS's long per-user folder. + const root = await realpath(await mkdtemp(join(process.platform === "win32" ? tmpdir() : "/tmp", "ctty-acct-"))); + t.after(() => rm(root, { recursive: true, force: true })); + const userData = join(root, "u"); + const pluginData = join(userData, "plugin-data"); + const accountData = join(pluginData, PLUGIN); + const otherData = join(pluginData, OTHER); + const homes = join(accountData, "homes"); + const project = join(root, "p"); + const temp = join(root, "t"); + const home = join(root, "h"); + await Promise.all([homes, otherData, project, temp, home].map((path) => mkdir(path, { recursive: true }))); + return { root, userData, pluginData, accountData, otherData, homes, project, temp, home }; +} + +async function prepare(f, options = {}) { + const provider = options.provider ?? "codex"; + const pluginId = options.pluginId ?? PLUGIN; + const dataDir = Object.hasOwn(options, "dataDir") ? options.dataDir : join(f.pluginData, pluginId); + const env = options.env ?? { CODEX_HOME: join(f.homes, "codex-alternate") }; + const requests = []; + const pipeline = new LaunchPipeline({ + contributors: () => [{ pluginId, pluginName: "Accounts", serviceId: "accounts", launch: { fields: [] }, secrets: false, + ...(dataDir === undefined ? {} : { dataDir }) }], + call: async (calledId, _serviceId, _method, params) => { + requests.push({ calledId, params }); + return { env, ...(options.answerExtra ?? {}) }; + }, + secret: async () => null, + runsRoot: join(f.userData, "launch-runs") + }); + const result = await pipeline.prepare({ sessionId: "fixture", provider, profile: "normal", role: "agent", cwd: f.project, + restoring: false, resume: false, options: { [pluginId]: { account: "alternate" } }, environment: null }); + return { result, requests }; +} + +function isolation(f, extra = {}) { + return new AgentIsolation({ + userDataPath: f.userData, + enabled: () => true, + tempRoot: f.temp, + hostEnvironment: { HOME: f.home, PATH: "/usr/bin:/bin" }, + ...extra + }); +} + +const fakeSeatbelt = { platform: "darwin", exists: (path) => path === SANDBOX_EXEC }; + +test("the selected account home is the exact CLI home a chosen contributor set inside its own data, and stays out of plugin RPC", async (t) => { + const f = await fixture(t); + const accountHome = join(f.homes, "codex-alternate"); + const { result, requests } = await prepare(f, { env: { CODEX_HOME: accountHome } }); + assert.equal(result.ok, true); + assert.equal(result.accountHome, accountHome); + assert.deepEqual(result.env, { CODEX_HOME: accountHome }); + assert.equal(requests.length, 1); + assert.equal(requests[0].calledId, PLUGIN); + for (const hostOnly of ["dataDir", "accountHome", "grantedPrivate"]) { + assert.equal(Object.hasOwn(requests[0].params, hostOnly), false, `${hostOnly} is host-only`); + } + await result.cleanup(); + + for (const [provider, key] of [["claude", "CLAUDE_CONFIG_DIR"], ["grok", "GROK_HOME"]]) { + const own = join(f.homes, `${provider}-alternate`); + const other = await prepare(f, { provider, env: { [key]: own } }); + assert.equal(other.result.ok, true); + assert.equal(other.result.accountHome, own, provider); + await other.result.cleanup(); + } +}); + +test("no account home without host authority, outside the contributor's own data, at its data root or through another variable", async (t) => { + const f = await fixture(t); + const cases = [ + { name: "no host data folder", dataDir: undefined, env: { CODEX_HOME: join(f.homes, "codex-a") } }, + { name: "another plugin's data", env: { CODEX_HOME: join(f.otherData, "codex-a") } }, + { name: "the plugin's whole data folder", env: { CODEX_HOME: f.accountData } }, + { name: "all plugin data", env: { CODEX_HOME: f.pluginData } }, + { name: "a path that climbs out", env: { CODEX_HOME: `${f.homes}/../../${OTHER}/codex-a` } }, + { name: "another provider's variable", env: { CLAUDE_CONFIG_DIR: join(f.homes, "claude-a") } }, + { name: "a variable that is not a CLI home", env: { SOME_DIR: join(f.homes, "codex-a") } }, + { name: "a Claude home for Codex", provider: "claude", env: { CODEX_HOME: join(f.homes, "codex-a") } }, + { name: "outside plugin data", env: { CODEX_HOME: join(f.root, "elsewhere") } } + ]; + for (const row of cases) { + const { result } = await prepare(f, row); + assert.equal(result.ok, true, row.name); + assert.equal(result.accountHome, undefined, row.name); + await result.cleanup(); + } +}); + +test("a plugin cannot name an isolation grant in its answer", async (t) => { + const f = await fixture(t); + const accountHome = join(f.homes, "codex-alternate"); + for (const key of ["accountHome", "grantedPrivate", "dataDir"]) { + const { result } = await prepare(f, { env: { CODEX_HOME: accountHome }, answerExtra: { [key]: [f.pluginData] } }); + assert.equal(result.ok, false, key); + assert.match(result.reason, /unknown key/u); + } +}); + +test("an isolated agent's paths hide all plugin data and reopen only the selected home", async (t) => { + const f = await fixture(t); + const selected = join(f.homes, "codex-alternate"); + const sibling = join(f.homes, "codex-other"); + await Promise.all([selected, sibling].map((path) => mkdir(path))); + const base = { provider: "codex", cwd: f.project, sessionTemp: join(f.temp, "s"), userDataPath: f.userData, sessionId: "s", + hostEnvironment: { HOME: f.home } }; + + const plain = isolationPaths({ ...base, env: { HOME: f.home } }); + assert.ok(plain.unreadable.includes(f.pluginData), "plugin data is private app data"); + assert.ok(!plain.readableAgain.some((path) => path.startsWith(f.pluginData)), "nothing of it is reopened without a selection"); + + const paths = isolationPaths({ ...base, env: { HOME: f.home, CODEX_HOME: selected }, grantedPrivate: [selected] }); + assert.ok(paths.unreadable.includes(f.pluginData)); + assert.deepEqual(paths.readableAgain.filter((path) => path.startsWith(f.pluginData)), [selected]); + assert.ok(paths.writable.includes(selected), "the CLI's own home stays writable"); + assert.ok(!paths.readableAgain.includes(sibling) && !paths.readableAgain.includes(f.homes) && !paths.readableAgain.includes(f.accountData)); + + // The CLI started with a home in plugin data that the launch was not handed: refused, never left readable. + assert.throws(() => isolationPaths({ ...base, env: { HOME: f.home, CODEX_HOME: sibling }, grantedPrivate: [selected] }), + /overlaps protected CanvasTTY data/u); + + // Linux: an empty tmpfs over all plugin data, then only the selected home bound back (writable: it is the CLI's). + // Its config.toml exists (LinuxHostPaths puts a placeholder there before a launch) and is read-only again. + const kinds = new Map([[f.project, "directory"], [f.pluginData, "directory"], [selected, "directory"], [sibling, "directory"], + [join(selected, "config.toml"), "file"]]); + const text = bubblewrapArguments(paths, { command: "/usr/bin/codex", args: [], cwd: f.project }, (path) => kinds.get(path) ?? null).join(" "); + const afterHidden = text.slice(text.indexOf(`--tmpfs ${f.pluginData}`)); + assert.ok(text.includes(`--tmpfs ${f.pluginData}`)); + assert.ok(afterHidden.includes(`--bind ${selected} ${selected}`)); + assert.ok(!text.includes(sibling)); +}); + +test("AgentIsolation.wrap reopens only a real folder inside one plugin's data, never through a symbolic link", async (t) => { + const f = await fixture(t); + const sandbox = isolation(f, fakeSeatbelt); + const wrap = (home, env = { CODEX_HOME: home }) => sandbox.wrap({ sessionId: "a", provider: "codex", cwd: f.project, command: "/bin/sh", + args: [], env: { HOME: f.home, PATH: "/usr/bin:/bin", ...env }, profile: "acceptEdits", accountHome: home }); + + const target = join(f.otherData, "secret"); + const alias = join(f.homes, "codex-alias"); + await mkdir(target, { recursive: true }); + await symlink(target, alias, process.platform === "win32" ? "junction" : "dir"); + // The refusal says once that the agent was not started. + const refusal = (why) => ({ name: "LaunchRefusal", message: `agent isolation could not be set up: ${why} The agent was not started without it.` }); + assert.throws(() => wrap(alias), refusal("The selected account home is missing or crosses a symbolic link.")); + assert.throws(() => wrap(join(f.homes, "codex-missing")), refusal("The selected account home is missing or crosses a symbolic link.")); + assert.throws(() => wrap(f.accountData), refusal("The selected account home must be a folder inside one plugin's data.")); + assert.throws(() => wrap("relative/home"), refusal("The selected account home must be an exact absolute folder.")); + + const selected = join(f.homes, "codex-alternate"); + await mkdir(selected); + const ok = wrap(selected); + try { + const profile = await readFile(ok.args[1], "utf8"); + assert.ok(profile.includes(`(deny file-read* file-write*`) && profile.includes(`(subpath "${f.pluginData}")`)); + assert.match(profile, new RegExp(`\\(allow file-read\\*[^\\n]*\\(subpath "${selected.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")}"\\)`, "u")); + } finally { + ok.cleanup(); + } + // An environment that replaced the CLI's home: nothing of plugin data is reopened, and the launch is not refused for it. + const elsewhere = join(f.root, "elsewhere"); + await mkdir(elsewhere); + const replaced = wrap(selected, { CODEX_HOME: elsewhere }); + try { + assert.ok(!(await readFile(replaced.args[1], "utf8")).includes(`(subpath "${selected}")`)); + } finally { + replaced.cleanup(); + } +}); + +test("a project inside plugin data is reopened exactly; all of plugin data or a whole plugin's data is never a project", async (t) => { + const f = await fixture(t); + const worktree = join(f.otherData, "worktrees", "one"); + const sibling = join(f.otherData, "worktrees", "two"); + await Promise.all([worktree, sibling].map((path) => mkdir(path, { recursive: true }))); + const base = { provider: "codex", sessionTemp: join(f.temp, "s"), userDataPath: f.userData, sessionId: "s", env: { HOME: f.home } }; + const paths = isolationPaths({ ...base, cwd: worktree }); + assert.ok(paths.unreadable.includes(f.pluginData)); + assert.deepEqual(paths.readableAgain.filter((path) => path.startsWith(f.pluginData)), [worktree]); + assert.ok(paths.writable.includes(worktree)); + const plan = isolationPaths({ ...base, cwd: worktree, readOnlyProject: true }); + assert.ok(plan.readableAgain.includes(worktree) && !plan.writable.includes(worktree), "plan keeps it read-only"); + for (const cwd of [f.pluginData, f.otherData]) { + assert.throws(() => isolationPaths({ ...base, cwd }), /private plugin data/u, cwd); + } +}); + +test("seatbelt, for real: an isolated agent reads and writes only its selected home in plugin data", onMac, async (t) => { + const f = await fixture(t); + const selected = join(f.homes, "codex-alternate"); + const sibling = join(f.homes, "codex-other"); + const otherPlugin = join(f.otherData, "worktrees", "sibling"); + const settings = join(f.accountData, "accounts.json"); + await Promise.all([selected, sibling, otherPlugin].map((path) => mkdir(path, { recursive: true }))); + await writeFile(join(selected, "marker"), "selected fixture home\n"); + await writeFile(join(sibling, "marker"), "sibling fixture home\n"); + await writeFile(join(otherPlugin, "marker"), "other plugin fixture\n"); + await writeFile(settings, "private account settings\n"); + const wrapped = isolation(f).wrap({ + sessionId: "selected-account", + provider: "codex", + cwd: f.project, + command: "/bin/sh", + args: ["-c", [ + 'cat "$CODEX_HOME/marker" >/dev/null && echo OWN-READ-ok', + 'echo actor > "$CODEX_HOME/actor.txt" && echo OWN-WRITE-ok', + 'echo actor > "$CODEX_HOME/config.toml" 2>/dev/null || echo CLI-SETTINGS-WRITE-denied', + `cat ${JSON.stringify(join(sibling, "marker"))} >/dev/null 2>&1 || echo ACCOUNT-SIBLING-READ-denied`, + `echo actor >> ${JSON.stringify(join(sibling, "marker"))} 2>/dev/null || echo ACCOUNT-SIBLING-WRITE-denied`, + `cat ${JSON.stringify(join(otherPlugin, "marker"))} >/dev/null 2>&1 || echo OTHER-PLUGIN-READ-denied`, + `cat ${JSON.stringify(settings)} >/dev/null 2>&1 || echo SETTINGS-READ-denied`, + `echo actor >> ${JSON.stringify(settings)} 2>/dev/null || echo SETTINGS-WRITE-denied`, + `ls ${JSON.stringify(f.pluginData)} >/dev/null 2>&1 || echo PLUGIN-DATA-LIST-denied` + ].join("; ")], + env: { HOME: f.home, PATH: "/usr/bin:/bin", CODEX_HOME: selected }, + profile: "acceptEdits", + accountHome: selected + }); + try { + const result = spawnSync(wrapped.command, wrapped.args, { cwd: f.project, env: wrapped.env, encoding: "utf8", timeout: 20_000 }); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(result.stdout.split("\n").filter(Boolean), [ + "OWN-READ-ok", "OWN-WRITE-ok", "CLI-SETTINGS-WRITE-denied", "ACCOUNT-SIBLING-READ-denied", "ACCOUNT-SIBLING-WRITE-denied", + "OTHER-PLUGIN-READ-denied", "SETTINGS-READ-denied", "SETTINGS-WRITE-denied", "PLUGIN-DATA-LIST-denied" + ]); + assert.equal(await readFile(join(selected, "actor.txt"), "utf8"), "actor\n"); + assert.equal(existsSync(join(selected, "config.toml")), false); + assert.equal(await readFile(join(sibling, "marker"), "utf8"), "sibling fixture home\n"); + assert.equal(await readFile(settings, "utf8"), "private account settings\n"); + } finally { + wrapped.cleanup(); + } +}); + +test("seatbelt, for real: without a selected home an isolated agent reads no plugin data", onMac, async (t) => { + const f = await fixture(t); + const home = join(f.homes, "codex-a"); + await mkdir(home); + await writeFile(join(home, "auth.json"), "FAKE-TOKEN\n"); + const wrapped = isolation(f).wrap({ sessionId: "plain", provider: "codex", cwd: f.project, command: "/bin/sh", + args: ["-c", `cat ${JSON.stringify(join(home, "auth.json"))} 2>/dev/null || echo TOKEN-READ-denied`], + env: { HOME: f.home, PATH: "/usr/bin:/bin" }, profile: "acceptEdits" }); + try { + const result = spawnSync(wrapped.command, wrapped.args, { cwd: f.project, env: wrapped.env, encoding: "utf8", timeout: 20_000 }); + assert.equal(result.stdout.trim(), "TOKEN-READ-denied"); + } finally { + wrapped.cleanup(); + } +}); + +test("a launch hands the isolation layer the account home its contributor selected", async (t) => { + const f = await fixture(t); + const selected = join(f.homes, "codex-alternate"); + await mkdir(selected); + const pipeline = new LaunchPipeline({ + contributors: () => [{ pluginId: PLUGIN, pluginName: "Accounts", serviceId: "accounts", dataDir: f.accountData, secrets: false, + launch: { fields: [{ key: "account", label: "Account", kind: "text", default: "" }] } }], + call: async () => ({ env: { CODEX_HOME: selected } }), + secret: async () => null, + runsRoot: join(f.userData, "launch-runs") + }); + const calls = []; + const spawner = (command, args, options) => { + calls.push({ command, args, options }); + return { pid: 41_000 + calls.length, process: command, write() {}, resize() {}, kill() {}, pause() {}, resume() {}, + onData() { return { dispose() {} }; }, onExit() { return { dispose() {} }; } }; + }; + const registry = { + get: (provider) => ({ state: "available", provider, executable: `/resolved/${provider}`, launcher: "native", environment: { PATH: "/usr/bin" }, checked: [] }), + snapshot: () => ({}) + }; + const terminals = new TerminalManager(() => undefined, registry, undefined, undefined, true, spawner); + t.after(() => terminals.shutdown()); + terminals.configureLaunchPipeline(pipeline); + const wraps = []; + const layer = isolation(f, fakeSeatbelt); + terminals.configureIsolation({ + containment: () => true, + decide: (input) => layer.decide(input), + wrap: (launch) => { + wraps.push(launch); + return { command: SANDBOX_EXEC, args: ["-f", "/p.sb", launch.command, ...launch.args], env: launch.env, cleanup() {} }; + } + }); + terminals.create({ provider: "codex", profile: "acceptEdits", cwd: f.project, position: { x: 0, y: 0 }, + launchOptions: { [PLUGIN]: { account: "alternate" } } }); + const deadline = Date.now() + 5_000; + while (wraps.length === 0 && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 10)); + assert.equal(wraps.length, 1); + assert.equal(wraps[0].accountHome, selected); + assert.equal(wraps[0].env.CODEX_HOME, selected); + assert.equal(calls.at(-1).command, SANDBOX_EXEC); +}); From 253a50e2ad240dcf2c321ff2cc21ebb5dfb7dd2b Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:21:50 +0000 Subject: [PATCH 11/28] fix(isolation): bind only CanvasTTY's own gateway sockets, read-only bubblewrap bound the shared gateway socket folders writable into every isolated agent, so an agent could replace a gateway socket and receive other agents' requests. It also took a socket folder from dirname() of every CANVASTTY_*_ADDRESS in the launch environment, which holds the person's variables and plugin contributions: $HOME/foo.sock bound all of HOME after the tmpfs masks, and an address inside plugin data reopened every account home. The control grant folder was read from the same environment. Socket folders are now bound read-only (connect() needs no write access to the folder) and come only from the sockets CanvasTTY's own gateways prepared for the launch; the control grant only from the control environment the host gave an orchestrator. A socket that must not be read is masked with /dev/null, and a gateway socket folder that holds a hidden path is refused. On macOS the seatbelt Unix-socket allow list is built from the same gateway sockets. --- src/main/services/TerminalManager.ts | 24 ++-- src/main/services/isolation/AgentIsolation.ts | 26 ++++- src/main/services/isolation/bubblewrap.ts | 27 +++-- src/main/services/isolation/isolationPaths.ts | 9 +- tests/agent-isolation.test.mjs | 109 +++++++++++++++++- tests/isolation-linux-host.test.mjs | 20 ++++ 6 files changed, 191 insertions(+), 24 deletions(-) diff --git a/src/main/services/TerminalManager.ts b/src/main/services/TerminalManager.ts index ff057520..805bd362 100644 --- a/src/main/services/TerminalManager.ts +++ b/src/main/services/TerminalManager.ts @@ -66,7 +66,7 @@ import { terminalFailureDetails } from "./terminalFailureDetails.ts"; import { canResumeThreadById, resolveTerminalLaunch } from "./terminalLaunch.ts"; import { isLaunchProfile, PROFILE_RANK, profileAvailable, profileCeiling, type LaunchProfile } from "../../shared/autoMode.ts"; import type { AgentIsolation, IsolationDecision } from "./isolation/AgentIsolation.ts"; -import { controlGrantFolder } from "./isolation/AgentIsolation.ts"; +import { controlGrantFolder, gatewaySocketAddresses } from "./isolation/AgentIsolation.ts"; import { auditRepositories, neutralizeRepositories, type GitRiskRepository } from "./isolation/gitAudit.ts"; import { LaunchRefusal } from "./launchRefusal.ts"; import { configuredMode } from "./configuredMode.ts"; @@ -215,6 +215,8 @@ interface PlannedSpawn { agentBrowser: PreparedAgentBrowserPtyLaunch | null; agentRuntime: PreparedAgentRuntimePtyLaunch | null; agentOrchestration: PreparedOrchestrationPtyLaunch | null; + /** The folder of the control grant CanvasTTY itself gave this launch (orchestrators only), never read from `env`. */ + controlGrant: string | null; cleanup(): void; } /** Quitting with saving off asks environments to stop compute, but never waits longer than this. */ @@ -1791,7 +1793,7 @@ export class TerminalManager { let spawn: { command: string; args: string[] | string; env: Record } = planned; if (decision.apply) { try { - spawn = this.wrapIsolated(id, provider, profile, planned, contribution?.accountHome); + spawn = this.wrapIsolated(id, provider, profile, planned, planned, contribution?.accountHome); } catch (error) { planned.cleanup(); return { ...none, failure: { diagnostic: `Launch refused: ${error instanceof Error ? error.message : String(error)}`, exitCode: 1 } }; @@ -1855,12 +1857,13 @@ export class TerminalManager { } /** Wraps a planned launch in the isolation layer (throws when the layer cannot start: the launch is refused). */ - private wrapIsolated(id: string, provider: ProviderId, profile: LaunchProfile, - planned: { command: string; args: string[] | string; cwd: string; env: Record }, + private wrapIsolated(id: string, provider: ProviderId, profile: LaunchProfile, planned: { command: string; args: string[] | string; cwd: string; env: Record }, + gateways: Pick, accountHome?: string): { command: string; args: string[]; env: Record } { if (!this.isolation) throw new LaunchRefusal("agent isolation is not configured; the agent was not started without it."); if (typeof planned.args === "string") throw new LaunchRefusal("a Windows batch launcher cannot run inside agent isolation."); - const grant = controlGrantFolder(planned.env); + // Only the grant CanvasTTY handed this launch: a CANVASTTY_CONTROL_CONNECTION a plugin set reopens nothing. + const grant = gateways.controlGrant; const wrapped = this.isolation.wrap({ sessionId: id, provider, @@ -1870,7 +1873,10 @@ export class TerminalManager { env: planned.env, profile, ...(accountHome ? { accountHome } : {}), - ...(grant ? { grantedPrivate: [grant] } : {}) + ...(grant ? { grantedPrivate: [grant] } : {}), + // Only the sockets CanvasTTY's own gateways prepared; a CANVASTTY_*_ADDRESS from elsewhere is never bound in. + gatewaySockets: gatewaySocketAddresses(gateways.agentBrowser?.environment, gateways.agentRuntime?.environment, + gateways.agentOrchestration?.environment) }); this.releaseIsolation(id); this.isolationCleanups.set(id, wrapped.cleanup); @@ -1965,13 +1971,14 @@ export class TerminalManager { const browserEnvironment = agentBrowser?.environment ?? {}; const runtimeEnvironment = agentRuntime?.environment ?? {}; const orchestrationEnvironment = agentOrchestration?.environment ?? {}; + const hostControlEnvironment = controlEnvironment(role, this.controlConnection, id); const providerEnvironment: Record = { ...(provider === "opencode" ? mergeOpenCodeLaunchEnvironment(browserEnvironment, runtimeEnvironment) : { ...browserEnvironment, ...runtimeEnvironment }), ...orchestrationEnvironment, // Orchestrators alone learn where the control descriptor and CLI are. - ...controlEnvironment(role, this.controlConnection, id) + ...hostControlEnvironment }; // OpenCode: the project folder in its other Unicode spelling is still this folder, not an external one. if (provider === "opencode") Object.assign(providerEnvironment, openCodeProjectFolderEnvironment({ ...baseEnvironment, ...providerEnvironment }, cwd)); @@ -2019,6 +2026,7 @@ export class TerminalManager { agentBrowser, agentRuntime, agentOrchestration, + controlGrant: controlGrantFolder(hostControlEnvironment), cleanup }; } catch (error) { @@ -2291,7 +2299,7 @@ export class TerminalManager { } if (decision.apply) { try { - spawn = { ...this.wrapIsolated(id, metadata.provider, decision.profile, spawn, contribution?.accountHome), cwd: spawn.cwd }; + spawn = { ...this.wrapIsolated(id, metadata.provider, decision.profile, spawn, planned, contribution?.accountHome), cwd: spawn.cwd }; } catch (error) { return refuse(error instanceof Error ? error.message : String(error)); } diff --git a/src/main/services/isolation/AgentIsolation.ts b/src/main/services/isolation/AgentIsolation.ts index 74047d26..a64e160b 100644 --- a/src/main/services/isolation/AgentIsolation.ts +++ b/src/main/services/isolation/AgentIsolation.ts @@ -1,14 +1,14 @@ import { spawnSync } from "node:child_process"; import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, realpathSync, rmdirSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { delimiter, dirname, join } from "node:path"; +import { delimiter, dirname, isAbsolute, join } from "node:path"; import type { ProviderId, SessionIsolation } from "../../../shared/contracts.ts"; import { autoKind, PROFILE_RANK, type LaunchProfile } from "../../../shared/autoMode.ts"; import { LaunchRefusal } from "../launchRefusal.ts"; import { isolationPaths } from "./isolationPaths.ts"; import { validateAccountHome } from "./accountHomeIsolation.ts"; import { seatbeltProfile } from "./seatbelt.ts"; -import { bubblewrapArguments, projectHooks } from "./bubblewrap.ts"; +import { bubblewrapArguments, projectHooks, type PathKind } from "./bubblewrap.ts"; import { LinuxHostPaths } from "./linuxHostPaths.ts"; export const SANDBOX_EXEC = "/usr/bin/sandbox-exec"; @@ -78,6 +78,8 @@ export interface IsolationLaunch { grantedPrivate?: readonly string[]; /** The CLI home a launch contributor selected inside its plugin data (LaunchPipeline); checked again here. */ accountHome?: string; + /** CanvasTTY's own gateway sockets for this launch (gatewaySocketAddresses), never read from `env`. */ + gatewaySockets?: readonly string[]; /** The launch profile: in plan the project is not writable. */ profile?: LaunchProfile; } @@ -200,6 +202,7 @@ export class AgentIsolation { userDataPath: this.options.userDataPath, sessionId: launch.sessionId, ...(granted.length > 0 ? { grantedPrivate: granted } : {}), + ...(launch.gatewaySockets ? { gatewaySockets: launch.gatewaySockets } : {}), ...(launch.profile === "plan" ? { readOnlyProject: true } : {}) }); // `git init` and `git clone` copy git's template, sample hooks included: an empty one writes no hooks. @@ -215,8 +218,8 @@ export class AgentIsolation { // bubblewrap mounts only what exists: the CLI's own missing folders are created and a missing protected file // gets a placeholder first (LinuxHostPaths), both undone by cleanup(). releaseHostPaths = this.linuxHostPaths.prepare(paths); - const kind = (path: string): "file" | "directory" | null => { - try { const stat = statSync(path); return stat.isDirectory() ? "directory" : stat.isFile() ? "file" : null; } catch { return null; } + const kind = (path: string): PathKind => { + try { const stat = statSync(path); return stat.isDirectory() ? "directory" : stat.isFile() ? "file" : stat.isSocket() ? "socket" : null; } catch { return null; } }; const args = bubblewrapArguments(paths, { command: launch.command, args: launch.args, cwd, ...(launch.env.XDG_RUNTIME_DIR ? { runtimeDir: launch.env.XDG_RUNTIME_DIR } : {}) }, kind); const hooks = projectHooks(cwd); @@ -292,7 +295,20 @@ function findOnPath(name: string, exists: (path: string) => boolean): string | n return null; } -/** The folder of a session's control grant, from the launch environment (it is readable inside the layer). */ +/** + * The Unix socket addresses in environments CanvasTTY's own gateways prepared for a launch (browser, lifecycle, + * orchestration). Pass only those: the launch's full environment also holds the person's variables and plugins'. + */ +export function gatewaySocketAddresses(...environments: ReadonlyArray> | undefined>): string[] { + return [...new Set(environments.flatMap((environment) => Object.entries(environment ?? {}) + .filter(([name, value]) => /^CANVASTTY_.*_ADDRESS$/u.test(name) && isAbsolute(value)) + .map(([, value]) => value)))]; +} + +/** + * The folder of a session's control grant, from the control environment CanvasTTY itself prepared for the launch + * (`controlEnvironment`), never from the launch's full environment: a plugin can set the variable there too. + */ export function controlGrantFolder(env: Readonly>): string | null { const connection = env.CANVASTTY_CONTROL_CONNECTION; return connection ? dirname(connection) : null; diff --git a/src/main/services/isolation/bubblewrap.ts b/src/main/services/isolation/bubblewrap.ts index bd0c47b2..cf71c6d1 100644 --- a/src/main/services/isolation/bubblewrap.ts +++ b/src/main/services/isolation/bubblewrap.ts @@ -1,6 +1,9 @@ import { dirname, join } from "node:path"; import type { IsolationPaths } from "./isolationPaths.ts"; +/** What is at a path, as far as mounting goes; null when nothing is there. */ +export type PathKind = "file" | "directory" | "socket" | null; + /** The project's own git hooks folder. */ export function projectHooks(cwd: string): string { return join(cwd, ".git", "hooks"); @@ -13,8 +16,9 @@ function within(path: string, folder: string): boolean { /** * The bubblewrap arguments of one isolated agent on Linux: the whole file system read-only, the project, this * launch's temporary folder and the CLI's own folders writable, an empty tmpfs over every folder it must not read (and - * /dev/null over such files), its own PID namespace (it cannot signal the person's processes) and the person's user - * runtime folder hidden (the systemd user manager, D-Bus session bus, SSH agent and Podman sockets live there). + * /dev/null over such files and sockets), CanvasTTY's gateway socket folders read-only, its own PID namespace (it + * cannot signal the person's processes) and the person's user runtime folder hidden (the systemd user manager, D-Bus + * session bus, SSH agent and Podman sockets live there). * * Linux cannot filter which Unix sockets a process connects to, so a socket outside those hidden folders (a system * Docker socket the person's account may use) stays reachable; docs/installing-and-security.md says so. `exists` @@ -23,7 +27,7 @@ function within(path: string, folder: string): boolean { export function bubblewrapArguments( paths: IsolationPaths, launch: { command: string; args: readonly string[]; cwd: string; runtimeDir?: string }, - exists: (path: string) => "file" | "directory" | null + exists: (path: string) => PathKind ): string[] { const args = [ "--die-with-parent", @@ -45,7 +49,8 @@ export function bubblewrapArguments( for (const path of paths.unreadable) { const kind = exists(path); if (kind === "directory") args.push("--tmpfs", path); - else if (kind === "file") args.push("--ro-bind", "/dev/null", path); + // A socket is hidden like a file: /dev/null over it, so connecting fails. + else if (kind === "file" || kind === "socket") args.push("--ro-bind", "/dev/null", path); } // What this launch was handed stays read-only (as in the macOS profile), except the CLI's own moved home, which is // one of its writable folders. @@ -83,16 +88,24 @@ export function bubblewrapArguments( else if (!exists(info)) args.push("--tmpfs", info); } if (launch.runtimeDir && exists(launch.runtimeDir) === "directory") args.push("--tmpfs", launch.runtimeDir); - // CanvasTTY's gateway sockets may sit in the hidden runtime folder or in /tmp; bind their folders back. + // CanvasTTY's gateway sockets may sit in the hidden runtime folder or in /tmp; bind their folders back read-only. + // Connecting to a Unix socket needs no write access to its folder, and a writable one would let an agent remove a + // gateway socket and bind its own in its place, receiving other agents' requests. This launch's own temporary + // folder is already bound writable above. + // A bind takes the host folder as it is, so one over a hidden path would show it again: refused, never mounted. + const hidden = [...paths.unreadable, ...(launch.runtimeDir ? [launch.runtimeDir] : [])]; for (const path of paths.socketFolders) { - if (exists(path) === "directory" && !seen.has(path)) args.push("--bind", path, path); + if (exists(path) !== "directory" || seen.has(path)) continue; + const covered = hidden.find((secret) => within(secret, path)); + if (covered) throw new Error(`The gateway socket folder ${path} contains ${covered}, which the agent may not read.`); + args.push("--ro-bind", path, path); } args.push("--chdir", launch.cwd, "--", launch.command, ...launch.args); return args; } /** A missing `path` whose nearest existing ancestor is one of the writable folders (or inside one). */ -export function creatableInside(path: string, writable: readonly string[], exists: (path: string) => "file" | "directory" | null): boolean { +export function creatableInside(path: string, writable: readonly string[], exists: (path: string) => PathKind): boolean { let current = dirname(path); for (let i = 0; i < 128 && !exists(current); i++) { const parent = dirname(current); diff --git a/src/main/services/isolation/isolationPaths.ts b/src/main/services/isolation/isolationPaths.ts index ba8d1989..041d759a 100644 --- a/src/main/services/isolation/isolationPaths.ts +++ b/src/main/services/isolation/isolationPaths.ts @@ -28,6 +28,11 @@ export interface IsolationPathInput { grantedPrivate?: readonly string[]; /** Extra socket folders this launch may connect to (the control grant's endpoint folder). */ socketFolders?: readonly string[]; + /** + * The sockets of CanvasTTY's own gateways for this launch, as the host set them up. Their folders are bound back; + * a CANVASTTY_*_ADDRESS in the launch environment (the person's own or a plugin's) never is. + */ + gatewaySockets?: readonly string[]; /** Plan: the project is readable only (the CLI's own folders stay writable). */ readOnlyProject?: boolean; } @@ -261,9 +266,7 @@ export function isolationPaths(input: IsolationPathInput): IsolationPaths { } const configSources = input.provider === "opencode" ? openCodeConfigPaths(input.env, input.cwd) : { jsonFiles: [], agentDirectories: [] }; const socketFolders = [ - ...Object.entries(input.env) - .filter(([name, value]) => /^CANVASTTY_.*_ADDRESS$/u.test(name) && typeof value === "string" && isAbsolute(value)) - .map(([, value]) => dirname(value!)), + ...(input.gatewaySockets ?? []).filter((path) => isAbsolute(path)).map((path) => dirname(path)), ...(input.socketFolders ?? []), input.sessionTemp, join(input.userDataPath, "browser", "runtime"), diff --git a/tests/agent-isolation.test.mjs b/tests/agent-isolation.test.mjs index 89b2266a..cf4c35ee 100644 --- a/tests/agent-isolation.test.mjs +++ b/tests/agent-isolation.test.mjs @@ -6,12 +6,13 @@ import { createServer } from "node:net"; import { tmpdir } from "node:os"; import { delimiter, join } from "node:path"; import test from "node:test"; -import { AgentIsolation, ISOLATION_FOLDER_PREFIX, probeBubblewrap } from "../src/main/services/isolation/AgentIsolation.ts"; +import { AgentIsolation, gatewaySocketAddresses, ISOLATION_FOLDER_PREFIX, probeBubblewrap } from "../src/main/services/isolation/AgentIsolation.ts"; import { isolationPaths } from "../src/main/services/isolation/isolationPaths.ts"; import { seatbeltProfile } from "../src/main/services/isolation/seatbelt.ts"; import { bubblewrapArguments } from "../src/main/services/isolation/bubblewrap.ts"; import { AgentControlService } from "../src/main/services/AgentControlService.ts"; import { codexInsideIsolation } from "../src/main/services/terminalLaunch.ts"; +import { LaunchPipeline } from "../src/main/services/LaunchPipeline.ts"; import { TerminalManager } from "../src/main/services/TerminalManager.ts"; import { availableRegistry, fakeSpawner } from "./helpers/terminal.mjs"; @@ -180,6 +181,32 @@ test("bubblewrap: read-only root, writable project, tmpfs over what may not be r assert.deepEqual(args.slice(-5), ["--chdir", w.project, "--", "/usr/bin/claude", "--x"]); }); +test("bubblewrap: CanvasTTY's gateway socket folders are read-only, a hidden socket is masked", async (t) => { + const w = await world(t); + const lifecycle = join(w.userData, "lifecycle", "runtime"); + const gateway = join(w.base, "ctty-1000-gw"); + const sessionTemp = join(w.temp, "s"); + const paths = isolationPaths({ provider: "codex", cwd: w.project, sessionTemp, + env: w.env, gatewaySockets: [join(lifecycle, "r.sock"), join(gateway, "gateway.sock")], userDataPath: w.userData, sessionId: "s" }); + const ssh = join(w.home, ".ssh", "agent.sock"); + const kinds = new Map([[w.project, "directory"], [sessionTemp, "directory"], [lifecycle, "directory"], [gateway, "directory"], + [join(w.userData, "browser", "runtime"), "directory"], [join(w.userData, "orchestration", "runtime"), "directory"], [ssh, "socket"]]); + const args = bubblewrapArguments({ ...paths, unreadable: [...paths.unreadable, ssh] }, { command: "/usr/bin/codex", args: [], cwd: w.project }, + (path) => kinds.get(path) ?? null); + const pairs = args.map((arg, i) => `${arg} ${args[i + 1]} ${args[i + 2]}`); + // Connecting to a Unix socket needs no write access to its folder; a writable folder would let one agent replace a + // gateway socket and receive another agent's requests. + for (const folder of [lifecycle, gateway, join(w.userData, "browser", "runtime"), join(w.userData, "orchestration", "runtime")]) { + assert.ok(pairs.includes(`--ro-bind ${folder} ${folder}`), `${folder} read-only`); + assert.ok(!pairs.includes(`--bind ${folder} ${folder}`), `${folder} not writable`); + } + // This launch's own temporary folder (also a socket folder) stays writable, bound once. + assert.equal(pairs.filter((pair) => pair === `--bind ${sessionTemp} ${sessionTemp}`).length, 1); + assert.ok(!pairs.includes(`--ro-bind ${sessionTemp} ${sessionTemp}`)); + // A socket it may not reach is hidden like a file, never left in place. + assert.ok(pairs.includes(`--ro-bind /dev/null ${ssh}`)); +}); + test("the paths: another CLI's home moved by its own variable is unreadable, never this CLI's own", async (t) => { const w = await world(t); const custom = (name) => join(w.base, "creds", name); @@ -304,6 +331,86 @@ test("the launch: wrapped when the layer applies, refused (never unwrapped) when assert.deepEqual(cleaned, [auto.id], "its folder goes with the card"); }); +test("the launch: only CanvasTTY's own gateway sockets reach the layer, never a CANVASTTY_*_ADDRESS from the person or a plugin", async (t) => { + const w = await world(t); + const calls = []; + const runtimeSocket = join(w.userData, "lifecycle", "runtime", "r.sock"); + const runtime = { + prepareLaunch: () => ({ args: [], environment: { CANVASTTY_RUNTIME_ADDRESS: runtimeSocket, CANVASTTY_RUNTIME_CAPABILITY: "c" }, cleanup() {} }), + currentStatus: () => null + }; + const terminals = new TerminalManager(() => undefined, availableRegistry(), undefined, runtime, true, fakeSpawner(calls)); + t.after(() => terminals.shutdown()); + const launches = []; + terminals.configureIsolation({ + containment: () => true, + decide: (input) => new AgentIsolation({ userDataPath: w.userData, enabled: () => true, platform: "darwin", exists: () => true }).decide(input), + wrap: (launch) => { launches.push(launch); return { command: "/usr/bin/sandbox-exec", args: [launch.command, ...launch.args], env: launch.env, cleanup() {} }; } + }); + const before = process.env.CANVASTTY_FOO_ADDRESS; + process.env.CANVASTTY_FOO_ADDRESS = join(w.home, "foo.sock"); + t.after(() => { if (before === undefined) delete process.env.CANVASTTY_FOO_ADDRESS; else process.env.CANVASTTY_FOO_ADDRESS = before; }); + terminals.create({ provider: "claude", profile: "auto", cwd: w.project, position: at }); + assert.equal(launches.length, 1); + assert.equal(launches[0].env.CANVASTTY_FOO_ADDRESS, join(w.home, "foo.sock"), "the variable itself still reaches the agent"); + assert.deepEqual(launches[0].gatewaySockets, [runtimeSocket]); + assert.deepEqual(gatewaySocketAddresses({ CANVASTTY_A_ADDRESS: "/a/s.sock", CANVASTTY_B_ADDRESS: "127.0.0.1:1", OTHER_ADDRESS: "/o" }, + undefined, { CANVASTTY_C_ADDRESS: "/a/s.sock" }), ["/a/s.sock"], "absolute CanvasTTY addresses only, once each"); +}); + +test("the launch: only the control grant CanvasTTY gave an orchestrator is reopened, never one a plugin names", async (t) => { + const w = await world(t); + const calls = []; + const terminals = new TerminalManager(() => undefined, availableRegistry(), undefined, undefined, true, fakeSpawner(calls)); + t.after(() => terminals.shutdown()); + const launches = []; + terminals.configureIsolation({ + containment: () => true, + decide: (input) => new AgentIsolation({ userDataPath: w.userData, enabled: () => true, platform: "darwin", exists: () => true }).decide(input), + wrap: (launch) => { launches.push(launch); return { command: "/usr/bin/sandbox-exec", args: [launch.command, ...launch.args], env: launch.env, cleanup() {} }; } + }); + const own = join(w.userData, "agent-control", "sessions", "own", "connection.json"); + terminals.setControlConnection({ connectionPath: join(w.userData, "agent-control", "connection.json"), cliPath: "/cli.mjs", grant: () => own }); + // A launch contributor names another session's grant on an ordinary (non-orchestrator) launch. + const victim = join(w.userData, "agent-control", "sessions", "victim", "connection.json"); + const runsRoot = join(w.temp, "runs"); + await mkdir(runsRoot, { recursive: true }); + const pipeline = new LaunchPipeline({ + contributors: () => [{ pluginId: "p.one", pluginName: "Plugin p.one", serviceId: "launcher", secrets: false, + launch: { fields: [{ key: "on", label: "On", kind: "boolean", default: true }] } }], + call: async () => ({ env: { CANVASTTY_CONTROL_CONNECTION: victim } }), + secret: async () => null, + runsRoot + }); + const waitFor = async (predicate) => { + for (let tries = 0; !predicate() && tries < 500; tries++) await new Promise((resolve) => setTimeout(resolve, 10)); + assert.ok(predicate(), "in time"); + }; + // The launch pipeline refuses the name already... + terminals.configureLaunchPipeline(pipeline); + const refused = terminals.create({ provider: "claude", profile: "auto", cwd: w.project, position: at, launchOptions: { "p.one": {} } }); + const card = () => terminals.list().find((session) => session.id === refused.id); + await waitFor(() => card().status === "failed"); + assert.match(card().failureDetails, /env CANVASTTY_CONTROL_CONNECTION is reserved for CanvasTTY/u); + assert.equal(launches.length, 0); + // ...and the isolation layer does not depend on that: a value that reaches the launch environment reopens nothing. + terminals.configureLaunchPipeline({ + normalizeOptions: (...args) => pipeline.normalizeOptions(...args), + unavailable: (options) => pipeline.unavailable(options), + forgetSession: async () => undefined, + prepare: async () => ({ ok: true, env: { CANVASTTY_CONTROL_CONNECTION: victim }, args: [], secrets: [], + envSources: { CANVASTTY_CONTROL_CONNECTION: "Plugin p.one" }, thirdPartyModel: false, cleanup: async () => undefined }) + }); + terminals.create({ provider: "claude", profile: "auto", cwd: w.project, position: at, launchOptions: { "p.one": {} } }); + await waitFor(() => launches.length === 1); + assert.equal(launches[0].env.CANVASTTY_CONTROL_CONNECTION, victim, "the variable itself reaches the agent"); + assert.equal(launches[0].grantedPrivate, undefined, "but no folder is reopened for it"); + + terminals.create({ provider: "claude", profile: "auto", cwd: w.project, position: at, role: "orchestrator" }); + assert.equal(launches.length, 2); + assert.deepEqual(launches[1].grantedPrivate, [join(w.userData, "agent-control", "sessions", "own")], "an orchestrator's own grant is"); +}); + test("without a layer a subagent runs in normal (it asks) and the card says why", async (t) => { const w = await world(t); const calls = []; diff --git a/tests/isolation-linux-host.test.mjs b/tests/isolation-linux-host.test.mjs index a9ac1b77..085e65a4 100644 --- a/tests/isolation-linux-host.test.mjs +++ b/tests/isolation-linux-host.test.mjs @@ -84,6 +84,26 @@ test("bubblewrap: a missing CLI permission file inside its writable folder is pr assert.equal(existsSync(join(w.home, ".claude", "settings.json")), false); }); +test("bubblewrap: a CANVASTTY_*_ADDRESS into HOME binds nothing and un-hides nothing; a core gateway folder over a hidden path is refused", posixHost, async (t) => { + const w = await freshHome(t); + await mkdir(join(w.home, ".ssh")); + await writeFile(join(w.home, ".ssh", "id_test"), "FAKE-PRIVATE-KEY"); + const lifecycle = join(w.userData, "lifecycle", "runtime"); + await mkdir(lifecycle, { recursive: true }); + // The person's or a plugin's variable: in the environment only, not a gateway CanvasTTY set up. + const wrapped = linux(w).wrap({ sessionId: "s1", provider: "codex", cwd: w.project, command: "/bin/sh", args: ["-c", "true"], + env: { ...w.env, CANVASTTY_FOO_ADDRESS: join(w.home, "foo.sock") }, gatewaySockets: [join(lifecycle, "r.sock")] }); + try { + assert.equal(mountFor(wrapped.args, w.home)?.target, "/", "HOME is not bound"); + assert.equal(mountFor(wrapped.args, join(w.home, ".ssh", "id_test"))?.mode, "tmpfs", "its keys stay hidden"); + assert.equal(persistsWrite(wrapped.args, join(w.home, "notes.txt")), false); + assert.deepEqual(mountFor(wrapped.args, join(lifecycle, "r.sock")), { target: lifecycle, mode: "ro" }, "the core gateway folder, read-only"); + } finally { wrapped.cleanup(); } + // A core gateway whose folder holds something hidden is refused rather than mounted over it. + assert.throws(() => linux(w).wrap({ sessionId: "s2", provider: "codex", cwd: w.project, command: "/bin/sh", args: ["-c", "true"], env: w.env, + gatewaySockets: [join(w.home, "gw.sock")] }), /gateway socket folder .* contains .*\.ssh, which the agent may not read/u); +}); + test("bubblewrap: a fresh HOME gets the CLI's own state folders, so its first run can write them", posixHost, async (t) => { const w = await freshHome(t); const wrapped = wrapIn(linux(w), w, "opencode"); From ef4fc82898933e23600fd124d7fc3591f4ca512a Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:21:50 +0000 Subject: [PATCH 12/28] fix(isolation): open plugin data projects only where CanvasTTY placed them With a project that contains userData (the home folder), an orchestrator could start a subagent in /plugin-data//homes. The isolation layer reopened any project at least two folders deep in plugin data, so that subagent could read and write every account's sign-in. A subagent's folder may no longer be inside CanvasTTY's private data, unless the project itself is a folder inside that same plugin's data (an environment's worktree). The layer reopens a project inside plugin data only when the host placed the launch there: a folder an environment prepared inside its own plugin's data, or the folder the person chose for the card the launch descends from. Anything else is refused with the reason. Also: a gateway socket folder that contains a writable folder is refused instead of mounted over it; a trailing separator in a CLI home variable names the same account home; the docs list which variables count as an account home, in English, Russian and Chinese. --- docs/agent-orchestration.md | 2 +- docs/installing-and-security.md | 2 +- docs/installing-and-security.ru.md | 2 +- docs/installing-and-security.zh-CN.md | 2 +- docs/plugins.md | 2 +- docs/plugins.ru.md | 1 + docs/plugins.zh-CN.md | 1 + src/main/index.ts | 3 +- src/main/services/AgentControlService.ts | 36 +- src/main/services/TerminalManager.ts | 40 ++- src/main/services/isolation/AgentIsolation.ts | 5 +- .../isolation/accountHomeIsolation.ts | 22 +- src/main/services/isolation/bubblewrap.ts | 5 + src/main/services/isolation/isolationPaths.ts | 50 ++- tests/account-home-isolation.test.mjs | 10 +- tests/isolation-hardening.test.mjs | 333 ++++++++++++++++++ 16 files changed, 486 insertions(+), 30 deletions(-) create mode 100644 tests/isolation-hardening.test.mjs diff --git a/docs/agent-orchestration.md b/docs/agent-orchestration.md index c45d53af..be0f8efb 100644 --- a/docs/agent-orchestration.md +++ b/docs/agent-orchestration.md @@ -43,7 +43,7 @@ The core applies these to every way an agent can start another one: `spawn_agent | Rule | What it guarantees | | --- | --- | | Profile ceiling | A subagent's profile is at most its orchestrator's, never YOLO. A saved subagent whose record says more is restored with its orchestrator's ceiling. | -| Project folder | A subagent's `cwd` must be the folder the person chose for the agent it descends from, or a folder inside it, compared as real paths in both Unicode spellings (NFC and NFD name the same folder on macOS). `/`, the home folder or another project is refused: "A subagent works only inside this project's folder …". A relative `cwd` is taken from the orchestrator's folder. | +| Project folder | A subagent's `cwd` must be the folder the person chose for the agent it descends from, or a folder inside it, compared as real paths in both Unicode spellings (NFC and NFD name the same folder on macOS). `/`, the home folder or another project is refused: "A subagent works only inside this project's folder …". CanvasTTY's own private data is refused even when the project contains it (a project at the home folder): its tokens, account homes and plugins' data ("A subagent cannot work in … it is CanvasTTY's private data"); a folder inside one plugin's data only when the project itself is a folder inside that same plugin's data (an environment's worktree). A relative `cwd` is taken from the orchestrator's folder. | | Limits | At most `orchestrationMaxDepth` levels of subagents below the agent the person started (2 by default) and `orchestrationMaxSubagents` live subagents in one orchestration, all levels together (8 by default). Only the person sets them, in Settings → Agents. Exited subagents do not count. | | Plugin launch options | `launchOptions` from an orchestrator reach only plugins that declared `launch.delegable: true` (a plugin the spawn does not name keeps the orchestrator's own values for a subagent of the same CLI; repeating them needs no declaration; a plugin turned off since is left out, or refused when it set the model; changed plugin fields refuse with that reason); everything a plugin contributes still passes the core-owned flag and configuration checks (no permission, sandbox, hook or bypass flags, no OpenCode/Kimi configuration that sets approvals). | | YOLO | Enforced in the main process: only for a CLI the person acknowledged YOLO for in the launcher, never for a subagent, never from an orchestrator's control connection; a plugin with `sessions:launch` needs the same acknowledgement. | diff --git a/docs/installing-and-security.md b/docs/installing-and-security.md index 58870261..21af9dc9 100644 --- a/docs/installing-and-security.md +++ b/docs/installing-and-security.md @@ -91,7 +91,7 @@ Agents work in their own mode; CanvasTTY's layers sit outside it as a quiet safe | Delegation rules | A subagent never gets more than its orchestrator (never YOLO), works only in its orchestrator's project folder, within the depth and count limits the person set; orchestrators get a control connection of their own; no agent-facing tool changes settings, protection, profiles, trust or isolation; plugin launch options from an agent only where the plugin declared them delegable. | What a plugin the person trusted does with its own permissions. | | Agent isolation (OS layer) | On macOS (sandbox-exec) and Linux (bubblewrap), for subagents, plugin-started agents and every agent not in Manual: the whole process tree writes only in the project, its own temporary folder and its CLI's own folders; keys, other CLIs' credentials and CanvasTTY's tokens are unreadable; no other process, app, Apple event, preference write, launchd job or foreign Unix socket (Docker, tmux, SSH agent) is reachable. Fails closed. | Network is not restricted. Linux cannot filter Unix sockets, only hide the user runtime folder. The CLI's own sandbox cannot run inside the layer on macOS, so it is switched off there (approvals unchanged) and commands can use the network. A Claude launch may rewrite the login keychain file to save a refreshed sign-in, so it could also damage that file (other items stay behind their own access lists). Windows has no layer yet: subagents run in Manual there unless the person turns isolation off. | -Isolation keeps approval configuration read-only, including OpenCode's project and custom config files and its `agent`/`agents` directories. CLI state and caches remain writable. A moved CLI home is refused if its real path overlaps host credentials or private CanvasTTY data; a selected account folder below `account-homes` remains available. Plugins' private data (`plugin-data`) is unreadable as a whole; only the CLI home a chosen launch plugin started this agent with, an exact real folder inside that plugin's own data, is reopened, and a project inside plugin data (an environment's worktree) is reopened as that one folder. Credential exclusions use the host environment captured before launch contributions, even if the launch changes `HOME`. +Isolation keeps approval configuration read-only, including OpenCode's project and custom config files and its `agent`/`agents` directories. CLI state and caches remain writable. A moved CLI home is refused if its real path overlaps host credentials or private CanvasTTY data; a selected account folder below `account-homes` remains available. Plugins' private data (`plugin-data`) is unreadable as a whole; only the CLI home a chosen launch plugin started this agent with, an exact real folder inside that plugin's own data, is reopened, and a project inside plugin data is reopened as that one folder only where CanvasTTY placed the launch: a folder an environment prepared inside its own plugin's data, or the folder the person chose for the card (and its subagents' folders below it). Any other project inside plugin data, such as a subagent folder an agent asked for, is refused. On Linux, CanvasTTY's gateway socket folders are bound read-only, and only the sockets CanvasTTY's own gateways set up for the launch are: a `CANVASTTY_*_ADDRESS` from the person's environment or a plugin binds nothing, and a gateway socket folder that holds a hidden or writable folder is refused. Credential exclusions use the host environment captured before launch contributions, even if the launch changes `HOME`. OpenCode Auto inspects only regular local config files of at most 1 MiB. An existing source that cannot be inspected adds no Auto allowances. A launch contributor's uninspectable config is refused; only absent optional directory candidates are skipped. JSONC comments and trailing commas are parsed without changing quoted strings. diff --git a/docs/installing-and-security.ru.md b/docs/installing-and-security.ru.md index 0532080d..977ee961 100644 --- a/docs/installing-and-security.ru.md +++ b/docs/installing-and-security.ru.md @@ -85,7 +85,7 @@ gh variable set SPARKLE_PUBLIC_ED_KEY --repo howdeploy/CanvasTTY \ - **Режимы запуска**: Авто (по умолчанию), Вручную, Правки, План, Обход — только там, где их поддерживает CLI. Обход выбирает человек для каждого CLI, он проверяется в main-процессе, никогда не даётся субагенту и всё равно остаётся внутри слоёв ниже. - **Базовая защита** (хуки) запрещает повышение прав, `curl | sh`, дисковые команды и запись вне проекта у агентов с хуками; это проверка команд, а не песочница. - **Правила делегирования**: субагент получает не больше оркестратора (никогда YOLO), работает только в папке проекта, в пределах глубины и числа, заданных человеком; агенты не могут менять настройки, защиту, профили и изоляцию. -- **Изоляция агентов** (macOS — sandbox-exec, Linux — bubblewrap): запись только в проект, свою временную папку и папки своего CLI; ключи и токены недоступны; другие процессы, приложения и демоны недоступны; при сбое запуск отклоняется. Сеть не ограничивается; на Windows слоя пока нет, и субагенты там работают вручную. Подробности — в [agent-orchestration.md](agent-orchestration.md). +- **Изоляция агентов** (macOS — sandbox-exec, Linux — bubblewrap): запись только в проект, свою временную папку и папки своего CLI; ключи и токены недоступны; другие процессы, приложения и демоны недоступны; при сбое запуск отклоняется. Приватные данные плагинов (`plugin-data`) недоступны целиком; снова открываются только папка CLI, с которой выбранный плагин запуска стартовал агента (точная реальная папка внутри данных этого плагина), и проект внутри данных плагинов — как одна эта папка и только там, где запуск разместил CanvasTTY: папка, подготовленная окружением внутри данных своего плагина, или папка, выбранная человеком для карточки (и папки её субагентов ниже). Любой другой проект внутри данных плагинов, например папка субагента, о которой попросил агент, отклоняется. На Linux папки сокетов шлюзов CanvasTTY подключаются только для чтения, и только сокеты, которые собственные шлюзы CanvasTTY подготовили для этого запуска: `CANVASTTY_*_ADDRESS` из окружения человека или от плагина ничего не подключает, а папка сокетов шлюза со скрытой или записываемой папкой отклоняется. Сеть не ограничивается; на Windows слоя пока нет, и субагенты там работают вручную. Подробности — в [agent-orchestration.md](agent-orchestration.md). **Аудит git.** Когда изолированная сессия агента заканчивается, закрывается или восстанавливается после выхода, CanvasTTY проверяет репозитории в её папке, у которых изменилась папка git. Если git теперь запустит что-то вне изоляции (`core.hooksPath`, фильтр или diff-драйвер, `fsmonitor`, файл хука, `info/attributes`), появляется уведомление со списком изменений; **Обезвредить** удаляет эти ключи и отключает файлы, **Оставить как есть** ничего не трогает. Другие изменения файлов в проекте аудит не откатывает. diff --git a/docs/installing-and-security.zh-CN.md b/docs/installing-and-security.zh-CN.md index 605deda0..765240e5 100644 --- a/docs/installing-and-security.zh-CN.md +++ b/docs/installing-and-security.zh-CN.md @@ -85,7 +85,7 @@ gh variable set SPARKLE_PUBLIC_ED_KEY --repo howdeploy/CanvasTTY \ - **启动模式**:自动(默认)、手动、接受编辑、计划、绕过,仅在 CLI 支持时提供。绕过由用户按 CLI 确认,在主进程中检查,绝不交给子智能体,并且仍处于下列各层之内。 - **基础保护**(hooks)为有 hooks 的智能体拒绝提权、`curl | sh`、磁盘命令以及项目外的写入;它检查命令,不是沙箱。 - **委派规则**:子智能体的权限不超过其编排者(绝不为 YOLO),只在项目文件夹内工作,并受用户设定的深度和数量限制;智能体无法更改设置、防护、配置档或隔离。 -- **智能体隔离**(macOS 使用 sandbox-exec,Linux 使用 bubblewrap):只能写入项目、自身临时目录和其 CLI 的目录;密钥和令牌不可读;其他进程、应用和守护进程不可达;无法建立时拒绝启动。网络不受限制;Windows 暂无此层,子智能体在那里以手动模式运行。详见 [agent-orchestration.md](agent-orchestration.md)。 +- **智能体隔离**(macOS 使用 sandbox-exec,Linux 使用 bubblewrap):只能写入项目、自身临时目录和其 CLI 的目录;密钥和令牌不可读;其他进程、应用和守护进程不可达;无法建立时拒绝启动。插件的私有数据(`plugin-data`)整体不可读;只重新开放两类路径:所选启动插件启动该智能体时使用的 CLI 目录(该插件自身数据内的一个确切真实文件夹),以及插件数据内的项目——仅作为这一个文件夹,且仅限 CanvasTTY 安排启动的位置:环境在其插件自身数据内准备的文件夹,或用户为卡片选择的文件夹(及其下方子智能体的文件夹)。插件数据内的任何其他项目(例如智能体要求的子智能体文件夹)都会被拒绝。在 Linux 上,CanvasTTY 网关的 socket 文件夹以只读方式挂载,且只挂载 CanvasTTY 自身网关为本次启动准备的 socket:来自用户环境或插件的 `CANVASTTY_*_ADDRESS` 不会挂载任何东西,包含隐藏或可写文件夹的网关 socket 文件夹会被拒绝。网络不受限制;Windows 暂无此层,子智能体在那里以手动模式运行。详见 [agent-orchestration.md](agent-orchestration.md)。 **Git 审计。** 隔离的智能体会话结束、被关闭或在退出后恢复时,CanvasTTY 会检查其文件夹下 git 目录发生变化的仓库。如果 git 现在会在隔离外运行某些东西(`core.hooksPath`、filter 或 diff 驱动、`fsmonitor`、hook 文件、`info/attributes`),会出现一条列出具体变化的通知;**Neutralize** 删除这些键并停用这些文件,**Keep as is** 保持不变。它不会撤销项目内的其他文件改动。 diff --git a/docs/plugins.md b/docs/plugins.md index 5a3946a5..3cb327cf 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -222,7 +222,7 @@ Rules the host enforces, none of which is ever skipped: - Claude Code applies only its last `--settings`, so a plugin's inline `--settings` JSON is merged into CanvasTTY's own (objects such as `env` key by key, hook lists appended); one that sets `permissions`, `hooks`, `disableAllHooks`, `sandbox`, `defaultMode` or `apiKeyHelper` is refused. Both `--settings ` and `--settings=` are checked; a settings file is accepted only as one of the contribution's own launch files (`{launchFiles}/…`), which CanvasTTY reads, checks the same way and passes inline; any other file path is refused. `--bare`, `--safe-mode`, `--allowedTools`, `--permission-prompt-tool` and `--permission-prompts` are CanvasTTY's too. - No answer within 5 s, an error, an invalid answer, a missing secret, or a plugin that is disabled, removed or no longer trusted refuses the launch with the reason on the card. The agent is never started without a contribution the person chose. A restored card whose plugin is unavailable comes back stopped with that reason and keeps its record until the plugin returns or the card is closed. - A plain terminal takes no launch options. -- An isolated agent cannot read any plugin's data folder (`/plugin-data`). If an `env` value sets the agent CLI's own home variable (`CODEX_HOME`, `CLAUDE_CONFIG_DIR`, `GROK_HOME`, …) to an exact absolute folder inside the plugin's own data folder (not the data folder itself), that one folder is reopened for this agent. It must be a real folder, with no symbolic link on the way. Nothing else in plugin data is reopened, and a plugin cannot name other folders to reopen. +- An isolated agent cannot read any plugin's data folder (`/plugin-data`). If an `env` value sets the agent CLI's own home variable to an exact absolute folder inside the plugin's own data folder (not the data folder itself; a trailing `/` is ignored), that one folder is reopened for this agent. Only these variables count, each for its own CLI: `CODEX_HOME` (Codex), `CLAUDE_CONFIG_DIR` (Claude Code), `GROK_HOME` (Grok), `HERMES_HOME` (Hermes), `KIMI_HOME` (Kimi), `OPENCODE_CONFIG_DIR` (OpenCode) and `QWEN_HOME` (Qwen Code); never `HOME` or `XDG_*`. It must be a real folder, with no symbolic link on the way. Nothing else in plugin data is reopened, and a plugin cannot name other folders to reopen. **Launch profiles.** `profile` is `normal` (the default), `yolo`, or `auto`. `auto` exists only for agents whose CLI has a native auto mode, checked against each CLI's `--help`: Codex `--approve-for-me` (its own reviewer, in its `workspace-write` sandbox), Claude Code `--permission-mode auto` with its sandbox (`{"sandbox":{"enabled":true,"autoAllowBashIfSandboxed":false}}` merged into the one `--settings`), and Grok `--permission-mode auto` (no Grok sandbox is relied on). Base protection and decision services keep answering in front of it (Codex, Claude Code). When any contributor answers `thirdPartyModel: true`, `auto` becomes the CLI's accept-edits mode in the same sandbox (Codex `--sandbox workspace-write --ask-for-approval on-request`, Claude Code and Grok `--permission-mode acceptEdits`): the native reviewer would be that same model, and a weak model's own classifier is not a safety boundary. diff --git a/docs/plugins.ru.md b/docs/plugins.ru.md index 5d2cfe9d..66860fc7 100644 --- a/docs/plugins.ru.md +++ b/docs/plugins.ru.md @@ -203,6 +203,7 @@ host.service.onEvent(({ serviceId, event, data }) => { /* … */ }); - Claude Code применяет только последний `--settings`, поэтому встроенный JSON `--settings` плагина сливается с собственным JSON CanvasTTY (объекты вроде `env` по ключам, списки хуков дописываются); если он задаёт `permissions`, `hooks`, `disableAllHooks`, `sandbox`, `defaultMode` или `apiKeyHelper`, запуск отклоняется. Проверяются обе формы: `--settings ` и `--settings=`; файл настроек принимается только как один из собственных файлов запуска вклада (`{launchFiles}/…`) — CanvasTTY читает его, проверяет так же и передаёт встроенным JSON; любой другой путь к файлу отклоняется. `--bare`, `--safe-mode`, `--allowedTools`, `--permission-prompt-tool` и `--permission-prompts` тоже передаёт только CanvasTTY. - Нет ответа за 5 с, ошибка, неверный ответ, отсутствующий секрет или выключенный, удалённый либо переставший быть доверенным плагин отклоняют запуск, и причина видна в окне. Агент никогда не запускается без выбранного вклада. Восстановленное окно с недоступным плагином возвращается остановленным с этой причиной и сохраняет запись, пока плагин не вернётся или окно не закроют. - Обычный терминал параметров запуска не принимает. +- Изолированный агент не может читать данные плагинов (`/plugin-data`). Если значение `env` задаёт собственную переменную домашней папки CLI агента точным абсолютным путём к папке внутри папки данных этого плагина (не к самой папке данных; завершающий `/` не учитывается), для этого агента открывается только эта папка. Учитываются только эти переменные, каждая для своего CLI: `CODEX_HOME` (Codex), `CLAUDE_CONFIG_DIR` (Claude Code), `GROK_HOME` (Grok), `HERMES_HOME` (Hermes), `KIMI_HOME` (Kimi), `OPENCODE_CONFIG_DIR` (OpenCode) и `QWEN_HOME` (Qwen Code); никогда `HOME` или `XDG_*`. Это должна быть настоящая папка без символических ссылок на пути. Больше ничего из данных плагинов не открывается, и плагин не может назвать другие папки для открытия. **Профили запуска.** `profile` — это `normal` (по умолчанию), `yolo` или `auto`. `auto` есть только у агентов, чей CLI имеет собственный авторежим, проверенный по `--help` каждого CLI: Codex `--approve-for-me` (его собственная проверка в песочнице `workspace-write`), Claude Code `--permission-mode auto` с его песочницей (`{"sandbox":{"enabled":true,"autoAllowBashIfSandboxed":false}}` сливается в единственный `--settings`) и Grok `--permission-mode auto` (на песочницу Grok не полагаемся). Базовая защита и сервисы решений отвечают перед ним (Codex, Claude Code). Если хотя бы один вклад ответил `thirdPartyModel: true`, `auto` превращается в режим «только правки» того же CLI в той же песочнице (Codex `--sandbox workspace-write --ask-for-approval on-request`, Claude Code и Grok `--permission-mode acceptEdits`): собственной проверкой была бы та же модель, а классификатор слабой модели — не граница безопасности. diff --git a/docs/plugins.zh-CN.md b/docs/plugins.zh-CN.md index c91ff2c6..675b426c 100644 --- a/docs/plugins.zh-CN.md +++ b/docs/plugins.zh-CN.md @@ -201,6 +201,7 @@ host.service.onEvent(({ serviceId, event, data }) => { /* … */ }); - Claude Code 只应用最后一个 `--settings`,因此插件的内联 `--settings` JSON 会合并进 CanvasTTY 自己的 JSON(`env` 等对象按键合并,hook 列表追加);若其中设置了 `permissions`、`hooks`、`disableAllHooks`、`sandbox`、`defaultMode` 或 `apiKeyHelper`,启动会被拒绝。`--settings ` 与 `--settings=` 两种形式都会检查;设置文件只接受贡献自身的启动文件(`{launchFiles}/…`),CanvasTTY 读取并以同样方式检查后以内联 JSON 传递;其他文件路径一律拒绝。`--bare`、`--safe-mode`、`--allowedTools`、`--permission-prompt-tool` 和 `--permission-prompts` 也只由 CanvasTTY 传递。 - 5 秒内无应答、出错、应答无效、缺少机密,或插件被禁用、删除或不再受信任,都会拒绝启动并在卡片上显示原因。智能体绝不会在缺少用户所选贡献的情况下启动。插件不可用的恢复卡片以停止状态返回并显示该原因,记录保留到插件恢复或卡片被关闭。 - 普通终端不接受启动选项。 +- 隔离的智能体无法读取任何插件的数据文件夹(`/plugin-data`)。如果某个 `env` 值把该智能体 CLI 自己的主目录变量设为该插件自身数据文件夹内的一个精确绝对路径文件夹(不能是数据文件夹本身;末尾的 `/` 会被忽略),就只为这个智能体重新开放这一个文件夹。只有以下变量算数,且各自只对应自己的 CLI:`CODEX_HOME`(Codex)、`CLAUDE_CONFIG_DIR`(Claude Code)、`GROK_HOME`(Grok)、`HERMES_HOME`(Hermes)、`KIMI_HOME`(Kimi)、`OPENCODE_CONFIG_DIR`(OpenCode)和 `QWEN_HOME`(Qwen Code);`HOME` 和 `XDG_*` 永远不算。它必须是真实的文件夹,路径上不能有符号链接。插件数据中的其他内容都不会被重新开放,插件也不能指定其他要开放的文件夹。 **启动配置档。** `profile` 为 `normal`(默认)、`yolo` 或 `auto`。`auto` 仅适用于 CLI 自带自动模式的智能体,均按各 CLI 的 `--help` 核实:Codex `--approve-for-me`(其自身审查,运行于 `workspace-write` 沙箱),Claude Code `--permission-mode auto` 及其沙箱(`{"sandbox":{"enabled":true,"autoAllowBashIfSandboxed":false}}` 合并进唯一的 `--settings`),以及 Grok `--permission-mode auto`(不依赖 Grok 沙箱)。基础保护和决策服务仍在其前面作答(Codex、Claude Code)。只要任一贡献者回答 `thirdPartyModel: true`,`auto` 就改为同一沙箱中 CLI 的“仅接受编辑”模式(Codex `--sandbox workspace-write --ask-for-approval on-request`,Claude Code 和 Grok `--permission-mode acceptEdits`):原生审查者将是同一个模型,而弱模型自己的分类器不是安全边界。 diff --git a/src/main/index.ts b/src/main/index.ts index 7de30338..b43c73bb 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -689,7 +689,8 @@ async function initializeServices(ipc: IpcRegistrar): Promise { const managedTerminals = terminalManager; const agentControlService = new AgentControlService(managedTerminals, { limits: () => ({ maxDepth: settings.get().orchestrationMaxDepth, maxSubagents: settings.get().orchestrationMaxSubagents }), - containment: () => managedTerminals.containment() + containment: () => managedTerminals.containment(), + userDataPath }); orchestrationGateway = new OrchestrationGateway({ runtimeDirectory: join(userDataPath, "orchestration", "runtime"), diff --git a/src/main/services/AgentControlService.ts b/src/main/services/AgentControlService.ts index 579ffefe..b4464b67 100644 --- a/src/main/services/AgentControlService.ts +++ b/src/main/services/AgentControlService.ts @@ -6,7 +6,7 @@ import type { SessionSnapshot } from "../../shared/contracts.ts"; import { realpathSync } from "node:fs"; -import { isAbsolute, resolve } from "node:path"; +import { isAbsolute, join, relative, resolve, sep } from "node:path"; import { PROVIDER_CAPABILITIES } from "../../shared/contracts.ts"; import { isLaunchProfile, PROFILE_RANK, profileAvailable, profileCeiling, type LaunchProfile } from "../../shared/autoMode.ts"; import { isPathInside } from "../../agent-runtime/path-inside.mjs"; @@ -16,6 +16,7 @@ import { LaunchRefusal } from "./launchRefusal.ts"; import { terminalFailureDetails } from "./terminalFailureDetails.ts"; import { terminalScreenText } from "./terminalScreenText.ts"; import { RESULT_CAPTURE_PROVIDERS } from "./resultCapture.ts"; +import { PLUGIN_DATA_FOLDER, privateAppData } from "./isolation/isolationPaths.ts"; // Cards one parent may have in total, live or exited (the live limit is the person's setting). const MAX_CHILDREN_PER_PARENT = 16; @@ -100,6 +101,8 @@ export interface AgentControlOptions { limits?: () => DelegationLimits; /** CanvasTTY's isolation layer can contain an agent on this computer now (a "contained" auto needs it). */ containment?: () => boolean; + /** CanvasTTY's userData folder: a subagent never works in its private data (tokens, account homes, plugin data). */ + userDataPath?: string; } /** The longest wait one call may ask for (wait_for_agent's timeoutSeconds maximum). */ @@ -182,7 +185,7 @@ export class AgentControlService { const lineage = this.lineage(parent.id); const root = lineage.at(-1)!; // What the request asks for first (its folder, its profile), then the person's limits. - const cwd = subagentFolder(root.cwd, parent.cwd, request.cwd); + const cwd = subagentFolder(root.cwd, parent.cwd, request.cwd, this.options.userDataPath); if ("error" in cwd) throw new DelegationRefusal(cwd.error); const profile = subagentProfile(parent.profile, request.provider, request.profile, this.containment()); if ("error" in profile) throw new DelegationRefusal(profile.error); @@ -603,9 +606,11 @@ export function subagentProfile( /** * Where a subagent may work: its orchestrator's project folder (the folder the person chose for the agent it * descends from) or a folder inside it, compared as real paths in the spelling the disk uses (NFC and NFD name the - * same folder on macOS). A relative folder is taken from the orchestrator's own folder. + * same folder on macOS). A relative folder is taken from the orchestrator's own folder. Never CanvasTTY's private + * data under `userDataPath` (a project such as the home folder contains it): a folder inside one plugin's data only + * when the project itself is a folder inside that same plugin's data (an environment's worktree). */ -export function subagentFolder(projectRoot: string, parentCwd: string, requested: unknown): { cwd: string } | { error: string } { +export function subagentFolder(projectRoot: string, parentCwd: string, requested: unknown, userDataPath?: string): { cwd: string } | { error: string } { if (typeof requested !== "string" || requested.trim().length === 0) return { error: "cwd is required: a folder inside this project." }; const wanted = onDiskPath(isAbsolute(requested) ? requested : resolve(parentCwd, requested)); const real = (path: string): string | null => { @@ -620,9 +625,32 @@ export function subagentFolder(projectRoot: string, parentCwd: string, requested if (!root || !(isPathInside(root, folder) || isPathInside(root.normalize("NFC"), folder.normalize("NFC")))) { return { error: `A subagent works only inside this project's folder (${projectRoot}); ${requested} is outside it. Only the person can start an agent in another folder.` }; } + if (userDataPath && insidePrivateData(root, folder, userDataPath, real)) { + return { error: `A subagent cannot work in ${requested}: it is CanvasTTY's private data (tokens, account homes, plugins' data). Only the person can start an agent there.` }; + } return { cwd: folder }; } +/** + * `folder` (a real path) is inside CanvasTTY's private data, and not inside a project that is itself a folder within + * one plugin's data (an environment's worktree; then that plugin folder is the project's own). + */ +function insidePrivateData(root: string, folder: string, userDataPath: string, real: (path: string) => string | null): boolean { + const inside = (base: string, path: string): boolean => isPathInside(base, path) || isPathInside(base.normalize("NFC"), path.normalize("NFC")); + const data = real(resolve(userDataPath)) ?? resolve(userDataPath); + for (const hidden of privateAppData(data)) { + const base = real(hidden) ?? hidden; + if (!inside(base, folder)) continue; + if (hidden !== join(data, PLUGIN_DATA_FOLDER)) return true; + // //…: the project must be strictly inside that same plugin's folder. + const plugin = relative(base.normalize("NFC"), folder.normalize("NFC")).split(sep)[0]; + if (!plugin) return true; + const pluginFolder = join(base, plugin); + if (!inside(pluginFolder, root) || inside(root, pluginFolder)) return true; + } + return false; +} + /** Sleeps, or rejects with an AbortError as soon as `signal` aborts. */ function pause(ms: number, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { diff --git a/src/main/services/TerminalManager.ts b/src/main/services/TerminalManager.ts index 805bd362..727896ff 100644 --- a/src/main/services/TerminalManager.ts +++ b/src/main/services/TerminalManager.ts @@ -68,6 +68,7 @@ import { isLaunchProfile, PROFILE_RANK, profileAvailable, profileCeiling, type L import type { AgentIsolation, IsolationDecision } from "./isolation/AgentIsolation.ts"; import { controlGrantFolder, gatewaySocketAddresses } from "./isolation/AgentIsolation.ts"; import { auditRepositories, neutralizeRepositories, type GitRiskRepository } from "./isolation/gitAudit.ts"; +import type { PlacedProject } from "./isolation/isolationPaths.ts"; import { LaunchRefusal } from "./launchRefusal.ts"; import { configuredMode } from "./configuredMode.ts"; import { envKey, RESERVED_ENV, type LaunchPipeline, type PreparedLaunch } from "./LaunchPipeline.ts"; @@ -1793,7 +1794,10 @@ export class TerminalManager { let spawn: { command: string; args: string[] | string; env: Record } = planned; if (decision.apply) { try { - spawn = this.wrapIsolated(id, provider, profile, planned, planned, contribution?.accountHome); + spawn = this.wrapIsolated(id, provider, profile, planned, planned, { + ...(contribution?.accountHome ? { accountHome: contribution.accountHome } : {}), + placedProjects: this.placedProjects(id, parentSessionId, planned.cwd, undefined) + }); } catch (error) { planned.cleanup(); return { ...none, failure: { diagnostic: `Launch refused: ${error instanceof Error ? error.message : String(error)}`, exitCode: 1 } }; @@ -1859,7 +1863,7 @@ export class TerminalManager { /** Wraps a planned launch in the isolation layer (throws when the layer cannot start: the launch is refused). */ private wrapIsolated(id: string, provider: ProviderId, profile: LaunchProfile, planned: { command: string; args: string[] | string; cwd: string; env: Record }, gateways: Pick, - accountHome?: string): { command: string; args: string[]; env: Record } { + placement: { accountHome?: string; placedProjects: readonly PlacedProject[] }): { command: string; args: string[]; env: Record } { if (!this.isolation) throw new LaunchRefusal("agent isolation is not configured; the agent was not started without it."); if (typeof planned.args === "string") throw new LaunchRefusal("a Windows batch launcher cannot run inside agent isolation."); // Only the grant CanvasTTY handed this launch: a CANVASTTY_CONTROL_CONNECTION a plugin set reopens nothing. @@ -1872,7 +1876,8 @@ export class TerminalManager { args: planned.args, env: planned.env, profile, - ...(accountHome ? { accountHome } : {}), + ...(placement.accountHome ? { accountHome: placement.accountHome } : {}), + placedProjects: placement.placedProjects, ...(grant ? { grantedPrivate: [grant] } : {}), // Only the sockets CanvasTTY's own gateways prepared; a CANVASTTY_*_ADDRESS from elsewhere is never bound in. gatewaySockets: gatewaySocketAddresses(gateways.agentBrowser?.environment, gateways.agentRuntime?.environment, @@ -2081,6 +2086,30 @@ export class TerminalManager { } } + /** + * The folders CanvasTTY itself placed a launch in, the only ones the isolation layer opens inside plugin data: the + * folder an environment prepared for this card (inside that plugin's own data), or, for a card the person started + * and its subagents, the top card's folder (the person's choice, or its environment's). A subagent's own folder is + * never one by itself: an agent asked for it. A plugin's own card is placed by nothing but an environment. + */ + private placedProjects(id: string, parentSessionId: string | undefined, cwd: string, + environment: { pluginId: string } | undefined): PlacedProject[] { + if (environment) return [{ folder: cwd, pluginId: environment.pluginId }]; + if (parentSessionId === undefined) { + return (this.sessions.get(id)?.extras.ownerPluginId ?? this.startingOwners.get(id)) === undefined ? [{ folder: cwd }] : []; + } + let root: ManagedSession | undefined; + let next: string | undefined = parentSessionId; + for (let depth = 0; next !== undefined && depth < 64; depth++) { + root = this.sessions.get(next); + if (!root) return []; + next = root.metadata.parentSessionId; + } + if (!root || next !== undefined) return []; + if (root.extras.environment) return [{ folder: root.metadata.cwd, pluginId: root.extras.environment.pluginId }]; + return root.extras.ownerPluginId === undefined ? [{ folder: root.metadata.cwd }] : []; + } + private contributed(session: ManagedSession): boolean { return Boolean(session.extras.options) || Boolean(session.extras.environment) || Boolean(session.extras.environmentChoice) || this.policyApplies(session.metadata.provider); @@ -2299,7 +2328,10 @@ export class TerminalManager { } if (decision.apply) { try { - spawn = { ...this.wrapIsolated(id, metadata.provider, decision.profile, spawn, planned, contribution?.accountHome), cwd: spawn.cwd }; + spawn = { ...this.wrapIsolated(id, metadata.provider, decision.profile, spawn, planned, { + ...(contribution?.accountHome ? { accountHome: contribution.accountHome } : {}), + placedProjects: this.placedProjects(id, metadata.parentSessionId, spawn.cwd, environment) + }), cwd: spawn.cwd }; } catch (error) { return refuse(error instanceof Error ? error.message : String(error)); } diff --git a/src/main/services/isolation/AgentIsolation.ts b/src/main/services/isolation/AgentIsolation.ts index a64e160b..c0c82a02 100644 --- a/src/main/services/isolation/AgentIsolation.ts +++ b/src/main/services/isolation/AgentIsolation.ts @@ -5,7 +5,7 @@ import { delimiter, dirname, isAbsolute, join } from "node:path"; import type { ProviderId, SessionIsolation } from "../../../shared/contracts.ts"; import { autoKind, PROFILE_RANK, type LaunchProfile } from "../../../shared/autoMode.ts"; import { LaunchRefusal } from "../launchRefusal.ts"; -import { isolationPaths } from "./isolationPaths.ts"; +import { isolationPaths, type PlacedProject } from "./isolationPaths.ts"; import { validateAccountHome } from "./accountHomeIsolation.ts"; import { seatbeltProfile } from "./seatbelt.ts"; import { bubblewrapArguments, projectHooks, type PathKind } from "./bubblewrap.ts"; @@ -80,6 +80,8 @@ export interface IsolationLaunch { accountHome?: string; /** CanvasTTY's own gateway sockets for this launch (gatewaySocketAddresses), never read from `env`. */ gatewaySockets?: readonly string[]; + /** Folders CanvasTTY itself placed this launch in (an environment's worktree, the person's chosen folder). */ + placedProjects?: readonly PlacedProject[]; /** The launch profile: in plan the project is not writable. */ profile?: LaunchProfile; } @@ -203,6 +205,7 @@ export class AgentIsolation { sessionId: launch.sessionId, ...(granted.length > 0 ? { grantedPrivate: granted } : {}), ...(launch.gatewaySockets ? { gatewaySockets: launch.gatewaySockets } : {}), + ...(launch.placedProjects ? { placedProjects: launch.placedProjects } : {}), ...(launch.profile === "plan" ? { readOnlyProject: true } : {}) }); // `git init` and `git clone` copy git's template, sample hooks included: an empty one writes no hooks. diff --git a/src/main/services/isolation/accountHomeIsolation.ts b/src/main/services/isolation/accountHomeIsolation.ts index 6ed5e3e0..9f3eee6b 100644 --- a/src/main/services/isolation/accountHomeIsolation.ts +++ b/src/main/services/isolation/accountHomeIsolation.ts @@ -10,11 +10,19 @@ function segmentsBelow(root: string, path: string): string[] | null { return rest.split(sep); } +/** A folder path without trailing separators (`/a/b/` names the same folder as `/a/b`); the root stays as it is. */ +function withoutTrailingSeparator(path: string): string { + const trimmed = path.replace(/[\\/]+$/u, ""); + return trimmed === "" || /^[A-Za-z]:$/u.test(trimmed) ? path : trimmed; +} + /** * The CLI home a launch contributor handed this launch inside its own data folder: the value of the provider's own - * home variable (CODEX_HOME, CLAUDE_CONFIG_DIR, GROK_HOME, …) when it is an exact absolute path strictly below the - * contributor's data folder. `dataDir` comes from the host's contributor record, never from the plugin's answer, so a - * plugin can only point at a folder of its own data, and only the one the CLI is actually started with. + * home variable (providerHomeVariables: CODEX_HOME, CLAUDE_CONFIG_DIR, GROK_HOME, HERMES_HOME, KIMI_HOME, + * OPENCODE_CONFIG_DIR, QWEN_HOME; never HOME or XDG_*) when it is an exact absolute path strictly below the + * contributor's data folder (a trailing separator is dropped: `homes/a/` is `homes/a`). `dataDir` comes from the + * host's contributor record, never from the plugin's answer, so a plugin can only point at a folder of its own data, + * and only the one the CLI is actually started with. */ export function contributedAccountHome( provider: ProviderId, @@ -23,8 +31,9 @@ export function contributedAccountHome( value: string ): string | undefined { if (!dataDir || !isAbsolute(dataDir) || !providerHomeVariables(provider).includes(key)) return undefined; - if (!isAbsolute(value) || resolve(value) !== value) return undefined; - return segmentsBelow(resolve(dataDir), value) ? value : undefined; + const folder = withoutTrailingSeparator(value); + if (!isAbsolute(folder) || resolve(folder) !== folder) return undefined; + return segmentsBelow(resolve(dataDir), folder) ? folder : undefined; } /** @@ -39,7 +48,8 @@ export function validateAccountHome( candidate: string, env: Readonly> ): string | undefined { - if (!providerHomeVariables(provider).some((name) => env[name] === candidate)) return undefined; + const started = (value: string | undefined): boolean => typeof value === "string" && withoutTrailingSeparator(value) === candidate; + if (!providerHomeVariables(provider).some((name) => started(env[name]))) return undefined; if (!isAbsolute(candidate) || resolve(candidate) !== candidate) { throw new Error("The selected account home must be an exact absolute folder."); } diff --git a/src/main/services/isolation/bubblewrap.ts b/src/main/services/isolation/bubblewrap.ts index cf71c6d1..e78d3487 100644 --- a/src/main/services/isolation/bubblewrap.ts +++ b/src/main/services/isolation/bubblewrap.ts @@ -93,11 +93,16 @@ export function bubblewrapArguments( // gateway socket and bind its own in its place, receiving other agents' requests. This launch's own temporary // folder is already bound writable above. // A bind takes the host folder as it is, so one over a hidden path would show it again: refused, never mounted. + // Over a writable folder (the project, a CLI home) it would make that read-only and undo the throwaway hooks + // folder above: refused too. Core gateways use dedicated folders, so neither happens with them. const hidden = [...paths.unreadable, ...(launch.runtimeDir ? [launch.runtimeDir] : [])]; + const writable = [...seen, ...paths.writableFiles]; for (const path of paths.socketFolders) { if (exists(path) !== "directory" || seen.has(path)) continue; const covered = hidden.find((secret) => within(secret, path)); if (covered) throw new Error(`The gateway socket folder ${path} contains ${covered}, which the agent may not read.`); + const shadowed = writable.find((folder) => within(folder, path)); + if (shadowed) throw new Error(`The gateway socket folder ${path} contains ${shadowed}, which the agent writes; it is not mounted over it.`); args.push("--ro-bind", path, path); } args.push("--chdir", launch.cwd, "--", launch.command, ...launch.args); diff --git a/src/main/services/isolation/isolationPaths.ts b/src/main/services/isolation/isolationPaths.ts index 041d759a..a4bbc37b 100644 --- a/src/main/services/isolation/isolationPaths.ts +++ b/src/main/services/isolation/isolationPaths.ts @@ -33,10 +33,24 @@ export interface IsolationPathInput { * a CANVASTTY_*_ADDRESS in the launch environment (the person's own or a plugin's) never is. */ gatewaySockets?: readonly string[]; + /** + * Folders the host itself placed this launch in (an environment's worktree, the folder the person chose for the + * card the launch descends from). A project inside plugin data is reopened only when it is inside one of them. + */ + placedProjects?: readonly PlacedProject[]; /** Plan: the project is readable only (the CLI's own folders stay writable). */ readOnlyProject?: boolean; } +/** + * A folder the host placed a launch in. `pluginId`: an environment of that plugin placed it, so it counts only inside + * that plugin's own data folder; without it, the person chose the folder. + */ +export interface PlacedProject { + folder: string; + pluginId?: string; +} + export interface IsolationPaths { /** Writable folders (subpaths), every spelling. */ writable: string[]; @@ -227,12 +241,14 @@ export function isolationPaths(input: IsolationPathInput): IsolationPaths { const privateData = privateAppData(input.userDataPath); const project = input.cwd; // A project inside plugin data (a plugin environment's worktree) stays this launch's project: that exact folder is - // reopened, never its siblings, its plugin's other data or all plugin data. - const pluginData = pathSpellings(join(input.userDataPath, PLUGIN_DATA_FOLDER)); - const projectDepth = Math.max(-1, ...pathSpellings(project).flatMap((candidate) => pluginData - .filter((root) => isWithin(candidate, root)) - .map((root) => relative(root, candidate).split(/[\\/]+/u).filter(Boolean).length))); + // reopened, never its siblings, its plugin's other data or all plugin data. Only when the host placed the launch + // there: a folder an agent asked for (a subagent's cwd) could otherwise be a plugin's account homes. + const pluginDataRoot = join(input.userDataPath, PLUGIN_DATA_FOLDER); + const projectDepth = pluginDataDepth(pathSpellings, pluginDataRoot, project); if (projectDepth >= 0 && projectDepth < 2) throw new Error(`The project ${project} is CanvasTTY's private plugin data.`); + if (projectDepth >= 2 && !(input.placedProjects ?? []).some((placed) => placesProject(pathSpellings, pluginDataRoot, placed, project))) { + throw new Error(`The project ${project} is inside CanvasTTY's private plugin data, and CanvasTTY did not place this launch there. Only a plugin environment's own folder or a folder the person chose is opened inside plugin data.`); + } const grants = [...(input.grantedPrivate ?? []), join(input.userDataPath, "launch-runs", safeSegment(input.sessionId)), ...(projectDepth >= 2 ? [project] : [])]; const trustedOwn = providerFolders(input.provider, {}, hostHome); @@ -302,6 +318,30 @@ export function isolationPaths(input: IsolationPathInput): IsolationPaths { }; } +/** How many folders below plugin data `path` is (0: plugin data itself), in any spelling; -1 when it is not inside. */ +function pluginDataDepth(spellingsOf: (path: string) => string[], pluginDataRoot: string, path: string): number { + const roots = spellingsOf(pluginDataRoot); + return Math.max(-1, ...spellingsOf(path).flatMap((candidate) => roots + .filter((root) => isWithin(candidate, root)) + .map((root) => relative(root, candidate).split(/[\\/]+/u).filter(Boolean).length))); +} + +/** + * `project` is inside a folder the host placed the launch in, and that folder is itself a folder inside one plugin's + * data (never all of it or a whole plugin's data); an environment's folder counts only inside its own plugin's data. + */ +function placesProject(spellingsOf: (path: string) => string[], pluginDataRoot: string, placed: PlacedProject, project: string): boolean { + if (!isAbsolute(placed.folder)) return false; + if (pluginDataDepth(spellingsOf, pluginDataRoot, placed.folder) < 2) return false; + if (placed.pluginId !== undefined) { + // One folder name (PluginManager's `/`), and the placed folder strictly inside it. + if (!/^[^/\\.][^/\\]*$/u.test(placed.pluginId)) return false; + if (pluginDataDepth(spellingsOf, join(pluginDataRoot, placed.pluginId), placed.folder) < 1) return false; + } + const folders = spellingsOf(placed.folder); + return spellingsOf(project).some((candidate) => folders.some((folder) => isWithin(candidate, folder))); +} + /** `path` is `folder` or inside it, by the host's path rules (separators, a drive on Windows). */ function isWithin(path: string, folder: string): boolean { const rest = relative(folder, path); diff --git a/tests/account-home-isolation.test.mjs b/tests/account-home-isolation.test.mjs index cf5aa923..5fa01ff1 100644 --- a/tests/account-home-isolation.test.mjs +++ b/tests/account-home-isolation.test.mjs @@ -191,20 +191,22 @@ test("AgentIsolation.wrap reopens only a real folder inside one plugin's data, n } }); -test("a project inside plugin data is reopened exactly; all of plugin data or a whole plugin's data is never a project", async (t) => { +test("a project inside plugin data that CanvasTTY placed there is reopened exactly; all of plugin data or a whole plugin's data is never a project", async (t) => { const f = await fixture(t); const worktree = join(f.otherData, "worktrees", "one"); const sibling = join(f.otherData, "worktrees", "two"); await Promise.all([worktree, sibling].map((path) => mkdir(path, { recursive: true }))); const base = { provider: "codex", sessionTemp: join(f.temp, "s"), userDataPath: f.userData, sessionId: "s", env: { HOME: f.home } }; - const paths = isolationPaths({ ...base, cwd: worktree }); + // An environment of the plugin that owns the folder placed the launch there (TerminalManager.placedProjects). + const placed = { placedProjects: [{ folder: worktree, pluginId: OTHER }] }; + const paths = isolationPaths({ ...base, cwd: worktree, ...placed }); assert.ok(paths.unreadable.includes(f.pluginData)); assert.deepEqual(paths.readableAgain.filter((path) => path.startsWith(f.pluginData)), [worktree]); assert.ok(paths.writable.includes(worktree)); - const plan = isolationPaths({ ...base, cwd: worktree, readOnlyProject: true }); + const plan = isolationPaths({ ...base, cwd: worktree, readOnlyProject: true, ...placed }); assert.ok(plan.readableAgain.includes(worktree) && !plan.writable.includes(worktree), "plan keeps it read-only"); for (const cwd of [f.pluginData, f.otherData]) { - assert.throws(() => isolationPaths({ ...base, cwd }), /private plugin data/u, cwd); + assert.throws(() => isolationPaths({ ...base, cwd, placedProjects: [{ folder: cwd }] }), /private plugin data/u, cwd); } }); diff --git a/tests/isolation-hardening.test.mjs b/tests/isolation-hardening.test.mjs new file mode 100644 index 00000000..06ca0aaa --- /dev/null +++ b/tests/isolation-hardening.test.mjs @@ -0,0 +1,333 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { AgentControlService, subagentFolder } from "../src/main/services/AgentControlService.ts"; +import { EnvironmentRegistry } from "../src/main/services/EnvironmentRegistry.ts"; +import { AgentIsolation, SANDBOX_EXEC } from "../src/main/services/isolation/AgentIsolation.ts"; +import { contributedAccountHome, validateAccountHome } from "../src/main/services/isolation/accountHomeIsolation.ts"; +import { bubblewrapArguments } from "../src/main/services/isolation/bubblewrap.ts"; +import { isolationPaths } from "../src/main/services/isolation/isolationPaths.ts"; +import { TerminalManager } from "../src/main/services/TerminalManager.ts"; +import { availableRegistry, fakeSpawner } from "./helpers/terminal.mjs"; + +// Plugin data and gateway sockets together: the account home a launch plugin selected stays open, a CANVASTTY_*_ADDRESS +// that did not come from CanvasTTY's own gateways binds nothing, and a folder inside plugin data is a project only +// where CanvasTTY itself placed the launch. Any plugin ids: the host treats every plugin's data the same way. +const ACCOUNTS = "com.example.accounts"; +const ENVIRONMENTS = "com.example.env"; +const posixHost = { skip: process.platform === "win32" ? "bubblewrap and seatbelt paths are POSIX paths" : false }; +const mac = process.platform === "darwin" && existsSync(SANDBOX_EXEC); +const onMac = { skip: mac ? false : "macOS seatbelt (sandbox-exec) only" }; +const at = { x: 0, y: 0 }; +const BWRAP = "/usr/bin/bwrap"; +// The program an environment wraps the agent in; the spawner is fake, so it never runs. +const WRAPPER = process.execPath; + +/** A folder that holds CanvasTTY's userData (like a project at HOME), with an accounts plugin and an environment plugin. */ +async function world(t) { + // Short: Unix socket paths are limited to ~104 bytes, so /tmp rather than macOS's long per-user folder. + const base = await realpath(await mkdtemp(join(process.platform === "win32" ? tmpdir() : "/tmp", "ctty-hard-"))); + t.after(() => rm(base, { recursive: true, force: true })); + const home = join(base, "h"); + const userData = join(base, "u"); + const pluginData = join(userData, "plugin-data"); + const homes = join(pluginData, ACCOUNTS, "homes"); + const selected = join(homes, "acct-1"); + const sibling = join(homes, "acct-2"); + const worktrees = join(pluginData, ENVIRONMENTS, "worktrees"); + const worktree = join(worktrees, "w1"); + const otherWorktree = join(worktrees, "w2"); + const lifecycle = join(userData, "lifecycle", "runtime"); + const project = join(base, "p"); + const temp = join(base, "t"); + for (const dir of [home, join(home, ".ssh"), selected, sibling, worktree, otherWorktree, lifecycle, project, temp, + join(userData, "agent-control")]) await mkdir(dir, { recursive: true }); + await writeFile(join(selected, "auth.json"), "FAKE-SELECTED-TOKEN\n"); + await writeFile(join(sibling, "auth.json"), "FAKE-SIBLING-TOKEN\n"); + await writeFile(join(otherWorktree, "file.txt"), "other worktree\n"); + await writeFile(join(userData, "agent-control", "token-app"), "FAKE-APP-TOKEN\n"); + const env = { HOME: home, PATH: "/usr/bin:/bin" }; + return { base, home, userData, pluginData, homes, selected, sibling, worktrees, worktree, otherWorktree, lifecycle, project, temp, env }; +} + +const linux = (w) => new AgentIsolation({ userDataPath: w.userData, enabled: () => true, platform: "linux", bubblewrapPath: BWRAP, + bubblewrapProbe: () => null, tempRoot: w.temp, hostEnvironment: w.env }); + +/** The last mount whose target is `path` or one of its folders (a later mount on a folder hides earlier ones under it). */ +function mountFor(args, path) { + let found = null; + for (let i = 0; i < args.length; i++) { + const flag = args[i]; + if (flag === "--") break; + let target = null; + let mode = null; + if (flag === "--bind" || flag === "--dev-bind") { target = args[i + 2]; mode = "bind"; i += 2; } + else if (flag === "--ro-bind") { target = args[i + 2]; mode = "ro"; i += 2; } + else if (flag === "--tmpfs") { target = args[i + 1]; mode = "tmpfs"; i += 1; } + else if (flag === "--proc" || flag === "--chdir") { i += 1; continue; } + else continue; + if (path === target || path.startsWith(`${target.replace(/\/+$/u, "")}/`)) found = { target, mode }; + } + return found; +} + +const waitFor = async (predicate, timeoutMs = 5_000) => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error("Condition was not met in time."); +}; + +/** A TerminalManager whose isolation layer is the real Linux one (bubblewrap arguments, the PTY spawner is fake). */ +function manager(t, w, { runtime, environments } = {}) { + const calls = []; + const terminals = new TerminalManager(() => undefined, availableRegistry(), undefined, runtime, true, fakeSpawner(calls)); + t.after(() => terminals.shutdown()); + const layer = linux(w); + // The launch environment comes from this process; the layer gets the fixture's HOME instead (it may put placeholder + // files into the CLI's folders there), and a CLI home only when a launch plugin set one inside the fixture. + const hermetic = (env) => { + const kept = Object.fromEntries(Object.entries(env).filter(([key]) => !/^(XDG_|CLAUDE_CONFIG_DIR$|GROK_HOME$)/u.test(key))); + return { ...kept, HOME: w.home, CODEX_HOME: env.CODEX_HOME?.startsWith(`${w.base}/`) ? env.CODEX_HOME : join(w.home, ".codex") }; + }; + terminals.configureIsolation({ containment: () => true, decide: (input) => layer.decide(input), + wrap: (launch) => layer.wrap({ ...launch, env: hermetic(launch.env) }) }); + if (environments) terminals.configureEnvironments(environments); + const card = (id) => terminals.list().find((session) => session.id === id); + return { terminals, calls, card }; +} + +test("together: the selected account home stays open, a plugin's CANVASTTY_*_ADDRESS binds nothing, the gateway folder is read-only", posixHost, async (t) => { + const w = await world(t); + const runtimeSocket = join(w.lifecycle, "r.sock"); + // A plugin aims a socket address at the accounts plugin's homes: before, its folder was bound writable after the + // tmpfs over plugin data, which reopened every account's sign-in. + const foreign = join(w.homes, "foo.sock"); + const runtime = { + prepareLaunch: () => ({ args: [], environment: { CANVASTTY_RUNTIME_ADDRESS: runtimeSocket, CANVASTTY_RUNTIME_CAPABILITY: "c" }, cleanup() {} }), + currentStatus: () => null + }; + const { terminals, calls, card } = manager(t, w, { runtime }); + // The launch pipeline already refuses CANVASTTY_* names from plugins; the layer must not depend on that. + terminals.configureLaunchPipeline({ + normalizeOptions: (_provider, options) => options, + unavailable: () => [], + forgetSession: async () => undefined, + prepare: async () => ({ ok: true, env: { CODEX_HOME: w.selected, CANVASTTY_FOO_ADDRESS: foreign }, args: [], secrets: [], + envSources: { CODEX_HOME: "Plugin Accounts", CANVASTTY_FOO_ADDRESS: "Plugin Accounts" }, thirdPartyModel: false, + accountHome: w.selected, cleanup: async () => undefined }) + }); + const created = terminals.create({ provider: "codex", profile: "acceptEdits", cwd: w.project, position: at, + launchOptions: { [ACCOUNTS]: { account: "acct-1" } } }); + await waitFor(() => calls.length === 1 || card(created.id).status === "failed"); + assert.equal(card(created.id).failureDetails, null); + const { command, args, options } = calls[0]; + assert.equal(command, BWRAP); + assert.equal(options.env.CODEX_HOME, w.selected); + assert.equal(options.env.CANVASTTY_FOO_ADDRESS, foreign, "the variable itself still reaches the agent"); + // The selected account home: bound back, writable (it is the CLI's own home). + assert.deepEqual(mountFor(args, join(w.selected, "auth.json")), { target: w.selected, mode: "bind" }); + // The plugin's address bound nothing: the other account and the homes folder stay under the tmpfs over plugin data. + assert.deepEqual(mountFor(args, join(w.sibling, "auth.json")), { target: w.pluginData, mode: "tmpfs" }); + assert.deepEqual(mountFor(args, foreign), { target: w.pluginData, mode: "tmpfs" }); + assert.ok(!args.some((arg, i) => (arg === "--bind" || arg === "--ro-bind") && args[i + 2] === w.homes), "the homes folder is never bound"); + // CanvasTTY's own gateway folder: read-only, never writable. + assert.deepEqual(mountFor(args, runtimeSocket), { target: w.lifecycle, mode: "ro" }); + assert.ok(!args.some((arg, i) => arg === "--bind" && args[i + 2] === w.lifecycle)); +}); + +test("a subagent cannot work in CanvasTTY's private data, even when its project (the home folder) contains it", async (t) => { + const w = await world(t); + const refused = /cannot work in .*: it is CanvasTTY's private data/u; + for (const requested of [w.homes, w.selected, join(w.pluginData, ACCOUNTS), w.pluginData, join(w.userData, "agent-control"), w.worktree]) { + assert.match(subagentFolder(w.base, w.base, requested, w.userData).error ?? "", refused, requested); + } + assert.deepEqual(subagentFolder(w.base, w.base, w.project, w.userData), { cwd: w.project }, "the rest of the project is fine"); + assert.deepEqual(subagentFolder(w.base, w.base, join(w.userData, "lifecycle"), w.userData), { cwd: join(w.userData, "lifecycle") }, + "userData outside its private data is the project's like any folder"); + // A project that is an environment's worktree: its own folders, never a whole plugin's data above it. + const sub = join(w.worktree, "src"); + await mkdir(sub); + assert.deepEqual(subagentFolder(w.worktree, w.worktree, "src", w.userData), { cwd: sub }); + assert.match(subagentFolder(join(w.pluginData, ENVIRONMENTS), w.worktree, w.worktree, w.userData).error ?? "", refused, + "a project that is a whole plugin's data does not open its folders"); + + // Through the service: an orchestrator the person started in the folder above userData. + const { terminals } = manager(t, w); + const control = new AgentControlService(terminals, { containment: () => true, userDataPath: w.userData }); + const parent = terminals.create({ provider: "codex", profile: "normal", cwd: w.base, position: at, role: "orchestrator" }); + assert.throws(() => control.spawn({ parentSessionId: parent.id, provider: "codex", cwd: w.homes }), refused); + const child = await control.spawn({ parentSessionId: parent.id, provider: "codex", cwd: w.project }); + assert.equal(child.cwd, w.project); +}); + +test("the layer opens a project inside plugin data only where CanvasTTY placed the launch", posixHost, async (t) => { + const w = await world(t); + const notPlaced = /inside CanvasTTY's private plugin data, and CanvasTTY did not place this launch there/u; + + // The exploit, past the subagent check: an agent's subagent asked for the account homes as its folder. + const { terminals, calls, card } = manager(t, w); + const parent = terminals.create({ provider: "codex", profile: "normal", cwd: w.base, position: at, role: "orchestrator" }); + const exploit = terminals.create({ provider: "codex", profile: "acceptEdits", cwd: w.homes, position: at, role: "subagent", + parentSessionId: parent.id }); + assert.equal(card(exploit.id).status, "failed"); + assert.match(card(exploit.id).failureDetails, notPlaced); + assert.equal(calls.filter((call) => call.command === BWRAP).length, 0, "nothing ran"); + + // The folder the person chose for a card, and its subagents below it: opened (the person's choice). + const chosen = terminals.create({ provider: "codex", profile: "acceptEdits", cwd: w.worktree, position: at, role: "orchestrator" }); + assert.equal(card(chosen.id).failureDetails, null); + const below = join(w.worktree, "src"); + await mkdir(below); + const nested = terminals.create({ provider: "codex", profile: "acceptEdits", cwd: below, position: at, role: "subagent", parentSessionId: chosen.id }); + assert.equal(card(nested.id).failureDetails, null); + const launched = calls.filter((call) => call.command === BWRAP); + assert.equal(launched.length, 2); + assert.deepEqual(mountFor(launched[0].args, join(w.worktree, "f")), { target: w.worktree, mode: "bind" }); + assert.deepEqual(mountFor(launched[0].args, join(w.otherWorktree, "file.txt")), { target: w.pluginData, mode: "tmpfs" }); + + // The pure check: no placement, a placement elsewhere, or one an environment of another plugin claims: refused. + const base = { provider: "codex", cwd: w.worktree, sessionTemp: join(w.temp, "s"), userDataPath: w.userData, sessionId: "s", env: w.env }; + assert.throws(() => isolationPaths(base), notPlaced); + assert.throws(() => isolationPaths({ ...base, placedProjects: [{ folder: w.base }] }), notPlaced, "a project above plugin data places nothing in it"); + assert.throws(() => isolationPaths({ ...base, placedProjects: [{ folder: w.worktree, pluginId: ACCOUNTS }] }), notPlaced, "another plugin's environment"); + assert.throws(() => isolationPaths({ ...base, placedProjects: [{ folder: w.worktree, pluginId: "../com.example.env" }] }), notPlaced); + assert.throws(() => isolationPaths({ ...base, cwd: w.homes, placedProjects: [{ folder: w.worktree, pluginId: ENVIRONMENTS }] }), notPlaced); + const paths = isolationPaths({ ...base, placedProjects: [{ folder: w.worktree, pluginId: ENVIRONMENTS }] }); + assert.deepEqual(paths.readableAgain.filter((path) => path.startsWith(w.pluginData)), [w.worktree]); +}); + +test("an environment's worktree inside its own plugin data still runs inside the layer; one in another plugin's data does not", posixHost, async (t) => { + const w = await world(t); + const environments = (cwd) => new EnvironmentRegistry({ + providers: () => [{ pluginId: ENVIRONMENTS, pluginName: "Env", serviceId: "env", secrets: false, + kinds: [{ kind: "worktree", label: "Worktree", keeps: { launch: true } }] }], + call: async (_pluginId, _serviceId, method, params) => { + const step = method.replace("canvastty.environment.", ""); + if (step === "prepare") return { ref: { id: "w1" }, label: "worktree w1", cwd }; + if (step === "wrap") return { command: WRAPPER, args: ["run", params.command, ...params.args], cwd: params.cwd }; + if (step === "resume") return { ok: true }; + return {}; + }, + secret: async () => null + }); + const create = (terminals) => terminals.create({ provider: "codex", profile: "acceptEdits", cwd: w.project, position: at, + environment: { pluginId: ENVIRONMENTS, kind: "worktree" } }); + + const own = manager(t, w, { environments: environments(w.worktree) }); + const placed = create(own.terminals); + await waitFor(() => own.calls.length === 1 || own.card(placed.id).status === "failed"); + assert.equal(own.card(placed.id).failureDetails, null); + assert.equal(own.card(placed.id).cwd, w.worktree); + const { command, args } = own.calls[0]; + assert.equal(command, BWRAP); + assert.deepEqual(mountFor(args, join(w.worktree, "new.txt")), { target: w.worktree, mode: "bind" }); + assert.deepEqual(mountFor(args, join(w.otherWorktree, "file.txt")), { target: w.pluginData, mode: "tmpfs" }); + assert.deepEqual(mountFor(args, join(w.selected, "auth.json")), { target: w.pluginData, mode: "tmpfs" }); + + // The environment plugin answers with the accounts plugin's homes: not its own data, so the launch is refused. + const foreign = manager(t, w, { environments: environments(w.homes) }); + const refused = create(foreign.terminals); + await waitFor(() => foreign.card(refused.id).status === "failed"); + assert.match(foreign.card(refused.id).failureDetails, /CanvasTTY did not place this launch there/u); + assert.equal(foreign.calls.length, 0); +}); + +test("account homes and socket folders: a trailing separator names the same home; a socket folder over a writable one is refused", posixHost, async (t) => { + const w = await world(t); + const dataDir = join(w.pluginData, ACCOUNTS); + assert.equal(contributedAccountHome("codex", dataDir, "CODEX_HOME", `${w.selected}/`), w.selected); + assert.equal(contributedAccountHome("codex", dataDir, "CODEX_HOME", `${w.selected}//`), w.selected); + assert.equal(validateAccountHome(w.userData, "codex", w.selected, { CODEX_HOME: `${w.selected}/` }), w.selected); + // Only the CLI's own home variable counts: never HOME or XDG_* (OpenCode's is OPENCODE_CONFIG_DIR). + for (const [provider, key] of [["opencode", "XDG_CONFIG_HOME"], ["opencode", "XDG_DATA_HOME"], ["codex", "HOME"]]) { + assert.equal(contributedAccountHome(provider, dataDir, key, w.selected), undefined, `${provider} ${key}`); + } + assert.equal(contributedAccountHome("opencode", dataDir, "OPENCODE_CONFIG_DIR", w.selected), w.selected); + + // A gateway socket folder that holds the writable project would make it read-only and undo its hooks tmpfs. + const work = join(w.base, "work"); + const project = join(work, "p"); + await mkdir(project, { recursive: true }); + const sessionTemp = join(w.temp, "s"); + const paths = isolationPaths({ provider: "codex", cwd: project, sessionTemp, env: w.env, userDataPath: w.userData, sessionId: "s", + gatewaySockets: [join(work, "gw.sock")] }); + const kinds = new Map([[project, "directory"], [work, "directory"], [sessionTemp, "directory"]]); + assert.throws(() => bubblewrapArguments(paths, { command: "/usr/bin/codex", args: [], cwd: project }, (path) => kinds.get(path) ?? null), + /gateway socket folder .*work contains .*p, which the agent writes/u); +}); + +test("seatbelt, for real: account home open, siblings and a foreign socket closed, gateway folder read-only, homes never a project", onMac, async (t) => { + const w = await world(t); + const runtimeSocket = join(w.lifecycle, "r.sock"); + const foreignFolder = join(w.base, "o"); + await mkdir(foreignFolder); + const foreignSocket = join(foreignFolder, "f.sock"); + const servers = [runtimeSocket, foreignSocket].map((path) => { + const server = createServer((socket) => socket.end()); + server.listen(path); + return server; + }); + t.after(() => servers.forEach((server) => server.close())); + await new Promise((resolve) => setTimeout(resolve, 100)); + const layer = new AgentIsolation({ userDataPath: w.userData, enabled: () => true, tempRoot: w.temp, hostEnvironment: w.env }); + const probe = (path, label) => `/usr/bin/python3 -c "import socket +s=socket.socket(socket.AF_UNIX) +try: + s.connect('${path}'); print('${label}-CONNECT-ok') +except Exception: print('${label}-CONNECT-denied')"`; + const wrapped = layer.wrap({ + sessionId: "together", + provider: "codex", + cwd: w.project, + command: "/bin/sh", + args: ["-c", [ + 'cat "$CODEX_HOME/auth.json" >/dev/null && echo OWN-READ-ok', + 'echo actor > "$CODEX_HOME/actor.txt" && echo OWN-WRITE-ok', + `cat ${JSON.stringify(join(w.sibling, "auth.json"))} >/dev/null 2>&1 || echo SIBLING-READ-denied`, + `ls ${JSON.stringify(w.homes)} >/dev/null 2>&1 || echo HOMES-LIST-denied`, + probe(runtimeSocket, "GATEWAY"), + probe(foreignSocket, "FOREIGN"), + `rm -f ${JSON.stringify(runtimeSocket)} 2>/dev/null; [ -S ${JSON.stringify(runtimeSocket)} ] && echo GATEWAY-RM-denied`, + `touch ${JSON.stringify(join(w.lifecycle, "planted"))} 2>/dev/null || echo GATEWAY-WRITE-denied` + ].join("; ")], + env: { ...w.env, CODEX_HOME: w.selected, CANVASTTY_RUNTIME_ADDRESS: runtimeSocket, CANVASTTY_FOO_ADDRESS: foreignSocket }, + profile: "acceptEdits", + accountHome: w.selected, + gatewaySockets: [runtimeSocket] + }); + try { + const result = spawnSync(wrapped.command, wrapped.args, { cwd: w.project, env: wrapped.env, encoding: "utf8", timeout: 20_000 }); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(result.stdout.split("\n").filter(Boolean), [ + "OWN-READ-ok", "OWN-WRITE-ok", "SIBLING-READ-denied", "HOMES-LIST-denied", + "GATEWAY-CONNECT-ok", "FOREIGN-CONNECT-denied", "GATEWAY-RM-denied", "GATEWAY-WRITE-denied" + ]); + assert.equal(await readFile(join(w.selected, "actor.txt"), "utf8"), "actor\n"); + assert.equal(existsSync(join(w.lifecycle, "planted")), false); + } finally { + wrapped.cleanup(); + } + // The exploit's folder as a project (a subagent's cwd, not placed by CanvasTTY): the layer is never set up. + assert.throws(() => layer.wrap({ sessionId: "exploit", provider: "codex", cwd: w.homes, command: "/bin/sh", args: ["-c", "true"], + env: w.env, profile: "acceptEdits" }), /did not place this launch there/u); + // An environment's worktree placed by its own plugin: writable there, nothing else of plugin data readable. + const placed = layer.wrap({ sessionId: "worktree", provider: "codex", cwd: w.worktree, command: "/bin/sh", args: ["-c", [ + "echo x > new.txt && echo WORKTREE-WRITE-ok", + `cat ${JSON.stringify(join(w.otherWorktree, "file.txt"))} >/dev/null 2>&1 || echo OTHER-WORKTREE-denied`, + `cat ${JSON.stringify(join(w.selected, "auth.json"))} >/dev/null 2>&1 || echo ACCOUNT-denied` + ].join("; ")], env: w.env, profile: "acceptEdits", placedProjects: [{ folder: w.worktree, pluginId: ENVIRONMENTS }] }); + try { + const result = spawnSync(placed.command, placed.args, { cwd: w.worktree, env: placed.env, encoding: "utf8", timeout: 20_000 }); + assert.deepEqual(result.stdout.split("\n").filter(Boolean), ["WORKTREE-WRITE-ok", "OTHER-WORKTREE-denied", "ACCOUNT-denied"]); + } finally { + placed.cleanup(); + } +}); From dc96b368584f95cc8fd0200ffc6eb5fdb038ab21 Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:55:29 +0000 Subject: [PATCH 13/28] fix(isolation): keep CanvasTTY's data folder read-only inside a project that contains it When an isolated agent's project contains CanvasTTY's userData folder (a project at the home folder), the whole project was writable, so the app's settings.json and the plugins folder were writable to the agent; only the private parts were hidden. The isolation paths now carry readOnlyFolders (userData when a writable folder contains it) and fixedFolders (its parents inside that folder). Seatbelt denies writes in userData after the project allow, re-allows the writable folders inside it (a selected account home) and denies renaming or replacing the parents. bubblewrap binds each parent onto itself (a mount point cannot be renamed), mounts userData read-only over the project and binds the writable folders inside it again. A project located inside userData is unaffected. --- docs/agent-orchestration.md | 1 + src/main/services/isolation/bubblewrap.ts | 10 ++ src/main/services/isolation/isolationPaths.ts | 31 ++++- src/main/services/isolation/seatbelt.ts | 13 +- tests/isolation-userdata-readonly.test.mjs | 131 ++++++++++++++++++ 5 files changed, 179 insertions(+), 7 deletions(-) create mode 100644 tests/isolation-userdata-readonly.test.mjs diff --git a/docs/agent-orchestration.md b/docs/agent-orchestration.md index be0f8efb..f431300f 100644 --- a/docs/agent-orchestration.md +++ b/docs/agent-orchestration.md @@ -58,6 +58,7 @@ Settings → Agents → Agent isolation (on by default) wraps an agent's whole p - SSH, cloud and package-registry credentials, other CLIs' folders (their credentials) and CanvasTTY's own tokens, secret stores, account homes, launch files and plugins' private data are unreadable, except what this launch was handed (its own control connection, its account home, the one CLI home its launch plugin selected); - no other process can be signalled, no app opened through Launch Services, no Apple events sent (osascript driving Terminal), no preferences written through cfprefsd, no launchd job submitted, and Unix sockets reach only DNS, syslog, the launch's own folder and CanvasTTY's token-checked gateways (not Docker, tmux or an SSH agent); - network and the keychain's services work as before: the CLI reaches its provider and reads its own sign-in. +- when the project contains CanvasTTY's own data folder (a project at the home folder), that folder stays read-only as a whole (its settings, its plugins); only the CLI's own folders inside it (a selected account home) stay writable. Its parent folders inside the project (such as `~/.config`) cannot be renamed or replaced; entries can still be created in them. A project CanvasTTY keeps inside its data folder is writable as usual. The agent gets `CANVASTTY_ISOLATION` explaining the rule, so "Operation not permitted" outside it is understood rather than worked around. macOS uses `sandbox-exec` with a profile generated per launch; Linux uses bubblewrap (`bwrap`) when it is installed (read-only root, the same writable folders, empty tmpfs over what may not be read, its own PID namespace, the user runtime folder hidden). Windows has no layer yet. Where there is no layer (Windows, Linux without bubblewrap) a subagent runs in normal (it asks) and a CLI without an auto mode of its own gets no auto; the card shows the reason. Turning isolation off is the person's opt-in: subagents then keep their profile without the layer. The layer fails closed: when it cannot be set up the launch is refused with the reason, never started without it. diff --git a/src/main/services/isolation/bubblewrap.ts b/src/main/services/isolation/bubblewrap.ts index e78d3487..26ceef07 100644 --- a/src/main/services/isolation/bubblewrap.ts +++ b/src/main/services/isolation/bubblewrap.ts @@ -43,6 +43,16 @@ export function bubblewrapArguments( seen.add(path); args.push("--bind", path, path); } + // CanvasTTY's data folder inside a writable project: its parent folders there become mount points (a mount point + // cannot be renamed: EBUSY), the folder itself read-only over it, then its writable folders bound again. + for (const path of paths.fixedFolders) { + if (exists(path) === "directory") args.push("--bind", path, path); + } + for (const path of paths.readOnlyFolders) { + if (exists(path) !== "directory") continue; + args.push("--ro-bind", path, path); + for (const folder of seen) if (folder !== path && within(folder, path)) args.push("--bind", folder, folder); + } for (const path of paths.writableFiles) { if (exists(path) === "file") args.push("--bind", path, path); } diff --git a/src/main/services/isolation/isolationPaths.ts b/src/main/services/isolation/isolationPaths.ts index a4bbc37b..0dc7049c 100644 --- a/src/main/services/isolation/isolationPaths.ts +++ b/src/main/services/isolation/isolationPaths.ts @@ -54,6 +54,18 @@ export interface PlacedProject { export interface IsolationPaths { /** Writable folders (subpaths), every spelling. */ writable: string[]; + /** + * Read-only as a whole although a writable folder holds it: CanvasTTY's own data folder when the project contains it + * (a project at HOME). Its settings and the plugins it runs are not the agent's to change; the writable folders above + * that sit inside it (an account home, this launch's own folders) stay writable. + */ + readOnlyFolders: string[]; + /** + * The folders between a writable folder and a read-only folder inside it (`~/.config` for `~/.config/canvastty` in a + * project at HOME): what is inside stays writable, but they cannot be renamed or replaced, which would move the + * read-only folder out of its protected path and let a different one take its place. + */ + fixedFolders: string[]; /** Writable single files (with their `.lock` / `.tmp` / backup siblings). */ writableFiles: string[]; /** Folders that may be created (empty) on the way to a writable one: `~/.local/state` for `~/.local/state/opencode`. */ @@ -289,13 +301,20 @@ export function isolationPaths(input: IsolationPathInput): IsolationPaths { join(input.userDataPath, "lifecycle", "runtime"), join(input.userDataPath, "orchestration", "runtime") ]; + const writable = all([ + ...(input.readOnlyProject ? [] : [project]), + input.sessionTemp, + ...ownFolders, + join(home, ".npm"), join(home, ".bun"), join(xdg.cache, "npm"), join(xdg.cache, "bun") + ]); + const userData = pathSpellings(input.userDataPath); + const readOnlyFolders = writable.some((folder) => userData.some((path) => isWithin(path, folder))) ? userData : []; + const fixedFolders = readOnlyFolders.flatMap((path) => writable.filter((folder) => folder !== path && isWithin(path, folder)) + .flatMap((folder) => ancestorsBelow(folder, path))); return { - writable: all([ - ...(input.readOnlyProject ? [] : [project]), - input.sessionTemp, - ...ownFolders, - join(home, ".npm"), join(home, ".bun"), join(xdg.cache, "npm"), join(xdg.cache, "bun") - ]), + writable, + readOnlyFolders, + fixedFolders: [...new Set(fixedFolders)], writableFiles: all(own.files), creatableFolders: all([...ownFolders, join(home, ".npm"), join(home, ".bun")].flatMap((folder) => ancestorsBelow(home, folder))), protectedWrites: all([ diff --git a/src/main/services/isolation/seatbelt.ts b/src/main/services/isolation/seatbelt.ts index 1342435f..e1f0459b 100644 --- a/src/main/services/isolation/seatbelt.ts +++ b/src/main/services/isolation/seatbelt.ts @@ -5,7 +5,8 @@ import type { IsolationPaths } from "./isolationPaths.ts"; * except what is listed, and a later rule wins over an earlier one: * * - writes: nowhere, then the project (every spelling), this launch's own temporary folder, the CLI's own state, - * config and cache folders and the package caches, then again nowhere in the project's git hooks and git config + * config and cache folders and the package caches, then again nowhere in CanvasTTY's own data folder when the project + * contains it (its writable folders inside excepted), the project's git hooks and git config * (a hook runs outside the layer the next time the person commits) and the CLI's own permission settings; * - reads: not of other CLIs' credential folders, SSH/cloud keys and CanvasTTY's own tokens and secret stores, except * what this launch was handed (its control grant, its account home, its plugin launch files); @@ -31,6 +32,16 @@ export function seatbeltProfile(paths: IsolationPaths): string { if (paths.writableFiles.length > 0) { lines.push(`(allow file-write*${paths.writableFiles.map((path) => ` (regex ${regex(`^${escapeRegex(path)}(\\.[^/]*)?$`)})`).join("")})`); } + // CanvasTTY's data folder inside a writable project: read-only again, except the writable folders inside it. + if (paths.readOnlyFolders.length > 0) { + lines.push(`(deny file-write*${paths.readOnlyFolders.map((path) => ` (subpath ${quote(path)})`).join("")})`); + const inside = paths.writable.filter((path) => paths.readOnlyFolders.some((folder) => path.startsWith(`${folder}/`))); + if (inside.length > 0) lines.push(`(allow file-write*${inside.map((path) => ` (subpath ${quote(path)})`).join("")})`); + } + // Its parent folders inside the project: entries can be created in them, but they cannot be renamed or replaced. + if (paths.fixedFolders.length > 0) { + lines.push(`(deny file-write-unlink${paths.fixedFolders.map((path) => ` (literal ${quote(path)})`).join("")})`); + } if (paths.unreadable.length > 0) { lines.push(`(deny file-read* file-write*${paths.unreadable.map((path) => ` (subpath ${quote(path)})`).join("")})`); } diff --git a/tests/isolation-userdata-readonly.test.mjs b/tests/isolation-userdata-readonly.test.mjs new file mode 100644 index 00000000..a250408c --- /dev/null +++ b/tests/isolation-userdata-readonly.test.mjs @@ -0,0 +1,131 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { AgentIsolation } from "../src/main/services/isolation/AgentIsolation.ts"; +import { isolationPaths } from "../src/main/services/isolation/isolationPaths.ts"; +import { seatbeltProfile } from "../src/main/services/isolation/seatbelt.ts"; +import { bubblewrapArguments } from "../src/main/services/isolation/bubblewrap.ts"; + +const mac = process.platform === "darwin" && existsSync("/usr/bin/sandbox-exec"); +const onMac = { skip: mac ? false : "macOS seatbelt (sandbox-exec) only" }; + +/** A fake HOME that holds CanvasTTY's data folder (its settings, a plugin, an account home, a project the app keeps). */ +async function world(t) { + const base = await realpath(await mkdtemp(join(process.platform === "win32" ? tmpdir() : "/tmp", "ctty-ud-test-"))); + t.after(() => rm(base, { recursive: true, force: true })); + const home = join(base, "h"); + const userData = join(home, ".config", "canvastty"); + const temp = join(base, "t"); + const accountHome = join(userData, "account-homes", "a1"); + const appProject = join(userData, "projects", "p"); + for (const dir of [temp, join(home, "src"), join(userData, "plugins", "x"), accountHome, appProject, join(userData, "agent-control")]) { + await mkdir(dir, { recursive: true }); + } + await writeFile(join(userData, "settings.json"), "{\"agentIsolation\":\"on\"}\n"); + await writeFile(join(userData, "plugins", "x", "index.js"), "// plugin\n"); + await writeFile(join(userData, "agent-control", "token-app"), "APP-TOKEN"); + const env = { HOME: home, PATH: "/usr/bin:/bin:/usr/sbin:/sbin", LANG: "en_US.UTF-8" }; + return { base, home, userData, temp, accountHome, appProject, env }; +} + +function pathsFor(w, cwd, env = {}) { + return isolationPaths({ provider: "codex", cwd, sessionTemp: join(w.temp, "s"), env: { ...w.env, ...env }, userDataPath: w.userData, sessionId: "s" }); +} + +test("the paths: a project that contains CanvasTTY's data folder leaves that folder read-only, its account home writable", async (t) => { + const w = await world(t); + const paths = pathsFor(w, w.home, { CODEX_HOME: w.accountHome }); + assert.ok(paths.writable.includes(w.home), "the project stays writable"); + assert.ok(paths.readOnlyFolders.includes(w.userData), "CanvasTTY's data folder is read-only"); + const profile = seatbeltProfile(paths); + const deny = profile.indexOf(`(deny file-write* (subpath "${w.userData}")`); + assert.ok(deny > profile.indexOf(`(subpath "${w.home}")`), "denied after the project is allowed"); + const again = profile.indexOf(`(allow file-write* (subpath "${w.accountHome}"))`); + assert.ok(again > deny, "the CLI's own account home is writable again"); + // Its parent inside the project cannot be renamed away (and back) or replaced. + assert.deepEqual(paths.fixedFolders, [join(w.home, ".config")]); + assert.ok(profile.includes(`(deny file-write-unlink (literal "${join(w.home, ".config")}"))`)); + // A project the app keeps inside its data folder (Even G2's default), or any other project: nothing changes. + assert.deepEqual(pathsFor(w, w.appProject).readOnlyFolders, []); + assert.deepEqual(pathsFor(w, w.appProject).fixedFolders, []); + assert.ok(pathsFor(w, w.appProject).writable.includes(w.appProject)); + assert.deepEqual(pathsFor(w, join(w.home, "src")).readOnlyFolders, []); + // A read-only (plan) project at HOME has nothing to take back. + assert.deepEqual(isolationPaths({ provider: "codex", cwd: w.home, sessionTemp: join(w.temp, "s"), env: w.env, userDataPath: w.userData, + sessionId: "s", readOnlyProject: true }).readOnlyFolders, []); +}); + +test("bubblewrap: CanvasTTY's data folder is mounted read-only over a project at HOME, its account home bound writable again", async (t) => { + const w = await world(t); + const paths = pathsFor(w, w.home, { CODEX_HOME: w.accountHome }); + const kinds = new Map([[w.home, "directory"], [join(w.home, ".config"), "directory"], [w.userData, "directory"], [w.accountHome, "directory"], [join(w.userData, "agent-control"), "directory"]]); + const args = bubblewrapArguments(paths, { command: "/usr/bin/codex", args: [], cwd: w.home }, + // The CLI's permission settings exist (LinuxHostPaths puts a placeholder where one is missing). + (path) => kinds.get(path) ?? (paths.protectedWrites.includes(path) && !path.endsWith(".lock") ? "file" : null)).join(" "); + const project = args.indexOf(`--bind ${w.home} ${w.home}`); + const readOnly = args.indexOf(`--ro-bind ${w.userData} ${w.userData}`); + assert.ok(project >= 0 && readOnly > project, "read-only over the writable project"); + const parent = args.indexOf(`--bind ${join(w.home, ".config")} ${join(w.home, ".config")}`); + assert.ok(parent > project && parent < readOnly, "its parent is a mount point (cannot be renamed), mounted before it"); + assert.ok(args.indexOf(`--bind ${w.accountHome} ${w.accountHome}`, readOnly) > readOnly, "the account home is writable again"); + assert.ok(args.indexOf(`--tmpfs ${join(w.userData, "agent-control")}`) > readOnly, "private data still hidden"); + // A project inside the data folder is bound writable, nothing over it. + const inside = bubblewrapArguments(pathsFor(w, w.appProject), { command: "/usr/bin/codex", args: [], cwd: w.appProject }, + (path) => path === w.appProject || path === w.userData ? "directory" : null).join(" "); + assert.ok(inside.includes(`--bind ${w.appProject} ${w.appProject}`)); + assert.ok(!inside.includes(`--ro-bind ${w.userData} ${w.userData}`)); +}); + +function run(w, cwd, script, env = {}) { + const wrapped = new AgentIsolation({ userDataPath: w.userData, enabled: () => true, tempRoot: w.temp }) + .wrap({ sessionId: "s1", provider: "codex", cwd, command: "/bin/sh", args: ["-c", script], env: { ...w.env, ...env } }); + try { + const result = spawnSync(wrapped.command, wrapped.args, { cwd, env: wrapped.env, encoding: "utf8", timeout: 20_000 }); + return result.stdout.split("\n").filter(Boolean); + } finally { + wrapped.cleanup(); + } +} + +test("seatbelt, for real: a project at HOME cannot change CanvasTTY's settings or plugins; ordinary work still succeeds", onMac, async (t) => { + const w = await world(t); + const ud = w.userData; + const lines = run(w, w.home, [ + 'echo ok > notes.txt && echo W-project-ok', + 'mkdir -p src/deep && echo ok > src/deep/f && echo W-subfolder-ok', + `echo '{}' > "${ud}/settings.json" 2>/dev/null || echo W-settings-denied`, + `echo x > "${ud}/settings.json.tmp" 2>/dev/null || echo W-settings-tmp-denied`, + `echo x > "${ud}/plugins/x/index.js" 2>/dev/null || echo W-plugin-denied`, + `mkdir "${ud}/plugins/y" 2>/dev/null || echo W-new-plugin-denied`, + `rm -rf "${ud}/plugins" 2>/dev/null; [ -f "${ud}/plugins/x/index.js" ] && echo RM-plugins-denied`, + `mv "${ud}" "$HOME/moved" 2>/dev/null || echo MV-userdata-denied`, + 'mv "$HOME/.config" "$HOME/moved" 2>/dev/null || echo MV-parent-denied', + 'rm -rf "$HOME/.config" 2>/dev/null; [ -d "$HOME/.config" ] && echo RM-parent-denied', + 'mkdir "$HOME/.config/other-app" && echo x > "$HOME/.config/other-app/f" && echo W-parent-entry-ok', + `echo x > "${ud}/projects/p/f" 2>/dev/null || echo W-app-project-denied`, + `cat "${ud}/settings.json" >/dev/null && echo R-settings-ok`, + `cat "${ud}/agent-control/token-app" 2>/dev/null || echo R-token-denied`, + 'echo s > "$CODEX_HOME/auth.json" && echo W-account-home-ok' + ].join("; "), { CODEX_HOME: w.accountHome }); + assert.deepEqual(lines, ["W-project-ok", "W-subfolder-ok", "W-settings-denied", "W-settings-tmp-denied", "W-plugin-denied", "W-new-plugin-denied", + "RM-plugins-denied", "MV-userdata-denied", "MV-parent-denied", "RM-parent-denied", "W-parent-entry-ok", "W-app-project-denied", "R-settings-ok", "R-token-denied", "W-account-home-ok"]); + assert.equal(await readFile(join(ud, "settings.json"), "utf8"), "{\"agentIsolation\":\"on\"}\n"); + assert.equal(await readFile(join(ud, "plugins", "x", "index.js"), "utf8"), "// plugin\n"); + assert.equal(existsSync(join(ud, "plugins", "y")), false); + assert.equal(existsSync(join(w.home, "moved")), false); + assert.equal(await readFile(join(w.home, "notes.txt"), "utf8"), "ok\n"); +}); + +test("seatbelt, for real: a project CanvasTTY keeps inside its data folder stays writable", onMac, async (t) => { + const w = await world(t); + const lines = run(w, w.appProject, [ + 'echo ok > f && mkdir -p sub && echo ok > sub/g && echo W-project-ok', + `echo '{}' > "${w.userData}/settings.json" 2>/dev/null || echo W-settings-denied` + ].join("; ")); + assert.deepEqual(lines, ["W-project-ok", "W-settings-denied"]); + assert.equal(await readFile(join(w.appProject, "sub", "g"), "utf8"), "ok\n"); +}); From 8eb366e37c2a31a30d42e681b20704dc76d71058 Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:55:29 +0000 Subject: [PATCH 14/28] fix(isolation): write-protect the git files that redirect config and worktrees Inside the isolation layer only a repository's hooks, info/attributes and an existing .git/config were protected. .git/commondir (where git reads the shared config and hooks from), .git/config.worktree and the linked worktrees' records in .git/worktrees stayed writable, and the path-based rules could be sidestepped by renaming .git, .git/hooks or .git/info away and back. Seatbelt now denies writes to commondir, config.worktree and worktrees/ in any repository of the project, existing or not, and denies renaming or removing .git, .git/hooks and .git/info. For an existing repository the paths list config.worktree and worktrees/ (Linux mounts them read-only, with neutral placeholders when missing) and an existing commondir; on Linux the project's .git is bound onto itself so it cannot be renamed. Objects, refs, the index, logs and HEAD stay writable. --- docs/agent-orchestration.md | 2 + src/main/services/isolation/bubblewrap.ts | 4 + src/main/services/isolation/isolationPaths.ts | 17 +- src/main/services/isolation/seatbelt.ts | 8 +- tests/isolation-git-metadata.test.mjs | 160 ++++++++++++++++++ 5 files changed, 185 insertions(+), 6 deletions(-) create mode 100644 tests/isolation-git-metadata.test.mjs diff --git a/docs/agent-orchestration.md b/docs/agent-orchestration.md index f431300f..b46943fd 100644 --- a/docs/agent-orchestration.md +++ b/docs/agent-orchestration.md @@ -68,6 +68,8 @@ Claude Code keeps its sign-in in the macOS login keychain and rewrites that file After an isolated session ends, is closed or is restored after a quit, CanvasTTY audits the repositories under its folder whose git folder changed: config keys and files git would execute outside the layer (`core.hooksPath`, filter and diff drivers, `fsmonitor`, `!` aliases, hooks, `info/attributes`) are listed in a notice, and the person can neutralize them or keep them. See [Installing and security](installing-and-security.md#agent-protection-layers). +Inside the layer, the files git reads its config and paths from are not writable in any repository of the project, existing or not: `.git/commondir`, `.git/config.worktree` and the linked worktrees' records in `.git/worktrees/` (so `git worktree add` fails there); objects, refs, the index, logs and `HEAD` stay writable, so add, commit, checkout, branch and stash work. A repository's `.git`, `.git/hooks` and `.git/info` folders cannot be renamed or removed inside the layer, so these rules cannot be sidestepped by moving the folder away and back. On Linux these are mounted read-only for the project's own existing repository; a `commondir` that does not exist yet cannot be mounted (an empty one would stop git), and repositories created during the session are left to the audit above. + ## Plugin environments A plugin environment declares in its manifest what it keeps (`keeps`): `launch` (the launch's arguments and environment reach the agent unchanged, so CanvasTTY's hooks and the profile's per-run settings work there), `isolated` (it does not run on this computer's files: a container or a remote host) and `confines`. Without `launch`, any profile but normal is refused and the card says that base protection and CanvasTTY's hooks do not reach the agent there. An `isolated` environment is not wrapped again (its own boundary applies; the card says so); a local one (a worktree) runs inside the layer with the worktree as its project. diff --git a/src/main/services/isolation/bubblewrap.ts b/src/main/services/isolation/bubblewrap.ts index 26ceef07..c0950696 100644 --- a/src/main/services/isolation/bubblewrap.ts +++ b/src/main/services/isolation/bubblewrap.ts @@ -67,6 +67,10 @@ export function bubblewrapArguments( for (const path of paths.readableAgain) { if (exists(path)) args.push(seen.has(path) ? "--bind" : "--ro-bind", path, path); } + // The project's repository folder becomes a mount point before anything is mounted inside it: a mount point cannot + // be renamed (EBUSY), so `.git` cannot be moved away from the read-only mounts below and back. + const repository = join(launch.cwd, ".git"); + if ([...seen].some((folder) => within(launch.cwd, folder)) && exists(repository) === "directory") args.push("--bind", repository, repository); // Last, over every bind above (a rebound home included): the read-only files and the project's git hooks. for (const path of [...paths.gitHooks, ...(paths.protectedDirectories ?? []), ...paths.protectedWrites]) { const kind = exists(path); diff --git a/src/main/services/isolation/isolationPaths.ts b/src/main/services/isolation/isolationPaths.ts index 0dc7049c..b9439014 100644 --- a/src/main/services/isolation/isolationPaths.ts +++ b/src/main/services/isolation/isolationPaths.ts @@ -70,9 +70,9 @@ export interface IsolationPaths { writableFiles: string[]; /** Folders that may be created (empty) on the way to a writable one: `~/.local/state` for `~/.local/state/opencode`. */ creatableFolders: string[]; - /** Inside writable folders, still not writable: the repository's git config, the CLIs' own permission settings. */ + /** Inside writable folders, still not writable: the repository's git config and `commondir`, the CLIs' own permission settings. */ protectedWrites: string[]; - /** Permission-bearing agent directories; empty directories are neutral, unlike a fabricated build.md. */ + /** Permission-bearing agent directories and the repository's linked-worktree records; empty directories are neutral. */ protectedDirectories?: string[]; /** The project's git hook folders: only `*.sample` files may be written there (what `git init` creates). */ gitHooks: string[]; @@ -292,6 +292,8 @@ export function isolationPaths(input: IsolationPathInput): IsolationPaths { if (!withinGrant && !ownAccount) throw new Error(`CLI home ${folder} overlaps protected CanvasTTY data.`); } } + const gitDir = join(project, ".git"); + const repository = existsSync(join(gitDir, "config")); const configSources = input.provider === "opencode" ? openCodeConfigPaths(input.env, input.cwd) : { jsonFiles: [], agentDirectories: [] }; const socketFolders = [ ...(input.gatewaySockets ?? []).filter((path) => isAbsolute(path)).map((path) => dirname(path)), @@ -319,13 +321,18 @@ export function isolationPaths(input: IsolationPathInput): IsolationPaths { creatableFolders: all([...ownFolders, join(home, ".npm"), join(home, ".bun")].flatMap((folder) => ancestorsBelow(home, folder))), protectedWrites: all([ // A repository that exists keeps its config (hooksPath, fsmonitor, filters run code when the person uses git - // later, outside the layer); a new one may be created, which writes its config. - ...(existsSync(join(project, ".git", "config")) ? [join(project, ".git", "config"), join(project, ".git", "config.lock")] : []), + // later, outside the layer), and its per-worktree config; a new one may be created, which writes its config. + ...(repository ? [join(gitDir, "config"), join(gitDir, "config.lock"), join(gitDir, "config.worktree"), join(gitDir, "config.worktree.lock")] : []), + // Where git reads the shared config and hooks from. Protected where it exists: an empty placeholder (Linux) would + // stop git from working; seatbelt also denies creating it (projectRoots). + ...(existsSync(join(gitDir, "commondir")) ? [join(gitDir, "commondir")] : []), ...(input.provider === "codex" ? ownFolders.map((folder) => join(folder, "config.toml")) : []), ...(input.provider === "claude" ? ownFolders.flatMap((folder) => [join(folder, "settings.json"), join(folder, "settings.local.json")]) : []), ...configSources.jsonFiles ]), - protectedDirectories: all(configSources.agentDirectories.flatMap((folder) => [join(folder, "agent"), join(folder, "agents")])), + // Linked worktrees' records (their git folder, `commondir` and config): what git reads when the person works there. + protectedDirectories: all([...configSources.agentDirectories.flatMap((folder) => [join(folder, "agent"), join(folder, "agents")]), + ...(repository ? [join(gitDir, "worktrees")] : [])]), gitHooks: all([join(project, ".git", "hooks")]), projectRoots: input.readOnlyProject ? [] : all([project]), unreadable: all([...sensitive, ...others, ...privateData]), diff --git a/src/main/services/isolation/seatbelt.ts b/src/main/services/isolation/seatbelt.ts index e1f0459b..166ef8f5 100644 --- a/src/main/services/isolation/seatbelt.ts +++ b/src/main/services/isolation/seatbelt.ts @@ -64,8 +64,14 @@ export function seatbeltProfile(paths: IsolationPaths): string { lines.push(`(deny file-write*${hookRules.map((pattern) => ` (regex ${regex(pattern)})`).join("")})`); lines.push(`(allow file-write*${hookRules.map((pattern) => ` (regex ${regex(`${pattern}[^/]+\\.sample$`)})`).join("")})`); } + // Also what git reads its config and paths from, existing or not: `commondir`, `config.worktree` and the linked + // worktrees' records (`worktrees/`). Objects, refs, the index, logs and HEAD stay writable: add, commit, checkout work. if (paths.projectRoots.length > 0) { - lines.push(`(deny file-write*${paths.projectRoots.map((path) => ` (regex ${regex(`^${escapeRegex(path)}/(.*/)?\\.git/info/attributes$`)})`).join("")})`); + const files = "(info/attributes|commondir|config\\.worktree(\\.lock)?|worktrees(/.*)?)"; + lines.push(`(deny file-write*${paths.projectRoots.map((path) => ` (regex ${regex(`^${escapeRegex(path)}/(.*/)?\\.git/${files}$`)})`).join("")})`); + // These rules match by path: a `.git`, `hooks` or `info` folder renamed away and back would carry changes past + // them. They cannot be renamed or removed; entries inside them can still be created where allowed. + lines.push(`(deny file-write-unlink${paths.projectRoots.map((path) => ` (regex ${regex(`^${escapeRegex(path)}/(.*/)?\\.git(/hooks|/info)?$`)})`).join("")})`); } const protectedPaths = [...paths.protectedWrites, ...(paths.protectedDirectories ?? [])]; if (protectedPaths.length > 0) { diff --git a/tests/isolation-git-metadata.test.mjs b/tests/isolation-git-metadata.test.mjs new file mode 100644 index 00000000..1972ad38 --- /dev/null +++ b/tests/isolation-git-metadata.test.mjs @@ -0,0 +1,160 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync, statSync } from "node:fs"; +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { AgentIsolation } from "../src/main/services/isolation/AgentIsolation.ts"; +import { isolationPaths } from "../src/main/services/isolation/isolationPaths.ts"; +import { seatbeltProfile } from "../src/main/services/isolation/seatbelt.ts"; +import { bubblewrapArguments } from "../src/main/services/isolation/bubblewrap.ts"; +import { LinuxHostPaths } from "../src/main/services/isolation/linuxHostPaths.ts"; + +const mac = process.platform === "darwin" && existsSync("/usr/bin/sandbox-exec"); +const onMac = { skip: mac ? false : "macOS seatbelt (sandbox-exec) only" }; +const hasGit = spawnSync("git", ["--version"]).status === 0; +const withGit = { skip: process.platform === "win32" ? "POSIX paths" : hasGit ? false : "git is not installed" }; +const GIT_IDENTITY = "-c user.email=a@b -c user.name=n"; + +async function world(t) { + const base = await realpath(await mkdtemp(join(process.platform === "win32" ? tmpdir() : "/tmp", "ctty-git-test-"))); + t.after(() => rm(base, { recursive: true, force: true })); + const home = join(base, "h"); + const userData = join(base, "u"); + const project = join(base, "p"); + const temp = join(base, "t"); + const template = join(base, "tpl"); + for (const dir of [home, userData, project, temp, template]) await mkdir(dir, { recursive: true }); + const env = { HOME: home, PATH: "/usr/bin:/bin:/usr/sbin:/sbin", LANG: "en_US.UTF-8", GIT_CONFIG_NOSYSTEM: "1" }; + return { base, home, userData, project, temp, template, env }; +} + +function git(w, ...args) { + const result = spawnSync("git", args, { cwd: w.project, env: { ...w.env, GIT_TEMPLATE_DIR: w.template }, encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); + return result.stdout; +} + +function pathsFor(w, options = {}) { + return isolationPaths({ provider: "codex", cwd: w.project, sessionTemp: join(w.temp, "s"), env: w.env, userDataPath: w.userData, sessionId: "s", ...options }); +} + +test("the paths: an existing repository's per-worktree config, commondir and worktree records are protected", withGit, async (t) => { + const w = await world(t); + const gitDir = join(w.project, ".git"); + // No repository yet: nothing to protect by path (seatbelt's pattern still covers what `git init` makes). + const fresh = pathsFor(w); + assert.ok(!fresh.protectedWrites.some((path) => path.startsWith(`${gitDir}/`))); + assert.match(seatbeltProfile(fresh), /\\\.git\/\(info\/attributes\|commondir\|config\\\.worktree\(\\\.lock\)\?\|worktrees\(\/\.\*\)\?\)\$/u); + git(w, "init", "-q"); + const paths = pathsFor(w); + for (const file of ["config", "config.worktree", "config.worktree.lock"]) assert.ok(paths.protectedWrites.includes(join(gitDir, file)), file); + assert.ok(!paths.protectedWrites.includes(join(gitDir, "commondir")), "a missing commondir gets no placeholder: git would refuse an empty one"); + assert.ok(paths.protectedDirectories.includes(join(gitDir, "worktrees"))); + await writeFile(join(gitDir, "commondir"), ".\n"); + assert.ok(pathsFor(w).protectedWrites.includes(join(gitDir, "commondir")), "an existing one is protected"); + // Objects, refs, the index, logs and HEAD stay writable. + for (const name of ["objects", "refs", "index", "logs", "HEAD", "packed-refs", "ORIG_HEAD"]) { + const path = join(gitDir, name); + assert.ok(![...paths.protectedWrites, ...paths.protectedDirectories].some((p) => path === p || path.startsWith(`${p}/`)), name); + } +}); + +test("bubblewrap: the records are mounted read-only; a missing config.worktree and worktrees folder get neutral placeholders", withGit, async (t) => { + const w = await world(t); + git(w, "init", "-q"); + const gitDir = join(w.project, ".git"); + const kind = (path) => { + try { const stat = statSync(path); return stat.isDirectory() ? "directory" : stat.isFile() ? "file" : null; } catch { return null; } + }; + const paths = pathsFor(w); + const hostPaths = new LinuxHostPaths(); + const release = hostPaths.prepare(paths); + try { + assert.equal(await readFile(join(gitDir, "config.worktree"), "utf8"), "", "an empty config.worktree means no per-worktree config"); + assert.equal(kind(join(gitDir, "worktrees")), "directory"); + assert.equal(existsSync(join(gitDir, "commondir")), false, "no commondir placeholder"); + // The person's git outside the layer keeps working with them in place. + git(w, "status", "--short"); + assert.equal(git(w, "rev-parse", "--git-common-dir").trim(), ".git"); + const args = bubblewrapArguments(paths, { command: "/usr/bin/codex", args: [], cwd: w.project }, kind).join(" "); + const project = args.indexOf(`--bind ${w.project} ${w.project}`); + const repository = args.indexOf(`--bind ${gitDir} ${gitDir}`); + assert.ok(repository > project, ".git is a mount point of its own: it cannot be renamed away and back"); + for (const path of [join(gitDir, "config"), join(gitDir, "config.worktree"), join(gitDir, "worktrees")]) { + assert.ok(args.indexOf(`--ro-bind ${path} ${path}`) > repository, path); + } + } finally { + release(); + } + assert.equal(existsSync(join(gitDir, "config.worktree")), false, "placeholders removed"); + assert.equal(existsSync(join(gitDir, "worktrees")), false); + // An existing commondir is mounted read-only. + await writeFile(join(gitDir, "commondir"), ".\n"); + const placeholders = new Map([[join(gitDir, "config.worktree"), "file"], [join(gitDir, "worktrees"), "directory"]]); + const withCommon = bubblewrapArguments(pathsFor(w), { command: "/usr/bin/codex", args: [], cwd: w.project }, + (path) => kind(path) ?? placeholders.get(path) ?? null).join(" "); + assert.ok(withCommon.includes(`--ro-bind ${join(gitDir, "commondir")} ${join(gitDir, "commondir")}`)); +}); + +function run(w, script) { + const wrapped = new AgentIsolation({ userDataPath: w.userData, enabled: () => true, tempRoot: w.temp }) + .wrap({ sessionId: "s1", provider: "codex", cwd: w.project, command: "/bin/sh", args: ["-c", script], env: w.env }); + try { + const result = spawnSync(wrapped.command, wrapped.args, { cwd: w.project, env: wrapped.env, encoding: "utf8", timeout: 30_000 }); + return result.stdout.split("\n").filter(Boolean); + } finally { + wrapped.cleanup(); + } +} + +const WORKFLOW = [ + `git init -q . && echo a > a.txt && git add a.txt && git ${GIT_IDENTITY} commit -q -m one && echo GIT-COMMIT-ok`, + `git checkout -q -b x && echo b > b.txt && git add b.txt && git ${GIT_IDENTITY} commit -q -m two && git checkout -q - && git branch -q y && echo GIT-BRANCH-ok`, + `echo c >> a.txt && git ${GIT_IDENTITY} stash -q && git ${GIT_IDENTITY} stash pop -q && git ${GIT_IDENTITY} commit -q -am three && echo GIT-STASH-ok` +]; +const PROTECTED = [ + 'echo .. > .git/commondir 2>/dev/null || echo W-commondir-denied', + '(printf "[core]\\n\\thooksPath = /tmp\\n" > .git/config.worktree) 2>/dev/null || echo W-config-worktree-denied', + 'mkdir .git/worktrees 2>/dev/null || echo W-worktrees-denied', + 'git worktree add -q wt 2>/dev/null || echo GIT-WORKTREE-ADD-denied', + `git init -q deep/n && (echo .. > deep/n/.git/commondir) 2>/dev/null || echo W-nested-commondir-denied`, + 'mv .git moved 2>/dev/null || echo MV-git-denied', + 'mkdir -p .git/hooks .git/info && mv .git/hooks .git/h2 2>/dev/null || echo MV-hooks-denied', + 'mv .git/info .git/i2 2>/dev/null || echo MV-info-denied', + 'mv deep/n/.git deep/n/moved 2>/dev/null || echo MV-nested-git-denied' +]; + +test("seatbelt, for real: init, add, commit, checkout, branch and stash work; git's pointers and worktree records cannot be created", onMac, async (t) => { + const w = await world(t); + const lines = run(w, [...WORKFLOW, ...PROTECTED, 'git log --oneline | wc -l | tr -d " "'].join("; ")); + assert.deepEqual(lines, ["GIT-COMMIT-ok", "GIT-BRANCH-ok", "GIT-STASH-ok", "W-commondir-denied", "W-config-worktree-denied", "W-worktrees-denied", + "GIT-WORKTREE-ADD-denied", "W-nested-commondir-denied", "MV-git-denied", "MV-hooks-denied", "MV-info-denied", "MV-nested-git-denied", "2"]); + for (const path of [".git/commondir", ".git/config.worktree", ".git/worktrees", "deep/n/.git/commondir", "moved", ".git/h2", ".git/i2"]) { + assert.equal(existsSync(join(w.project, path)), false, path); + } +}); + +test("seatbelt, for real: an existing repository's records stay as they are; ordinary work continues", onMac, async (t) => { + const w = await world(t); + git(w, "init", "-q"); + await writeFile(join(w.project, "a.txt"), "a\n"); + git(w, "add", "a.txt"); + git(w, "-c", "user.email=a@b", "-c", "user.name=n", "commit", "-q", "-m", "one"); + git(w, "worktree", "add", "-q", join(w.base, "linked")); + await writeFile(join(w.project, ".git", "config.worktree"), "[user]\n\tname = kept\n"); + const record = join(w.project, ".git", "worktrees", "linked"); + const before = await readFile(join(record, "gitdir"), "utf8"); + const lines = run(w, [ + `echo b > b.txt && git add b.txt && git ${GIT_IDENTITY} commit -q -m two && git checkout -q -b x && echo GIT-ok`, + 'echo /tmp/x > .git/worktrees/linked/gitdir 2>/dev/null || echo W-record-denied', + 'echo .. > .git/worktrees/linked/commondir 2>/dev/null || echo W-record-commondir-denied', + 'rm -rf .git/worktrees 2>/dev/null; [ -d .git/worktrees/linked ] && echo RM-worktrees-denied', + 'echo x >> .git/config.worktree 2>/dev/null || echo W-config-worktree-denied' + ].join("; ")); + assert.deepEqual(lines, ["GIT-ok", "W-record-denied", "W-record-commondir-denied", "RM-worktrees-denied", "W-config-worktree-denied"]); + assert.equal(await readFile(join(record, "gitdir"), "utf8"), before); + assert.equal(await readFile(join(w.project, ".git", "config.worktree"), "utf8"), "[user]\n\tname = kept\n"); + assert.equal(git(w, "rev-parse", "--abbrev-ref", "HEAD").trim(), "x"); +}); From a051042de437f1fb7af8c1548a1591acbe3547f8 Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:40:35 +0000 Subject: [PATCH 15/28] fix(safety): keep CanvasTTY-private data private under broad projects Base protection treated anything inside the working folder (and the agent's config folder) as the agent's own before it checked CanvasTTY's private data. With the project at the home folder, or at any folder that holds the app's userData, an agent's shell command or file tool could read or overwrite provider-secrets.bin, the control token, other account homes and the gateway records. A project in a temporary folder likewise reopened the gateways' socket folders. A folder is now the agent's own only below the private data: a private path or socket folder inside the project or config folder stays private. A folder inside a private path (the account home the agent was launched with) is still its own, and its neighbours are not. A recursive walk of a folder in the app's data that holds private paths is refused wherever the project is. The per-card Claude Code draft folders (/claude-draft) are listed as private data too; nothing creates them yet. --- docs/plugins.md | 2 +- docs/plugins.zh-CN.md | 2 +- src/main/services/safety/baseProtection.ts | 7 ++- src/main/services/safety/commandFacts.ts | 22 +++++-- tests/base-protection-app-private.test.mjs | 68 ++++++++++++++++++++++ 5 files changed, 92 insertions(+), 9 deletions(-) diff --git a/docs/plugins.md b/docs/plugins.md index 3cb327cf..c8772125 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -407,7 +407,7 @@ A service may run another browser engine for agents' background tabs, for exampl Two safety parts are built in and need no plugin: -- **Base protection** (Settings → Agents, on by default; the person can turn it off) denies, through the same hook, sudo and other elevation, piping downloaded or generated text into a shell, download-and-run, disk and format commands, fork bombs, and writing or deleting outside the working folder: the home folder, other projects and `/tmp` included, and deleting the working folder itself. An agent's own plan and memory folders (`~/.claude/plans`, `~/.claude/projects//memory`, and the same inside the run's `CLAUDE_CONFIG_DIR`) are not "outside". It also denies any use of CanvasTTY's own private data (from the app's userData folder: the agent-control token and descriptor, the gateways' connection records and sockets, the secret stores, account homes; and the control/runtime socket folders under the temporary folder), by any program, interpreter one-liners and socket clients included; the reason points the model at an **Orchestrator** launch and the `canvastty_agents` tools. The bundled control CLI may name its descriptor. It only ever denies; each reason tells the model what to do instead (a write to `/tmp` suggests a scratch folder inside the project). +- **Base protection** (Settings → Agents, on by default; the person can turn it off) denies, through the same hook, sudo and other elevation, piping downloaded or generated text into a shell, download-and-run, disk and format commands, fork bombs, and writing or deleting outside the working folder: the home folder, other projects and `/tmp` included, and deleting the working folder itself. An agent's own plan and memory folders (`~/.claude/plans`, `~/.claude/projects//memory`, and the same inside the run's `CLAUDE_CONFIG_DIR`) are not "outside". It also denies any use of CanvasTTY's own private data (from the app's userData folder: the agent-control token and descriptor, the gateways' connection records and sockets, the secret stores, account homes; and the control/runtime socket folders under the temporary folder), even when the working folder contains it (a project at the home folder), by any program, interpreter one-liners and socket clients included; the reason points the model at an **Orchestrator** launch and the `canvastty_agents` tools. The bundled control CLI may name its descriptor. It only ever denies; each reason tells the model what to do instead (a write to `/tmp` suggests a scratch folder inside the project). - **Secret redaction**: every text CanvasTTY hands from one agent to another (`observe_agent`, `wait_for_agent`, `get_agent_result`, the control CLI's `screen`, `result` and failure details) is masked: provider keys CanvasTTY holds, launch `secretEnv` values, values a service registered with `redaction.register`, also when the terminal wrapped them over lines, plus common key shapes (`sk-…`, GitHub, Slack, AWS, Google, JWT, `Bearer …`, `"apiKey": "…"`, PEM private keys, long random runs). Plugin tool answers, `screen` in session events, card badges and card action messages are masked the same way. host.onStorageChange(listener) notifies every live contribution of the same plugin — canvases, HOME widgets, and separate windows — of writes made through host.storage.set, avoiding polling when a plugin coordinates several surfaces. diff --git a/docs/plugins.zh-CN.md b/docs/plugins.zh-CN.md index 675b426c..abccc9f9 100644 --- a/docs/plugins.zh-CN.md +++ b/docs/plugins.zh-CN.md @@ -383,7 +383,7 @@ interface PluginSessionEvent { 两项安全功能内置,无需插件: -- **基础保护**(设置 → Agents → Base protection,默认开启;用户可以关闭)通过同一个 hook 拒绝:sudo 及其他提权、把下载或生成的文本管道给 shell、下载后直接运行、磁盘和格式化命令、fork 炸弹,以及在工作文件夹之外写入或删除(包括主目录、其他项目和 `/tmp`),以及删除工作文件夹本身。agent 自己的计划和记忆文件夹(`~/.claude/plans`、`~/.claude/projects//memory`,以及本次运行 `CLAUDE_CONFIG_DIR` 中的相同位置)不算"外部"。它还会拒绝任何程序(包括解释器单行命令和套接字客户端)使用 CanvasTTY 自己的私有数据(来自应用的 userData 文件夹:agent-control 令牌与描述文件、各网关的连接记录与套接字、密钥存储、账户主目录;以及临时文件夹下的控制/运行时套接字文件夹);拒绝原因会引导模型改用 **Orchestrator** 启动和 `canvastty_agents` 工具。内置控制 CLI 可以引用它的描述文件。它只会拒绝;每条原因都告诉模型应当改做什么(写入 `/tmp` 时建议在项目内建立临时文件夹)。 +- **基础保护**(设置 → Agents → Base protection,默认开启;用户可以关闭)通过同一个 hook 拒绝:sudo 及其他提权、把下载或生成的文本管道给 shell、下载后直接运行、磁盘和格式化命令、fork 炸弹,以及在工作文件夹之外写入或删除(包括主目录、其他项目和 `/tmp`),以及删除工作文件夹本身。agent 自己的计划和记忆文件夹(`~/.claude/plans`、`~/.claude/projects//memory`,以及本次运行 `CLAUDE_CONFIG_DIR` 中的相同位置)不算"外部"。它还会拒绝任何程序(包括解释器单行命令和套接字客户端)使用 CanvasTTY 自己的私有数据(来自应用的 userData 文件夹:agent-control 令牌与描述文件、各网关的连接记录与套接字、密钥存储、账户主目录;以及临时文件夹下的控制/运行时套接字文件夹),即使工作文件夹包含它们(例如项目就是主目录);拒绝原因会引导模型改用 **Orchestrator** 启动和 `canvastty_agents` 工具。内置控制 CLI 可以引用它的描述文件。它只会拒绝;每条原因都告诉模型应当改做什么(写入 `/tmp` 时建议在项目内建立临时文件夹)。 - **密钥遮蔽**:CanvasTTY 从一个 agent 交给另一个 agent 的所有文本(`observe_agent`、`wait_for_agent`、`get_agent_result`,以及 control CLI 的 `screen`、`result` 和失败详情)都会被遮蔽:CanvasTTY 保存的服务商密钥、启动时的 `secretEnv` 值、服务通过 `redaction.register` 注册的值(包括被终端折行拆开的情况),以及常见密钥形式(`sk-…`、GitHub、Slack、AWS、Google、JWT、`Bearer …`、`"apiKey": "…"`、PEM 私钥、长随机串)。插件工具的回答、会话事件中的 `screen`、卡片标记和卡片动作消息也以同样方式遮蔽。 host.onStorageChange(listener) 会把 host.storage.set 的写入通知给同一插件的所有活动界面——画布卡片、HOME 小组件和独立窗口——从而避免轮询。 diff --git a/src/main/services/safety/baseProtection.ts b/src/main/services/safety/baseProtection.ts index 2675e022..fac58420 100644 --- a/src/main/services/safety/baseProtection.ts +++ b/src/main/services/safety/baseProtection.ts @@ -83,15 +83,16 @@ export function actionFromHook(toolName: string, toolInput: unknown, preview: st /** * CanvasTTY's own private data under its userData folder (the app passes `app.getPath('userData')`; tests pass a * temporary folder): the control token and descriptor, the gateways' connection records and sockets, the secret - * stores, the per-account homes and the prepared launch runs. Its settings and layouts are not listed. + * stores, the per-account homes, the prepared launch runs and the per-card Claude Code draft folders (claude-draft, where + * the app and one card exchange that card's prompt draft). Its settings and layouts are not listed. */ export function canvasTtyPrivateData(userDataPath: string): PrivateData { const names = ['agent-control', join('browser', 'runtime'), join('lifecycle', 'runtime'), join('orchestration', 'runtime'), - 'provider-secrets.bin', 'plugin-secrets', 'account-homes', 'github-oauth.json', 'launch-runs']; + 'provider-secrets.bin', 'plugin-secrets', 'account-homes', 'github-oauth.json', 'launch-runs', 'claude-draft']; return { appRoots: [userDataPath], paths: names.map(name => join(userDataPath, name)), - markers: ['agent-control', 'provider-secrets', 'plugin-secrets', 'account-homes', 'github-oauth'] + markers: ['agent-control', 'provider-secrets', 'plugin-secrets', 'account-homes', 'github-oauth', 'claude-draft'] }; } diff --git a/src/main/services/safety/commandFacts.ts b/src/main/services/safety/commandFacts.ts index fe515213..c72e6978 100644 --- a/src/main/services/safety/commandFacts.ts +++ b/src/main/services/safety/commandFacts.ts @@ -583,17 +583,31 @@ function componentMatches(pattern: string, name: string): boolean { try { return new RegExp(`^${source}$`, 'u').test(name); } catch { return true; } } +/** The gateway socket folder under a temporary folder that holds `abs` (`$TMPDIR/ctty-control-XXXX`), or null. */ +function socketFolder(abs: string, ctx: PathContext): string | null { + for (const temp of ctx.tempRoots) { + if (!isPathInside(temp, abs, { allowRoot: false })) continue; + const first = parts(relative(temp, abs))[0] ?? ''; + if (SOCKET_DIR.test(first)) return join(temp, first); + } + return null; +} + /** * Whether one path (absolute, maybe a glob) is CanvasTTY's private data: inside a private path, a folder of the app * that holds one when the command walks folders, or a gateway's socket folder under a temporary folder. */ function privatePath(abs: string, glob: boolean, recursive: boolean, ctx: PathContext): boolean { if (!glob) { - // The project, and the agent's own config folder (an account home it was launched with), are its own. - if (isPathInside(ctx.rootReal, abs) || ctx.agentRoots.some(dir => isPathInside(dir, abs))) return false; - if (ctx.privatePaths.some(path => isPathInside(path, abs))) return true; if (recursive && ctx.privatePaths.some(path => isPathInside(abs, path)) && ctx.appRoots.some(root => isPathInside(root, abs))) return true; - return ctx.tempRoots.some(temp => isPathInside(temp, abs, { allowRoot: false }) && SOCKET_DIR.test(parts(relative(temp, abs))[0] ?? '')); + const socket = socketFolder(abs, ctx); + const holders = [...ctx.privatePaths.filter(path => isPathInside(path, abs)), ...(socket ? [socket] : [])]; + if (!holders.length) return false; + // The project, and the agent's own config folder (an account home it was launched with), are its own, but only + // below the private data: a broad folder (HOME, or one that holds the app's data or a temporary folder) does not + // make the private data inside it the agent's. A folder inside a private path (that account home) stays its own. + const owns = (root: string): boolean => isPathInside(root, abs) && !holders.some(holder => isPathInside(root, holder)); + return !(owns(ctx.rootReal) || ctx.agentRoots.some(owns)); } // A glob: the folders before its first wildcard resolved, the rest matched name by name. const all = parts(abs); diff --git a/tests/base-protection-app-private.test.mjs b/tests/base-protection-app-private.test.mjs index e19321d0..54f9de11 100644 --- a/tests/base-protection-app-private.test.mjs +++ b/tests/base-protection-app-private.test.mjs @@ -102,10 +102,78 @@ test("CanvasTTY's own tokens, secret stores and sockets are refused for every re for (const command of DENY) assert.equal(rule(command), "app-private", command); }); +test("the per-card Claude Code draft folders are private data too", () => { + const draft = join(userData, "claude-draft", "card-2", "draft.1"); + assert.equal(rule(`cat ${q(draft)}`), "app-private"); + assert.equal(rule(`touch ${q(join(userData, "claude-draft", "card-2", "req.1"))}`), "app-private"); + assert.equal(rule("cat \"$HOME/Library/Application Support/canvastty/claude-draft/card-2/draft.1\""), "app-private"); + assert.equal(rule("cat notes/claude-drafting.md"), null, "a project file with a similar name stays allowed"); +}); + test("look-alikes in the project, the app's other files and other sockets stay allowed", () => { for (const command of ALLOW) assert.equal(rule(command), null, command); }); +test("a broad project (HOME, or a folder holding the app's data) cannot reopen the private data below it", () => { + const secrets = join(userData, "provider-secrets.bin"); + const otherAccount = join(userData, "account-homes", "acct-2", "auth.json"); + for (const root of [home, join(home, "Library"), userData]) { + const broad = (command, extra = {}) => denyRule(analyzeAction(shell(command), root, { home, privateData, ...extra })); + const tool = (toolName, toolInput, extra = {}) => checkBaseProtection({ toolName, toolInput, root, home, privateData, ...extra })?.rule ?? null; + for (const command of [ + `cat ${q(secrets)}`, + "cat ~/Library/Application\\ Support/canvastty/provider-secrets.bin", + `cat ${q(token)}`, + `cat ${q(join(userData, "github-oauth.json"))}`, + `cat ${q(otherAccount)}`, + `ls ${q(join(userData, "plugin-secrets"))}`, + `cat ${q(join(userData, "lifecycle", "runtime", "connection.json"))}`, + `cd ${q(userData)} && cat provider-secrets.bin`, + `python3 -c "print(open('${secrets}','rb').read())"`, + `echo x > ${q(secrets)}`, + `cp ./x ${q(token)}`, + `grep -r token ${q(userData)}` + ]) assert.equal(broad(command), "app-private", `${root}: ${command}`); + assert.equal(tool("Write", { file_path: secrets, content: "x" }), "app-private", `${root}: Write`); + assert.equal(tool("Edit", { file_path: join(userData, "github-oauth.json"), old_string: "a", new_string: "b" }), "app-private", `${root}: Edit`); + assert.equal(tool("apply_patch", { input: `*** Begin Patch\n*** Update File: ${token}\n*** End Patch` }), "app-private", `${root}: apply_patch`); + assert.equal(tool("Bash", { command: `cat ${q(secrets)}` }), "app-private", `${root}: Bash`); + // The app's other files, and the project's own ordinary files, stay usable. + assert.equal(broad(`cat ${q(join(userData, "settings.json"))}`), null, root); + assert.equal(broad(`ls ${q(userData)}`), null, root); + // The agent's own account home (strictly inside account-homes) stays its own; its neighbours do not. + const accountHome = join(userData, "account-homes", "acct-1", ".claude"); + assert.equal(broad(`cat ${q(join(accountHome, "projects", "p", "memory", "MEMORY.md"))}`, { agentRoots: [accountHome] }), null, root); + assert.equal(tool("Write", { file_path: join(accountHome, "plans", "p.md"), content: "x" }, { agentRoots: [accountHome] }), null, root); + assert.equal(broad(`cat ${q(otherAccount)}`, { agentRoots: [accountHome] }), "app-private", root); + assert.equal(broad(`cat ${q(secrets)}`, { agentRoots: [accountHome] }), "app-private", root); + } + const atHome = (command) => denyRule(analyzeAction(shell(command), home, { home, privateData })); + assert.equal(atHome(`cat ${q(join(home, "notes.txt"))}`), null); + assert.equal(atHome("grep -rn todo ."), null, "a recursive search of the project itself is not a read of the app's folder"); +}); + +test("a broad agent config folder does not reopen the private data below it either", () => { + assert.equal(rule(`cat ${q(token)}`, { agentRoots: [home] }), "app-private"); + assert.equal(rule(`cat ${q(join(home, "notes.txt"))}`, { agentRoots: [home] }), null); +}); + +test("a project in a temporary folder does not make the gateways' socket folders its own", () => { + const temp = realpathSync(tmpdir()); + const atTemp = (command) => denyRule(analyzeAction(shell(command), temp, { home, privateData })); + assert.equal(atTemp(`nc -U ${q(join(temp, "ctty-control-Ab12Cd", "c.sock"))}`), "app-private"); + assert.equal(atTemp(`cat ${q(join(temp, "ctty-runtime-501-ab12", "connection.json"))}`), "app-private"); + assert.equal(atTemp(`cat ${q(join(temp, "notes.txt"))}`), null); +}); + +test("a project inside the app's private data keeps its own files, not its neighbours", () => { + const accountProject = join(userData, "account-homes", "acct-1"); + const inside = (command) => denyRule(analyzeAction(shell(command), accountProject, { home, privateData })); + assert.equal(inside("cat .claude/settings.json"), null); + assert.equal(inside(`cat ${q(join(userData, "account-homes", "acct-2", "auth.json"))}`), "app-private"); + assert.equal(inside(`cat ${q(join(userData, "provider-secrets.bin"))}`), "app-private"); +}); + test("file tools that name private data are refused; the agent's own account home is its own", () => { const check = (toolName, toolInput, extra = {}) => checkBaseProtection({ toolName, toolInput, root: project, home, privateData, ...extra }); assert.equal(check("Write", { file_path: join(control, "token-x"), content: "x" })?.rule, "app-private"); From 0a9ed4612d5101bca6b2ff1c2a859cc102126a97 Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:00:09 +0000 Subject: [PATCH 16/28] fix(safety): refuse read-only file tools on CanvasTTY-private data Base protection is documented to refuse use of CanvasTTY's private data, but its hooks only saw shells and file writes: Claude Code's Read, Grep, Glob or LS, and the Qwen Code and OpenCode equivalents, could read provider-secrets.bin, the control token or another account's home without a check. While base protection is on at launch, the decision hook now also matches those read-only tools (Claude Code and Qwen Code matchers, OpenCode's guard through CANVASTTY_RUNTIME_DECISION_READS). With base protection off, a decision plugin's fail-closed hook still covers only shells and writes, so reads never wait on the gateway. They map to a read action that is checked only against the private data, with the same rules as a shell read: the agent's own account home stays its own, a search walks its folder, and nothing outside the private data is refused or asked about. Decision services are not asked about reads. OpenCode's grep without a path is sent as a search of ".", and glob's pattern is always sent. Qwen Code's read_many_files is refused when its paths and include globs together exceed 32, instead of checking only the first 32, and include globs are checked. A cut read-only call is refused like a cut shell call. Codex has no read-only file tool in its hooks; it reads through its shell, which is already checked. The docs now say base protection is best effort: it does not see paths built at run time or folder walks started above the app's data, so a project at the home folder needs agent isolation to keep the data private. --- docs/installing-and-security.md | 2 +- docs/installing-and-security.ru.md | 2 +- docs/installing-and-security.zh-CN.md | 2 +- docs/plugins.md | 8 +- docs/plugins.ru.md | 2 + docs/plugins.zh-CN.md | 4 +- src/agent-runtime/opencode-decisions.mjs | 31 +++++-- src/agent-runtime/runtime-protocol.d.mts | 1 + src/agent-runtime/runtime-protocol.mjs | 2 + src/main/index.ts | 1 + src/main/services/DecisionHooks.ts | 15 +++- .../agent-runtime/AgentRuntimeBridge.ts | 11 ++- .../agent-runtime/ProviderRuntimeLaunch.ts | 28 ++++-- src/main/services/safety/baseProtection.ts | 43 +++++++++- src/main/services/safety/commandFacts.ts | 14 ++- tests/base-protection-app-private.test.mjs | 85 +++++++++++++++++++ tests/base-protection.test.mjs | 6 +- tests/decision-hooks.test.mjs | 40 +++++++-- tests/permission-gate-fail-closed.test.mjs | 21 ++++- 19 files changed, 280 insertions(+), 38 deletions(-) diff --git a/docs/installing-and-security.md b/docs/installing-and-security.md index 21af9dc9..a372e960 100644 --- a/docs/installing-and-security.md +++ b/docs/installing-and-security.md @@ -87,7 +87,7 @@ Agents work in their own mode; CanvasTTY's layers sit outside it as a quiet safe | Layer | Guarantees | Does not guarantee | | --- | --- | --- | | The agent's own permission system | What its CLI promises in the chosen mode (Claude Code, Codex, OpenCode, Grok…). | Anything the CLI's own classifier or reviewer lets through. | -| Base protection (hooks) | Denies elevation, pipes into a shell, disk commands, fork bombs, writes and deletes outside the project and access to CanvasTTY's private data, before the tool call runs, for agents whose CLI has hooks (Claude Code, Codex, Qwen Code, OpenCode). A decision plugin's "ask" is shown by Claude Code; for a CLI that cannot ask it is a deny with the reason. | It reads commands; code that hides what it does, or a CLI without hooks, is not covered. It is a guard, not a sandbox. | +| Base protection (hooks) | Denies elevation, pipes into a shell, disk commands, fork bombs, writes and deletes outside the project and access to CanvasTTY's private data, before the tool call runs, for agents whose CLI has hooks (Claude Code, Codex, Qwen Code, OpenCode). A decision plugin's "ask" is shown by Claude Code; for a CLI that cannot ask it is a deny with the reason. | Best effort: it reads tool calls, so code that hides what it does, a path built at run time, a script that walks folders itself, a recursive search started above CanvasTTY's data (a project at the home folder), or a CLI without hooks is not covered. It is a guard, not a sandbox: a project that contains CanvasTTY's data needs agent isolation to keep that data private. | | Delegation rules | A subagent never gets more than its orchestrator (never YOLO), works only in its orchestrator's project folder, within the depth and count limits the person set; orchestrators get a control connection of their own; no agent-facing tool changes settings, protection, profiles, trust or isolation; plugin launch options from an agent only where the plugin declared them delegable. | What a plugin the person trusted does with its own permissions. | | Agent isolation (OS layer) | On macOS (sandbox-exec) and Linux (bubblewrap), for subagents, plugin-started agents and every agent not in Manual: the whole process tree writes only in the project, its own temporary folder and its CLI's own folders; keys, other CLIs' credentials and CanvasTTY's tokens are unreadable; no other process, app, Apple event, preference write, launchd job or foreign Unix socket (Docker, tmux, SSH agent) is reachable. Fails closed. | Network is not restricted. Linux cannot filter Unix sockets, only hide the user runtime folder. The CLI's own sandbox cannot run inside the layer on macOS, so it is switched off there (approvals unchanged) and commands can use the network. A Claude launch may rewrite the login keychain file to save a refreshed sign-in, so it could also damage that file (other items stay behind their own access lists). Windows has no layer yet: subagents run in Manual there unless the person turns isolation off. | diff --git a/docs/installing-and-security.ru.md b/docs/installing-and-security.ru.md index 977ee961..939392b3 100644 --- a/docs/installing-and-security.ru.md +++ b/docs/installing-and-security.ru.md @@ -83,7 +83,7 @@ gh variable set SPARKLE_PUBLIC_ED_KEY --repo howdeploy/CanvasTTY \ Агент работает в своём режиме; слои CanvasTTY стоят снаружи как тихая страховка: не добавляют вопросов при обычной работе в проекте и останавливают опасное с короткой причиной. - **Режимы запуска**: Авто (по умолчанию), Вручную, Правки, План, Обход — только там, где их поддерживает CLI. Обход выбирает человек для каждого CLI, он проверяется в main-процессе, никогда не даётся субагенту и всё равно остаётся внутри слоёв ниже. -- **Базовая защита** (хуки) запрещает повышение прав, `curl | sh`, дисковые команды и запись вне проекта у агентов с хуками; это проверка команд, а не песочница. +- **Базовая защита** (хуки) запрещает повышение прав, `curl | sh`, дисковые команды и запись вне проекта у агентов с хуками, а также доступ к закрытым данным CanvasTTY; это проверка команд по возможности, а не песочница: проект, содержащий данные CanvasTTY (например, домашняя папка), защищает только изоляция. - **Правила делегирования**: субагент получает не больше оркестратора (никогда YOLO), работает только в папке проекта, в пределах глубины и числа, заданных человеком; агенты не могут менять настройки, защиту, профили и изоляцию. - **Изоляция агентов** (macOS — sandbox-exec, Linux — bubblewrap): запись только в проект, свою временную папку и папки своего CLI; ключи и токены недоступны; другие процессы, приложения и демоны недоступны; при сбое запуск отклоняется. Приватные данные плагинов (`plugin-data`) недоступны целиком; снова открываются только папка CLI, с которой выбранный плагин запуска стартовал агента (точная реальная папка внутри данных этого плагина), и проект внутри данных плагинов — как одна эта папка и только там, где запуск разместил CanvasTTY: папка, подготовленная окружением внутри данных своего плагина, или папка, выбранная человеком для карточки (и папки её субагентов ниже). Любой другой проект внутри данных плагинов, например папка субагента, о которой попросил агент, отклоняется. На Linux папки сокетов шлюзов CanvasTTY подключаются только для чтения, и только сокеты, которые собственные шлюзы CanvasTTY подготовили для этого запуска: `CANVASTTY_*_ADDRESS` из окружения человека или от плагина ничего не подключает, а папка сокетов шлюза со скрытой или записываемой папкой отклоняется. Сеть не ограничивается; на Windows слоя пока нет, и субагенты там работают вручную. Подробности — в [agent-orchestration.md](agent-orchestration.md). diff --git a/docs/installing-and-security.zh-CN.md b/docs/installing-and-security.zh-CN.md index 765240e5..533c9913 100644 --- a/docs/installing-and-security.zh-CN.md +++ b/docs/installing-and-security.zh-CN.md @@ -83,7 +83,7 @@ gh variable set SPARKLE_PUBLIC_ED_KEY --repo howdeploy/CanvasTTY \ 智能体按自己的模式工作;CanvasTTY 的各层位于其外部,作为安静的安全网:项目内的常规工作不会增加提问,只拦截危险操作并给出简短原因。 - **启动模式**:自动(默认)、手动、接受编辑、计划、绕过,仅在 CLI 支持时提供。绕过由用户按 CLI 确认,在主进程中检查,绝不交给子智能体,并且仍处于下列各层之内。 -- **基础保护**(hooks)为有 hooks 的智能体拒绝提权、`curl | sh`、磁盘命令以及项目外的写入;它检查命令,不是沙箱。 +- **基础保护**(hooks)为有 hooks 的智能体拒绝提权、`curl | sh`、磁盘命令以及项目外的写入,并拒绝访问 CanvasTTY 的私有数据;它是尽力而为的命令检查,不是沙箱:包含 CanvasTTY 数据的项目(例如主目录)只有在隔离下才受保护。 - **委派规则**:子智能体的权限不超过其编排者(绝不为 YOLO),只在项目文件夹内工作,并受用户设定的深度和数量限制;智能体无法更改设置、防护、配置档或隔离。 - **智能体隔离**(macOS 使用 sandbox-exec,Linux 使用 bubblewrap):只能写入项目、自身临时目录和其 CLI 的目录;密钥和令牌不可读;其他进程、应用和守护进程不可达;无法建立时拒绝启动。插件的私有数据(`plugin-data`)整体不可读;只重新开放两类路径:所选启动插件启动该智能体时使用的 CLI 目录(该插件自身数据内的一个确切真实文件夹),以及插件数据内的项目——仅作为这一个文件夹,且仅限 CanvasTTY 安排启动的位置:环境在其插件自身数据内准备的文件夹,或用户为卡片选择的文件夹(及其下方子智能体的文件夹)。插件数据内的任何其他项目(例如智能体要求的子智能体文件夹)都会被拒绝。在 Linux 上,CanvasTTY 网关的 socket 文件夹以只读方式挂载,且只挂载 CanvasTTY 自身网关为本次启动准备的 socket:来自用户环境或插件的 `CANVASTTY_*_ADDRESS` 不会挂载任何东西,包含隐藏或可写文件夹的网关 socket 文件夹会被拒绝。网络不受限制;Windows 暂无此层,子智能体在那里以手动模式运行。详见 [agent-orchestration.md](agent-orchestration.md)。 diff --git a/docs/plugins.md b/docs/plugins.md index c8772125..feb6b217 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -287,7 +287,7 @@ Before a local agent's shell command or file write runs, CanvasTTY can ask a plu }] ``` -`pre-tool` is every shell and file-writing tool call, before it runs and in every permission mode, YOLO included: Claude Code, Codex and Qwen Code through their `PreToolUse` hook, OpenCode through CanvasTTY's OpenCode plugin (`tool.execute.before`). `appliesTo` limits the agents; all four when omitted. The host sends `canvastty.decide` (host-only) and waits at most `timeoutMs` (1000 to 60000; 3000 when omitted). The agent's call waits as long, so ask for more only when the answer needs it (for example a local model reading the command); CanvasTTY sizes each card's hook for the longest budget of the services that apply when the card starts, and a service trusted later gets no more than its card allows. The request carries the budget as `budgetMs`: +`pre-tool` is every shell and file-writing tool call (read-only file tools reach only base protection, never a service, and only while base protection was on when the agent started), before it runs and in every permission mode, YOLO included: Claude Code, Codex and Qwen Code through their `PreToolUse` hook, OpenCode through CanvasTTY's OpenCode plugin (`tool.execute.before`). `appliesTo` limits the agents; all four when omitted. The host sends `canvastty.decide` (host-only) and waits at most `timeoutMs` (1000 to 60000; 3000 when omitted). The agent's call waits as long, so ask for more only when the answer needs it (for example a local model reading the command); CanvasTTY sizes each card's hook for the longest budget of the services that apply when the card starts, and a service trusted later gets no more than its card allows. The request carries the budget as `budgetMs`: ```ts interface DecisionRequest { @@ -317,7 +317,7 @@ The full example is [`examples/plugins/deny-rm`](../examples/plugins/deny-rm): i ### Agent tools (`tools:agents`) -A service may offer up to 16 `tools` to agents. They appear in the `canvastty_agents` MCP server as `__` (dots in the plugin id become `_`, so `com.example.tools` + `lookup` is `com_example_tools__lookup`; Anthropic and OpenAI allow only letters, digits, `_` and `-` in tool names, at most 64 characters, and a longer name keeps the start of the id plus a short hash), next to CanvasTTY's own orchestration tools, and count as CanvasTTY's own tools: base protection does not check them (it checks shells and file writes only). +A service may offer up to 16 `tools` to agents. They appear in the `canvastty_agents` MCP server as `__` (dots in the plugin id become `_`, so `com.example.tools` + `lookup` is `com_example_tools__lookup`; Anthropic and OpenAI allow only letters, digits, `_` and `-` in tool names, at most 64 characters, and a longer name keeps the start of the id plus a short hash), next to CanvasTTY's own orchestration tools, and count as CanvasTTY's own tools: base protection does not check them (it checks shells and file writes, and read-only file tools for its private-data rule only). ```json "permissions": ["tools:agents"], @@ -407,7 +407,9 @@ A service may run another browser engine for agents' background tabs, for exampl Two safety parts are built in and need no plugin: -- **Base protection** (Settings → Agents, on by default; the person can turn it off) denies, through the same hook, sudo and other elevation, piping downloaded or generated text into a shell, download-and-run, disk and format commands, fork bombs, and writing or deleting outside the working folder: the home folder, other projects and `/tmp` included, and deleting the working folder itself. An agent's own plan and memory folders (`~/.claude/plans`, `~/.claude/projects//memory`, and the same inside the run's `CLAUDE_CONFIG_DIR`) are not "outside". It also denies any use of CanvasTTY's own private data (from the app's userData folder: the agent-control token and descriptor, the gateways' connection records and sockets, the secret stores, account homes; and the control/runtime socket folders under the temporary folder), even when the working folder contains it (a project at the home folder), by any program, interpreter one-liners and socket clients included; the reason points the model at an **Orchestrator** launch and the `canvastty_agents` tools. The bundled control CLI may name its descriptor. It only ever denies; each reason tells the model what to do instead (a write to `/tmp` suggests a scratch folder inside the project). +- **Base protection** (Settings → Agents, on by default; the person can turn it off) denies, through the same hook, sudo and other elevation, piping downloaded or generated text into a shell, download-and-run, disk and format commands, fork bombs, and writing or deleting outside the working folder: the home folder, other projects and `/tmp` included, and deleting the working folder itself. An agent's own plan and memory folders (`~/.claude/plans`, `~/.claude/projects//memory`, and the same inside the run's `CLAUDE_CONFIG_DIR`) are not "outside". It also denies use of CanvasTTY's own private data (from the app's userData folder: the agent-control token and descriptor, the gateways' connection records and sockets, the secret stores, account homes; and the control/runtime socket folders under the temporary folder), even when the working folder contains it (a project at the home folder): commands that name it, including interpreter one-liners and socket clients it recognises, file writes, and read-only file tools (Claude Code's Read, NotebookRead, Grep, Glob and LS; Qwen Code's and OpenCode's equivalents, checked for nothing else and only while base protection was on when the agent started); the reason points the model at an **Orchestrator** launch and the `canvastty_agents` tools. The bundled control CLI may name its descriptor. It only ever denies; each reason tells the model what to do instead (a write to `/tmp` suggests a scratch folder inside the project). + + Base protection is best effort: it reads each tool call before it runs, so it does not catch a path the call builds at run time (a command substitution, a variable it cannot resolve, a script that walks folders itself such as Python's `os.walk`), and it does not refuse a recursive search started above the app's data folder (`grep -r token ~`, a Grep of the home folder). A project set to the home folder, or to any folder that contains CanvasTTY's data, is therefore not a safe configuration on its own. Run such agents with agent isolation (sandbox-exec on macOS, bubblewrap on Linux): it hides the private data from every program in the agent's process tree, walks included. - **Secret redaction**: every text CanvasTTY hands from one agent to another (`observe_agent`, `wait_for_agent`, `get_agent_result`, the control CLI's `screen`, `result` and failure details) is masked: provider keys CanvasTTY holds, launch `secretEnv` values, values a service registered with `redaction.register`, also when the terminal wrapped them over lines, plus common key shapes (`sk-…`, GitHub, Slack, AWS, Google, JWT, `Bearer …`, `"apiKey": "…"`, PEM private keys, long random runs). Plugin tool answers, `screen` in session events, card badges and card action messages are masked the same way. host.onStorageChange(listener) notifies every live contribution of the same plugin — canvases, HOME widgets, and separate windows — of writes made through host.storage.set, avoiding polling when a plugin coordinates several surfaces. diff --git a/docs/plugins.ru.md b/docs/plugins.ru.md index 66860fc7..9647f1ca 100644 --- a/docs/plugins.ru.md +++ b/docs/plugins.ru.md @@ -386,6 +386,8 @@ interface PluginSessionEvent { Две части безопасности встроены и не требуют плагина: - **Базовая защита** (Настройки → Агенты, включена по умолчанию; человек может её выключить) через тот же хук запрещает sudo и другое повышение прав, передачу скачанного или сгенерированного текста в shell, скачивание с запуском, команды для дисков и форматирования, форк-бомбы, а также запись и удаление вне рабочей папки — включая домашнюю папку, другие проекты и `/tmp` — и удаление самой рабочей папки. Собственные папки планов и памяти агента (`~/.claude/plans`, `~/.claude/projects//memory` и то же внутри `CLAUDE_CONFIG_DIR` запуска) не считаются «вне». Она только запрещает; каждая причина говорит модели, что сделать вместо этого (для записи в `/tmp` — завести временную папку внутри проекта). + + Она также запрещает использование собственных закрытых данных CanvasTTY (токен и описание agent-control, записи подключений и сокеты шлюзов, хранилища секретов, домашние папки аккаунтов, папки сокетов во временной папке), даже если рабочая папка их содержит: команды, которые называют эти пути, запись файлов и инструменты только для чтения (Read, NotebookRead, Grep, Glob и LS у Claude Code; аналоги у Qwen Code и OpenCode, только если базовая защита была включена при запуске агента). Эта проверка — по возможности: она читает вызов инструмента до запуска, поэтому не замечает путь, собранный во время выполнения (подстановка команды, неизвестная переменная, скрипт, который сам обходит папки, например `os.walk` в Python), и не запрещает рекурсивный поиск, начатый выше папки данных приложения (`grep -r token ~`, Grep по домашней папке). Поэтому проект в домашней папке или в любой папке, содержащей данные CanvasTTY, сам по себе небезопасен. Запускайте такие агенты в изоляции (sandbox-exec на macOS, bubblewrap на Linux): она скрывает закрытые данные от всех программ процесса агента, включая обход папок. - **Скрытие секретов**: весь текст, который CanvasTTY передаёт от одного агента другому (`observe_agent`, `wait_for_agent`, `get_agent_result`, `screen`, `result` и детали ошибок в control CLI), маскируется: ключи провайдеров, которые хранит CanvasTTY, значения `secretEnv` запуска, значения, зарегистрированные сервисом через `redaction.register`, в том числе перенесённые терминалом на несколько строк, а также типичные формы ключей (`sk-…`, GitHub, Slack, AWS, Google, JWT, `Bearer …`, `"apiKey": "…"`, приватные ключи PEM, длинные случайные строки). Так же маскируются ответы инструментов плагинов, `screen` в событиях сессий, метки на окнах и сообщения действий. host.onStorageChange(listener) сообщает всем открытым поверхностям того же плагина — canvas cards, HOME widgets и отдельным окнам — об изменениях через host.storage.set, поэтому нескольким поверхностям не требуется постоянный polling. diff --git a/docs/plugins.zh-CN.md b/docs/plugins.zh-CN.md index abccc9f9..511e0d61 100644 --- a/docs/plugins.zh-CN.md +++ b/docs/plugins.zh-CN.md @@ -383,7 +383,9 @@ interface PluginSessionEvent { 两项安全功能内置,无需插件: -- **基础保护**(设置 → Agents → Base protection,默认开启;用户可以关闭)通过同一个 hook 拒绝:sudo 及其他提权、把下载或生成的文本管道给 shell、下载后直接运行、磁盘和格式化命令、fork 炸弹,以及在工作文件夹之外写入或删除(包括主目录、其他项目和 `/tmp`),以及删除工作文件夹本身。agent 自己的计划和记忆文件夹(`~/.claude/plans`、`~/.claude/projects//memory`,以及本次运行 `CLAUDE_CONFIG_DIR` 中的相同位置)不算"外部"。它还会拒绝任何程序(包括解释器单行命令和套接字客户端)使用 CanvasTTY 自己的私有数据(来自应用的 userData 文件夹:agent-control 令牌与描述文件、各网关的连接记录与套接字、密钥存储、账户主目录;以及临时文件夹下的控制/运行时套接字文件夹),即使工作文件夹包含它们(例如项目就是主目录);拒绝原因会引导模型改用 **Orchestrator** 启动和 `canvastty_agents` 工具。内置控制 CLI 可以引用它的描述文件。它只会拒绝;每条原因都告诉模型应当改做什么(写入 `/tmp` 时建议在项目内建立临时文件夹)。 +- **基础保护**(设置 → Agents → Base protection,默认开启;用户可以关闭)通过同一个 hook 拒绝:sudo 及其他提权、把下载或生成的文本管道给 shell、下载后直接运行、磁盘和格式化命令、fork 炸弹,以及在工作文件夹之外写入或删除(包括主目录、其他项目和 `/tmp`),以及删除工作文件夹本身。agent 自己的计划和记忆文件夹(`~/.claude/plans`、`~/.claude/projects//memory`,以及本次运行 `CLAUDE_CONFIG_DIR` 中的相同位置)不算"外部"。它还会拒绝使用 CanvasTTY 自己的私有数据(来自应用的 userData 文件夹:agent-control 令牌与描述文件、各网关的连接记录与套接字、密钥存储、账户主目录;以及临时文件夹下的控制/运行时套接字文件夹),即使工作文件夹包含它们(例如项目就是主目录):包括点名这些路径的命令(含它能识别的解释器单行命令和套接字客户端)、文件写入,以及只读文件工具(Claude Code 的 Read、NotebookRead、Grep、Glob、LS,以及 Qwen Code 和 OpenCode 的对应工具;只读工具不检查其他内容,且仅在 agent 启动时基础保护已开启的情况下才检查);拒绝原因会引导模型改用 **Orchestrator** 启动和 `canvastty_agents` 工具。内置控制 CLI 可以引用它的描述文件。它只会拒绝;每条原因都告诉模型应当改做什么(写入 `/tmp` 时建议在项目内建立临时文件夹)。 + + 基础保护是尽力而为的检查:它在工具调用运行前读取调用内容,因此无法发现运行时才拼出的路径(命令替换、无法解析的变量、像 Python `os.walk` 这样自行遍历文件夹的脚本),也不会拒绝从应用数据文件夹上层开始的递归搜索(`grep -r token ~`、对主目录的 Grep)。因此,把项目设为主目录或任何包含 CanvasTTY 数据的文件夹,本身并不安全。请在 agent 隔离下运行这类 agent(macOS 上的 sandbox-exec,Linux 上的 bubblewrap):它会对 agent 进程树中的所有程序隐藏私有数据,包括遍历。 - **密钥遮蔽**:CanvasTTY 从一个 agent 交给另一个 agent 的所有文本(`observe_agent`、`wait_for_agent`、`get_agent_result`,以及 control CLI 的 `screen`、`result` 和失败详情)都会被遮蔽:CanvasTTY 保存的服务商密钥、启动时的 `secretEnv` 值、服务通过 `redaction.register` 注册的值(包括被终端折行拆开的情况),以及常见密钥形式(`sk-…`、GitHub、Slack、AWS、Google、JWT、`Bearer …`、`"apiKey": "…"`、PEM 私钥、长随机串)。插件工具的回答、会话事件中的 `screen`、卡片标记和卡片动作消息也以同样方式遮蔽。 host.onStorageChange(listener) 会把 host.storage.set 的写入通知给同一插件的所有活动界面——画布卡片、HOME 小组件和独立窗口——从而避免轮询。 diff --git a/src/agent-runtime/opencode-decisions.mjs b/src/agent-runtime/opencode-decisions.mjs index d9e9c4ca..3915290c 100644 --- a/src/agent-runtime/opencode-decisions.mjs +++ b/src/agent-runtime/opencode-decisions.mjs @@ -1,12 +1,13 @@ import { FAIL_CLOSED_MESSAGE, buildRequest, exchange, identityFrom } from "./permission-gate.mjs"; -import { OPENCODE_DECISIONS_ENV, helperDeadlineMs } from "./runtime-protocol.mjs"; +import { OPENCODE_DECISIONS_ENV, OPENCODE_DECISION_READS_ENV, helperDeadlineMs } from "./runtime-protocol.mjs"; /** * Decision hooks for OpenCode, used by opencode-plugin.mjs while `CANVASTTY_RUNTIME_DECISIONS` is "1". * * `guard` runs in `tool.execute.before` (before OpenCode's own permission check, in every mode, YOLO included) * and sends each shell and file-writing call (bash, write, edit, multiedit, apply_patch/patch) to CanvasTTY the way - * the Claude/Codex/Qwen PreToolUse hook does. A deny throws, which fails that tool call with the reason as its error + * the Claude/Codex/Qwen PreToolUse hook does; with `CANVASTTY_RUNTIME_DECISION_READS` "1" (base protection on at + * launch) also read, grep, glob and list, for base protection's private-data rule. A deny throws, which fails that tool call with the reason as its error * text for the model. An allow (only from plugins the person let allow) is remembered by call id: when OpenCode then * asks for that call (`permission.asked`), `permissionAsked` answers `once` through OpenCode's own API * (POST /permission/{requestID}/reply), never `always`. Anything else (ask, no verdict, an error) leaves OpenCode's @@ -23,6 +24,9 @@ const MAX_ALLOWED = 64; export function createOpenCodeDecisions({ client, env = process.env, send = exchange } = {}) { const identity = identityFrom(env); const enabled = env[OPENCODE_DECISIONS_ENV] === "1" && identity?.provider === "opencode"; + // Read-only file tools are sent only when the launch asked for it (base protection was on): otherwise a read never + // waits on, or fails closed because of, the gateway. + const reads = enabled && env[OPENCODE_DECISION_READS_ENV] === "1"; /** callIDs a plugin allowed; their permission request is answered `once`. */ const allowed = new Set(); @@ -30,7 +34,7 @@ export function createOpenCodeDecisions({ client, env = process.env, send = exch if (!enabled) return; let decision = null; try { - const call = guardedCall(stringOf(input?.tool), output && typeof output === "object" ? output.args : null); + const call = guardedCall(stringOf(input?.tool), output && typeof output === "object" ? output.args : null, reads); if (!call) return; const message = buildRequest({ tool_name: call.toolName, tool_input: call.toolInput }, identity); if (message) decision = await send(identity.address, message, helperDeadlineMs(env)); @@ -63,10 +67,11 @@ export function createOpenCodeDecisions({ client, env = process.env, send = exch /** * An OpenCode tool call in the shape the decision hook reads (the Claude/Codex hook tool names), or null for a tool - * that neither runs commands nor writes files. OpenCode's own names: bash {command, workdir}, write {filePath, - * content}, edit {filePath, oldString, newString}, multiedit {filePath, edits}, apply_patch {patchText}. + * that neither runs commands, writes files nor reads them. OpenCode's own names: bash {command, workdir}, write {filePath, + * content}, edit {filePath, oldString, newString}, multiedit {filePath, edits}, apply_patch {patchText}. Read-only + * tools (read, grep, glob, list) are mapped only when `reads` is set. */ -export function guardedCall(tool, args) { +export function guardedCall(tool, args, reads = false) { if (!tool || !args || typeof args !== "object") return null; if (tool === "bash") { if (typeof args.command !== "string" || args.command.length === 0) return null; @@ -82,6 +87,20 @@ export function guardedCall(tool, args) { : multieditText(args.edits); return { toolName: "edit", toolInput: { file_path: filePath, ...(content !== null ? { content } : {}) } }; } + // Read-only tools, only when the launch checks them (base protection on): only CanvasTTY's private data is checked + // (read {filePath}, grep {pattern, path, include}, glob {pattern, path}, list {path}). A grep without a path + // searches the current folder, so it is always sent, as `.`. + if (reads && (tool === "read" || tool === "grep" || tool === "glob" || tool === "list")) { + const text = (value) => typeof value === "string" && value.length > 0 ? value : null; + const toolInput = {}; + const filePath = text(args.filePath) ?? text(args.file_path); + if (filePath) toolInput.file_path = filePath; + const path = text(args.path) ?? (tool === "grep" ? "." : null); + if (path) toolInput.path = path; + if (tool === "glob" && text(args.pattern)) toolInput.pattern = args.pattern; + if (Object.keys(toolInput).length === 0) return null; + return { toolName: { read: "Read", grep: "Grep", glob: "Glob", list: "LS" }[tool], toolInput }; + } if (tool === "apply_patch" || tool === "patch") { const patch = typeof args.patchText === "string" ? args.patchText : typeof args.patch === "string" ? args.patch : null; return patch ? { toolName: "apply_patch", toolInput: { patch } } : null; diff --git a/src/agent-runtime/runtime-protocol.d.mts b/src/agent-runtime/runtime-protocol.d.mts index cdf16b46..83349128 100644 --- a/src/agent-runtime/runtime-protocol.d.mts +++ b/src/agent-runtime/runtime-protocol.d.mts @@ -25,6 +25,7 @@ export const PERMISSION_GATE: Readonly<{ messageChars: number; }>; export const OPENCODE_DECISIONS_ENV: "CANVASTTY_RUNTIME_DECISIONS"; +export const OPENCODE_DECISION_READS_ENV: "CANVASTTY_RUNTIME_DECISION_READS"; export const DECISION_BUDGET_ENV: "CANVASTTY_RUNTIME_DECISION_MS"; export const DECISION_FAIL_CLOSED_ENV: "CANVASTTY_RUNTIME_FAIL_CLOSED"; export const DEFAULT_DECIDE_TIMEOUT_MS: number; diff --git a/src/agent-runtime/runtime-protocol.mjs b/src/agent-runtime/runtime-protocol.mjs index 4aad8753..ff1beb36 100644 --- a/src/agent-runtime/runtime-protocol.mjs +++ b/src/agent-runtime/runtime-protocol.mjs @@ -59,6 +59,8 @@ export const PERMISSION_GATE = Object.freeze({ }); // Set to "1" for an OpenCode session launched with decision hooks: its plugin then checks each shell and file call. export const OPENCODE_DECISIONS_ENV = "CANVASTTY_RUNTIME_DECISIONS"; +// Set to "1" as well when base protection was on at launch: the plugin then also checks read-only file tools. +export const OPENCODE_DECISION_READS_ENV = "CANVASTTY_RUNTIME_DECISION_READS"; // A decision service may ask for more time than the default 3 s (`decide.timeoutMs`, 1-60 s), for example to ask a // local model. The session's hook, helper and gateway deadlines are set at launch from the longest such budget and // passed to the helper in this variable; without it the defaults above hold. diff --git a/src/main/index.ts b/src/main/index.ts index b43c73bb..f5d354a6 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -567,6 +567,7 @@ async function initializeServices(ipc: IpcRegistrar): Promise { permissionGate: helpers.permissionGate, wantsDecisions: (provider) => decisionHooks.wanted(provider), decisionBudgetMs: (provider) => decisionHooks.budgetMs(provider), + decisionReads: () => decisionHooks.checksReads(), claudeHttpHooks: (facts) => claudeHttpHookPolicy.verdict(facts), pluginHooks: { runner: { diff --git a/src/main/services/DecisionHooks.ts b/src/main/services/DecisionHooks.ts index a4c91073..7cda9858 100644 --- a/src/main/services/DecisionHooks.ts +++ b/src/main/services/DecisionHooks.ts @@ -85,7 +85,8 @@ const MAX_SERVICES = 8; * first and its deny is final. Then every trusted decision service that applies answers deny, ask, allow or * nothing, in parallel, within 3 s. Any deny wins; else any ask (a timeout, an error or an unreadable answer is an * ask); else an allow counts only from a plugin the person separately let allow; else no verdict and the agent goes - * on as it would without CanvasTTY. Nothing here ever turns a failure into an allow. + * on as it would without CanvasTTY. Nothing here ever turns a failure into an allow. A read-only file tool call is + * checked by base protection's private-data rule only; decision services are not asked about it. */ export class DecisionHooks { private readonly deps: DecisionHooksDependencies; @@ -101,6 +102,11 @@ export class DecisionHooks { return services === null || services.length > 0; } + /** Whether a launch's hook should also see read-only file tools: only base protection checks them. */ + checksReads(): boolean { + return this.protects(); + } + /** The longest wait a decision service of this agent asked for: the session's gate is sized for it at launch. */ budgetMs(provider: AgentProviderId): number { return Math.max(DECIDE_TIMEOUT_MS, ...(this.applicable(provider) ?? []).map((service) => this.timeoutFor(service))); @@ -129,15 +135,18 @@ export class DecisionHooks { // Cut input: the rules could not see the command, or where a file tool writes. Unchecked is not allowed. if (request.truncated) { const cut = actionFromHook(request.toolName, request.toolInput, request.toolInputPreview); - if (cut.kind === "shell" || cut.kind === "edit" && cut.paths.length === 0) return { behavior: "deny", message: TOO_LARGE_MESSAGE }; + // A cut read-only call (a read_many_files list, a huge Grep pattern) may hide the path it reads. + if (cut.kind === "shell" || cut.kind === "read" || cut.kind === "edit" && cut.paths.length === 0) return { behavior: "deny", message: TOO_LARGE_MESSAGE }; } } + // Read-only file tools reach the hook only for base protection's private-data rule: plugins are not asked. + const action = actionFromHook(request.toolName, request.toolInput, request.toolInputPreview); + if (action.kind === "read") return { behavior: "none" }; const services = this.applicable(session.provider); // The plugin list itself failed: that is the gateway's failure (the person is asked, or a fail-closed gate // denies), never "no plugin has an opinion". if (services === null) throw new Error("decision services unavailable"); if (services.length === 0) return { behavior: "none" }; - const action = actionFromHook(request.toolName, request.toolInput, request.toolInputPreview); const params: Omit = { event: "pre-tool", sessionId, diff --git a/src/main/services/agent-runtime/AgentRuntimeBridge.ts b/src/main/services/agent-runtime/AgentRuntimeBridge.ts index 18b6ef2d..739382f8 100644 --- a/src/main/services/agent-runtime/AgentRuntimeBridge.ts +++ b/src/main/services/agent-runtime/AgentRuntimeBridge.ts @@ -47,6 +47,11 @@ export interface AgentRuntimeBridgeOptions extends ProviderRuntimeLaunchOptions wantsDecisions?(provider: Exclude): boolean; /** The longest decision budget for this agent (ms); the session's gate deadlines are sized from it. */ decisionBudgetMs?(provider: Exclude): number; + /** + * Whether the decision hook also sees read-only file tools (base protection on): only then does a read wait on the + * gateway, and fail closed without it. + */ + decisionReads?(): boolean; /** Whether a Claude launch may use HTTP lifecycle hooks; without it every launch uses the command helper. */ claudeHttpHooks?(facts: ClaudeHttpLaunchFacts): ClaudeHttpVerdict; } @@ -60,12 +65,14 @@ export class AgentRuntimeBridge implements AgentRuntimeLaunchCoordinator { private coreHooksEnabled: boolean; private readonly wantsDecisions: AgentRuntimeBridgeOptions["wantsDecisions"]; private readonly decisionBudgetMs: AgentRuntimeBridgeOptions["decisionBudgetMs"]; + private readonly decisionReads: AgentRuntimeBridgeOptions["decisionReads"]; private readonly claudeHttpHooks: AgentRuntimeBridgeOptions["claudeHttpHooks"]; constructor(gateway: RuntimeGateway, options: AgentRuntimeBridgeOptions) { this.gateway = gateway; this.wantsDecisions = options.wantsDecisions; this.decisionBudgetMs = options.decisionBudgetMs; + this.decisionReads = options.decisionReads; this.claudeHttpHooks = options.claudeHttpHooks; this.providers = new ProviderRuntimeLaunchAdapters(options); this.coreHooksEnabled = options.coreHooksEnabled !== false; @@ -78,6 +85,8 @@ export class AgentRuntimeBridge implements AgentRuntimeLaunchCoordinator { && this.providers.decisionsSupported(input.provider); let budgetMs: number | undefined; try { budgetMs = decisions ? this.decisionBudgetMs?.(input.provider) : undefined; } catch { budgetMs = undefined; } + let reads = false; + try { reads = decisions && this.decisionReads?.() === true; } catch { reads = false; } const capability = this.coreHooksEnabled || decisions ? this.gateway.registerSession( input.terminalSessionId, @@ -92,7 +101,7 @@ export class AgentRuntimeBridge implements AgentRuntimeLaunchCoordinator { let prepared; try { prepared = this.providers.prepare(input.provider, input.terminalSessionId, this.coreHooksEnabled, decisions, budgetMs, - httpHookBase ?? undefined); + httpHookBase ?? undefined, reads); } catch (error) { if (capability) this.gateway.revokeTerminalSession(input.terminalSessionId, capability.capabilityToken); throw error; diff --git a/src/main/services/agent-runtime/ProviderRuntimeLaunch.ts b/src/main/services/agent-runtime/ProviderRuntimeLaunch.ts index 949d29ba..c9fca1ed 100644 --- a/src/main/services/agent-runtime/ProviderRuntimeLaunch.ts +++ b/src/main/services/agent-runtime/ProviderRuntimeLaunch.ts @@ -26,6 +26,7 @@ import { CLAUDE_HTTP_HOOK, DECISION_BUDGET_ENV, OPENCODE_DECISIONS_ENV, + OPENCODE_DECISION_READS_ENV, permissionGateTimings } from "../../../agent-runtime/runtime-protocol.mjs"; @@ -175,6 +176,7 @@ export class ProviderRuntimeLaunchAdapters { * `decisionBudgetMs` (a decision service's `decide.timeoutMs`) lengthens the hook's deadlines to fit it. * `claudeHttpHookBase` (Claude Code only) sends its lifecycle events, except SessionStart, as HTTP hooks to the * gateway's loopback listener instead of running the helper; the decision hook stays a command. + * `decisionReads` (base protection on at launch) lets the decision hook see read-only file tools as well. */ prepare( provider: AgentProvider, @@ -182,13 +184,14 @@ export class ProviderRuntimeLaunchAdapters { coreHooksEnabled = true, decisions = false, decisionBudgetMs?: number, - claudeHttpHookBase?: string + claudeHttpHookBase?: string, + decisionReads = false ): PreparedProviderRuntimeLaunch { const pluginRegistrations = this.options.pluginHooks?.list(provider) ?? []; const gate = decisions && this.decisionsSupported(provider); const pluginCommands = [ ...this.pluginHookCommands(provider, pluginRegistrations), - ...(gate && provider !== "opencode" ? decisionHookCommands(provider as DecisionHookProvider, this.options.permissionGate!, this.platform, decisionBudgetMs) : []) + ...(gate && provider !== "opencode" ? decisionHookCommands(provider as DecisionHookProvider, this.options.permissionGate!, this.platform, decisionBudgetMs, decisionReads) : []) ]; const openCodeDecisions = gate && provider === "opencode"; // Only providers with a hook adapter get lifecycle configuration. Cursor, @@ -232,7 +235,11 @@ export class ProviderRuntimeLaunchAdapters { return prepared([], { ...environment, ...pluginEnvironment, - ...(openCodeDecisions ? { [OPENCODE_DECISIONS_ENV]: "1", ...budgetEnvironment(decisionBudgetMs) } : {}), + ...(openCodeDecisions ? { + [OPENCODE_DECISIONS_ENV]: "1", + ...(decisionReads ? { [OPENCODE_DECISION_READS_ENV]: "1" } : {}), + ...budgetEnvironment(decisionBudgetMs) + } : {}), [OPENCODE_CONFIG_CONTENT]: openCodeLifecycleConfig( this.environment[OPENCODE_CONFIG_CONTENT], this.options.openCodePluginPath @@ -549,17 +556,28 @@ const DECISION_TOOL_MATCHERS: Readonly> = { qwen: "^(run_shell_command|write_file|edit|replace)$" }; +/** + * With base protection on at launch the hook also matches read-only file tools, for its private-data rule only. + * Codex has no read-only file tool in its hooks: it reads through its shell. + */ +const DECISION_READ_TOOL_MATCHERS: Readonly> = { + claude: "Bash|Write|Edit|MultiEdit|NotebookEdit|Read|NotebookRead|Grep|Glob|LS", + codex: "Bash|apply_patch|Edit|Write", + qwen: "^(run_shell_command|write_file|edit|replace|read_file|read_many_files|grep_search|search_file_content|glob|list_directory)$" +}; + function decisionHookCommands( provider: DecisionHookProvider, gate: RuntimeHookHelperLaunch, platform: NodeJS.Platform, - decisionBudgetMs?: number + decisionBudgetMs?: number, + reads = false ): ProviderHookCommand[] { validateHelper(gate); const { hookSeconds } = permissionGateTimings(decisionBudgetMs); return [{ event: "PreToolUse", - matcher: DECISION_TOOL_MATCHERS[provider], + matcher: (reads ? DECISION_READ_TOOL_MATCHERS : DECISION_TOOL_MATCHERS)[provider], // The hook is installed only when something decides for this session, so it always fails closed. command: commandWithEnvironment( [gate.command, ...gate.args, "pretool"], diff --git a/src/main/services/safety/baseProtection.ts b/src/main/services/safety/baseProtection.ts index fac58420..8b04c927 100644 --- a/src/main/services/safety/baseProtection.ts +++ b/src/main/services/safety/baseProtection.ts @@ -1,5 +1,5 @@ import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { isAbsolute, join } from 'node:path'; import { isPathInside } from '../../../agent-runtime/path-inside.mjs'; import { analyzeAction, commandFromArgv, realish, type HardFacts, type PrivateData, type ToolAction } from './commandFacts.ts'; @@ -10,7 +10,7 @@ import { analyzeAction, commandFromArgv, realish, type HardFacts, type PrivateDa * model: local rules only, no git, no network. */ -const BASE_DENY_RULES = ['app-private', 'elevation', 'pipe-to-shell', 'download-exec', 'disk', 'fork-bomb', 'delete-outside', 'write-outside', 'unknown-target'] as const; +const BASE_DENY_RULES = ['app-private', 'elevation', 'pipe-to-shell', 'download-exec', 'disk', 'fork-bomb', 'delete-outside', 'write-outside', 'unknown-target', 'too-many-paths'] as const; export type BaseDenyRule = typeof BASE_DENY_RULES[number]; /** What the model reads: why the call was refused and what to do instead. */ @@ -23,7 +23,8 @@ const DENY_MESSAGES: Readonly> = { 'fork-bomb': 'CanvasTTY blocked this command: it would exhaust the computer\'s processes. Do not run it.', 'delete-outside': 'CanvasTTY blocked this command: it deletes files outside the project folder (or the folder itself). Delete only inside the project; if something elsewhere must go, ask the person.', 'write-outside': 'CanvasTTY blocked this command: it writes outside the project folder. Keep changes inside the project; if a file elsewhere must change, ask the person.', - 'unknown-target': 'CanvasTTY blocked this command: it deletes or moves a path held in a shell variable or command output that CanvasTTY cannot resolve, so it cannot tell whether the path is inside the project folder. Write the path out, or set the variable in the same command to a path inside the project, and run it again.' + 'unknown-target': 'CanvasTTY blocked this command: it deletes or moves a path held in a shell variable or command output that CanvasTTY cannot resolve, so it cannot tell whether the path is inside the project folder. Write the path out, or set the variable in the same command to a path inside the project, and run it again.', + 'too-many-paths': 'CanvasTTY blocked this tool call: it names more than 32 paths or patterns, more than base protection checks in one call. Read the files in smaller groups of at most 32.' }; const TEMP_WRITE_MESSAGE = 'CanvasTTY blocked this command: it writes to the temporary folder (/tmp or $TMPDIR), which is outside the project folder. Make a scratch folder inside the project instead (for example ./tmp, added to .gitignore if needed) and use that; if a file elsewhere must change, ask the person.'; @@ -32,6 +33,14 @@ export interface BaseVerdict { rule: BaseDenyRule; message: string } /** Tool names of the CLIs' hooks: shells and file writes. Anything else is not checked. */ const SHELL_TOOLS = new Set(['Bash', 'bash', 'run_shell_command', 'shell', 'local_shell', 'exec_command']); const EDIT_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'write_file', 'edit', 'replace', 'apply_patch']); +/** + * Read-only file tools: Claude Code's Read, NotebookRead, Grep, Glob and LS; Qwen Code's read_file, read_many_files, + * grep_search (search_file_content), glob and list_directory; OpenCode's read, grep, glob and list arrive under the + * Claude names. They are checked only against CanvasTTY's private data, never against the project folder. + */ +const READ_TOOLS = new Set(['Read', 'NotebookRead', 'Grep', 'Glob', 'LS', 'read_file', 'read_many_files', 'grep_search', 'search_file_content', 'glob', 'list_directory']); +/** Read-only tools whose `path` is a folder they walk (search), rather than one file or one listing. */ +const SEARCH_TOOLS = new Set(['Grep', 'Glob', 'grep_search', 'search_file_content', 'glob']); const MAX_PATHS = 32; const MAX_PATH_CHARS = 4_096; @@ -63,6 +72,7 @@ export function actionFromHook(toolName: string, toolInput: unknown, preview: st : Array.isArray(candidate) && candidate.length && candidate.every(item => typeof item === 'string') ? commandFromArgv(candidate as string[]) : null; return { kind: 'shell', command, commandCwd, paths: [] }; } + if (READ_TOOLS.has(toolName)) return readAction(toolName, raw, preview); if (!EDIT_TOOLS.has(toolName)) return { kind: null, command: null, commandCwd: null, paths: [] }; if (!raw && preview && toolName !== 'apply_patch') { const paths: string[] = []; @@ -80,6 +90,32 @@ export function actionFromHook(toolName: string, toolInput: unknown, preview: st return { kind: 'edit', command: null, commandCwd: null, paths: path ? [path] : [] }; } +/** A read-only tool's files, glob patterns and searched folders. Cut input: the paths at the start of its preview. */ +function readAction(toolName: string, raw: Record | null, preview: string | null): ToolAction { + const text = (value: unknown): string | null => typeof value === 'string' && value.length > 0 && value.length <= MAX_PATH_CHARS ? value : null; + const action: ToolAction = { kind: 'read', command: null, commandCwd: null, paths: [], searched: [] }; + if (!raw) { + for (const match of (preview ?? '').matchAll(PREVIEW_PATH)) { + try { const value: unknown = JSON.parse(`"${match[1]}"`); if (typeof value === 'string' && value) action.searched!.push(value); } catch { /* a cut escape */ } + if (action.searched!.length >= 4) break; + } + return action; + } + const file = text(raw.file_path) ?? text(raw.filePath) ?? text(raw.notebook_path) ?? text(raw.absolute_path); + if (file) action.paths.push(file); + const folder = text(raw.path) ?? text(raw.dir_path) ?? text(raw.directory); + if (folder) (SEARCH_TOOLS.has(toolName) ? action.searched! : action.paths).push(folder); + // A glob pattern names files itself (`/…/canvastty/provider-*`); relative ones resolve against the searched folder. + const pattern = toolName === 'Glob' || toolName === 'glob' ? text(raw.pattern) : null; + if (pattern) action.paths.push(folder && !isAbsolute(pattern) && !pattern.startsWith('~') && !pattern.startsWith('$') ? join(folder, pattern) : pattern); + // Qwen Code's read_many_files: `paths` and `include` are both globs it expands and reads. More than are checked is + // refused, not cut short. + const many = [raw.paths, raw.include].flatMap(list => Array.isArray(list) ? list : []); + if (many.length > MAX_PATHS) { action.tooMany = true; return action; } + for (const item of many) { const path = text(item); if (path) action.searched!.push(path); } + return action; +} + /** * CanvasTTY's own private data under its userData folder (the app passes `app.getPath('userData')`; tests pass a * temporary folder): the control token and descriptor, the gateways' connection records and sockets, the secret @@ -124,6 +160,7 @@ export function checkBaseProtection(input: { try { const action = actionFromHook(input.toolName, input.toolInput, input.preview ?? null); if (action.kind === null) return null; + if (action.tooMany) return { rule: 'too-many-paths', message: DENY_MESSAGES['too-many-paths'] }; // The agent's current folder (the hook reports it) resolves relative paths; the working folder stays the root. if (!action.commandCwd && input.commandCwd) action.commandCwd = input.commandCwd; const facts = analyzeAction(action, input.root, { diff --git a/src/main/services/safety/commandFacts.ts b/src/main/services/safety/commandFacts.ts index c72e6978..c3fd8e5e 100644 --- a/src/main/services/safety/commandFacts.ts +++ b/src/main/services/safety/commandFacts.ts @@ -12,15 +12,19 @@ import { lexShell, shellQuote, type Segment, type Word } from './shellParse.ts'; * data (its access tokens, secret stores and control sockets). */ -/** One tool call, source-neutral: a shell command or the files a file tool writes. */ +/** One tool call, source-neutral: a shell command, the files a file tool writes, or the files a read-only tool reads. */ export interface ToolAction { - kind: 'shell' | 'edit' | null; + kind: 'shell' | 'edit' | 'read' | null; /** The shell command; null when none could be read. */ command: string | null; /** The command's own working directory, if the agent said one. */ commandCwd: string | null; - /** Paths a file tool writes. */ + /** Paths a file tool writes, or (`read`) the files and glob patterns a read-only tool reads. */ paths: string[]; + /** `read` only: folders a search or listing tool walks (Grep's or Glob's `path`). */ + searched?: string[]; + /** `read` only: the call names more paths than are checked; it is refused. */ + tooMany?: boolean; } export type Where = 'inside' | 'outside' | 'unresolved'; @@ -1139,6 +1143,10 @@ export function analyzeAction(action: ToolAction, root: string, options: { home? else if (action.kind === 'edit') { acc.writes.push(...action.paths.map(path => resolveTarget(path, cwd, ctx))); if (action.paths.some(path => { const text = codePath(path, ctx) ?? path; return privateHit(text, cwd, acc, false, false); })) acc.flags.appPrivate = true; + } else if (action.kind === 'read') { + // A read-only tool is only checked against CanvasTTY's private data: reading elsewhere is not "outside". + const hit = (path: string, recursive: boolean): boolean => { const text = codePath(path, ctx) ?? path; return privateHit(text, cwd, acc, recursive); }; + if (action.paths.some(path => hit(path, false)) || (action.searched ?? []).some(path => hit(path, true))) acc.flags.appPrivate = true; } const outside = acc.writes.filter(t => t.where === 'outside' && !t.device && !(t.abs && isAgentServicePath(t.abs, ctx))); return { diff --git a/tests/base-protection-app-private.test.mjs b/tests/base-protection-app-private.test.mjs index 54f9de11..7bbb9b56 100644 --- a/tests/base-protection-app-private.test.mjs +++ b/tests/base-protection-app-private.test.mjs @@ -11,6 +11,7 @@ import { join } from "node:path"; import { analyzeAction } from "../src/main/services/safety/commandFacts.ts"; import { canvasTtyPrivateData, checkBaseProtection, denyRule } from "../src/main/services/safety/baseProtection.ts"; import { DecisionHooks } from "../src/main/services/DecisionHooks.ts"; +import { guardedCall } from "../src/agent-runtime/opencode-decisions.mjs"; const base = realpathSync(mkdtempSync(join(tmpdir(), "canvastty-app-private-"))); const home = join(base, "home"); @@ -184,6 +185,60 @@ test("file tools that name private data are refused; the agent's own account hom assert.equal(check("Bash", { command: `cat ${q(token)}` }, { agentRoots: [accountHome] })?.rule, "app-private"); }); +test("read-only file tools are refused only for CanvasTTY's private data", () => { + const secrets = join(userData, "provider-secrets.bin"); + const accountHome = join(userData, "account-homes", "acct-1", ".claude"); + for (const root of [project, home]) { + const read = (toolName, toolInput, extra = {}) => checkBaseProtection({ toolName, toolInput, root, home, privateData, ...extra })?.rule ?? null; + for (const [toolName, toolInput, extra] of [ + ["Read", { file_path: secrets }], + ["Read", { file_path: "~/Library/Application Support/canvastty/agent-control/token-0123abcd" }], + ["NotebookRead", { notebook_path: join(userData, "github-oauth.json") }], + ["Grep", { pattern: "token", path: userData }], + ["Grep", { pattern: "x", path: join(userData, "plugin-secrets") }], + ["Glob", { pattern: "*", path: control }], + ["Glob", { pattern: join(userData, "provider-*") }], + ["LS", { path: join(userData, "account-homes") }], + ["read_file", { absolute_path: secrets }], + ["read_many_files", { paths: [join(userData, "plugin-secrets")] }], + ["grep_search", { pattern: "x", path: userData }], + ["list_directory", { path: control }], + ["Read", { file_path: join(userData, "account-homes", "acct-2", "auth.json") }, { agentRoots: [accountHome] }] + ]) assert.equal(read(toolName, toolInput, extra), "app-private", `${root}: ${toolName} ${JSON.stringify(toolInput)}`); + // Everything else stays as it was: reads outside the project are not "outside", and nothing new is refused. + for (const [toolName, toolInput] of [ + ["Read", { file_path: join(project, "src", "a.ts") }], + ["Read", { file_path: "src/a.ts" }], + ["Read", { file_path: "/etc/hosts" }], + ["Read", { file_path: join(userData, "settings.json") }], + ["Grep", { pattern: "todo", path: "src" }], + ["Grep", { pattern: "todo" }], + ["Grep", { pattern: "todo", path: home }], + ["Glob", { pattern: "**/*.ts" }], + ["LS", { path: userData }], + ["read_file", { absolute_path: join(project, "src", "a.ts") }] + ]) assert.equal(read(toolName, toolInput), null, `${root}: ${toolName} ${JSON.stringify(toolInput)}`); + // The agent's own account home stays its own. + assert.equal(read("Read", { file_path: join(accountHome, "settings.json") }, { agentRoots: [accountHome] }), null, root); + assert.equal(read("Grep", { pattern: "x", path: accountHome }, { agentRoots: [accountHome] }), null, root); + } +}); + +test("decision hooks refuse a private read without asking decision plugins about reads", async () => { + let asked = 0; + const hooks = new DecisionHooks({ + baseProtection: () => true, services: () => [{ pluginId: "p", pluginName: "P", serviceId: "s", mayAllow: false }], + call: async () => { asked++; return { behavior: "deny", message: "plugin" }; }, home, privateData, + session: () => ({ provider: "claude", role: "agent", cwd: home, configDirs: [] }) + }); + const request = (toolInput) => ({ toolName: "Read", toolInput, toolInputPreview: null, cwd: null, truncated: false }); + const denied = await hooks.decide("s1", request({ file_path: join(userData, "provider-secrets.bin") }), new AbortController().signal); + assert.equal(denied.behavior, "deny"); + assert.match(denied.message, /Orchestrator role/u); + assert.equal((await hooks.decide("s1", request({ file_path: join(home, "notes.txt") }), new AbortController().signal)).behavior, "none"); + assert.equal(asked, 0, "plugins are not asked about read-only tools"); +}); + test("without the app's folder the socket folders are still known; the userData paths are not guessed", () => { assert.equal(denyRule(analyzeAction(shell(`nc -U ${sock}`), project, { home })), "app-private"); assert.equal(denyRule(analyzeAction(shell(`cat ${q(token)}`), project, { home })), null); @@ -209,3 +264,33 @@ test("decision hooks pass the app's private data to base protection", async () = const ordinary = await hooks.decide("s1", { toolName: "bash", toolInput: { command: "cat src/a.ts" }, toolInputPreview: null, cwd: null, truncated: false }, new AbortController().signal); assert.equal(ordinary.behavior, "none"); }); + +test("Qwen read_many_files: more than 32 paths is refused, and its include globs are checked", () => { + const secrets = join(userData, "provider-secrets.bin"); + const read = (toolInput) => checkBaseProtection({ toolName: "read_many_files", toolInput, root: project, home, privateData }) ?? null; + const benign = Array.from({ length: 32 }, (_, i) => `src/f${i}.ts`); + assert.equal(read({ paths: benign }), null, "32 project paths are checked and allowed"); + const many = read({ paths: [...benign, secrets] }); + assert.equal(many?.rule, "too-many-paths", "the 33rd path is not skipped"); + assert.match(many.message, /at most 32/u); + assert.equal(read({ paths: [...benign, "src/g.ts"] })?.rule, "too-many-paths"); + assert.equal(read({ paths: benign.slice(0, 20), include: benign.slice(0, 13) })?.rule, "too-many-paths", "include counts toward the limit"); + assert.equal(read({ paths: ["src/a.ts"], include: [secrets] })?.rule, "app-private"); + assert.equal(read({ paths: ["src/a.ts"], include: [join(userData, "account-homes", "**", "*.json")] })?.rule, "app-private"); + assert.equal(read({ paths: ["src/a.ts"], include: ["**/*.ts"] }), null); +}); + +test("OpenCode's read-only tools reach base protection: a grep without a path searches the current folder, a glob's pattern is checked", () => { + const check = (tool, args, root) => { + const call = guardedCall(tool, args, true); + assert.ok(call, `${tool} is forwarded`); + return checkBaseProtection({ toolName: call.toolName, toolInput: call.toolInput, root, home, privateData })?.rule ?? null; + }; + assert.equal(check("grep", { pattern: "token" }, userData), "app-private", "grep with no path, run in the app's folder"); + assert.equal(check("grep", { pattern: "token", include: "*.json" }, control), "app-private"); + assert.equal(check("grep", { pattern: "token" }, project), null); + assert.equal(check("glob", { pattern: join(userData, "provider-*") }, project), "app-private"); + assert.equal(check("glob", { pattern: "agent-control/token-*", path: userData }, project), "app-private"); + assert.equal(check("glob", { pattern: "**/*.ts" }, project), null); +}); + diff --git a/tests/base-protection.test.mjs b/tests/base-protection.test.mjs index 86039234..91ac9adb 100644 --- a/tests/base-protection.test.mjs +++ b/tests/base-protection.test.mjs @@ -80,7 +80,7 @@ test("ordinary work inside the working folder has no deny", () => { assert.equal(rule(edit("src/new.ts")), null); }); -test("hook tool calls: Claude, Codex, Qwen and OpenCode names; only shells and file writes are checked", () => { +test("hook tool calls: Claude, Codex, Qwen and OpenCode names; shells and file writes are checked, reads only for private data", () => { const cases = [ ["Write", { file_path: join(home, "Downloads", "hello.txt"), content: "hello" }, "write-outside"], ["Edit", { file_path: "/etc/hosts", old_string: "a", new_string: "b" }, "write-outside"], @@ -99,7 +99,9 @@ test("hook tool calls: Claude, Codex, Qwen and OpenCode names; only shells and f assert.equal(check(tool, input), null, `${tool} ${JSON.stringify(input)}`); } assert.deepEqual(patchPaths("*** Begin Patch\n*** Add File: a.txt\n*** Delete File: b.txt\n*** End Patch"), ["a.txt", "b.txt"]); - assert.deepEqual(actionFromHook("Grep", { pattern: "x" }).kind, null); + assert.deepEqual(actionFromHook("Grep", { pattern: "x" }), { kind: "read", command: null, commandCwd: null, paths: [], searched: [] }); + assert.deepEqual(actionFromHook("Read", { file_path: "/etc/passwd" }).paths, ["/etc/passwd"]); + assert.deepEqual(actionFromHook("WebFetch", { url: "https://example.com" }).kind, null); }); test("each deny tells the model what to do instead; a write only to the temporary folder suggests a folder in the project", () => { diff --git a/tests/decision-hooks.test.mjs b/tests/decision-hooks.test.mjs index b7a4a230..27af58f5 100644 --- a/tests/decision-hooks.test.mjs +++ b/tests/decision-hooks.test.mjs @@ -11,7 +11,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; -import { AGENT_RUNTIME_ENV, OPENCODE_DECISIONS_ENV, RUNTIME_PROTOCOL_VERSION } from "../src/agent-runtime/runtime-protocol.mjs"; +import { AGENT_RUNTIME_ENV, OPENCODE_DECISIONS_ENV, OPENCODE_DECISION_READS_ENV, RUNTIME_PROTOCOL_VERSION } from "../src/agent-runtime/runtime-protocol.mjs"; import { buildRequest, hookOutput, parseDecision } from "../src/agent-runtime/permission-gate.mjs"; import { createOpenCodeDecisions, guardedCall } from "../src/agent-runtime/opencode-decisions.mjs"; import { DecisionHooks, TOO_LARGE_MESSAGE, mergeDecisions } from "../src/main/services/DecisionHooks.ts"; @@ -129,8 +129,10 @@ test("cut input under base protection: a shell call, or a file write whose targe // A file write whose path is at the start of the preview is judged by that path, as before. const inside = JSON.stringify({ file_path: join(project, "big.txt"), content: "x".repeat(300) }).slice(0, 200); assert.equal((await decisions.decide("s1", cut("Write", inside), live())).behavior, "none"); + // A cut read-only call may hide the file it reads (a long read_many_files list): denied too. + assert.deepEqual(await decisions.decide("s1", cut("read_many_files", "{\"paths\":[\"src/a.ts\""), live()), { behavior: "deny", message: TOO_LARGE_MESSAGE }); // Tools the rules do not read, and base protection off, are unchanged. - assert.equal((await decisions.decide("s1", cut("Read", "{\"file_path\":"), live())).behavior, "none"); + assert.equal((await decisions.decide("s1", cut("WebFetch", "{\"url\":"), live())).behavior, "none"); assert.equal((await hooks({ protect: false }).decide("s1", cut("Bash", "{\"command\":\"ls"), live())).behavior, "none"); }); @@ -302,6 +304,9 @@ test("launch: the decision hook is added only when wanted, per provider, with or const claude = JSON.parse(adapters.prepare("claude", "t1", false, true).args[1]); assert.deepEqual(Object.keys(claude.hooks), ["PreToolUse"]); assert.equal(claude.hooks.PreToolUse[0].matcher, "Bash|Write|Edit|MultiEdit|NotebookEdit"); + // Read-only file tools only when base protection was on at launch (they are checked for its private-data rule). + const claudeReads = JSON.parse(adapters.prepare("claude", "t1", false, true, undefined, undefined, true).args[1]); + assert.equal(claudeReads.hooks.PreToolUse[0].matcher, "Bash|Write|Edit|MultiEdit|NotebookEdit|Read|NotebookRead|Grep|Glob|LS"); assert.match(claude.hooks.PreToolUse[0].hooks[0].command, /permission-gate\.mjs['"] ['"]pretool['"]$/u); assert.ok(JSON.parse(adapters.prepare("claude", "t1", true, true).args[1]).hooks.Stop, "status hooks stay alongside"); const codex = adapters.prepare("codex", "t2", false, true).args.join(" "); @@ -309,8 +314,15 @@ test("launch: the decision hook is added only when wanted, per provider, with or const qwen = adapters.prepare("qwen", "t3", false, true); const qwenSettings = JSON.parse(await readFile(qwen.environment.QWEN_CODE_SYSTEM_SETTINGS_PATH, "utf8")); assert.equal(qwenSettings.hooks.PreToolUse[0].hooks[0].timeout, 15_000); + assert.equal(qwenSettings.hooks.PreToolUse[0].matcher, "^(run_shell_command|write_file|edit|replace)$"); qwen.releaseConfiguration(); + const qwenReads = adapters.prepare("qwen", "t3", false, true, undefined, undefined, true); + assert.equal(JSON.parse(await readFile(qwenReads.environment.QWEN_CODE_SYSTEM_SETTINGS_PATH, "utf8")).hooks.PreToolUse[0].matcher, + "^(run_shell_command|write_file|edit|replace|read_file|read_many_files|grep_search|search_file_content|glob|list_directory)$"); + qwenReads.releaseConfiguration(); assert.equal(adapters.prepare("opencode", "t4", false, true).environment[OPENCODE_DECISIONS_ENV], "1"); + assert.equal(adapters.prepare("opencode", "t4", false, true).environment[OPENCODE_DECISION_READS_ENV], undefined); + assert.equal(adapters.prepare("opencode", "t4", false, true, undefined, undefined, true).environment[OPENCODE_DECISION_READS_ENV], "1"); assert.equal(adapters.prepare("opencode", "t4", false, false).environment[OPENCODE_DECISIONS_ENV], undefined); assert.equal(adapters.decisionsSupported("kimi"), false); assert.deepEqual(adapters.prepare("kimi", "t5", false, true).args, []); @@ -325,9 +337,10 @@ test("launch: the decision hook is added only when wanted, per provider, with or }; const bridge = new AgentRuntimeBridge(gateway, { helper, runtimeDirectory, openCodePluginPath: "/opt/CanvasTTY/opencode-plugin.mjs", kimiHomeDirectory: join(runtimeDirectory, "kimi"), hermesHomeDirectory: join(runtimeDirectory, "hermes"), grokHomeDirectory: join(runtimeDirectory, "grok"), - permissionGate, coreHooksEnabled: true, wantsDecisions: (provider) => provider === "claude" }); + permissionGate, coreHooksEnabled: true, wantsDecisions: (provider) => provider === "claude", decisionReads: () => true }); const launched = bridge.prepareLaunch({ terminalSessionId: "a", provider: "claude", cwd: project }); assert.equal(launched.decisions, true); + assert.match(JSON.stringify(launched.args), /NotebookEdit\|Read\|NotebookRead/u, "base protection on: the hook sees reads"); assert.equal(registered[0][4], true); assert.equal(bridge.prepareLaunch({ terminalSessionId: "b", provider: "codex", cwd: project }).decisions, false); bridge.setCoreHooksEnabled(false); @@ -336,7 +349,7 @@ test("launch: the decision hook is added only when wanted, per provider, with or test("OpenCode: the guard throws a deny, remembers an allow and answers OpenCode's prompt once", async () => { const env = { - [OPENCODE_DECISIONS_ENV]: "1", [AGENT_RUNTIME_ENV.address]: "/tmp/x.sock", [AGENT_RUNTIME_ENV.terminalSessionId]: "t", + [OPENCODE_DECISIONS_ENV]: "1", [OPENCODE_DECISION_READS_ENV]: "1", [AGENT_RUNTIME_ENV.address]: "/tmp/x.sock", [AGENT_RUNTIME_ENV.terminalSessionId]: "t", [AGENT_RUNTIME_ENV.provider]: "opencode", [AGENT_RUNTIME_ENV.capabilityToken]: "c".repeat(40) }; const replies = []; @@ -354,10 +367,25 @@ test("OpenCode: the guard throws a deny, remembers an allow and answers OpenCode next = { behavior: "ask", message: "" }; await decisions.guard({ tool: "bash", callID: "c3" }, { args: { command: "ls" } }); assert.equal(await decisions.permissionAsked({ id: "r3", tool: { callID: "c3" } }), false, "ask leaves OpenCode's own prompt"); - await decisions.guard({ tool: "read", callID: "c4" }, { args: { filePath: "/etc/hosts" } }); - assert.equal(sent.length, 3, "tools that neither run commands nor write files are not sent"); + await decisions.guard({ tool: "webfetch", callID: "c4" }, { args: { url: "https://example.com" } }); + assert.equal(sent.length, 3, "tools that neither run commands, write files nor read them are not sent"); + await decisions.guard({ tool: "read", callID: "c5" }, { args: { filePath: "/etc/hosts" } }); + assert.equal(sent.length, 4, "a read-only file tool is sent for base protection's private-data rule"); + assert.equal(sent[3].toolName, "Read"); + // Base protection off at launch: reads are not sent at all. + const noReads = []; + await createOpenCodeDecisions({ client, env: { ...env, [OPENCODE_DECISION_READS_ENV]: undefined }, send: async (_address, message) => { noReads.push(message); return null; } }) + .guard({ tool: "read", callID: "c6" }, { args: { filePath: "/etc/hosts" } }); + assert.equal(noReads.length, 0); assert.equal(createOpenCodeDecisions({ env: { ...env, [OPENCODE_DECISIONS_ENV]: "0" } }).enabled, false); assert.deepEqual(guardedCall("apply_patch", { patchText: "*** Begin Patch" }), { toolName: "apply_patch", toolInput: { patch: "*** Begin Patch" } }); + assert.deepEqual(guardedCall("read", { filePath: "/p/a.ts" }, true), { toolName: "Read", toolInput: { file_path: "/p/a.ts" } }); + assert.deepEqual(guardedCall("grep", { pattern: "x", path: "/p" }, true), { toolName: "Grep", toolInput: { path: "/p" } }); + assert.deepEqual(guardedCall("glob", { pattern: "**/*.ts" }, true), { toolName: "Glob", toolInput: { pattern: "**/*.ts" } }); + assert.deepEqual(guardedCall("glob", { pattern: "*.md", path: "/p" }, true), { toolName: "Glob", toolInput: { path: "/p", pattern: "*.md" } }); + assert.deepEqual(guardedCall("list", { path: "/p" }, true), { toolName: "LS", toolInput: { path: "/p" } }); + assert.deepEqual(guardedCall("grep", { pattern: "x", include: "*.ts" }, true), { toolName: "Grep", toolInput: { path: "." } }, "a grep without a path searches the current folder"); + assert.equal(guardedCall("read", { filePath: "/p/a.ts" }), null, "reads are mapped only when the launch checks them"); // multiedit: every edit's new text reaches the decision, as Claude's MultiEdit tool_input does. assert.deepEqual(guardedCall("multiedit", { filePath: "/p/a.ts", edits: [{ oldString: "a", newString: "TOKEN=1" }, { oldString: "b", newString: "rm -rf /" }, { newString: 5 }] }), { toolName: "edit", toolInput: { file_path: "/p/a.ts", content: "TOKEN=1\nrm -rf /" } }); diff --git a/tests/permission-gate-fail-closed.test.mjs b/tests/permission-gate-fail-closed.test.mjs index a786939d..d4d57c57 100644 --- a/tests/permission-gate-fail-closed.test.mjs +++ b/tests/permission-gate-fail-closed.test.mjs @@ -14,7 +14,7 @@ import { join } from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; import { - AGENT_RUNTIME_ENV, DECISION_FAIL_CLOSED_ENV, OPENCODE_DECISIONS_ENV, RUNTIME_PROTOCOL_VERSION, permissionGateTimings + AGENT_RUNTIME_ENV, DECISION_FAIL_CLOSED_ENV, OPENCODE_DECISIONS_ENV, OPENCODE_DECISION_READS_ENV, RUNTIME_PROTOCOL_VERSION, permissionGateTimings } from "../src/agent-runtime/runtime-protocol.mjs"; import { FAIL_CLOSED_MESSAGE, parseDecision } from "../src/agent-runtime/permission-gate.mjs"; import { createOpenCodeDecisions } from "../src/agent-runtime/opencode-decisions.mjs"; @@ -262,6 +262,18 @@ test("launch: the decision hook carries the fail-closed flag; a launch without i assert.equal(launch.decisions, false); assert.equal(JSON.stringify(launch.args).includes(DECISION_FAIL_CLOSED_ENV), false); assert.equal(JSON.stringify(launch.args).includes("permission-gate"), false); + // Base protection off, a decision plugin applies: the fail-closed hook covers shells and writes, never reads. + const pluginOnly = new DecisionHooks({ baseProtection: () => false, services: () => [{ pluginId: "p", pluginName: "P", serviceId: "s", mayAllow: false }], + call: async () => null, session: () => null }); + const pluginBridge = new AgentRuntimeBridge(gateway, { ...options, coreHooksEnabled: true, wantsDecisions: (provider) => pluginOnly.wanted(provider), + decisionReads: () => pluginOnly.checksReads() }); + const pluginLaunch = pluginBridge.prepareLaunch({ terminalSessionId: "b", provider: "claude", cwd: root }); + assert.equal(pluginLaunch.decisions, true); + assert.equal(JSON.parse(pluginLaunch.args[1]).hooks.PreToolUse[0].matcher, "Bash|Write|Edit|MultiEdit|NotebookEdit"); + const on = new DecisionHooks({ baseProtection: () => true, services: () => [], call: async () => null, session: () => null }); + assert.equal(on.checksReads(), true); + assert.equal(new DecisionHooks({ baseProtection: () => { throw new Error("settings"); }, services: () => [], call: async () => null, session: () => null }).checksReads(), true, + "a settings read that fails counts as on"); }); test("OpenCode: with decisions on, a check that cannot be made fails the tool call; ordinary answers are unchanged", async () => { @@ -282,7 +294,12 @@ test("OpenCode: with decisions on, a check that cannot be made fails the tool ca await createOpenCodeDecisions({ env, send: async () => ({ behavior, message: "", unavailable: false }) }).guard(...call); } // Tools that are not checked are never sent and never fail. - await createOpenCodeDecisions({ env, send: async () => null }).guard({ tool: "read", callID: "c2" }, { args: { filePath: "/etc/hosts" } }); + await createOpenCodeDecisions({ env, send: async () => null }).guard({ tool: "webfetch", callID: "c2" }, { args: { url: "https://example.com" } }); + // Base protection off at launch: a read-only file tool is not checked, so it never fails closed. + await createOpenCodeDecisions({ env, send: async () => null }).guard({ tool: "read", callID: "c3" }, { args: { filePath: "/etc/hosts" } }); + // Base protection on at launch: it is checked (for CanvasTTY's private data), so it fails closed like the others. + await assert.rejects(createOpenCodeDecisions({ env: { ...env, [OPENCODE_DECISION_READS_ENV]: "1" }, send: async () => null }) + .guard({ tool: "read", callID: "c4" }, { args: { filePath: "/etc/hosts" } }), (error) => error.message === FAIL_CLOSED_MESSAGE); }); test("the gate script stays importable without side effects (no socket, no stdin read)", async () => { From b64348d2b88aa91b2f3d1584a2e6a2214e8b8bb7 Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:09:49 +0000 Subject: [PATCH 17/28] fix(safety): match ** and brace globs, refuse what cannot be checked The private-data rule matched globs one component at a time, so `**` stood for a single folder and `cat ~/Library/**/provider-secrets.bin` was allowed; brace expansion was not seen at all, so `cat /provider-secrets.{bin,}` was allowed too. - `**` now matches zero or more folders. A glob that only passes by the app's folder on its way to other names (`~/**/*.ts`) is not counted. - Unquoted braces in a shell word, and braces in a Glob tool pattern, are expanded (at most 64 words) before matching. - On macOS and Windows a glob matches private names in any letter case. Base protection also had limits that let the rest of a call through unchecked. A call now is refused ("uncheckable") instead when: - a path field is not a string, or is longer than 4096 characters after folding `./` and `a/..` padding; - read_many_files names more than 32 paths and include globs, or an apply_patch touches more than 256 files or a too-long path; - the private-path budget (now 1024 checks), the brace-expansion limit, the path candidates in one word or heredoc (256), or the nesting of shells and substitutions runs out; - checking a shell, file-edit or read call fails with an error. Before, an exception in the analyzer let the call through; other tools still get no opinion. An apply_patch whose command is longer than 4096 characters is read in full; before, it was treated as touching no file. --- docs/plugins.md | 2 +- docs/plugins.ru.md | 2 +- docs/plugins.zh-CN.md | 2 +- src/main/services/safety/baseProtection.ts | 94 +++++++++---- src/main/services/safety/commandFacts.ts | 150 +++++++++++++++++---- src/main/services/safety/shellParse.ts | 3 + tests/base-protection-app-private.test.mjs | 101 +++++++++++++- 7 files changed, 293 insertions(+), 61 deletions(-) diff --git a/docs/plugins.md b/docs/plugins.md index feb6b217..75c1a4ce 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -409,7 +409,7 @@ Two safety parts are built in and need no plugin: - **Base protection** (Settings → Agents, on by default; the person can turn it off) denies, through the same hook, sudo and other elevation, piping downloaded or generated text into a shell, download-and-run, disk and format commands, fork bombs, and writing or deleting outside the working folder: the home folder, other projects and `/tmp` included, and deleting the working folder itself. An agent's own plan and memory folders (`~/.claude/plans`, `~/.claude/projects//memory`, and the same inside the run's `CLAUDE_CONFIG_DIR`) are not "outside". It also denies use of CanvasTTY's own private data (from the app's userData folder: the agent-control token and descriptor, the gateways' connection records and sockets, the secret stores, account homes; and the control/runtime socket folders under the temporary folder), even when the working folder contains it (a project at the home folder): commands that name it, including interpreter one-liners and socket clients it recognises, file writes, and read-only file tools (Claude Code's Read, NotebookRead, Grep, Glob and LS; Qwen Code's and OpenCode's equivalents, checked for nothing else and only while base protection was on when the agent started); the reason points the model at an **Orchestrator** launch and the `canvastty_agents` tools. The bundled control CLI may name its descriptor. It only ever denies; each reason tells the model what to do instead (a write to `/tmp` suggests a scratch folder inside the project). - Base protection is best effort: it reads each tool call before it runs, so it does not catch a path the call builds at run time (a command substitution, a variable it cannot resolve, a script that walks folders itself such as Python's `os.walk`), and it does not refuse a recursive search started above the app's data folder (`grep -r token ~`, a Grep of the home folder). A project set to the home folder, or to any folder that contains CanvasTTY's data, is therefore not a safe configuration on its own. Run such agents with agent isolation (sandbox-exec on macOS, bubblewrap on Linux): it hides the private data from every program in the agent's process tree, walks included. + Base protection is best effort: it reads each tool call before it runs, so it does not catch a path the call builds at run time (a command substitution, a variable it cannot resolve, a script that walks folders itself such as Python's `os.walk`), and it does not refuse a recursive search started above the app's data folder (`grep -r token ~`, a Grep of the home folder). A call that names more paths, longer paths or more nested shells than it checks is refused rather than checked in part, and so is a shell, file-edit or read call whose check fails with an error. A project set to the home folder, or to any folder that contains CanvasTTY's data, is therefore not a safe configuration on its own. Run such agents with agent isolation (sandbox-exec on macOS, bubblewrap on Linux): it hides the private data from every program in the agent's process tree, walks included. - **Secret redaction**: every text CanvasTTY hands from one agent to another (`observe_agent`, `wait_for_agent`, `get_agent_result`, the control CLI's `screen`, `result` and failure details) is masked: provider keys CanvasTTY holds, launch `secretEnv` values, values a service registered with `redaction.register`, also when the terminal wrapped them over lines, plus common key shapes (`sk-…`, GitHub, Slack, AWS, Google, JWT, `Bearer …`, `"apiKey": "…"`, PEM private keys, long random runs). Plugin tool answers, `screen` in session events, card badges and card action messages are masked the same way. host.onStorageChange(listener) notifies every live contribution of the same plugin — canvases, HOME widgets, and separate windows — of writes made through host.storage.set, avoiding polling when a plugin coordinates several surfaces. diff --git a/docs/plugins.ru.md b/docs/plugins.ru.md index 9647f1ca..558a4695 100644 --- a/docs/plugins.ru.md +++ b/docs/plugins.ru.md @@ -387,7 +387,7 @@ interface PluginSessionEvent { - **Базовая защита** (Настройки → Агенты, включена по умолчанию; человек может её выключить) через тот же хук запрещает sudo и другое повышение прав, передачу скачанного или сгенерированного текста в shell, скачивание с запуском, команды для дисков и форматирования, форк-бомбы, а также запись и удаление вне рабочей папки — включая домашнюю папку, другие проекты и `/tmp` — и удаление самой рабочей папки. Собственные папки планов и памяти агента (`~/.claude/plans`, `~/.claude/projects//memory` и то же внутри `CLAUDE_CONFIG_DIR` запуска) не считаются «вне». Она только запрещает; каждая причина говорит модели, что сделать вместо этого (для записи в `/tmp` — завести временную папку внутри проекта). - Она также запрещает использование собственных закрытых данных CanvasTTY (токен и описание agent-control, записи подключений и сокеты шлюзов, хранилища секретов, домашние папки аккаунтов, папки сокетов во временной папке), даже если рабочая папка их содержит: команды, которые называют эти пути, запись файлов и инструменты только для чтения (Read, NotebookRead, Grep, Glob и LS у Claude Code; аналоги у Qwen Code и OpenCode, только если базовая защита была включена при запуске агента). Эта проверка — по возможности: она читает вызов инструмента до запуска, поэтому не замечает путь, собранный во время выполнения (подстановка команды, неизвестная переменная, скрипт, который сам обходит папки, например `os.walk` в Python), и не запрещает рекурсивный поиск, начатый выше папки данных приложения (`grep -r token ~`, Grep по домашней папке). Поэтому проект в домашней папке или в любой папке, содержащей данные CanvasTTY, сам по себе небезопасен. Запускайте такие агенты в изоляции (sandbox-exec на macOS, bubblewrap на Linux): она скрывает закрытые данные от всех программ процесса агента, включая обход папок. + Она также запрещает использование собственных закрытых данных CanvasTTY (токен и описание agent-control, записи подключений и сокеты шлюзов, хранилища секретов, домашние папки аккаунтов, папки сокетов во временной папке), даже если рабочая папка их содержит: команды, которые называют эти пути, запись файлов и инструменты только для чтения (Read, NotebookRead, Grep, Glob и LS у Claude Code; аналоги у Qwen Code и OpenCode, только если базовая защита была включена при запуске агента). Эта проверка — по возможности: она читает вызов инструмента до запуска, поэтому не замечает путь, собранный во время выполнения (подстановка команды, неизвестная переменная, скрипт, который сам обходит папки, например `os.walk` в Python), и не запрещает рекурсивный поиск, начатый выше папки данных приложения (`grep -r token ~`, Grep по домашней папке). Вызов, в котором больше путей, более длинные пути или больше вложенных shell, чем она проверяет, запрещается, а не проверяется частично; так же запрещается вызов shell, правки или чтения файлов, проверка которого завершилась ошибкой. Поэтому проект в домашней папке или в любой папке, содержащей данные CanvasTTY, сам по себе небезопасен. Запускайте такие агенты в изоляции (sandbox-exec на macOS, bubblewrap на Linux): она скрывает закрытые данные от всех программ процесса агента, включая обход папок. - **Скрытие секретов**: весь текст, который CanvasTTY передаёт от одного агента другому (`observe_agent`, `wait_for_agent`, `get_agent_result`, `screen`, `result` и детали ошибок в control CLI), маскируется: ключи провайдеров, которые хранит CanvasTTY, значения `secretEnv` запуска, значения, зарегистрированные сервисом через `redaction.register`, в том числе перенесённые терминалом на несколько строк, а также типичные формы ключей (`sk-…`, GitHub, Slack, AWS, Google, JWT, `Bearer …`, `"apiKey": "…"`, приватные ключи PEM, длинные случайные строки). Так же маскируются ответы инструментов плагинов, `screen` в событиях сессий, метки на окнах и сообщения действий. host.onStorageChange(listener) сообщает всем открытым поверхностям того же плагина — canvas cards, HOME widgets и отдельным окнам — об изменениях через host.storage.set, поэтому нескольким поверхностям не требуется постоянный polling. diff --git a/docs/plugins.zh-CN.md b/docs/plugins.zh-CN.md index 511e0d61..2b9db794 100644 --- a/docs/plugins.zh-CN.md +++ b/docs/plugins.zh-CN.md @@ -385,7 +385,7 @@ interface PluginSessionEvent { - **基础保护**(设置 → Agents → Base protection,默认开启;用户可以关闭)通过同一个 hook 拒绝:sudo 及其他提权、把下载或生成的文本管道给 shell、下载后直接运行、磁盘和格式化命令、fork 炸弹,以及在工作文件夹之外写入或删除(包括主目录、其他项目和 `/tmp`),以及删除工作文件夹本身。agent 自己的计划和记忆文件夹(`~/.claude/plans`、`~/.claude/projects//memory`,以及本次运行 `CLAUDE_CONFIG_DIR` 中的相同位置)不算"外部"。它还会拒绝使用 CanvasTTY 自己的私有数据(来自应用的 userData 文件夹:agent-control 令牌与描述文件、各网关的连接记录与套接字、密钥存储、账户主目录;以及临时文件夹下的控制/运行时套接字文件夹),即使工作文件夹包含它们(例如项目就是主目录):包括点名这些路径的命令(含它能识别的解释器单行命令和套接字客户端)、文件写入,以及只读文件工具(Claude Code 的 Read、NotebookRead、Grep、Glob、LS,以及 Qwen Code 和 OpenCode 的对应工具;只读工具不检查其他内容,且仅在 agent 启动时基础保护已开启的情况下才检查);拒绝原因会引导模型改用 **Orchestrator** 启动和 `canvastty_agents` 工具。内置控制 CLI 可以引用它的描述文件。它只会拒绝;每条原因都告诉模型应当改做什么(写入 `/tmp` 时建议在项目内建立临时文件夹)。 - 基础保护是尽力而为的检查:它在工具调用运行前读取调用内容,因此无法发现运行时才拼出的路径(命令替换、无法解析的变量、像 Python `os.walk` 这样自行遍历文件夹的脚本),也不会拒绝从应用数据文件夹上层开始的递归搜索(`grep -r token ~`、对主目录的 Grep)。因此,把项目设为主目录或任何包含 CanvasTTY 数据的文件夹,本身并不安全。请在 agent 隔离下运行这类 agent(macOS 上的 sandbox-exec,Linux 上的 bubblewrap):它会对 agent 进程树中的所有程序隐藏私有数据,包括遍历。 + 基础保护是尽力而为的检查:它在工具调用运行前读取调用内容,因此无法发现运行时才拼出的路径(命令替换、无法解析的变量、像 Python `os.walk` 这样自行遍历文件夹的脚本),也不会拒绝从应用数据文件夹上层开始的递归搜索(`grep -r token ~`、对主目录的 Grep)。如果一次调用包含的路径数量、路径长度或嵌套 shell 层数超过它的检查范围,该调用会被拒绝,而不是只检查一部分;检查过程出错的 shell、文件编辑或读取调用同样会被拒绝。因此,把项目设为主目录或任何包含 CanvasTTY 数据的文件夹,本身并不安全。请在 agent 隔离下运行这类 agent(macOS 上的 sandbox-exec,Linux 上的 bubblewrap):它会对 agent 进程树中的所有程序隐藏私有数据,包括遍历。 - **密钥遮蔽**:CanvasTTY 从一个 agent 交给另一个 agent 的所有文本(`observe_agent`、`wait_for_agent`、`get_agent_result`,以及 control CLI 的 `screen`、`result` 和失败详情)都会被遮蔽:CanvasTTY 保存的服务商密钥、启动时的 `secretEnv` 值、服务通过 `redaction.register` 注册的值(包括被终端折行拆开的情况),以及常见密钥形式(`sk-…`、GitHub、Slack、AWS、Google、JWT、`Bearer …`、`"apiKey": "…"`、PEM 私钥、长随机串)。插件工具的回答、会话事件中的 `screen`、卡片标记和卡片动作消息也以同样方式遮蔽。 host.onStorageChange(listener) 会把 host.storage.set 的写入通知给同一插件的所有活动界面——画布卡片、HOME 小组件和独立窗口——从而避免轮询。 diff --git a/src/main/services/safety/baseProtection.ts b/src/main/services/safety/baseProtection.ts index 8b04c927..b692043d 100644 --- a/src/main/services/safety/baseProtection.ts +++ b/src/main/services/safety/baseProtection.ts @@ -1,5 +1,5 @@ import { tmpdir } from 'node:os'; -import { isAbsolute, join } from 'node:path'; +import { isAbsolute, join, normalize } from 'node:path'; import { isPathInside } from '../../../agent-runtime/path-inside.mjs'; import { analyzeAction, commandFromArgv, realish, type HardFacts, type PrivateData, type ToolAction } from './commandFacts.ts'; @@ -10,7 +10,7 @@ import { analyzeAction, commandFromArgv, realish, type HardFacts, type PrivateDa * model: local rules only, no git, no network. */ -const BASE_DENY_RULES = ['app-private', 'elevation', 'pipe-to-shell', 'download-exec', 'disk', 'fork-bomb', 'delete-outside', 'write-outside', 'unknown-target', 'too-many-paths'] as const; +const BASE_DENY_RULES = ['app-private', 'elevation', 'pipe-to-shell', 'download-exec', 'disk', 'fork-bomb', 'delete-outside', 'write-outside', 'unknown-target', 'uncheckable'] as const; export type BaseDenyRule = typeof BASE_DENY_RULES[number]; /** What the model reads: why the call was refused and what to do instead. */ @@ -24,10 +24,12 @@ const DENY_MESSAGES: Readonly> = { 'delete-outside': 'CanvasTTY blocked this command: it deletes files outside the project folder (or the folder itself). Delete only inside the project; if something elsewhere must go, ask the person.', 'write-outside': 'CanvasTTY blocked this command: it writes outside the project folder. Keep changes inside the project; if a file elsewhere must change, ask the person.', 'unknown-target': 'CanvasTTY blocked this command: it deletes or moves a path held in a shell variable or command output that CanvasTTY cannot resolve, so it cannot tell whether the path is inside the project folder. Write the path out, or set the variable in the same command to a path inside the project, and run it again.', - 'too-many-paths': 'CanvasTTY blocked this tool call: it names more than 32 paths or patterns, more than base protection checks in one call. Read the files in smaller groups of at most 32.' + uncheckable: 'CanvasTTY blocked this tool call: it names more paths, longer paths or more nested commands than base protection checks in one call, so it cannot tell what the call reaches. Split it into smaller calls (for example at most 32 files per read), write paths out plainly, and run it again.' }; const TEMP_WRITE_MESSAGE = 'CanvasTTY blocked this command: it writes to the temporary folder (/tmp or $TMPDIR), which is outside the project folder. Make a scratch folder inside the project instead (for example ./tmp, added to .gitignore if needed) and use that; if a file elsewhere must change, ask the person.'; +const ANALYZER_FAILURE_MESSAGE = 'CanvasTTY blocked this tool call: base protection failed while checking it, so it cannot tell what the call reaches. Write the command and paths out plainly and run it again; if it keeps failing, ask the person.'; + export interface BaseVerdict { rule: BaseDenyRule; message: string } /** Tool names of the CLIs' hooks: shells and file writes. Anything else is not checked. */ @@ -42,17 +44,36 @@ const READ_TOOLS = new Set(['Read', 'NotebookRead', 'Grep', 'Glob', 'LS', 'read_ /** Read-only tools whose `path` is a folder they walk (search), rather than one file or one listing. */ const SEARCH_TOOLS = new Set(['Grep', 'Glob', 'grep_search', 'search_file_content', 'glob']); const MAX_PATHS = 32; +/** Files one `apply_patch` may touch and still be checked. */ +const MAX_PATCH_PATHS = 256; const MAX_PATH_CHARS = 4_096; -/** The files an `apply_patch` envelope touches. */ -export function patchPaths(patch: string): string[] { +/** + * A path field of a tool's input: the path, null when it is absent, or `false` when it is there but cannot be checked + * (not a string, or longer than MAX_PATH_CHARS even after `./` and `a/..` padding is folded away). + */ +function pathField(value: unknown): string | null | false { + if (value === undefined || value === null || value === '') return null; + if (typeof value !== 'string') return false; + if (value.length <= MAX_PATH_CHARS) return value; + const folded = normalize(value); + return folded.length <= MAX_PATH_CHARS ? folded : false; +} + +/** The files an `apply_patch` envelope touches; `uncheckable` when there are more, or longer ones, than are checked. */ +function patchTargets(patch: string): { paths: string[]; uncheckable: boolean } { const paths: string[] = []; for (const match of patch.matchAll(/^\*\*\* (?:Add|Update|Delete) File: (.+)$|^\*\*\* Move to: (.+)$/gmu)) { - const path = (match[1] ?? match[2] ?? '').trim(); - if (path && path.length <= MAX_PATH_CHARS) paths.push(path); - if (paths.length >= MAX_PATHS) break; + const path = pathField((match[1] ?? match[2] ?? '').trim()); + if (path === false || paths.length >= MAX_PATCH_PATHS) return { paths, uncheckable: true }; + if (path) paths.push(path); } - return paths; + return { paths, uncheckable: false }; +} + +/** The files an `apply_patch` envelope touches. */ +export function patchPaths(patch: string): string[] { + return patchTargets(patch).paths; } const PREVIEW_PATH = /"(?:file_path|filePath|path|notebook_path|absolute_path)"\s*:\s*"((?:[^"\\]|\\.){1,4096})"/gu; @@ -64,13 +85,22 @@ const PREVIEW_PATH = /"(?:file_path|filePath|path|notebook_path|absolute_path)"\ */ export function actionFromHook(toolName: string, toolInput: unknown, preview: string | null = null): ToolAction { const raw = toolInput && typeof toolInput === 'object' && !Array.isArray(toolInput) ? toolInput as Record : null; - const text = (value: unknown): string | null => typeof value === 'string' && value.length > 0 && value.length <= MAX_PATH_CHARS ? value : null; - const commandCwd = text(raw?.cwd) ?? text(raw?.workdir) ?? text(raw?.directory); + // A path field that is there but cannot be checked makes the call uncheckable: it is refused, never skipped. + let uncheckable = false; + const field = (...values: unknown[]): string | null => { + for (const value of values) { + const path = pathField(value); + if (path === false) uncheckable = true; + else if (path) return path; + } + return null; + }; + const commandCwd = field(raw?.cwd, raw?.workdir, raw?.directory); if (SHELL_TOOLS.has(toolName)) { const candidate = raw?.command ?? raw?.cmd; const command = typeof candidate === 'string' ? candidate : Array.isArray(candidate) && candidate.length && candidate.every(item => typeof item === 'string') ? commandFromArgv(candidate as string[]) : null; - return { kind: 'shell', command, commandCwd, paths: [] }; + return { kind: 'shell', command, commandCwd, paths: [], ...(uncheckable ? { uncheckable } : {}) }; } if (READ_TOOLS.has(toolName)) return readAction(toolName, raw, preview); if (!EDIT_TOOLS.has(toolName)) return { kind: null, command: null, commandCwd: null, paths: [] }; @@ -83,16 +113,16 @@ export function actionFromHook(toolName: string, toolInput: unknown, preview: st return { kind: 'edit', command: null, commandCwd: null, paths }; } if (toolName === 'apply_patch') { - const patch = text(raw?.command) ?? (typeof raw?.patch === 'string' ? raw.patch : typeof raw?.input === 'string' ? raw.input : typeof raw?.patchText === 'string' ? raw.patchText : ''); - return { kind: 'edit', command: null, commandCwd, paths: patchPaths(patch ?? '') }; + const patch = (typeof raw?.command === 'string' && raw.command ? raw.command : null) ?? (typeof raw?.patch === 'string' ? raw.patch : typeof raw?.input === 'string' ? raw.input : typeof raw?.patchText === 'string' ? raw.patchText : ''); + const targets = patchTargets(patch ?? ''); + return { kind: 'edit', command: null, commandCwd, paths: targets.paths, ...(uncheckable || targets.uncheckable ? { uncheckable: true } : {}) }; } - const path = text(raw?.file_path) ?? text(raw?.filePath) ?? text(raw?.path) ?? text(raw?.notebook_path) ?? text(raw?.absolute_path); - return { kind: 'edit', command: null, commandCwd: null, paths: path ? [path] : [] }; + const path = field(raw?.file_path, raw?.filePath, raw?.path, raw?.notebook_path, raw?.absolute_path); + return { kind: 'edit', command: null, commandCwd: null, paths: path ? [path] : [], ...(uncheckable ? { uncheckable } : {}) }; } /** A read-only tool's files, glob patterns and searched folders. Cut input: the paths at the start of its preview. */ function readAction(toolName: string, raw: Record | null, preview: string | null): ToolAction { - const text = (value: unknown): string | null => typeof value === 'string' && value.length > 0 && value.length <= MAX_PATH_CHARS ? value : null; const action: ToolAction = { kind: 'read', command: null, commandCwd: null, paths: [], searched: [] }; if (!raw) { for (const match of (preview ?? '').matchAll(PREVIEW_PATH)) { @@ -101,18 +131,27 @@ function readAction(toolName: string, raw: Record | null, previ } return action; } - const file = text(raw.file_path) ?? text(raw.filePath) ?? text(raw.notebook_path) ?? text(raw.absolute_path); + // A path field that is there but cannot be checked (not a string, too long) makes the call uncheckable: refused. + const field = (...values: unknown[]): string | null => { + for (const value of values) { + const path = pathField(value); + if (path === false) action.uncheckable = true; + else if (path) return path; + } + return null; + }; + const file = field(raw.file_path, raw.filePath, raw.notebook_path, raw.absolute_path); if (file) action.paths.push(file); - const folder = text(raw.path) ?? text(raw.dir_path) ?? text(raw.directory); + const folder = field(raw.path, raw.dir_path, raw.directory); if (folder) (SEARCH_TOOLS.has(toolName) ? action.searched! : action.paths).push(folder); // A glob pattern names files itself (`/…/canvastty/provider-*`); relative ones resolve against the searched folder. - const pattern = toolName === 'Glob' || toolName === 'glob' ? text(raw.pattern) : null; + const pattern = toolName === 'Glob' || toolName === 'glob' ? field(raw.pattern) : null; if (pattern) action.paths.push(folder && !isAbsolute(pattern) && !pattern.startsWith('~') && !pattern.startsWith('$') ? join(folder, pattern) : pattern); // Qwen Code's read_many_files: `paths` and `include` are both globs it expands and reads. More than are checked is // refused, not cut short. - const many = [raw.paths, raw.include].flatMap(list => Array.isArray(list) ? list : []); - if (many.length > MAX_PATHS) { action.tooMany = true; return action; } - for (const item of many) { const path = text(item); if (path) action.searched!.push(path); } + const many = [raw.paths, raw.include].flatMap(list => list === undefined || list === null ? [] : Array.isArray(list) ? list : [list]); + if (many.length > MAX_PATHS) { action.uncheckable = true; return action; } + for (const item of many) { const path = field(item); if (path) action.searched!.push(path); } return action; } @@ -135,6 +174,7 @@ export function canvasTtyPrivateData(userDataPath: string): PrivateData { /** The first deny rule a set of facts breaks, in a fixed order. */ export function denyRule(facts: HardFacts): BaseDenyRule | null { if (facts.appPrivate) return 'app-private'; + if (facts.uncheckable) return 'uncheckable'; if (facts.elevation) return 'elevation'; if (facts.pipeToShell) return 'pipe-to-shell'; if (facts.downloadExec) return 'download-exec'; @@ -149,7 +189,8 @@ export function denyRule(facts: HardFacts): BaseDenyRule | null { /** * Base protection for one hook call: a deny with its message, or null (no opinion). `root` is the session's * working folder; `agentRoots` the agent's own config folders, whose plan and memory folders are not "outside". - * Any failure is null: the rules only ever add a deny. + * A failure while checking a shell, file-edit or read call denies it as `uncheckable` (fail closed); any other tool + * has no opinion (null). */ export function checkBaseProtection(input: { toolName: string; toolInput: unknown; preview?: string | null; root: string; commandCwd?: string | null; @@ -160,7 +201,7 @@ export function checkBaseProtection(input: { try { const action = actionFromHook(input.toolName, input.toolInput, input.preview ?? null); if (action.kind === null) return null; - if (action.tooMany) return { rule: 'too-many-paths', message: DENY_MESSAGES['too-many-paths'] }; + if (action.uncheckable) return { rule: 'uncheckable', message: DENY_MESSAGES.uncheckable }; // The agent's current folder (the hook reports it) resolves relative paths; the working folder stays the root. if (!action.commandCwd && input.commandCwd) action.commandCwd = input.commandCwd; const facts = analyzeAction(action, input.root, { @@ -172,7 +213,8 @@ export function checkBaseProtection(input: { if (!rule) return null; return { rule, message: rule === 'write-outside' && writesOnlyToTemp(facts) ? TEMP_WRITE_MESSAGE : DENY_MESSAGES[rule] }; } catch { - return null; + const checked = SHELL_TOOLS.has(input.toolName) || EDIT_TOOLS.has(input.toolName) || READ_TOOLS.has(input.toolName); + return checked ? { rule: 'uncheckable', message: ANALYZER_FAILURE_MESSAGE } : null; } } diff --git a/src/main/services/safety/commandFacts.ts b/src/main/services/safety/commandFacts.ts index c3fd8e5e..6a24628f 100644 --- a/src/main/services/safety/commandFacts.ts +++ b/src/main/services/safety/commandFacts.ts @@ -23,8 +23,8 @@ export interface ToolAction { paths: string[]; /** `read` only: folders a search or listing tool walks (Grep's or Glob's `path`). */ searched?: string[]; - /** `read` only: the call names more paths than are checked; it is refused. */ - tooMany?: boolean; + /** The call names more paths, or longer ones, than are checked: it is refused, not checked in part. */ + uncheckable?: boolean; } export type Where = 'inside' | 'outside' | 'unresolved'; @@ -55,6 +55,11 @@ export interface HardFacts { * (inside the project or not) is unknown. */ unknownTarget: boolean; + /** + * More than the rules check in one call (paths against the private data, brace expansions, nested shells): the rest + * was not looked at, so the call is refused rather than let through unchecked. + */ + uncheckable: boolean; } /** @@ -232,7 +237,7 @@ interface Acc { ctx: PathContext; writes: Target[]; deletes: Target[]; - flags: { elevation: boolean; pipeToShell: boolean; downloadExec: boolean; disk: boolean; forkBomb: boolean; appPrivate: boolean; unknownTarget: boolean }; + flags: { elevation: boolean; pipeToShell: boolean; downloadExec: boolean; disk: boolean; forkBomb: boolean; appPrivate: boolean; unknownTarget: boolean; uncheckable: boolean }; depth: number; budget: number; /** Nesting of argv inside wrappers (nohup, env, xargs, su -c, busybox …): bounded like substitutions. */ @@ -247,7 +252,8 @@ function wordOf(text: string): Word { return { text, quoted: false, vars: [], su /** Analyses one shell command string (recursively for `bash -c`, `eval`, substitutions). */ function analyzeText(command: string, cwd: string | null, acc: Acc): string | null { - if (acc.depth > 4 || --acc.budget < 0) return cwd; + // Deeper or more nested commands than are followed: the rest is not checked, so the call is refused. + if (acc.depth > 4 || --acc.budget < 0) { acc.flags.uncheckable = true; return cwd; } acc.depth++; try { const lexed = lexShell(command); @@ -547,7 +553,11 @@ function runInterpreter(program: string, argWords: Word[], cwd: string | null, a } if (stdin.pipeIn) acc.flags.pipeToShell = true; // A program read from a heredoc (`python3 - < privateHit(text, cwd, acc, false)) || codeNamesPrivate(stdin.heredoc, acc.ctx))) acc.flags.appPrivate = true; + if (stdin.heredoc !== null) { + const found = privateCandidates(stdin.heredoc, acc.ctx); + if (found === null) acc.flags.uncheckable = true; + else if (found.some(text => privateHit(text, cwd, acc, false)) || codeNamesPrivate(stdin.heredoc, acc.ctx)) acc.flags.appPrivate = true; + } return cwd; } @@ -564,14 +574,20 @@ const CONTROL_CLI = /^canvastty-control(?:\.mjs)?$/u; /** Programs that walk folders by themselves: naming a folder that holds private data reads it. */ const RECURSIVE = new Set(['rg', 'ag', 'ack', 'find', 'fd', 'tar', 'bsdtar', 'zip', '7z', 'rsync', 'ditto', 'scp', 'rclone']); const GLOB_CHAR = /[*?[]/u; -const MAX_PRIVATE_CHECKS = 256; +const MAX_PRIVATE_CHECKS = 1024; +/** Paths found inside one word or program text (`--opt=/a:/b`, interpreter code). */ +const MAX_CANDIDATES = 256; const MAX_SCANNED_TEXT = 64 * 1024; const parts = (path: string): string[] => path.split(/[\\/]+/u).filter(Boolean); +/** macOS (APFS, HFS+) and Windows file systems ignore case by default: a pattern there matches names in any case. */ +const FOLD_CASE = process.platform === 'darwin' || process.platform === 'win32'; +const MAX_BRACE_EXPANSIONS = 64; + /** A glob component (`token-*`, `ctty-control-????`) against one real name. */ function componentMatches(pattern: string, name: string): boolean { - if (!GLOB_CHAR.test(pattern)) return pattern === name; + if (!GLOB_CHAR.test(pattern)) return FOLD_CASE ? pattern.toLowerCase() === name.toLowerCase() : pattern === name; let source = ''; for (let i = 0; i < pattern.length; i++) { const char = pattern[i]!; @@ -584,7 +600,70 @@ function componentMatches(pattern: string, name: string): boolean { i = close; } else source += char.replace(/[.+^${}()|\\\]]/gu, '\\$&'); } - try { return new RegExp(`^${source}$`, 'u').test(name); } catch { return true; } + try { return new RegExp(`^${source}$`, FOLD_CASE ? 'iu' : 'u').test(name); } catch { return true; } +} + +/** + * A glob's components against a real path's: `full` when the glob names the path or something inside it, `ancestor` + * when the whole glob already matched a folder above it. `**` matches zero or more components; a glob that only + * passes through the path on its way to other names (`~/**` then `*.ts`, past a private folder) does not name it. + */ +function globAgainst(pattern: readonly string[], target: readonly string[]): { full: boolean; ancestor: boolean } { + const close = (states: Set): Set => { + for (const state of [...states]) for (let i = state; i < pattern.length && pattern[i] === '**'; i++) states.add(i + 1); + return states; + }; + let states = close(new Set([0])); + let named = new Set(); + let ancestor = false; + for (const name of target) { + if (states.has(pattern.length)) ancestor = true; + const matched = new Set(); + const within = new Set(); + for (const state of states) { + if (state >= pattern.length) continue; + if (pattern[state] === '**') within.add(state); + else if (componentMatches(pattern[state]!, name)) matched.add(state + 1); + } + named = close(matched); + states = close(new Set([...named, ...within])); + if (!states.size) return { full: false, ancestor }; + } + // Named: the last name matched a component of the glob, or all that is left of the glob is `**`. + const full = [...states].some(state => named.has(state) || pattern.slice(state).every(part => part === '**')); + return { full, ancestor }; +} + +/** + * The words a shell's brace expansion makes of `text` (`a.{bin,json}` is `a.bin` and `a.json`); `text` itself when it + * has none. Sequences (`{1..3}`) are not expanded. Null when it makes more than MAX_BRACE_EXPANSIONS words: they are + * not checked, and the call is refused. + */ +function braceExpansions(text: string): string[] | null { + const out: string[] = []; + let over = false; + const walk = (value: string): void => { + if (over) return; + if (out.length >= MAX_BRACE_EXPANSIONS) { over = true; return; } + for (let open = value.indexOf('{'); open >= 0; open = value.indexOf('{', open + 1)) { + let depth = 0; + let end = -1; + const commas: number[] = []; + for (let i = open; i < value.length; i++) { + if (value[i] === '{') depth++; + else if (value[i] === '}' && --depth === 0) { end = i; break; } + else if (value[i] === ',' && depth === 1) commas.push(i); + } + if (end < 0) break; + if (!commas.length) continue; + const bounds = [open, ...commas, end]; + for (let k = 0; k + 1 < bounds.length; k++) walk(value.slice(0, open) + value.slice(bounds[k]! + 1, bounds[k + 1]) + value.slice(end + 1)); + return; + } + out.push(value); + }; + walk(text); + return over ? null : out; } /** The gateway socket folder under a temporary folder that holds `abs` (`$TMPDIR/ctty-control-XXXX`), or null. */ @@ -618,22 +697,16 @@ function privatePath(abs: string, glob: boolean, recursive: boolean, ctx: PathCo const first = all.findIndex(part => GLOB_CHAR.test(part)); const prefix = realish((abs.startsWith('/') ? '/' : '') + all.slice(0, first).join('/')); const pattern = [...parts(prefix), ...all.slice(first)]; - const matchesFrom = (target: string[]): number => { - let i = 0; - while (i < pattern.length && i < target.length && componentMatches(pattern[i]!, target[i]!)) i++; - return i; - }; for (const path of ctx.privatePaths) { - const target = parts(path); - const matched = matchesFrom(target); - if (matched >= target.length) return true; - if (matched === pattern.length && recursive && ctx.appRoots.some(root => parts(root).length <= pattern.length)) return true; + const { full, ancestor } = globAgainst(pattern, parts(path)); + if (full) return true; + if (ancestor && recursive && ctx.appRoots.some(root => parts(root).length <= pattern.length)) return true; } return ctx.tempRoots.some(temp => { const target = parts(temp); - if (matchesFrom(target) < target.length || pattern.length <= target.length) return false; + if (pattern.length <= target.length || !globAgainst(pattern.slice(0, target.length), target).full) return false; const next = pattern[target.length]!; - return SOCKET_DIR.test(next) || next.startsWith('ctty') && GLOB_CHAR.test(next); + return next === '**' || SOCKET_DIR.test(next) || next.startsWith('ctty') && GLOB_CHAR.test(next); }); } @@ -645,11 +718,19 @@ function codePath(text: string, ctx: PathContext): string | null { return isAbsolute(value) && value.length > 1 ? value : null; } -/** Paths inside a word or a program text: after `=` or `:` (`--unix-socket=P`, `UNIX-CONNECT:P`), quoted strings, bare tokens. */ -function privateCandidates(text: string, ctx: PathContext): string[] { - if (text.length > MAX_SCANNED_TEXT) text = text.slice(0, MAX_SCANNED_TEXT); +/** + * Paths inside a word or a program text: after `=` or `:` (`--unix-socket=P`, `UNIX-CONNECT:P`), quoted strings, bare + * tokens. Null when the text is longer, or holds more paths, than are checked. + */ +function privateCandidates(text: string, ctx: PathContext): string[] | null { + if (text.length > MAX_SCANNED_TEXT) return null; const found = new Set(); - const add = (value: string | undefined): void => { const path = value ? codePath(value, ctx) : null; if (path && found.size < 64) found.add(path); }; + let over = false; + const add = (value: string | undefined): void => { + const path = value ? codePath(value, ctx) : null; + if (!path || found.has(path)) return; + if (found.size >= MAX_CANDIDATES) over = true; else found.add(path); + }; for (const match of text.matchAll(/[=:]((?:~|\$\{?(?:HOME|TMPDIR)\}?|[A-Za-z]:[\\/]|\\\\|\/)[^\s,;'"`()<>|&]*)/gu)) { // An HTTP(S) authority is not a UNC path. Keep scanning other paths in the same text. if (match[0].startsWith('://') && /(?:^|[^A-Za-z0-9+.-])https?$/iu.test(text.slice(0, match.index))) continue; @@ -657,7 +738,7 @@ function privateCandidates(text: string, ctx: PathContext): string[] { } for (const match of text.matchAll(/(['"`])([^'"`\n]{1,4096}?)\1/gu)) add(match[2]); for (const token of text.split(/[\s,;()[\]{}<>|&'"`=]+/u)) if (/^(?:~|\$\{?(?:HOME|TMPDIR)\}?|[A-Za-z]:[\\/]|\\\\|\/)/u.test(token)) add(token); - return [...found]; + return over ? null : [...found]; } /** Interpreter code that builds a private path from pieces: an app folder's name together with a private name. */ @@ -668,7 +749,8 @@ function codeNamesPrivate(code: string, ctx: PathContext): boolean { } function privateHit(text: string, cwd: string | null, acc: Acc, recursive: boolean, glob = GLOB_CHAR.test(text)): boolean { - if (--acc.privateBudget < 0) return false; + // More paths than are checked: the call is refused rather than checked in part. + if (--acc.privateBudget < 0) { acc.flags.uncheckable = true; return false; } if (!isAbsolute(text) && cwd === null) return false; return privatePath(glob ? resolve(cwd ?? acc.ctx.rootReal, text) : realish(resolve(cwd ?? acc.ctx.rootReal, text)), glob, recursive, acc.ctx); } @@ -687,7 +769,8 @@ function runsControlCli(words: readonly Word[]): boolean { * the command uses them. CanvasTTY's own control CLI is the one program that may name its descriptor. */ function checkPrivate(segment: Segment, words: Word[], cwd: string | null, acc: Acc): void { - if (acc.flags.appPrivate || acc.privateBudget <= 0) return; + if (acc.flags.appPrivate) return; + if (acc.privateBudget <= 0) { acc.flags.uncheckable = true; return; } if (runsControlCli(words)) return; const recursive = words.some(word => RECURSIVE.has(programName(word.text)) || /^(?:-[a-zA-Z]*[rR][a-zA-Z]*|--recursive|--archive)$/u.test(word.text)) || programName(words[0]?.text ?? '') === 'cp' && words.some(word => /^-[a-zA-Z]*a/u.test(word.text)); @@ -697,7 +780,11 @@ function checkPrivate(segment: Segment, words: Word[], cwd: string | null, acc: if (word.substitution) continue; const text = expand(word, cwd, acc.ctx); if (text === null || text === '') continue; - const candidates = new Set([text, ...(text.length > 1 && /[=:'"\s]/u.test(text) ? privateCandidates(text, acc.ctx) : [])]); + // `provider-secrets.{bin,}`: the shell expands braces before the program sees the words. + const expanded = word.brace ? braceExpansions(text) : []; + const inner = text.length > 1 && /[=:'"\s]/u.test(text) ? privateCandidates(text, acc.ctx) : []; + if (expanded === null || inner === null) { acc.flags.uncheckable = true; return; } + const candidates = new Set([text, ...expanded, ...inner]); for (const candidate of candidates) { if (!privateHit(candidate, cwd, acc, recursive, candidate === text ? word.glob : GLOB_CHAR.test(candidate))) continue; acc.flags.appPrivate = true; @@ -1136,7 +1223,7 @@ export function commandFromArgv(argv: readonly string[]): string { return argv.m export function analyzeAction(action: ToolAction, root: string, options: { home?: string; agentRoots?: readonly string[]; privateData?: PrivateData } = {}): HardFacts { const ctx = pathContext(root, options.home, options.agentRoots, options.privateData); const acc: Acc = { ctx, writes: [], deletes: [], depth: 0, budget: 64, argvDepth: 0, privateBudget: MAX_PRIVATE_CHECKS, - flags: { elevation: false, pipeToShell: false, downloadExec: false, disk: false, forkBomb: false, appPrivate: false, unknownTarget: false } }; + flags: { elevation: false, pipeToShell: false, downloadExec: false, disk: false, forkBomb: false, appPrivate: false, unknownTarget: false, uncheckable: Boolean(action.uncheckable) } }; const commandCwd = action.commandCwd ? resolveTarget(action.commandCwd, ctx.rootReal, ctx) : null; const cwd = commandCwd ? commandCwd.abs : ctx.rootReal; if (action.kind === 'shell' && action.command) analyzeText(action.command, cwd, acc); @@ -1145,7 +1232,12 @@ export function analyzeAction(action: ToolAction, root: string, options: { home? if (action.paths.some(path => { const text = codePath(path, ctx) ?? path; return privateHit(text, cwd, acc, false, false); })) acc.flags.appPrivate = true; } else if (action.kind === 'read') { // A read-only tool is only checked against CanvasTTY's private data: reading elsewhere is not "outside". - const hit = (path: string, recursive: boolean): boolean => { const text = codePath(path, ctx) ?? path; return privateHit(text, cwd, acc, recursive); }; + // Glob tools expand braces (`provider-secrets.{bin,json}`); a literal name is checked as written too. + const hit = (path: string, recursive: boolean): boolean => { + const expanded = braceExpansions(path); + if (expanded === null) { acc.flags.uncheckable = true; return false; } + return [...new Set([path, ...expanded])].some(item => privateHit(codePath(item, ctx) ?? item, cwd, acc, recursive)); + }; if (action.paths.some(path => hit(path, false)) || (action.searched ?? []).some(path => hit(path, true))) acc.flags.appPrivate = true; } const outside = acc.writes.filter(t => t.where === 'outside' && !t.device && !(t.abs && isAgentServicePath(t.abs, ctx))); diff --git a/src/main/services/safety/shellParse.ts b/src/main/services/safety/shellParse.ts index 71cb4743..e46c50fb 100644 --- a/src/main/services/safety/shellParse.ts +++ b/src/main/services/safety/shellParse.ts @@ -23,6 +23,8 @@ export interface Word { glob: boolean; /** Starts with an unquoted `~`. */ tilde: boolean; + /** An unquoted `{` (a brace expansion such as `a.{bin,json}` may follow). */ + brace?: boolean; } export interface Redirect { op: string; target: Word | null; fdDup: boolean } @@ -223,6 +225,7 @@ export function lexShell(input: string): Lexed { const w = current(); if (!w.quoted && w.text === '' && c === '~') w.tilde = true; if (c === '*' || c === '?' || c === '[') w.glob = true; + if (c === '{') w.brace = true; // cmd.exe variables: %NAME%. if (c === '%') { const m = /^%([A-Za-z_][A-Za-z0-9_()]*)%/u.exec(text.slice(i)); diff --git a/tests/base-protection-app-private.test.mjs b/tests/base-protection-app-private.test.mjs index 7bbb9b56..90e6c916 100644 --- a/tests/base-protection-app-private.test.mjs +++ b/tests/base-protection-app-private.test.mjs @@ -271,10 +271,10 @@ test("Qwen read_many_files: more than 32 paths is refused, and its include globs const benign = Array.from({ length: 32 }, (_, i) => `src/f${i}.ts`); assert.equal(read({ paths: benign }), null, "32 project paths are checked and allowed"); const many = read({ paths: [...benign, secrets] }); - assert.equal(many?.rule, "too-many-paths", "the 33rd path is not skipped"); + assert.equal(many?.rule, "uncheckable", "the 33rd path is not skipped"); assert.match(many.message, /at most 32/u); - assert.equal(read({ paths: [...benign, "src/g.ts"] })?.rule, "too-many-paths"); - assert.equal(read({ paths: benign.slice(0, 20), include: benign.slice(0, 13) })?.rule, "too-many-paths", "include counts toward the limit"); + assert.equal(read({ paths: [...benign, "src/g.ts"] })?.rule, "uncheckable"); + assert.equal(read({ paths: benign.slice(0, 20), include: benign.slice(0, 13) })?.rule, "uncheckable", "include counts toward the limit"); assert.equal(read({ paths: ["src/a.ts"], include: [secrets] })?.rule, "app-private"); assert.equal(read({ paths: ["src/a.ts"], include: [join(userData, "account-homes", "**", "*.json")] })?.rule, "app-private"); assert.equal(read({ paths: ["src/a.ts"], include: ["**/*.ts"] }), null); @@ -294,3 +294,98 @@ test("OpenCode's read-only tools reach base protection: a grep without a path se assert.equal(check("glob", { pattern: "**/*.ts" }, project), null); }); +test("globs: ** matches any number of folders and braces are expanded before matching the private data", () => { + const secrets = q(join(userData, "provider-secrets.bin")).slice(1, -1); + for (const command of [ + "cat ~/Library/**/provider-secrets.bin", + "cat ~/**/provider-secrets.bin", + "cat ~/Library/Application\\ Support/canvastty/**", + "cat ~/Library/**/account-homes/*/auth.json", + `cat '${userData}'/provider-secrets.{bin,}`, + `cat '${userData}'/{settings.json,provider-secrets.bin}`, + `cat '${userData}'/agent-control/{connection.json,token-*}`, + `cp '${userData}'/{provider-secrets,x}.bin ./copy` + ]) assert.equal(rule(command), "app-private", command); + // Read-only tools: a Glob pattern with braces or ** is matched the same way. + const glob = (toolInput, root = project) => checkBaseProtection({ toolName: "Glob", toolInput, root, home, privateData })?.rule ?? null; + assert.equal(glob({ pattern: "provider-secrets.{bin,json}", path: userData }), "app-private"); + assert.equal(glob({ pattern: join(home, "Library", "**", "provider-secrets.bin") }), "app-private"); + // Ordinary globs stay allowed: in a normal project, and ones that only pass by the app's folder on the way. + for (const command of [ + "ls **/*.ts", + "rg todo **/*.ts", + "cat src/{a,b}.ts", + "cat ~/**/*.ts", + "ls ~/Library/**/*.plist", + `cat '${secrets}x'.{md,txt}`, + "echo {a,b}" + ]) assert.equal(rule(command), null, command); + assert.equal(glob({ pattern: "**/*.ts" }), null); + assert.equal(glob({ pattern: "**/*.ts" }, home), null, "a ** glob that only passes through the app's folder"); + assert.equal(glob({ pattern: "src/**/*.{ts,tsx}" }), null); +}); + +test("macOS and Windows: a glob matches the private data in any letter case", { skip: process.platform !== "darwin" && process.platform !== "win32" }, () => { + assert.equal(rule("cat ~/library/application\\ support/CANVASTTY/Provider-Secrets.*"), "app-private"); + assert.equal(rule("cat ~/LIBRARY/**/PROVIDER-SECRETS.BIN"), "app-private"); +}); + +test("what base protection cannot check is refused, not skipped: long or odd path fields, too many paths, expansions or nested shells", () => { + const secrets = join(userData, "provider-secrets.bin"); + const check = (toolName, toolInput, root = project) => checkBaseProtection({ toolName, toolInput, root, home, privateData })?.rule ?? null; + // Padding that folds away is checked as the path it names. + assert.equal(check("Read", { file_path: userData + "/.".repeat(3000) + "/provider-secrets.bin" }), "app-private"); + assert.equal(check("Read", { file_path: userData + "/x/..".repeat(1500) + "/provider-secrets.bin" }), "app-private"); + assert.equal(check("Write", { file_path: userData + "/./".repeat(2000) + "provider-secrets.bin", content: "x" }), "app-private"); + // A path field that cannot be checked: too long after folding, or not a string. + const long = "/" + "a".repeat(5000); + for (const [toolName, toolInput] of [ + ["Read", { file_path: long }], + ["Read", { file_path: 42 }], + ["Grep", { pattern: "x", path: long }], + ["Glob", { pattern: { glob: "*" } }], + ["read_many_files", { paths: ["src/a.ts", 7] }], + ["read_many_files", { paths: ["src/a.ts"], include: long }], + ["Write", { file_path: long, content: "x" }], + ["Bash", { command: "ls", cwd: long }], + ["apply_patch", { command: `*** Begin Patch\n*** Add File: ${"b".repeat(5000)}\n+x\n*** End Patch` }], + ["apply_patch", { command: `*** Begin Patch\n${Array.from({ length: 257 }, (_, i) => `*** Add File: f${i}.txt\n+x`).join("\n")}\n*** End Patch` }] + ]) assert.equal(check(toolName, toolInput), "uncheckable", `${toolName} ${JSON.stringify(toolInput).slice(0, 80)}`); + // A patch longer than one path is still read in full. + assert.equal(check("apply_patch", { command: `*** Begin Patch\n*** Add File: notes.md\n+${"x".repeat(5000)}\n*** Add File: ${secrets}\n+x\n*** End Patch` }), "app-private"); + // More paths, brace expansions or nested shells than are checked. + assert.equal(rule(`echo ${"a ".repeat(1100)}; cat ${q(secrets)}`) !== null, true, "the private path after the budget is not let through"); + assert.equal(rule(`echo ${"a ".repeat(1100)}`), "uncheckable"); + assert.equal(rule(`cat ${q(userData)}/{a,b}{c,d}{e,f}{g,h}{i,j}{k,l}{m,n}`), "uncheckable"); + assert.equal(check("Glob", { pattern: "{a,b}{c,d}{e,f}{g,h}{i,j}{k,l}{m,n}", path: project }), "uncheckable"); + const sq = (text) => `'${text.replace(/'/gu, "'\\''")}'`; + let nested = "ls"; + for (let i = 0; i < 6; i++) nested = `bash -c ${sq(nested)}`; + assert.equal(rule(nested), "uncheckable", "shells nested deeper than are followed"); + assert.equal(rule("bash -c 'bash -c ls'"), null); + // Ordinary large calls stay allowed. + assert.equal(rule(`git add ${Array.from({ length: 300 }, (_, i) => `src/f${i}.ts`).join(" ")}`), null); + assert.equal(rule("cat src/{a,b,c}/{x,y}.ts"), null); + assert.equal(check("apply_patch", { command: `*** Begin Patch\n${Array.from({ length: 40 }, (_, i) => `*** Add File: f${i}.txt\n+x`).join("\n")}\n*** End Patch` }), null); +}); + +test("an analyzer failure refuses a shell, edit or read call as uncheckable; other tools keep no opinion", () => { + const throwing = (field) => Object.defineProperty({}, field, { enumerable: true, get() { throw new Error("fixture analyzer failure"); } }); + const brokenPrivateData = { appRoots: [userData], get paths() { throw new Error("fixture analyzer failure"); }, markers: [] }; + const check = (toolName, toolInput, data = privateData) => checkBaseProtection({ toolName, toolInput, root: project, home, privateData: data }); + for (const [toolName, toolInput] of [ + ["Bash", throwing("command")], + ["Read", throwing("file_path")], + ["Write", throwing("file_path")], + ["apply_patch", throwing("command")] + ]) { + const verdict = check(toolName, toolInput); + assert.equal(verdict?.rule, "uncheckable", toolName); + assert.match(verdict.message, /failed while checking it/u); + assert.doesNotMatch(verdict.message, /fixture analyzer failure/u, "the error itself stays out of the message"); + } + for (const [toolName, toolInput] of [["Bash", { command: "ls" }], ["Read", { file_path: "src/a.ts" }], ["Edit", { file_path: "src/a.ts" }]]) { + assert.equal(check(toolName, toolInput, brokenPrivateData)?.rule, "uncheckable", `${toolName}: a failure inside the analysis`); + } + assert.equal(check("WebFetch", throwing("url")), null, "a tool base protection does not check stays without an opinion"); +}); From 902225aaafae3804ea138ccae5693bd40b390762 Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:03:46 +0000 Subject: [PATCH 18/28] fix(redaction): mask overlapping secrets whole and keep labels for credential rules Held values were masked leftmost-first and the scan jumped past each match, so a second held value overlapping the first showed its suffix. Masking held values before the generic rules could also erase a label such as password or Authorization that a rule needed, leaving the credential visible, and each call allocated text-sized typed arrays. Find held values, JSON values and credential shapes as source ranges on the original text and mask the union of overlapping ranges; search held values with indexOf/KMP streams and a ring buffer instead of text-sized arrays; count entropy characters without regex match arrays. The empty-registry path is unchanged. --- src/main/services/safety/SecretRedaction.ts | 532 +++++++++++++++--- ...secret-redaction-overlap-property.test.mjs | 114 ++++ tests/secret-redaction.test.mjs | 192 +++++++ 3 files changed, 761 insertions(+), 77 deletions(-) create mode 100644 tests/secret-redaction-overlap-property.test.mjs diff --git a/src/main/services/safety/SecretRedaction.ts b/src/main/services/safety/SecretRedaction.ts index 7a0df560..7edc1dc2 100644 --- a/src/main/services/safety/SecretRedaction.ts +++ b/src/main/services/safety/SecretRedaction.ts @@ -16,15 +16,21 @@ * `Authorization:` values, URL credentials, secret-looking query values and assignments, and long * high-entropy runs. * + * All three are found in the text as it came, and the union of their source ranges is masked: overlapping held + * values become one marker, so a later value that overlaps an earlier one shows none of its suffix, and a held + * value that happens to be a label (`password`, `Authorization`, `PRIVATE KEY`) cannot hide the credential the + * label introduces. With no held values the passes run one after another as before. + * * Each match becomes ``. Idempotent: a marker is never masked again. Values live in memory only * and are never logged, sent or shown. */ -const SECRET_MARKER = ''; /** Shorter values are not keys, and removing them would only garble ordinary text. */ const MIN_SECRET_CHARS = 8; /** Long enough for a PEM key or a service-account JSON; the search costs the same for any length. */ const MAX_SECRET_CHARS = 65_536; +/** Held forms up to this length are found with the native `indexOf`; longer ones with Knuth-Morris-Pratt. */ +const MAX_NATIVE_INDEXOF_PATTERN_CHARS = 128; const MAX_VALUES_PER_OWNER = 64; const MAX_OWNERS = 512; /** What wrapping may put between two characters of a key: whitespace and line breaks, box-drawing sides. */ @@ -108,9 +114,11 @@ export class SecretRedactionRegistry { redact(text: string): string { if (typeof text !== 'string' || text.length === 0) return typeof text === 'string' ? text : ''; - let result = maskKnownValues(text, this.knownForms()); - result = result.replace(JSON_SECRET_VALUE, (_match, prefix: string, closing: string) => `${prefix}${SECRET_MARKER}${closing}`); - return redactCredentials(result); + const forms = this.knownForms(); + // With no held values there is no structural text for a known match to erase, so reuse the direct generic + // pipeline instead of allocating and merging empty-registry source-range streams on ordinary output. + if (forms.bare.length === 0 && forms.exact.length === 0) return redactJsonAndCredentials(text); + return redactKnownAndCredentials(text, forms); } /** @@ -185,82 +193,211 @@ function withoutWrapCharacters(text: string): string { return from === 0 ? text : result + text.slice(from); } -/** - * Replaces every held value in `text`: the wrap-free forms also where wrapping put up to MAX_WRAP_GAP wrap - * characters between two of their characters, the exact forms as written. Matches are taken leftmost first, the - * longest at a position, and never overlap. - */ -function maskKnownValues(text: string, forms: KnownForms): string { - const endAt = knownMatchEnds(text, forms); - if (!endAt) return text; - let result = ""; - let kept = 0; - for (let at = 0; at < text.length;) { - const end = endAt[at]; - if (end === 0) { at++; continue; } - result += text.slice(kept, at) + SECRET_MARKER; - kept = end; - at = end; +/** All held-value matches in start order, keeping only the longest valid match at each start. */ +type KnownMatch = { start: number; end: number; kind?: string; priority?: number }; +type MatchStream = { iterator: Generator; current: KnownMatch }; + +function* knownMatches(text: string, forms: KnownForms): Generator { + if (forms.bare.length === 0 && forms.exact.length === 0) return; + const bareText = forms.bare.length ? withoutWrapCharacters(text) : ''; + const hasWrapCharacters = forms.bare.length > 0 && bareText !== text; + + const streams = forms.exact.map(form => candidateOccurrences(text, form)); + if (hasWrapCharacters) streams.push(wrappedMatches(text, bareText, forms.bare)); + else for (const form of forms.bare) streams.push(candidateOccurrences(text, form)); + yield* mergeMatches(streams); +} + +/** Merge already ordered hit streams by source start, choosing the longest at each start. */ +function* mergeMatches(streams: Generator[]): Generator { + const occurrences = orderedOccurrences(streams); + let next = occurrences.next(); + while (!next.done) { + const start = next.value.start; + let end = next.value.end; + next = occurrences.next(); + while (!next.done && next.value.start === start) { + end = Math.max(end, next.value.end); + next = occurrences.next(); + } + yield { start, end }; } - return kept === 0 ? text : result + text.slice(kept); } -/** - * For each position of `text`, the end of the longest held-value match that starts there (0: none), or null when - * nothing matches. The wrap-free forms are searched in the text with its wrap characters taken out (a map leads - * back to the original positions), the exact forms in the text itself; each with Knuth-Morris-Pratt, linear in - * the text plus the form, whatever either holds. - */ -function knownMatchEnds(text: string, forms: KnownForms): Int32Array | null { - if (forms.bare.length === 0 && forms.exact.length === 0) return null; - const exact = forms.exact.filter(form => text.includes(form)); - const bare = forms.bare.length ? withoutWrapCharacters(text) : ''; - const present = forms.bare.filter(form => bare.includes(form)); - if (present.length === 0 && exact.length === 0) return null; - const endAt = new Int32Array(text.length); - for (const form of exact) { - for (const start of occurrences(text, form)) endAt[start] = Math.max(endAt[start], start + form.length); +/** Merge hit streams into start order without discarding same-start candidates. */ +function* orderedOccurrences(streams: Generator[]): Generator { + if (streams.length === 1) { + yield* streams[0]!; + return; } - if (present.length === 0) return endAt; - // Where each character of `bare` stands in `text`, and how many oversized gaps lie before it (a match may - // not cross one). - const positions = new Int32Array(bare.length); - const oversized = new Int32Array(bare.length + 1); - for (let i = 0, count = 0, previous = -1; i < text.length; i++) { - if (isWrapCharacter(text.charCodeAt(i))) continue; - oversized[count + 1] = oversized[count] + (previous >= 0 && i - previous - 1 > MAX_WRAP_GAP ? 1 : 0); - positions[count++] = i; - previous = i; + const heap: MatchStream[] = []; + const push = (stream: MatchStream): void => { + let index = heap.length; + heap.push(stream); + while (index > 0) { + const parent = (index - 1) >> 1; + if (heap[parent]!.current.start <= stream.current.start) break; + heap[index] = heap[parent]!; + index = parent; + } + heap[index] = stream; + }; + const pop = (): MatchStream => { + const first = heap[0]!; + const last = heap.pop()!; + if (heap.length > 0) { + let index = 0; + while (true) { + const left = index * 2 + 1; + if (left >= heap.length) break; + const right = left + 1; + const child = right < heap.length && heap[right]!.current.start < heap[left]!.current.start ? right : left; + if (heap[child]!.current.start >= last.current.start) break; + heap[index] = heap[child]!; + index = child; + } + heap[index] = last; + } + return first; + }; + const enqueueFirst = (iterator: Generator): void => { + const next = iterator.next(); + if (!next.done) push({ iterator, current: next.value }); + }; + const advance = (stream: MatchStream): void => { + const next = stream.iterator.next(); + if (!next.done) { + stream.current = next.value; + push(stream); + } + }; + + for (const iterator of streams) enqueueFirst(iterator); + while (heap.length > 0) { + const first = pop(); + const current = first.current; + advance(first); + yield current; } - for (const form of present) { - for (const start of occurrences(bare, form)) { - // Only the gaps inside the match count, not the one before its first character. - if (oversized[start + form.length] - oversized[start + 1] !== 0) continue; - const from = positions[start]; - endAt[from] = Math.max(endAt[from], positions[start + form.length - 1] + 1); +} + +function* candidateOccurrences(text: string, pattern: string): Generator { + if (pattern.length > MAX_NATIVE_INDEXOF_PATTERN_CHARS) { + yield* kmpOccurrences(text, pattern); + return; + } + yield* exactOccurrences(text, pattern); +} + +function* exactOccurrences(text: string, pattern: string): Generator { + for (let from = 0; from <= text.length - pattern.length;) { + const start = text.indexOf(pattern, from); + if (start < 0) return; + yield { start, end: start + pattern.length }; + // Advancing by one preserves overlapping hits for the leftmost/longest merger. + from = start + 1; + } +} + +/** Keep worst-case scan cost linear for long patterns whose repetitive near-matches fool native indexOf. */ +function* kmpOccurrences(text: string, pattern: string): Generator { + const firstCode = pattern.charCodeAt(0); + const patternLength = pattern.length; + const searchPrefix = pattern.slice(0, MAX_NATIVE_INDEXOF_PATTERN_CHARS); + const prefixLength = searchPrefix.length; + const firstStart = text.indexOf(searchPrefix); + if (firstStart < 0) return; + + const failure = kmpFailure(pattern); + for (let i = firstStart + prefixLength - 1, k = prefixLength - 1; i < text.length; i++) { + // Skip ordinary spans in native code when no KMP prefix is active. The bounded prefix stays code-unit based. + if (k === 0) { + const start = text.indexOf(searchPrefix, i); + if (start < 0) return; + i = start + prefixLength - 1; + k = prefixLength - 1; + } + const textCode = text.charCodeAt(i); + let patternCode = k === 0 ? firstCode : pattern.charCodeAt(k); + while (k > 0 && textCode !== patternCode) { + k = failure[k - 1]!; + patternCode = k === 0 ? firstCode : pattern.charCodeAt(k); + } + if (textCode === patternCode) k++; + if (k === patternLength) { + yield { start: i - k + 1, end: i + 1 }; + k = failure[k - 1]!; } } - return endAt; } -/** Every start of `pattern` in `text`, overlapping ones included (Knuth-Morris-Pratt). */ -function* occurrences(text: string, pattern: string): Generator { +/** Prefix lengths for Knuth-Morris-Pratt. Long candidate patterns use this to retain linear scan cost. */ +function kmpFailure(pattern: string): Int32Array { const failure = new Int32Array(pattern.length); for (let i = 1, k = 0; i < pattern.length; i++) { - while (k > 0 && pattern.charCodeAt(i) !== pattern.charCodeAt(k)) k = failure[k - 1]; + while (k > 0 && pattern.charCodeAt(i) !== pattern.charCodeAt(k)) k = failure[k - 1]!; if (pattern.charCodeAt(i) === pattern.charCodeAt(k)) k++; failure[i] = k; } - for (let i = 0, k = 0; i < text.length; i++) { - while (k > 0 && text.charCodeAt(i) !== pattern.charCodeAt(k)) k = failure[k - 1]; - if (text.charCodeAt(i) === pattern.charCodeAt(k)) k++; - if (k === pattern.length) { - yield i - k + 1; - k = failure[k - 1]; + return failure; +} + +/** + * Find wrap-tolerant matches from one shared compact-text merge and one source-offset ring. All bare starts are + * processed monotonically; a ring as long as the longest form plus one retains every start through the farthest + * end considered at that start, including overlapping candidates at later starts. + */ +function* wrappedMatches(text: string, bareText: string, patterns: readonly string[]): Generator { + const occurrences = orderedOccurrences(patterns.map(pattern => candidateOccurrences(bareText, pattern))); + let next = occurrences.next(); + if (next.done) return; + + const longestPattern = patterns[0]!.length; // knownForms sorts patterns longest first + const ringLength = longestPattern + 1; + const positions = new Uint32Array(ringLength); + const oversizedAfter = new Uint32Array(ringLength); + let raw = 0; + let bare = 0; + let previous = -1; + let oversizedGaps = 0; + + const translate = (start: number, end: number): KnownMatch | null => { + while (bare < end) { + const code = text.charCodeAt(raw); + if (!isWrapCharacter(code)) { + if (previous >= 0 && raw - previous - 1 > MAX_WRAP_GAP) oversizedGaps++; + const slot = bare % ringLength; + positions[slot] = raw; + oversizedAfter[slot] = oversizedGaps; + bare++; + previous = raw; + } + raw++; + } + const startSlot = start % ringLength; + const endSlot = (end - 1) % ringLength; + // Exclude the gap before the first matched character; only interior gaps can invalidate this candidate. + if (oversizedAfter[endSlot]! - oversizedAfter[startSlot]! > 0) return null; + return { start: positions[startSlot]!, end: positions[endSlot]! + 1 }; + }; + + while (!next.done) { + const compactStart = next.value.start; + let rawStart = -1; + let rawEnd = -1; + while (!next.done && next.value.start === compactStart) { + // Check every candidate before selecting the longest. A longer form can cross an oversized gap while a + // shorter form at the same start remains valid. + const translated = translate(next.value.start, next.value.end); + if (translated) { + rawStart = translated.start; + rawEnd = Math.max(rawEnd, translated.end); + } + next = occurrences.next(); } + if (rawEnd >= 0) yield { start: rawStart, end: rawEnd }; } } - function lastChars(text: string, maxChars: number): string { return text.length <= maxChars ? text : text.slice(text.length - maxChars); } @@ -346,15 +483,15 @@ function isLineStartUncrossed(text: string, b: number, last: number, forms: Know // Held-value matches that start before `b` lie within knownSpan of it. const checkFrom = Math.max(0, Math.min(last, quote)); const from = Math.max(0, checkFrom - knownSpan); - const endAt = knownMatchEnds(text.slice(from, Math.min(text.length, b + knownSpan)), forms); - if (!endAt) return true; - for (let at = 0; from + at < b; at++) { - if (endAt[at] !== 0 && from + endAt[at] > checkFrom) return false; + const window = text.slice(from, Math.min(text.length, b + knownSpan)); + for (const match of knownMatches(window, forms)) { + if (from + match.start >= b) break; + if (from + match.end > checkFrom) return false; } return true; } -type Rule = { kind: string; pattern: RegExp; replace?: (match: string, ...groups: string[]) => string }; +type Rule = { kind: string; pattern?: RegExp; transform?: (text: string) => string; replace?: (match: string, ...groups: string[]) => string }; const marker = (kind: string): string => ``; const NOT_MASKED = '(?!|[:=])\\s*)(?:"${NOT_MASKED}[^"\\r\\n]{4,}"|'${NOT_MASKED}[^'\\r\\n]{4,}'|\`${NOT_MASKED}[^\`\\r\\n]{4,}\`|${NOT_MASKED}[^\\s"'\`<>,;]{4,})`, 'gu'), replace: (_match, prefix) => `${prefix}${marker('assignment')}` }, // A random run the terminal wrapped over lines, judged as one run. - { kind: 'high-entropy', pattern: new RegExp(`(? { const joined = match.replace(/[\s\u2500-\u257f|]/gu, ''); return joined.length >= 32 && looksRandom(joined) ? marker('high-entropy') : match; } }, + { kind: 'high-entropy', transform: text => maskEntropyRanges(text, wrappedEntropyRanges(text)) }, // A long run that mixes upper case, lower case and digits with high entropy. Pure hex (a commit SHA) has no // upper case and survives; paths never form one run because `/` and `.` end it. - { kind: 'high-entropy', pattern: /(? looksRandom(match) ? marker('high-entropy') : match } + { kind: 'high-entropy', transform: text => maskEntropyRanges(text, unwrappedEntropyRanges(text)) } ]; -function looksRandom(value: string): boolean { - const upper = value.match(/[A-Z]/gu)?.length ?? 0, lower = value.match(/[a-z]/gu)?.length ?? 0, digits = value.match(/[0-9]/gu)?.length ?? 0; +/** + * Redact registered values and credential shapes against their original source ranges together. Applying the + * registry first can destroy a structural label (for example `PRIVATE KEY`, `password`, or `Authorization`) + * before its shape is recognized. Applying generic rules first has the inverse problem: a generic match can + * split a longer registered value. Keeping both sets of source ranges lets the renderer mask their union. + */ +function redactKnownAndCredentials(text: string, forms: KnownForms): string { + const streams: Generator[] = []; + const known = forms.bare.length > 0 || forms.exact.length > 0 ? [...knownMatches(text, forms)] : []; + if (known.length > 0) { + streams.push((function* (): Generator { + for (const match of known) yield { ...match, kind: 'secret', priority: 0 }; + })()); + } + streams.push(jsonSecretRanges(text)); + for (let index = 0; index < RULES.length; index++) { + const rule = RULES[index]!; + if (rule.pattern) streams.push(ruleSecretRanges(text, rule, 10 + index)); + } + // A random run is judged without the held values in it, as when they were masked first: otherwise a name joined + // to a held value (`PROVIDER_API_KEY=`) would form one random-looking run and lose its name. + const entropyText = withHeldValuesBroken(text, known); + streams.push(wrappedEntropyRanges(entropyText)); + streams.push(unwrappedEntropyRanges(entropyText)); + + let result = ''; + let copied = 0; + let active: KnownMatch | null = null; + let chosen: KnownMatch | null = null; + for (const candidate of orderedOccurrences(streams)) { + if (!candidate.kind || candidate.end <= candidate.start) continue; + if (active && candidate.start < active.end) { + active.end = Math.max(active.end, candidate.end); + if (preferMask(candidate, chosen!)) chosen = candidate; + continue; + } + if (active && chosen) { + result += text.slice(copied, active.start) + marker(chosen.kind!); + copied = active.end; + } + active = { start: candidate.start, end: candidate.end }; + chosen = candidate; + } + if (!active || !chosen) return text; + return result + text.slice(copied, active.start) + marker(chosen.kind!) + text.slice(active.end); +} + +/** `text` with every held-value match replaced by as many characters that end a random run (offsets unchanged). */ +function withHeldValuesBroken(text: string, known: readonly KnownMatch[]): string { + if (known.length === 0) return text; + let result = ''; + let copied = 0; + for (const { start, end } of known) { + if (end <= copied) continue; + const from = Math.max(start, copied); + result += text.slice(copied, from) + '\u0001'.repeat(end - from); + copied = end; + } + return result + text.slice(copied); +} + +/** Prefer the most complete source match, while preserving JSON's earlier guard against later generic rules. */ +function preferMask(candidate: KnownMatch, current: KnownMatch): boolean { + const candidateIsJson = candidate.priority === 1; + const currentIsJson = current.priority === 1; + const candidateIsGeneric = (candidate.priority ?? 0) >= 10; + const currentIsGeneric = (current.priority ?? 0) >= 10; + if (candidateIsJson && currentIsGeneric) return !privateKeyContainsJson(current, candidate); + if (currentIsJson && candidateIsGeneric) return privateKeyContainsJson(candidate, current); + const candidateLength = candidate.end - candidate.start; + const currentLength = current.end - current.start; + if (candidateLength !== currentLength) return candidateLength > currentLength; + return (candidate.priority ?? Number.MAX_SAFE_INTEGER) < (current.priority ?? Number.MAX_SAFE_INTEGER); +} + +/** PEM masking runs after the JSON-value pass and can still consume a nested JSON marker inside its body. */ +function privateKeyContainsJson(privateKey: KnownMatch, json: KnownMatch): boolean { + return privateKey.kind === 'private-key' && privateKey.start <= json.start && privateKey.end >= json.end + && (privateKey.start < json.start || privateKey.end > json.end); +} + +function* jsonSecretRanges(text: string): Generator { + const pattern = new RegExp(JSON_SECRET_VALUE.source, JSON_SECRET_VALUE.flags); + for (let match = pattern.exec(text); match; match = pattern.exec(text)) { + const prefix = match[1] ?? ''; + const closing = match[2] ?? ''; + const start = match.index + prefix.length; + const end = match.index + match[0].length - closing.length; + if (end > start) yield { start, end, kind: 'secret', priority: 1 }; + } +} + +function* ruleSecretRanges(text: string, rule: Rule, priority: number): Generator { + const originalPattern = rule.pattern!; + const flags = originalPattern.flags.includes('g') ? originalPattern.flags : `${originalPattern.flags}g`; + const pattern = new RegExp(originalPattern.source, flags); + for (let match = pattern.exec(text); match; match = pattern.exec(text)) { + let start = match.index; + let end = match.index + match[0].length; + switch (rule.kind) { + case 'authorization': + // Its replacement keeps the Authorization label and masks an optional Bearer/Basic/etc. scheme too. + start += (match[1] ?? '').length; + break; + case 'bearer': { + const prefix = /^Bearer\s+/u.exec(match[0]); + start += prefix?.[0].length ?? 0; + break; + } + case 'url-credentials': { + const scheme = match[1] ?? ''; + const userinfo = match[2] ?? ''; + // Keep the existing rule's allowance for a short, plain user name without a password. + if (!userinfo.includes(':') && userinfo.length < 16) continue; + start += scheme.length; + end = start + userinfo.length; + break; + } + case 'url-secret': + start += (match[1] ?? '').length; + break; + case 'assignment': { + // JSON_SECRET_VALUE runs first in the public pipeline. When it recognizes the same quoted key/value, + // its marker blocks this later assignment match and preserves the JSON quotes. + const jsonMatch = new RegExp(JSON_SECRET_VALUE.source, JSON_SECRET_VALUE.flags).exec(match[0]); + if (jsonMatch?.index === 0 && jsonMatch[0].length === match[0].length) continue; + start += (match[1] ?? '').length; + break; + } + } + if (end > start) yield { start, end, kind: rule.kind, priority }; + } +} + +function* wrappedEntropyRanges(text: string): Generator { + for (let at = 0; at < text.length;) { + if (!isEntropyRunCharacter(text.charCodeAt(at)) || (at > 0 && isEntropyRunCharacter(text.charCodeAt(at - 1)))) { + at++; + continue; + } + const firstEnd = entropyRunEnd(text, at); + if (firstEnd - at < 12) { + at = firstEnd; + continue; + } + let end = firstEnd; + let joinedLength = firstEnd - at; + let continuations = 0; + while (true) { + let next = end; + if (text.charCodeAt(next) === 0x0d && text.charCodeAt(next + 1) === 0x0a) next += 2; + else if (text.charCodeAt(next) === 0x0a) next++; + else break; + let decoration = 0; + while (decoration < 8 && next < text.length) { + const code = text.charCodeAt(next); + if (!(code === 0x20 || code === 0x09 || code === 0x7c || (code >= 0x2500 && code <= 0x257f))) break; + next++; + decoration++; + } + const nextEnd = entropyRunEnd(text, next); + if (nextEnd - next < 4 || !hasDigit(text, next, nextEnd)) break; + joinedLength += nextEnd - next; + continuations++; + end = nextEnd; + } + if (continuations > 0) { + if (joinedLength >= 32 && looksRandomRange(text, at, end, true)) { + yield { start: at, end, kind: 'high-entropy', priority: 10 + RULES.length }; + } + // A low-entropy wrapped candidate consumes its continuation too. + at = end; + } else at = firstEnd; + } +} + +function* unwrappedEntropyRanges(text: string): Generator { + for (let at = 0; at < text.length;) { + if (!isEntropyRunCharacter(text.charCodeAt(at)) || (at > 0 && isEntropyRunCharacter(text.charCodeAt(at - 1)))) { + at++; + continue; + } + const end = entropyRunEnd(text, at); + if (end - at >= 32 && looksRandomRange(text, at, end, false)) { + yield { start: at, end, kind: 'high-entropy', priority: 11 + RULES.length }; + } + at = end; + } +} + +function looksRandomRange(value: string, from: number, to: number, skipWrap: boolean): boolean { + let upper = 0, lower = 0, digits = 0; + for (let i = from; i < to && (upper < 2 || lower < 2 || digits < 2); i++) { + const code = value.charCodeAt(i); + if (skipWrap && (code === 0x7c || isWrapCharacter(code))) continue; + if (code >= 0x41 && code <= 0x5a) upper++; + else if (code >= 0x61 && code <= 0x7a) lower++; + else if (code >= 0x30 && code <= 0x39) digits++; + } if (upper < 2 || lower < 2 || digits < 2) return false; - const counts = new Map(); - for (const character of value) counts.set(character, (counts.get(character) ?? 0) + 1); + const counts = new Map(); + let length = 0; + for (let i = from; i < to; i++) { + const code = value.charCodeAt(i); + if (skipWrap && (code === 0x7c || isWrapCharacter(code))) continue; + counts.set(code, (counts.get(code) ?? 0) + 1); + length++; + } let entropy = 0; - for (const count of counts.values()) { const p = count / value.length; entropy -= p * Math.log2(p); } + for (const count of counts.values()) { const p = count / length; entropy -= p * Math.log2(p); } return entropy >= 4.2; } +const isEntropyRunCharacter = (code: number): boolean => code >= 0x30 && code <= 0x39 + || code >= 0x41 && code <= 0x5a || code >= 0x61 && code <= 0x7a + || code === 0x2b || code === 0x3d || code === 0x5f || code === 0x2d; + +function entropyRunEnd(text: string, from: number): number { + let end = from; + while (end < text.length && isEntropyRunCharacter(text.charCodeAt(end))) end++; + return end; +} + +function hasDigit(text: string, from: number, to: number): boolean { + for (let i = from; i < to; i++) { + const code = text.charCodeAt(i); + if (code >= 0x30 && code <= 0x39) return true; + } + return false; +} + +/** Render the same bounded entropy scans used when registered values overlap credential shapes. */ +function maskEntropyRanges(text: string, ranges: Iterable): string { + let result = ''; + let copied = 0; + for (const { start, end } of ranges) { + result += text.slice(copied, start) + marker('high-entropy'); + copied = end; + } + return copied === 0 ? text : result + text.slice(copied); +} + /** The generic shapes alone (no registered values). */ export function redactCredentials(text: string): string { if (typeof text !== 'string' || text.length === 0) return typeof text === 'string' ? text : ''; let result = text; for (const rule of RULES) { + if (rule.transform) { + result = rule.transform(result); + continue; + } + if (!rule.pattern) continue; result = result.replace(rule.pattern, (match: string, ...rest: unknown[]) => { // Arguments after the match: the capture groups, then the numeric offset and the whole input. const end = rest.findIndex(value => typeof value === 'number'); @@ -420,3 +792,9 @@ export function redactCredentials(text: string): string { } return result; } + +/** The registry's pre-range pipeline when it has no held values to mask. */ +function redactJsonAndCredentials(text: string): string { + const withJsonValues = text.replace(JSON_SECRET_VALUE, (_match, prefix: string, closing: string) => `${prefix}${marker('secret')}${closing}`); + return redactCredentials(withJsonValues); +} diff --git a/tests/secret-redaction-overlap-property.test.mjs b/tests/secret-redaction-overlap-property.test.mjs new file mode 100644 index 00000000..babb593f --- /dev/null +++ b/tests/secret-redaction-overlap-property.test.mjs @@ -0,0 +1,114 @@ +/** + * Property: registered secrets that overlap one another, sit next to one another or are wrapped by the terminal + * never leave a character visible, in whole-text and tail masking alike. + * Secrets use only lower-case letters; the surrounding text never does, so any lower-case letter left outside a + * marker is part of a secret that leaked. + */ +import assert from "node:assert/strict"; +import test from "node:test"; +import { SecretRedactionRegistry } from "../src/main/services/safety/SecretRedaction.ts"; + +function prng(seed) { + let state = seed >>> 0; + return () => { + state = (state + 0x6d2b79f5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const FILLER = "0123456789 .,;:!?()[]#%"; +const WRAP = ["\n", "\n ", "\n│ ", " ", "\r\n"]; + +function scenario(random) { + const int = (n) => Math.floor(random() * n); + const pick = (items) => items[int(items.length)]; + // A small alphabet makes accidental overlaps between secrets frequent; derived secrets force them. + const alphabet = "abcdef".slice(0, 2 + int(5)); + const word = (length) => Array.from({ length }, () => pick([...alphabet])).join(""); + const secrets = []; + for (let count = 1 + int(5); secrets.length < count;) { + const roll = random(); + if (secrets.length && roll < 0.35) { + // Shares a suffix of an earlier secret as its prefix: the two overlap when written back to back. + const base = pick(secrets); + secrets.push(base.slice(base.length - 2 - int(Math.max(1, base.length - 3))) + word(4 + int(8))); + } else if (secrets.length && roll < 0.5) { + const base = pick(secrets); + secrets.push(word(1 + int(3)) + base.slice(0, Math.max(6, base.length - int(4)))); + } else secrets.push(word(8 + int(16))); + } + const valid = secrets.filter((secret) => secret.length >= 8); + const filler = () => Array.from({ length: int(30) }, () => pick([...FILLER])).join(""); + const wrapped = (secret) => { + if (random() < 0.6) return secret; + let out = ""; + for (let i = 0; i < secret.length; i++) out += secret[i] + (i < secret.length - 1 && random() < 0.15 ? pick(WRAP) : ""); + return out; + }; + let text = ""; + for (let pieces = 1 + int(12); pieces > 0; pieces--) { + text += filler(); + const first = pick(valid); + const second = pick(valid); + const roll = random(); + if (roll < 0.4) { + // Overlap: second starts inside first wherever their texts agree. + let joined = first + second; + for (let k = Math.min(first.length, second.length) - 1; k > 0; k--) { + if (first.endsWith(second.slice(0, k))) { joined = first + second.slice(k); break; } + } + text += wrapped(joined); + } else if (roll < 0.7) text += wrapped(first) + wrapped(second); // adjacent + else text += wrapped(first); + } + text += filler(); + return { secrets: valid, text }; +} + +const leaked = (masked) => masked.replace(//gu, "").match(/[a-z]+/gu); + +test("property: no character of overlapping, adjacent or wrapped secrets survives masking", () => { + for (let seed = 1; seed <= 1500; seed++) { + const random = prng(seed); + const { secrets, text } = scenario(random); + const registry = new SecretRedactionRegistry(); + registry.add("vault", secrets); + const masked = registry.redact(text); + assert.equal(leaked(masked), null, `seed ${seed}: ${JSON.stringify({ secrets, text, masked })}`); + assert.equal(registry.redact(masked), masked, `seed ${seed}: idempotent`); + const maxChars = 1 + Math.floor(random() * text.length); + // The tail is the whole masked text cut to its last maxChars, so it can start inside a marker. + assert.equal(registry.redactTail(text, maxChars), masked.slice(-maxChars), `seed ${seed}: tail`); + } +}); + +test("property: tail masking equals masking the whole text and cutting it", () => { + // Line ends next to tokens, header schemes, separators and wrapped runs: the cases a tail window's line start + // must never split. The tail must equal whole-text masking cut to the same length. + const anthropic = ["sk", "ant", ""].join("-"); + const fragments = [ + "plain words", "line 42 output", "x".repeat(30), "Authorization:", "Authorization: Bearer", "Bearer", "Basic", "token", + `${anthropic}abcdefghij0123456789`, "abcd1234", "abcdefgh", "Q7vX2mK9pL4sT8wZ", "1nB6cR3yH5jD0fGa", "MY_TOKEN", "MY_TOKEN =", + "= value123", ": secretvalue", '{"token": "', 'abc def"}', "?token=abc", "https://user:pw@host", "|", "│", " ", "\t", + "PASSWORD=", "hunter22", "界界界", "ünïcödé", "->", "=>", ",", "." + ]; + const breaks = ["\n", "\r\n", "\r", "\n ", "\n│ ", " ", ""]; + const registry = new SecretRedactionRegistry(); + registry.add("vault", ["registered-secret-value-1", "another_registered_value_22"]); + for (let seed = 1; seed <= 400; seed++) { + const random = prng(seed); + const pick = (items) => items[Math.floor(random() * items.length)]; + let text = ""; + // Longer than the held values' reach plus the tail margin, so both cut where they would in a terminal. + for (let pieces = 2_500 + Math.floor(random() * 1_000); pieces > 0; pieces--) { + text += random() < 0.05 ? pick(["registered-secret-value-1", "another_registered_value_22", "registered-secret-\nvalue-1"]) : pick(fragments); + text += pick(breaks); + } + const whole = registry.redact(text); + const maxChars = 1 + Math.floor(random() * 4_000); + assert.equal(registry.redactTail(text, maxChars), whole.slice(-maxChars), `seed ${seed}: tail`); + } +}); diff --git a/tests/secret-redaction.test.mjs b/tests/secret-redaction.test.mjs index 63ca2ea3..8400979d 100644 --- a/tests/secret-redaction.test.mjs +++ b/tests/secret-redaction.test.mjs @@ -87,6 +87,50 @@ test("masking stays linear on long runs of name-like text", () => { } }); +test("registry: known-value matching avoids text-sized typed arrays", () => { + const registry = new SecretRedactionRegistry(); + const key = "custom-secret-value-not-a-token"; + registry.add("vault", [key]); + const plain = `${"x".repeat(1_000_000)}${key}`; + const wrappedKey = `${key.slice(0, 12)}\n│ ${key.slice(12)}`; + const wrapped = `${"x".repeat(1_000_000)}${wrappedKey}`; + const NativeInt32Array = globalThis.Int32Array; + const NativeUint32Array = globalThis.Uint32Array; + let largestAllocation = 0; + const track = (Native) => new Proxy(Native, { + construct(target, args, newTarget) { + largestAllocation = Math.max(largestAllocation, Number(args[0]) || 0); + return Reflect.construct(target, args, newTarget); + } + }); + try { + globalThis.Int32Array = track(NativeInt32Array); + globalThis.Uint32Array = track(NativeUint32Array); + assert.equal(registry.redact(plain), `${"x".repeat(1_000_000)}`); + assert.equal(registry.redact(wrapped), `${"x".repeat(1_000_000)}`); + } finally { + globalThis.Int32Array = NativeInt32Array; + globalThis.Uint32Array = NativeUint32Array; + } + assert.ok(largestAllocation < 256, `allocated a typed array with ${largestAllocation} elements for a short held value`); +}); + +test("high-entropy checks do not build per-character match arrays for long ordinary runs", () => { + const ordinary = "x".repeat(1_000_000); + const originalMatch = String.prototype.match; + const largeMatchCalls = []; + try { + String.prototype.match = function (pattern) { + if (this.length >= ordinary.length) largeMatchCalls.push(pattern.source); + return originalMatch.call(this, pattern); + }; + assert.equal(redactCredentials(ordinary), ordinary); + } finally { + String.prototype.match = originalMatch; + } + assert.deepEqual(largeMatchCalls, [], "entropy classification counts characters without collecting regex matches"); +}); + /** What OpenCode printed in a live run: the config with its key, wrapped by the terminal inside a box. */ function boxedDump(key) { const config = `{"provider":{"x":{"options":{"baseURL":"https://api.example.test/v1","apiKey":"${key}"}}}}`; @@ -115,6 +159,75 @@ test("registry: a held value is removed also when wrapping split it across box l assert.equal(registry.redact(`x ${launch}`), `x ${launch}`); }); +test("registry: overlapping registered secrets are masked as one complete range", () => { + const registry = new SecretRedactionRegistry(); + registry.add("vault", ["abcdefgh", "abcdefghi", "defghijklmnop"]); + assert.equal(registry.redact("abc\n│ defghi tail"), " tail", "wrapped match at one start chooses the longer value"); + assert.equal(registry.redact("abcdefghi tail"), " tail", "unwrapped matches at one start choose the longer value"); + assert.equal(registry.redact("abcdefghi"), "", "wrap-free input preserves longest-match precedence"); + assert.equal(registry.redact("abcdefghijklmnop tail"), " tail", "a later overlapping secret cannot expose its suffix"); +}); + +test("registry: exact streams retain an overlap after an earlier match skips intervening hits", () => { + const registry = new SecretRedactionRegistry(); + const overlapping = "abcabcabcabc"; + registry.add("vault", ["xyabcabc", overlapping]); + assert.equal(registry.redact(`xy${"abc".repeat(8)}`), ""); +}); + +test("registry: every terminal wrap character is removed from the search view", () => { + const registry = new SecretRedactionRegistry(); + const value = "wrap-character-equivalence-key"; + registry.add("vault", [value]); + const wrapCharacters = [ + ...Array.from({ length: 5 }, (_, index) => String.fromCharCode(0x09 + index)), " ", "\u00a0", "\u1680", + ...Array.from({ length: 11 }, (_, index) => String.fromCharCode(0x2000 + index)), + "\u2028", "\u2029", "\u202f", "\u205f", "\u3000", "\ufeff", + ...Array.from({ length: 0x80 }, (_, index) => String.fromCharCode(0x2500 + index)) + ]; + const gaps = Array.from({ length: value.length - 1 }, () => []); + for (let index = 0; index < wrapCharacters.length; index++) { + gaps[Math.floor(index * gaps.length / wrapCharacters.length)].push(wrapCharacters[index]); + } + let wrapped = ""; + for (let index = 0; index < value.length; index++) wrapped += value[index] + (gaps[index]?.join("") ?? ""); + assert.equal(registry.redact(wrapped), ""); +}); + +test("registry: an invalid longer wrapped candidate does not hide a valid shorter candidate", () => { + const registry = new SecretRedactionRegistry(); + const shorter = "custom-secret-value-not-a-token"; + const longer = `${shorter}-tail`; + registry.add("vault", [shorter, longer]); + const text = `${shorter}${" ".repeat(65)}-tail`; + assert.equal(registry.redact(text), `${" ".repeat(65)}-tail`); +}); + +test("registry: exact-only wrapped values work when no bare candidate is present", () => { + const registry = new SecretRedactionRegistry(); + registry.add("vault", ["abc def", "not-present-secret"]); + assert.equal(registry.redact("abc def"), ""); +}); + +test("registry: wrapped overlaps survive the shared offset horizon and UTF-16 tail cut", () => { + const registry = new SecretRedactionRegistry(); + registry.add("vault", ["a".repeat(8), "a".repeat(10)]); + const tail = 4_000; + const base = scrollback(80_000); + const sample = `😀${Array.from({ length: 12 }, () => "a").join("\n│ ")}`; + // Place the first match three UTF-16 code units before the masked output's tail cut. + const removed = 12 + 11 * 3 - "".length; + const at = base.length + sample.length - removed - tail - 3 - "😀".length; + const text = `${base.slice(0, at)}${sample}${base.slice(at)}`; + const masked = registry.redact(text); + const marker = masked.indexOf(""); + assert.equal(marker, masked.length - tail - 3); + assert.equal(masked.slice(marker - 2, marker), "😀"); + assert.ok(masked.includes("")); + assert.ok(!masked.includes("\n│ a"), "every overlapping wrapped suffix is masked"); + assert.equal(registry.redactTail(text, tail), masked.slice(-tail)); +}); + test("registry: a value added again stays masked when an owner is full; only the least recently added one goes", (t) => { const warnings = []; t.mock.method(console, "warn", (message) => warnings.push(String(message))); @@ -145,6 +258,68 @@ test("registry: generic shapes without a held value, including keys the terminal assert.match(random, /plain words/u); }); +test("registry: held structural words cannot disable generic credential masking", () => { + const registry = new SecretRedactionRegistry(); + const privateKeyWords = ["PRIVATE", "KEY"].join(" "); + const passwordLabel = ["pass", "word"].join(""); + const authorizationLabel = ["Author", "ization"].join(""); + const embeddedToken = sk(run("R", 30)); + const fullRegisteredValue = `credential-envelope-${embeddedToken}-private-suffix`; + registry.add("vault", [privateKeyWords, passwordLabel, authorizationLabel, fullRegisteredValue]); + + const pem = `-----BEGIN RSA ${privateKeyWords}-----\n${run("M", 64)}\n-----END RSA ${privateKeyWords}-----`; + assert.equal(registry.redact(pem), "", "a held header fragment must not hide the PEM structure"); + + const assignment = `${passwordLabel}: ${"ordinary-secret-value-123"}`; + const redactedAssignment = registry.redact(assignment); + assert.equal(redactedAssignment.includes("ordinary-secret-value-123"), false, "the assignment value remains hidden after its label is masked"); + assert.match(redactedAssignment, //u); + + const json = `{"${passwordLabel}":"${"ordinary-json-secret-456"}"}`; + const redactedJson = registry.redact(json); + assert.equal(redactedJson.includes("ordinary-json-secret-456"), false, "the JSON value remains hidden after its key is masked"); + + const authorization = `${authorizationLabel}: Basic ${"opaqueCredentialValue123"}`; + const redactedAuthorization = registry.redact(authorization); + assert.equal(redactedAuthorization.includes("opaqueCredentialValue123"), false, "the authorization value remains hidden after its header is masked"); + assert.match(redactedAuthorization, //u); + + assert.equal(registry.redact(fullRegisteredValue), "", "an inner generic token cannot split a longer registered value"); +}); + +test("registry: a name joined to a held random value keeps its name", () => { + const registry = new SecretRedactionRegistry(); + registry.add("plugin:p.keys", [mixed]); + assert.equal(registry.redact(`env: PROVIDER_API_KEY=${mixed} PLAIN=visible`), "env: PROVIDER_API_KEY= PLAIN=visible"); + assert.equal(registry.redact(`token_${mixed}_tail`), "token__tail"); + assert.equal(registry.redact(`x ${mixed}${["aB3dE5gH", "iJ7kL9mN", "oP1qR2sT", "uV4wX6yZ"].join("")} y`), "x y", "a random run next to the value is still masked"); +}); + +test("registry: empty-registry fast path preserves overlapping JSON and generic redaction", () => { + const empty = new SecretRedactionRegistry(); + const rangePath = new SecretRedactionRegistry(); + rangePath.add("vault", ["unrelated-held-fixture-value-32"]); + const privateKeyWords = ["PRIVATE", "KEY"].join(" "); + const pem = (body) => `-----BEGIN RSA ${privateKeyWords}-----\n${body}\n-----END RSA ${privateKeyWords}-----`; + const apiKey = ["api", "Key"].join(""); + const authorization = ["Author", "ization"].join(""); + const basic = ["B", "asic"].join(""); + const url = `https://u:pw@fixture.example/?access_token=${run("Q", 12)}`; + const samples = [ + [`{"${apiKey}":"${pem(run("M", 24))}"}`, `{"${apiKey}":""}`], + [`{"${authorization}":"${authorization}: ${basic} ${"opaqueCredentialValue123"}"}`, `{"${authorization}":""}`], + [`{"token":"${url}"}`, `{"token":""}`], + [pem(`{"${apiKey}":"${"ordinary-json-secret-456"}"}`), ""] + ]; + + for (const [sample, expected] of samples) { + const fastPath = empty.redact(sample); + assert.equal(fastPath, rangePath.redact(sample), sample.slice(0, 32)); + assert.equal(fastPath, expected, sample.slice(0, 32)); + assert.equal(fastPath.includes("opaqueCredentialValue123") || fastPath.includes("ordinary-json-secret-456"), false); + } +}); + test("the vault hands every value it reads or writes to the registry; agent-readable text is masked", async (t) => { const directory = await mkdtemp(join(tmpdir(), "canvastty-redaction-vault-")); t.after(() => rm(directory, { recursive: true, force: true })); @@ -383,6 +558,23 @@ test("registry: held values of 4k, 16k and 64k characters are masked whole, also } }); +test("registry: maximum-length repetitive values mask dense overlapping occurrences", () => { + const registry = new SecretRedactionRegistry(); + const value = "a".repeat(65_536); + registry.add("vault", [value]); + const text = "a".repeat(150_000); + assert.equal(registry.redact(text), ""); +}); + +test("registry: long native-prefix skips preserve UTF-16 matches", () => { + const registry = new SecretRedactionRegistry(); + const value = `😀${"x".repeat(127)}`; + const distinctPrefix = `abcdefgH${"x".repeat(130)}`; + registry.add("vault", [value, distinctPrefix]); + assert.equal(registry.redact(`before 🛡️${value} after`), "before 🛡️ after"); + assert.equal(registry.redact(distinctPrefix), ""); +}); + test("registry: a long held value never breaks masking of anything else", () => { const registry = new SecretRedactionRegistry(); registry.add("plugin:p.big", [longSecret(4_000)]); From 9d6fffbde559a7c0f5c9a4b616bc6256a3859906 Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:58:48 +0000 Subject: [PATCH 19/28] fix(files): compare file ids as bigints and preserve the bytes that were read The Files reader and the usage history reader bind an opened descriptor to its path by dev/ino. As numbers, 64-bit file ids (NTFS, some network file systems) lose precision, so two different files could compare equal. Compare bigint stats instead. The history reader also compares the pre-open lstat with the descriptor, so a file swapped between the symlink check and the open is refused rather than read and then quarantined. When invalid records are dropped, the original is preserved from the bytes read through the checked handle (exclusive create, mode 0600) instead of a second copy by path. --- src/main/services/FileAccessService.ts | 8 +++- src/main/services/UsageHistoryService.ts | 23 +++++----- tests/file-access-service.test.mjs | 33 +++++++++++++- tests/usage-history-service.test.mjs | 55 +++++++++++++++++++++++- 4 files changed, 105 insertions(+), 14 deletions(-) diff --git a/src/main/services/FileAccessService.ts b/src/main/services/FileAccessService.ts index 07c735a0..6005c761 100644 --- a/src/main/services/FileAccessService.ts +++ b/src/main/services/FileAccessService.ts @@ -204,9 +204,13 @@ export class FileAccessService { if (!isContainedPath(root.path, resolvedAfterOpen)) { return { kind: "unsupported", reason: "not-permitted" }; } + // Bind the descriptor to that contained path: the file it names now must be the very file that + // was opened. Where no descriptor path is exposed (macOS, Windows) this is what catches an open + // redirected outside the root by a swapped intermediate directory. Inode numbers are compared as + // bigints: 64-bit file ids (NTFS, some network file systems) lose precision as numbers. try { - const current = await stat(resolvedAfterOpen); - if (current.dev !== metadata.dev || current.ino !== metadata.ino) { + const [current, opened] = [await stat(resolvedAfterOpen, { bigint: true }), await handle.stat({ bigint: true })]; + if (current.dev !== opened.dev || current.ino !== opened.ino) { return { kind: "unsupported", reason: "not-permitted" }; } } catch { diff --git a/src/main/services/UsageHistoryService.ts b/src/main/services/UsageHistoryService.ts index c57fc16d..0b085912 100644 --- a/src/main/services/UsageHistoryService.ts +++ b/src/main/services/UsageHistoryService.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import { constants } from "node:fs"; -import { copyFile, lstat, open, rename, unlink, type FileHandle } from "node:fs/promises"; +import { lstat, open, rename, unlink, writeFile, type FileHandle } from "node:fs/promises"; import { join } from "node:path"; import { isDeepStrictEqual } from "node:util"; import type { LimitProviderId, LimitsSnapshot } from "../../shared/contracts.ts"; @@ -114,19 +114,21 @@ export class UsageHistoryService { async load(): Promise { const now = this.now(); this.startedAt = now; - let raw: string; + let bytes: Buffer; let handle: FileHandle | undefined; try { - // Windows lacks O_NOFOLLOW; check the path before and after opening too. - if ((await lstat(this.filePath)).isSymbolicLink()) throw Object.assign(new Error("symlink"), { code: "ELOOP" }); + // Windows lacks O_NOFOLLOW; check the path before and after opening too. File ids are compared as + // bigints: 64-bit NTFS ids lose precision as numbers. + const checked = await lstat(this.filePath, { bigint: true }); + if (checked.isSymbolicLink()) throw Object.assign(new Error("symlink"), { code: "ELOOP" }); handle = await open(this.filePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); - const opened = await handle.stat(); - const current = await lstat(this.filePath); - if (current.isSymbolicLink() || opened.dev !== current.dev || opened.ino !== current.ino) { + const opened = await handle.stat({ bigint: true }); + const current = await lstat(this.filePath, { bigint: true }); + if (current.isSymbolicLink() || [checked, current].some((path) => path.dev !== opened.dev || path.ino !== opened.ino)) { throw Object.assign(new Error("history path changed"), { code: "ELOOP" }); } if (!opened.isFile()) throw Object.assign(new Error("not a file"), { code: "ENOTFILE" }); - raw = await handle.readFile("utf8"); + bytes = await handle.readFile(); } catch (error) { if (errorCode(error) === "ENOENT") return; this.block(`Usage history file could not be read (${errorCode(error)}); it was left untouched and new history is not being saved.`); @@ -137,7 +139,7 @@ export class UsageHistoryService { let parsed: unknown; try { - parsed = JSON.parse(raw); + parsed = JSON.parse(bytes.toString("utf8")); } catch { await this.quarantine("invalid JSON", now); return; @@ -160,7 +162,8 @@ export class UsageHistoryService { // The cleaned history will replace the file, so the original is copied aside first. const name = preservedName(now); try { - await copyFile(this.filePath, join(this.directory, name), constants.COPYFILE_EXCL); + // The bytes read through the checked handle, not a second read by path that a swap could redirect. + await writeFile(join(this.directory, name), bytes, { flag: "wx", mode: 0o600 }); } catch (error) { this.block(`Usage history contains ${restored.invalid} invalid records and could not be preserved (${errorCode(error)}); it was left untouched and new history is not being saved.`); return; diff --git a/tests/file-access-service.test.mjs b/tests/file-access-service.test.mjs index 08f30967..8f95583c 100644 --- a/tests/file-access-service.test.mjs +++ b/tests/file-access-service.test.mjs @@ -3,7 +3,7 @@ import fs, { realpathSync } from "node:fs"; import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; -import { syncBuiltinESMExports } from "node:module"; +import { createRequire, syncBuiltinESMExports } from "node:module"; import test from "node:test"; import { FileAccessService } from "../src/main/services/FileAccessService.ts"; @@ -11,6 +11,8 @@ async function makeTempDir(prefix) { return mkdtemp(join(tmpdir(), prefix)); } +const fsPromises = createRequire(import.meta.url)("node:fs/promises"); + function createService({ cwd, limits } = {}) { return new FileAccessService({ resolveSessionCwd: (sessionId) => (sessionId === "session-1" ? cwd : undefined), @@ -138,6 +140,35 @@ test("rejects symlinks whose real target escapes the root", async () => { } }); +test("rejects a read whose descriptor is not the file the contained path names", async (t) => { + // Simulates an intermediate directory swapped between realpath and open: the open lands on a file + // outside the root while the path re-resolved afterwards is still inside it. + const root = await makeTempDir("canvastty-files-swap-root-"); + const outside = await makeTempDir("canvastty-files-swap-outside-"); + const realOpen = fsPromises.open; + try { + const service = createService({ cwd: root }); + const { rootId } = service.registerSessionRoot("session-1"); + await writeFile(join(root, "notes.txt"), "inside"); + await writeFile(join(outside, "secret.txt"), "top secret"); + const redirected = join(outside, "secret.txt"); + t.mock.method(fsPromises, "open", (path, ...rest) => + String(path).endsWith("notes.txt") ? realOpen(redirected, ...rest) : realOpen(path, ...rest) + ); + syncBuiltinESMExports(); + + assert.deepEqual(await service.read(rootId, "notes.txt"), { + kind: "unsupported", + reason: "not-permitted" + }); + } finally { + t.mock.restoreAll(); + syncBuiltinESMExports(); + await rm(root, { recursive: true, force: true }); + await rm(outside, { recursive: true, force: true }); + } +}); + test("rejects non-regular files such as directories", async () => { const root = await makeTempDir("canvastty-files-nonregular-"); try { diff --git a/tests/usage-history-service.test.mjs b/tests/usage-history-service.test.mjs index 82cfce4a..3bb6960d 100644 --- a/tests/usage-history-service.test.mjs +++ b/tests/usage-history-service.test.mjs @@ -1,7 +1,8 @@ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { appendFile, chmod, lstat, mkdir, mkdtemp, readdir, readFile, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { appendFile, chmod, lstat, mkdir, mkdtemp, readdir, readFile, rename, rm, stat, symlink, writeFile } from "node:fs/promises"; import { tmpdir, devNull } from "node:os"; +import { createRequire, syncBuiltinESMExports } from "node:module"; import { join } from "node:path"; import test from "node:test"; import { LocalUsageCollector, discoverSqlite } from "../src/main/services/LocalUsageCollector.ts"; @@ -353,6 +354,58 @@ test("structurally invalid history is quarantined the same way", async () => { }); }); +const fsPromises = createRequire(import.meta.url)("node:fs/promises"); + +/** Runs `run` while every open of `path` first calls `before` and its handle calls `onClose` before closing. */ +async function withOpenHook(t, path, { before = async () => undefined, onClose = async () => undefined }, run) { + const realOpen = fsPromises.open; + t.mock.method(fsPromises, "open", async (target, ...rest) => { + if (target !== path) return realOpen(target, ...rest); + await before(); + const handle = await realOpen(target, ...rest); + const close = handle.close.bind(handle); + handle.close = async () => { await onClose(); return close(); }; + return handle; + }); + syncBuiltinESMExports(); + try { + await run(); + } finally { + t.mock.restoreAll(); + syncBuiltinESMExports(); + } +} + +test("a history file swapped between the symlink check and the open is refused and left alone", async (t) => { + await withDirectory(async (directory) => { + const path = join(directory, FILE); + await writeFile(path, "{}"); + await writeFile(join(directory, "planted.json"), "planted"); + const { history } = service(directory); + await withOpenHook(t, path, { before: () => rename(join(directory, "planted.json"), path) }, () => history.load()); + assert.match(history.get().error, /could not be read \(ELOOP\)/); + assert.equal(await readFile(path, "utf8"), "planted", "the swapped-in file is not touched"); + }); +}); + +test("the preserved original is the content that was read, even if the path is swapped afterwards", async (t) => { + await withDirectory(async (directory) => { + const original = JSON.stringify(envelope( + { events: [event("good", { from: T0 - DAY, to: T0 - DAY }), event("bad", { input: -5 })] }, + { version: 2, files: { [KEY]: cursor(1) }, hermes: {} } + )); + const path = join(directory, FILE); + await writeFile(path, original); + await writeFile(join(directory, "planted.json"), "planted"); + const { history } = service(directory); + await withOpenHook(t, path, { onClose: () => rename(join(directory, "planted.json"), path) }, () => history.load()); + const preserved = (await readdir(directory)).filter((name) => name.startsWith("usage-history.corrupt-")); + assert.equal(preserved.length, 1); + assert.equal(await readFile(join(directory, preserved[0]), "utf8"), original); + if (process.platform !== "win32") assert.equal((await stat(join(directory, preserved[0]))).mode & 0o777, 0o600); + }); +}); + test("partially invalid records are dropped openly and the original file is preserved", async () => { await withDirectory(async (directory) => { const original = JSON.stringify(envelope( From 1ed8ad0f7275eaa9a31e9661ae95e8ea4ab2d4d4 Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:53:10 +0000 Subject: [PATCH 20/28] fix(usage): read rollback-journal Hermes databases with locks, not as immutable A database without -wal/-shm was always opened with immutable=1, which skips locking and hot-journal checks, so a rollback-journal database could be read while Hermes was writing it. Use the immutable read only when the header says WAL (bytes 18 and 19 are 2) and no side files exist; open other databases with mode=ro and retry SQLITE_BUSY a bounded number of times before skipping the poll. --- src/main/services/LocalUsageCollector.ts | 42 ++++++++++- tests/usage-collector.test.mjs | 96 +++++++++++++++++++++++- 2 files changed, 134 insertions(+), 4 deletions(-) diff --git a/src/main/services/LocalUsageCollector.ts b/src/main/services/LocalUsageCollector.ts index 2ab948d5..14331003 100644 --- a/src/main/services/LocalUsageCollector.ts +++ b/src/main/services/LocalUsageCollector.ts @@ -149,6 +149,28 @@ export function sanitizeCollectorState(input: unknown): Clean { async function sideFile(path: string): Promise { try { return (await stat(path)).size; } catch { return null; } } +/** Attempts of a rollback-journal read while a writer holds the database, each after the CLI's busy timeout. */ +const BUSY_ATTEMPTS = 3; +const BUSY_RETRY_MS = 250; +const isBusy = (error: unknown): boolean => /database (?:table )?is locked|SQLITE_BUSY|\bbusy\b/i + .test(String((error as { stderr?: unknown }).stderr ?? (error as Error)?.message ?? '')); +/** + * Whether the database header marks WAL mode: file format write and read versions (bytes 18 and 19) are both 2. + * A rollback-journal database has 1 there; an unreadable or foreign header counts as not WAL. + */ +async function walHeader(path: string): Promise { + let handle: FileHandle | undefined; + try { + handle = await open(path, 'r'); + const header = Buffer.alloc(100); + const { bytesRead } = await handle.read(header, 0, header.length, 0); + return bytesRead === header.length && header.toString('latin1', 0, 16) === 'SQLite format 3\0' && header[18] === 2 && header[19] === 2; + } catch { + return false; + } finally { + await handle?.close().catch(() => undefined); + } +} function sqliteProblem(error: unknown): string { if (error instanceof SourceProblem) return error.message; const e = error as NodeJS.ErrnoException & { stderr?: unknown; killed?: boolean }; @@ -396,11 +418,16 @@ export class LocalUsageCollector { } /** - * A plain read-only connection creates a missing -shm file; readonly_shm never does. When no WAL - * frames can exist (no or empty -wal) an immutable read takes no locks and creates nothing. + * A rollback-journal database (header not WAL) is read through a plain read-only connection: it takes a shared + * lock, so a writer's half-written pages are never read, and it creates no side files. A busy database is + * retried a few times, then skipped for this poll. For a WAL database a plain read-only connection creates a + * missing -shm file; readonly_shm never does. When no WAL frames can exist (no or empty -wal) an immutable read + * takes no locks and creates nothing; only a WAL database may be read that way, since its writer always keeps + * -wal and -shm beside it while it runs. */ private async sqlite(database: string, sql: string): Promise[]> { const target = (query: string): string => `${pathToFileURL(database).href}?${query}`; + if (!await walHeader(database)) return await this.lockedQuery(target('mode=ro'), sql); const [wal, shm] = await Promise.all([sideFile(`${database}-wal`), sideFile(`${database}-shm`)]); if (shm === null && wal) throw new SourceProblem('WAL without shared-memory index (writer stopped uncleanly); skipped to avoid a stale read'); // A read-only WAL connection can still create side files beside a stopped writer. @@ -414,6 +441,17 @@ export class LocalUsageCollector { } } + private async lockedQuery(target: string, sql: string): Promise[]> { + for (let attempt = 1; ; attempt++) { + try { + return await this.query(target, sql); + } catch (error) { + if (attempt >= BUSY_ATTEMPTS || !isBusy(error)) throw error; + await new Promise(done => setTimeout(done, BUSY_RETRY_MS * attempt)); + } + } + } + private async query(target: string, sql: string): Promise[]> { const executable = this.sqlitePath ?? await discoverSqlite(this.sqliteEnvironment); if (!executable) throw new SourceProblem('sqlite3 is unavailable (not found on PATH)'); diff --git a/tests/usage-collector.test.mjs b/tests/usage-collector.test.mjs index ba0172ed..8e8e385f 100644 --- a/tests/usage-collector.test.mjs +++ b/tests/usage-collector.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { execFileSync } from 'node:child_process'; +import { execFileSync, spawn } from 'node:child_process'; import { createHash, randomUUID } from 'node:crypto'; import { existsSync } from 'node:fs'; import { appendFile, chmod, mkdir, mkdtemp, readFile, readdir, rename, rm, symlink, utimes, writeFile } from 'node:fs/promises'; @@ -193,7 +193,7 @@ test('Hermes queries select accounting columns only, read-only, and never touch }).collect(T0); const statements = argumentsSeen.filter((argument) => /^\s*SELECT/i.test(argument)); assert.equal(statements.length, 2); - assert.ok(argumentsSeen.includes('-readonly') && argumentsSeen.includes('-safe') && argumentsSeen.some((a) => /^file:.*\?mode=ro&immutable=1$/.test(a))); + assert.ok(argumentsSeen.includes('-readonly') && argumentsSeen.includes('-safe') && argumentsSeen.some((a) => /^file:.*\?mode=ro$/.test(a))); for (const statement of statements) { assert.doesNotMatch(statement, /\*|system_prompt|title|messages|content|INSERT|UPDATE|DELETE|CREATE|DROP|ATTACH|PRAGMA\s/i); } @@ -228,6 +228,98 @@ test('Hermes WAL stores: live writer, stopped writer without -wal/-shm, and orph assert.deepEqual((await readdir(dir)).sort(), ['state.db', 'state.db-wal']); }); +/** A collector whose sqlite3 calls are recorded (database URI per call) and can be made to fail first. */ +function recordingCollector(home, uris, failFirst = 0) { + let failures = failFirst; + return new LocalUsageCollector(home, {}, {}, { + sqlite: SQLITE, + sqliteRunner: async (file, args, options) => { + uris.push(args.find((argument) => argument.startsWith('file:'))); + if (failures > 0) { + failures--; + throw Object.assign(new Error('Command failed'), { stderr: 'Error: in prepare, database is locked (5)' }); + } + return { stdout: execFileSync(file, args, { ...options, encoding: 'utf8' }) }; + } + }); +} + +/** + * A sqlite3 process holding `statements` open in an exclusive transaction. The writer runs in its own process: POSIX + * locks are per process, and this process opening and closing the database (snapshots, header reads) would drop them. + */ +async function exclusiveWriter(t, db, statements) { + const child = spawn(SQLITE, ['-init', devNull, '-batch', db], { env: {}, stdio: ['pipe', 'pipe', 'pipe'] }); + t.after(() => { if (child.exitCode === null && child.signalCode === null) child.kill(); }); + let output = ''; + const ready = new Promise((resolve, reject) => { + child.stdout.on('data', (chunk) => { output += chunk; if (output.includes('writer-ready')) resolve(); }); + child.once('exit', () => reject(new Error(`writer exited early: ${output}`))); + }); + child.stdin.write(`BEGIN EXCLUSIVE; ${statements}; SELECT 'writer-ready';\n`); + await ready; + return async () => { + const exited = new Promise((resolve) => child.once('exit', resolve)); + child.stdin.end('COMMIT;\n.quit\n'); + await exited; + }; +} + +test('Hermes rollback-journal stores are read with locks, never as immutable; a busy writer is retried, then skipped', { skip: needsSqlite }, async (t) => { + const home = await fixture(t); + const dir = join(home, '.hermes'); + const path = await hermesDb(dir, session('r', 'openai-codex', [0, 0, 0, 0], T0 - DAY) + modelRow('r', 'gpt-5.5', 'openai-codex', '', [100, 10, 0, 0], T0 - DAY, T0 - DAY)); + const header = await readFile(path); + assert.deepEqual([header[18], header[19]], [1, 1], 'the fixture is a rollback-journal database'); + const uris = []; + const collector = recordingCollector(home, uris); + await collectUntouched(collector, T0, home); + assert.ok(uris.length > 0 && uris.every((uri) => /\?mode=ro$/.test(uri)), `rollback-journal reads take locks: ${uris.join(' ')}`); + + // A writer in the middle of a transaction holds the exclusive lock: an immutable read would ignore it and could + // see half-written pages. The locked read waits, retries a bounded number of times, then skips this poll. + const commit = await exclusiveWriter(t, path, 'UPDATE session_model_usage SET input_tokens = 150'); + uris.length = 0; + // Not collectUntouched: the writer's rollback journal exists while its transaction is open. + const busy = await collector.collect(T0 + MINUTE); + assert.deepEqual(busy.events, []); + assert.match(busy.coverage.join('\n'), /Hermes default: database is busy; not collected this time\./); + assert.equal(uris.length, 3, 'three attempts, then the poll moves on'); + assert.deepEqual((await readdir(dir)).sort(), ['state.db', 'state.db-journal'], 'only the writer\'s own journal exists'); + await commit(); + assert.deepEqual((await readdir(dir)).sort(), ['state.db'], 'no side files remain'); + + const after = await collectUntouched(collector, T0 + 2 * MINUTE, home); + assert.deepEqual(after.events.map((e) => e.input), [50], 'the committed change is counted on the next poll'); + assert.deepEqual((await readdir(dir)).sort(), ['state.db'], 'reading created no side files'); + + // A transient lock is retried within the same poll. + const retried = []; + sql(path, `UPDATE session_model_usage SET input_tokens = 170`); + const second = recordingCollector(home, retried, 1); + const first = await collectUntouched(second, T0 + 3 * MINUTE, home); + assert.doesNotMatch(first.coverage.join('\n'), /busy/); + assert.equal(retried.length, 3, 'one failed attempt, then the schema and the accounting query'); +}); + +test('Hermes WAL stores without side files keep the immutable read', { skip: needsSqlite }, async (t) => { + const { DatabaseSync } = await import('node:sqlite').catch(() => ({})); + if (!DatabaseSync) return t.skip('node:sqlite unavailable'); + const home = await fixture(t); + const dir = join(home, '.hermes'), path = join(dir, 'state.db'); + await mkdir(dir); + const writer = new DatabaseSync(path); + writer.exec('PRAGMA journal_mode=WAL;' + SCHEMA + session('w', 'openai-codex', [0, 0, 0, 0], T0 - DAY)); + writer.close(); + assert.deepEqual((await readdir(dir)).sort(), ['state.db']); + const header = await readFile(path); + assert.deepEqual([header[18], header[19]], [2, 2], 'the fixture is a WAL database'); + const uris = []; + await collectUntouched(recordingCollector(home, uris), T0, home); + assert.ok(uris.length > 0 && uris.every((uri) => /\?mode=ro&immutable=1$/.test(uri)), uris.join(' ')); + assert.deepEqual((await readdir(dir)).sort(), ['state.db']); +}); + // ---- JSONL fixtures const NOW = Math.floor(Date.now() / 1000) * 1000; const tokens = ([input, cached, output]) => ({ input_tokens: input, cached_input_tokens: cached, output_tokens: output, reasoning_output_tokens: Math.floor(output / 2), total_tokens: input + output }); From 3f62b7e1baa1285833b08dc9290855e671291ad7 Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Wed, 14 Oct 2026 08:41:30 +0000 Subject: [PATCH 21/28] fix(isolation): give isolated Claude Code its own temp folder Claude Code keeps its Bash tool output and external-editor files in ${CLAUDE_CODE_TMPDIR:-/tmp}/claude- and ignores TMPDIR. The isolation layer does not make /tmp/claude- writable, so every shell call of an isolated Claude failed with "Operation not permitted". Set CLAUDE_CODE_TMPDIR to the launch's private temp folder for Claude on both layers (seatbelt and bubblewrap). The shared /tmp/claude- is not granted: all of the person's Claude sessions use it, and an isolated agent could otherwise rewrite another session's task output. --- docs/agent-orchestration.md | 2 +- src/main/services/isolation/AgentIsolation.ts | 5 +- tests/agent-isolation.test.mjs | 48 ++++++++++++++++++- 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/docs/agent-orchestration.md b/docs/agent-orchestration.md index b46943fd..bf3c4a6b 100644 --- a/docs/agent-orchestration.md +++ b/docs/agent-orchestration.md @@ -54,7 +54,7 @@ The core applies these to every way an agent can start another one: `spawn_agent Settings → Agents → Agent isolation (on by default) wraps an agent's whole process tree (the CLI, its commands, its MCP servers and hooks) in an operating-system layer. It applies to every subagent, every agent a plugin starts, and every agent in a profile other than normal (manual), whoever launched it. Inside it: -- files can be written only in the project folder (both spellings), the launch's own temporary folder (`TMPDIR`), the CLI's own state, configuration and cache folders, and the npm/bun caches; the project's git hooks and an existing repository's git config, and the CLI's own permission settings files (`~/.codex/config.toml`, `~/.claude/settings.json`, OpenCode's config) are not writable; +- files can be written only in the project folder (both spellings), the launch's own temporary folder (`TMPDIR`; for Claude also `CLAUDE_CODE_TMPDIR`, so its Bash tool and editor files stay out of the `/tmp/claude-` folder all Claude sessions share), the CLI's own state, configuration and cache folders, and the npm/bun caches; the project's git hooks and an existing repository's git config, and the CLI's own permission settings files (`~/.codex/config.toml`, `~/.claude/settings.json`, OpenCode's config) are not writable; - SSH, cloud and package-registry credentials, other CLIs' folders (their credentials) and CanvasTTY's own tokens, secret stores, account homes, launch files and plugins' private data are unreadable, except what this launch was handed (its own control connection, its account home, the one CLI home its launch plugin selected); - no other process can be signalled, no app opened through Launch Services, no Apple events sent (osascript driving Terminal), no preferences written through cfprefsd, no launchd job submitted, and Unix sockets reach only DNS, syslog, the launch's own folder and CanvasTTY's token-checked gateways (not Docker, tmux or an SSH agent); - network and the keychain's services work as before: the CLI reaches its provider and reads its own sign-in. diff --git a/src/main/services/isolation/AgentIsolation.ts b/src/main/services/isolation/AgentIsolation.ts index c0c82a02..58ee5b33 100644 --- a/src/main/services/isolation/AgentIsolation.ts +++ b/src/main/services/isolation/AgentIsolation.ts @@ -212,7 +212,10 @@ export class AgentIsolation { const gitTemplate = join(folder, "git-template"); mkdirSync(gitTemplate, { mode: 0o500 }); // An agent that meets "Operation not permitted" can read why here, instead of trying other ways around it. - const env = { ...launch.env, TMPDIR: `${temp}/`, TMP: temp, TEMP: temp, GIT_TEMPLATE_DIR: gitTemplate, [ISOLATION_ENV]: ISOLATION_NOTE }; + const env: Record = { ...launch.env, TMPDIR: `${temp}/`, TMP: temp, TEMP: temp, GIT_TEMPLATE_DIR: gitTemplate, [ISOLATION_ENV]: ISOLATION_NOTE }; + // Claude Code ignores TMPDIR for its Bash tool and editor files: `${CLAUDE_CODE_TMPDIR:-/tmp}/claude-`, which + // all of the person's Claude sessions share and the layer keeps read-only. Its own folder is the launch's temp. + if (launch.provider === "claude") env.CLAUDE_CODE_TMPDIR = temp; if (available.layer === "seatbelt") { const profilePath = join(folder, "profile.sb"); writeFileSync(profilePath, seatbeltProfile(paths), { mode: 0o600, flag: "wx" }); diff --git a/tests/agent-isolation.test.mjs b/tests/agent-isolation.test.mjs index cf4c35ee..2324b719 100644 --- a/tests/agent-isolation.test.mjs +++ b/tests/agent-isolation.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { spawn, spawnSync } from "node:child_process"; -import { existsSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { mkdir, mkdtemp, readFile, readdir, realpath, rm, symlink, writeFile } from "node:fs/promises"; import { createServer } from "node:net"; import { tmpdir } from "node:os"; @@ -530,6 +530,52 @@ test("an isolated launch gets an empty git template, so git init writes no hooks } finally { wrapped.cleanup(); } }); +test("an isolated Claude keeps its Bash and editor files in the launch's own temp folder, on both layers; other CLIs are untouched", async (t) => { + const w = await world(t); + const uid = typeof process.getuid === "function" ? process.getuid() : 501; + const layers = [ + ["seatbelt", isolation(w, { platform: "darwin", exists: () => true })], + ["bubblewrap", isolation(w, { platform: "linux", bubblewrapPath: "/usr/bin/bwrap", bubblewrapProbe: () => null, exists: () => true })] + ]; + for (const [layer, isolated] of layers) { + // A CLAUDE_CODE_TMPDIR from the person's environment would point Claude back at a shared folder: replaced. + const claude = isolated.wrap({ sessionId: "c", provider: "claude", cwd: w.project, command: "/usr/bin/claude", args: [], env: { ...w.env, CLAUDE_CODE_TMPDIR: "/tmp" } }); + const codex = isolated.wrap({ sessionId: "x", provider: "codex", cwd: w.project, command: "/usr/bin/codex", args: [], env: w.env }); + try { + assert.equal(claude.env.CLAUDE_CODE_TMPDIR, claude.env.TMP, layer); + assert.equal(`${claude.env.CLAUDE_CODE_TMPDIR}/`, claude.env.TMPDIR, layer); + assert.match(claude.env.TMP, new RegExp(`^${w.temp}/${ISOLATION_FOLDER_PREFIX}`, "u"), layer); + assert.equal(codex.env.CLAUDE_CODE_TMPDIR, undefined, `${layer}: only Claude reads it`); + // The folder all of the person's Claude sessions share is not opened for writing. + const text = layer === "seatbelt" ? readFileSync(claude.args[1], "utf8") : claude.args.join(" "); + assert.ok(!text.includes(`claude-${uid}`), `${layer}: no grant of the shared /tmp/claude-${uid}`); + } finally { + claude.cleanup(); + codex.cleanup(); + } + } +}); + +test("seatbelt, for real: Claude's scratch folder is in its own temp folder; the shared /tmp/claude- stays read-only", onMac, async (t) => { + const w = await world(t); + const shared = `/private/tmp/claude-${process.getuid()}/ctty-iso-probe-${process.pid}`; + await mkdir(shared, { recursive: true }); + await writeFile(join(shared, "other-session.output"), "another session's output"); + t.after(() => rm(shared, { recursive: true, force: true })); + t.after(() => rm(`/private/tmp/claude-${process.getuid()}/ctty-iso-new-${process.pid}`, { recursive: true, force: true })); + const { lines, env } = run(w, [ + // What Claude Code does for its Bash tool: ${CLAUDE_CODE_TMPDIR:-/tmp}/claude-///tasks. + 'd="${CLAUDE_CODE_TMPDIR:-/tmp}/claude-$(id -u)/p/s/tasks"; mkdir -p "$d" && echo out > "$d/b.output" && cat "$d/b.output" >/dev/null && echo SCRATCH-ok || echo SCRATCH-denied', + `echo x >> "${shared}/other-session.output" 2>/dev/null && echo SHARED-WRITE-ok || echo SHARED-WRITE-denied`, + `: > "${shared}/planted" 2>/dev/null && echo SHARED-CREATE-ok || echo SHARED-CREATE-denied`, + `mkdir "/private/tmp/claude-$(id -u)/ctty-iso-new-${process.pid}" 2>/dev/null && echo SHARED-MKDIR-ok || echo SHARED-MKDIR-denied` + ].join("; "), { provider: "claude" }); + assert.deepEqual(lines, ["SCRATCH-ok", "SHARED-WRITE-denied", "SHARED-CREATE-denied", "SHARED-MKDIR-denied"]); + assert.equal(env.CLAUDE_CODE_TMPDIR, env.TMP); + assert.equal(await readFile(join(shared, "other-session.output"), "utf8"), "another session's output"); + assert.equal(existsSync(join(shared, "planted")), false); +}); + test("seatbelt, for real: Claude saves a refreshed sign-in in the login keychain; nothing else there; plan is read-only", onMac, async (t) => { const w = await world(t); const folder = join(w.home, "Library", "Keychains"); From b4b69198e5ccab056621022a5c32a2ba6ba234f4 Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Wed, 14 Oct 2026 08:45:50 +0000 Subject: [PATCH 22/28] fix(safety): keep plugin data private in base protection, except the card's own account home An accounts plugin keeps every account's sign-in in CanvasTTY's plugin data folder. Base protection's private list had account-homes but not plugin-data, so an agent outside the isolation layer (a person-launched card, or one with isolation off) could read another account's tokens. Add plugin-data to the private list. The account home the card's launch plugin selected (the home the launch pipeline already validates for the isolation layer) is recorded with the card and counts as one of its own config folders, as CLAUDE_CONFIG_DIR does, but only while the CLI is still started with it. Other accounts' homes, other plugins' data and the listing stay refused; a project CanvasTTY placed inside plugin data keeps its own files. plugin-data is not added as a name marker, since such a project names that folder in its own paths. --- docs/plugins.md | 2 +- src/main/services/DecisionHooks.ts | 5 +- src/main/services/TerminalManager.ts | 26 +++++++++-- src/main/services/safety/baseProtection.ts | 9 ++-- tests/account-home-isolation.test.mjs | 53 ++++++++++++++++++++++ tests/base-protection-app-private.test.mjs | 35 ++++++++++++++ 6 files changed, 120 insertions(+), 10 deletions(-) diff --git a/docs/plugins.md b/docs/plugins.md index 75c1a4ce..28732f39 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -407,7 +407,7 @@ A service may run another browser engine for agents' background tabs, for exampl Two safety parts are built in and need no plugin: -- **Base protection** (Settings → Agents, on by default; the person can turn it off) denies, through the same hook, sudo and other elevation, piping downloaded or generated text into a shell, download-and-run, disk and format commands, fork bombs, and writing or deleting outside the working folder: the home folder, other projects and `/tmp` included, and deleting the working folder itself. An agent's own plan and memory folders (`~/.claude/plans`, `~/.claude/projects//memory`, and the same inside the run's `CLAUDE_CONFIG_DIR`) are not "outside". It also denies use of CanvasTTY's own private data (from the app's userData folder: the agent-control token and descriptor, the gateways' connection records and sockets, the secret stores, account homes; and the control/runtime socket folders under the temporary folder), even when the working folder contains it (a project at the home folder): commands that name it, including interpreter one-liners and socket clients it recognises, file writes, and read-only file tools (Claude Code's Read, NotebookRead, Grep, Glob and LS; Qwen Code's and OpenCode's equivalents, checked for nothing else and only while base protection was on when the agent started); the reason points the model at an **Orchestrator** launch and the `canvastty_agents` tools. The bundled control CLI may name its descriptor. It only ever denies; each reason tells the model what to do instead (a write to `/tmp` suggests a scratch folder inside the project). +- **Base protection** (Settings → Agents, on by default; the person can turn it off) denies, through the same hook, sudo and other elevation, piping downloaded or generated text into a shell, download-and-run, disk and format commands, fork bombs, and writing or deleting outside the working folder: the home folder, other projects and `/tmp` included, and deleting the working folder itself. An agent's own plan and memory folders (`~/.claude/plans`, `~/.claude/projects//memory`, and the same inside the run's `CLAUDE_CONFIG_DIR`) are not "outside". It also denies use of CanvasTTY's own private data (from the app's userData folder: the agent-control token and descriptor, the gateways' connection records and sockets, the secret stores, account homes, plugins' data (`plugin-data`) except the account home this card's launch plugin selected for it; and the control/runtime socket folders under the temporary folder), even when the working folder contains it (a project at the home folder): commands that name it, including interpreter one-liners and socket clients it recognises, file writes, and read-only file tools (Claude Code's Read, NotebookRead, Grep, Glob and LS; Qwen Code's and OpenCode's equivalents, checked for nothing else and only while base protection was on when the agent started); the reason points the model at an **Orchestrator** launch and the `canvastty_agents` tools. The bundled control CLI may name its descriptor. It only ever denies; each reason tells the model what to do instead (a write to `/tmp` suggests a scratch folder inside the project). Base protection is best effort: it reads each tool call before it runs, so it does not catch a path the call builds at run time (a command substitution, a variable it cannot resolve, a script that walks folders itself such as Python's `os.walk`), and it does not refuse a recursive search started above the app's data folder (`grep -r token ~`, a Grep of the home folder). A call that names more paths, longer paths or more nested shells than it checks is refused rather than checked in part, and so is a shell, file-edit or read call whose check fails with an error. A project set to the home folder, or to any folder that contains CanvasTTY's data, is therefore not a safe configuration on its own. Run such agents with agent isolation (sandbox-exec on macOS, bubblewrap on Linux): it hides the private data from every program in the agent's process tree, walks included. - **Secret redaction**: every text CanvasTTY hands from one agent to another (`observe_agent`, `wait_for_agent`, `get_agent_result`, the control CLI's `screen`, `result` and failure details) is masked: provider keys CanvasTTY holds, launch `secretEnv` values, values a service registered with `redaction.register`, also when the terminal wrapped them over lines, plus common key shapes (`sk-…`, GitHub, Slack, AWS, Google, JWT, `Bearer …`, `"apiKey": "…"`, PEM private keys, long random runs). Plugin tool answers, `screen` in session events, card badges and card action messages are masked the same way. diff --git a/src/main/services/DecisionHooks.ts b/src/main/services/DecisionHooks.ts index 7cda9858..b768fbb0 100644 --- a/src/main/services/DecisionHooks.ts +++ b/src/main/services/DecisionHooks.ts @@ -25,7 +25,10 @@ export interface DecisionSession { role: SessionRole; /** The session's working folder: "outside" is measured from here. */ cwd: string; - /** The agent's own config folders (CLAUDE_CONFIG_DIR of this run), whose plans and memory are not "outside". */ + /** + * The agent's own config folders (CLAUDE_CONFIG_DIR of this run, and the account home a launch contributor selected + * for it): their plans and memory are not "outside", and CanvasTTY's private data does not include them. + */ configDirs: string[]; /** The profile the card runs in. */ profile?: LaunchProfileId; diff --git a/src/main/services/TerminalManager.ts b/src/main/services/TerminalManager.ts index 727896ff..3afd86bf 100644 --- a/src/main/services/TerminalManager.ts +++ b/src/main/services/TerminalManager.ts @@ -68,7 +68,7 @@ import { isLaunchProfile, PROFILE_RANK, profileAvailable, profileCeiling, type L import type { AgentIsolation, IsolationDecision } from "./isolation/AgentIsolation.ts"; import { controlGrantFolder, gatewaySocketAddresses } from "./isolation/AgentIsolation.ts"; import { auditRepositories, neutralizeRepositories, type GitRiskRepository } from "./isolation/gitAudit.ts"; -import type { PlacedProject } from "./isolation/isolationPaths.ts"; +import { providerHomeVariables, type PlacedProject } from "./isolation/isolationPaths.ts"; import { LaunchRefusal } from "./launchRefusal.ts"; import { configuredMode } from "./configuredMode.ts"; import { envKey, RESERVED_ENV, type LaunchPipeline, type PreparedLaunch } from "./LaunchPipeline.ts"; @@ -311,7 +311,11 @@ export class TerminalManager { // Every text an agent reads from another card passes through it (EP-8). private redaction = new SecretRedactionRegistry(); // Where each running card was actually started (an environment may move it) and its agent config folder. - private readonly launchContexts = new Map(); + /** + * Where each running card was started: its folder, and its own config folders (CLAUDE_CONFIG_DIR, and the account + * home a launch contributor selected inside its plugin data), which base protection treats as the agent's own. + */ + private readonly launchContexts = new Map(); private modelCheck: (provider: ProviderId, model: string) => string | null = () => null; // Whether base protection is on (Settings → Agents); OpenCode's auto profile lets shell commands run without asking // only then. Unknown counts as off. @@ -427,7 +431,7 @@ export class TerminalManager { provider: session.metadata.provider, role: session.metadata.role ?? "agent", cwd: launched?.cwd ?? session.metadata.cwd, - configDirs: launched?.configDir ? [launched.configDir] : [], + configDirs: launched ? [...launched.configDirs] : [], profile: session.metadata.profile }; } @@ -1807,7 +1811,7 @@ export class TerminalManager { const process = this.spawnPty(spawn.command, spawn.args, { name: "xterm-256color", cols, rows, cwd: planned.cwd, env: spawn.env }); - this.launchContexts.set(id, { cwd: planned.cwd, configDir: planned.env.CLAUDE_CONFIG_DIR ?? null }); + this.launchContexts.set(id, launchContext(planned.cwd, provider, planned.env, contribution?.accountHome)); return { process, agentBrowser: planned.agentBrowser, @@ -2352,7 +2356,7 @@ export class TerminalManager { } else { delete metadata.nativeEditor; } - this.launchContexts.set(id, { cwd: spawn.cwd, configDir: spawn.env.CLAUDE_CONFIG_DIR ?? null }); + this.launchContexts.set(id, launchContext(spawn.cwd, metadata.provider, spawn.env, contribution?.accountHome)); session.agentBrowser = planned.agentBrowser; session.agentRuntime = planned.agentRuntime; session.agentOrchestration = planned.agentOrchestration; @@ -2840,3 +2844,15 @@ function signalExitText(signal: number): string { const name = Object.entries(osConstants.signals).find(([, value]) => value === signal)?.[0]; return `The process was killed by signal ${name ? `${name} (${signal})` : signal}.`; } + +/** + * A started card's folder and own config folders. The selected account home counts only while the CLI is still + * started with it (an environment may have replaced the variable): then it is that card's, and only that one's. + */ +function launchContext(cwd: string, provider: ProviderId, env: Readonly>, accountHome: string | undefined): { cwd: string; configDirs: string[] } { + const configDirs = env.CLAUDE_CONFIG_DIR ? [env.CLAUDE_CONFIG_DIR] : []; + const started = accountHome !== undefined && providerHomeVariables(provider) + .some((name) => typeof env[name] === "string" && env[name]!.replace(/[\\/]+$/u, "") === accountHome); + if (started && !configDirs.includes(accountHome)) configDirs.push(accountHome); + return { cwd, configDirs }; +} diff --git a/src/main/services/safety/baseProtection.ts b/src/main/services/safety/baseProtection.ts index b692043d..ee1116da 100644 --- a/src/main/services/safety/baseProtection.ts +++ b/src/main/services/safety/baseProtection.ts @@ -158,12 +158,15 @@ function readAction(toolName: string, raw: Record | null, previ /** * CanvasTTY's own private data under its userData folder (the app passes `app.getPath('userData')`; tests pass a * temporary folder): the control token and descriptor, the gateways' connection records and sockets, the secret - * stores, the per-account homes, the prepared launch runs and the per-card Claude Code draft folders (claude-draft, where - * the app and one card exchange that card's prompt draft). Its settings and layouts are not listed. + * stores, the per-account homes, the prepared launch runs, the per-card Claude Code draft folders (claude-draft, where + * the app and one card exchange that card's prompt draft) and the plugins' data (plugin-data: an accounts plugin keeps + * every account's sign-in there). Its settings and layouts are not listed. A card's own account home or project inside + * them stays its own (agentRoots, the project). plugin-data is not a marker: a project or account home inside it names + * that folder in its own paths. */ export function canvasTtyPrivateData(userDataPath: string): PrivateData { const names = ['agent-control', join('browser', 'runtime'), join('lifecycle', 'runtime'), join('orchestration', 'runtime'), - 'provider-secrets.bin', 'plugin-secrets', 'account-homes', 'github-oauth.json', 'launch-runs', 'claude-draft']; + 'provider-secrets.bin', 'plugin-secrets', 'account-homes', 'github-oauth.json', 'launch-runs', 'claude-draft', 'plugin-data']; return { appRoots: [userDataPath], paths: names.map(name => join(userDataPath, name)), diff --git a/tests/account-home-isolation.test.mjs b/tests/account-home-isolation.test.mjs index 5fa01ff1..ee869067 100644 --- a/tests/account-home-isolation.test.mjs +++ b/tests/account-home-isolation.test.mjs @@ -10,6 +10,8 @@ import { AgentIsolation, SANDBOX_EXEC } from "../src/main/services/isolation/Age import { isolationPaths } from "../src/main/services/isolation/isolationPaths.ts"; import { bubblewrapArguments } from "../src/main/services/isolation/bubblewrap.ts"; import { TerminalManager } from "../src/main/services/TerminalManager.ts"; +import { DecisionHooks } from "../src/main/services/DecisionHooks.ts"; +import { canvasTtyPrivateData } from "../src/main/services/safety/baseProtection.ts"; // Any plugin id: the host treats every plugin's data the same way. const PLUGIN = "com.example.accounts"; @@ -316,3 +318,54 @@ test("a launch hands the isolation layer the account home its contributor select assert.equal(wraps[0].env.CODEX_HOME, selected); assert.equal(calls.at(-1).command, SANDBOX_EXEC); }); + +test("base protection: a card's own selected account home is its own; other accounts' homes and the rest of plugin data stay private", async (t) => { + const f = await fixture(t); + const selected = join(f.homes, "claude-alternate"); + const neighbour = join(f.homes, "claude-other"); + await Promise.all([selected, neighbour].map((path) => mkdir(path))); + const pipeline = new LaunchPipeline({ + contributors: () => [{ pluginId: PLUGIN, pluginName: "Accounts", serviceId: "accounts", dataDir: f.accountData, secrets: false, + launch: { fields: [{ key: "account", label: "Account", kind: "text", default: "" }] } }], + call: async (_pluginId, _serviceId, _method, params) => ({ env: params.provider === "codex" ? { CODEX_HOME: `${selected}/` } : { CLAUDE_CONFIG_DIR: selected } }), + secret: async () => null, + runsRoot: join(f.userData, "launch-runs") + }); + const spawned = []; + const spawner = (command) => { + spawned.push(command); + return { pid: 42_000 + spawned.length, process: command, write() {}, resize() {}, kill() {}, pause() {}, resume() {}, + onData() { return { dispose() {} }; }, onExit() { return { dispose() {} }; } }; + }; + const registry = { + get: (provider) => ({ state: "available", provider, executable: `/resolved/${provider}`, launcher: "native", environment: { PATH: "/usr/bin" }, checked: [] }), + snapshot: () => ({}) + }; + const terminals = new TerminalManager(() => undefined, registry, undefined, undefined, true, spawner); + t.after(() => terminals.shutdown()); + terminals.configureLaunchPipeline(pipeline); + const hooks = new DecisionHooks({ baseProtection: () => true, services: () => [], call: async () => null, home: f.home, + privateData: canvasTtyPrivateData(f.userData), session: (id) => terminals.decisionContext(id) }); + const decide = async (id, toolName, toolInput) => + (await hooks.decide(id, { toolName, toolInput, toolInputPreview: null, cwd: null, truncated: false }, new AbortController().signal)).behavior; + // Person-launched (no isolation layer): base protection is the guard here. + const withAccount = (provider) => terminals.create({ provider, profile: "auto", cwd: f.project, position: { x: 0, y: 0 }, + launchOptions: { [PLUGIN]: { account: "alternate" } } }).id; + const plain = terminals.create({ provider: "claude", profile: "auto", cwd: f.project, position: { x: 0, y: 0 } }).id; + const cards = [withAccount("claude"), withAccount("codex")]; + const deadline = Date.now() + 5_000; + while (spawned.length < 3 && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 10)); + assert.equal(spawned.length, 3); + for (const id of cards) { + assert.ok(terminals.decisionContext(id).configDirs.includes(selected), id); + assert.equal(await decide(id, "Read", { file_path: join(selected, "auth.json") }), "none", "own home"); + assert.equal(await decide(id, "Bash", { command: `cat '${join(selected, "auth.json")}'` }), "none", "own home"); + assert.equal(await decide(id, "Read", { file_path: join(neighbour, "auth.json") }), "deny", "another account's home"); + assert.equal(await decide(id, "Bash", { command: `cat '${join(neighbour, "auth.json")}'` }), "deny", "another account's home"); + assert.equal(await decide(id, "Read", { file_path: join(f.otherData, "state.json") }), "deny", "another plugin's data"); + assert.equal(await decide(id, "Read", { file_path: join(f.project, "a.ts") }), "none", "the project"); + } + assert.equal(terminals.decisionContext(plain).configDirs.includes(selected), false); + assert.equal(await decide(plain, "Read", { file_path: join(selected, "auth.json") }), "deny", "not this card's home"); + assert.equal(await decide(plain, "Bash", { command: "cat a.ts && grep -r todo ." }), "none", "a normal project is unaffected"); +}); diff --git a/tests/base-protection-app-private.test.mjs b/tests/base-protection-app-private.test.mjs index 90e6c916..b7a46ed0 100644 --- a/tests/base-protection-app-private.test.mjs +++ b/tests/base-protection-app-private.test.mjs @@ -175,6 +175,41 @@ test("a project inside the app's private data keeps its own files, not its neigh assert.equal(inside(`cat ${q(join(userData, "provider-secrets.bin"))}`), "app-private"); }); +test("plugin data is private: another account's home is refused; the card's own selected home and a project placed there stay its own", () => { + const pluginData = join(userData, "plugin-data"); + const own = join(pluginData, "com.example.accounts", "homes", "acct-1"); + const other = join(pluginData, "com.example.accounts", "homes", "acct-2"); + const check = (toolName, toolInput, extra = {}) => checkBaseProtection({ toolName, toolInput, root: project, home, privateData, ...extra })?.rule ?? null; + const ownRoots = { agentRoots: [join(home, ".claude"), own] }; + for (const extra of [{}, ownRoots]) { + assert.equal(check("Bash", { command: `cat ${q(join(other, "auth.json"))}` }, extra), "app-private"); + assert.equal(check("Read", { file_path: join(other, "auth.json") }, extra), "app-private"); + assert.equal(check("Bash", { command: `cat ${q(join(pluginData, "com.example.accounts", "state.json"))}` }, extra), "app-private"); + assert.equal(check("LS", { path: pluginData }, extra), "app-private"); + assert.equal(check("Grep", { pattern: "token", path: join(pluginData, "com.example.accounts") }, extra), "app-private"); + assert.equal(check("Bash", { command: "cat ~/Library/Application\\ Support/canvastty/plugin-data/*/homes/*/auth.json" }, extra), "app-private"); + assert.equal(check("Bash", { command: `cat ${q(join(pluginData, "com.example.other", "data.json"))}` }, extra), "app-private"); + } + // Without the launch's record the selected home is plugin data like any other. + assert.equal(check("Read", { file_path: join(own, "auth.json") }), "app-private"); + assert.equal(check("Read", { file_path: join(own, "auth.json") }, ownRoots), null); + assert.equal(check("Bash", { command: `cat ${q(join(own, "auth.json"))} && echo x > ${q(join(own, "plans", "p.md"))}` }, ownRoots), null); + assert.equal(check("Grep", { pattern: "x", path: own }, ownRoots), null); + // A whole plugin's data folder is never one card's own. + assert.equal(check("Read", { file_path: join(other, "auth.json") }, { agentRoots: [pluginData] }), "app-private"); + // A project CanvasTTY placed inside plugin data (an environment's worktree) keeps its own files, code naming them too. + const worktree = join(pluginData, "com.example.environments", "worktrees", "w1"); + const inWorktree = (command) => denyRule(analyzeAction(shell(command), worktree, { home, privateData })); + assert.equal(inWorktree("cat src/a.ts && git status"), null); + assert.equal(inWorktree(`python3 -c "open(${JSON.stringify(join(worktree, "src", "a.ts"))}).read()"`), null); + assert.equal(inWorktree(`cat ${q(join(pluginData, "com.example.environments", "worktrees", "w2", "a.ts"))}`), "app-private"); + // A normal project is unaffected. + for (const command of ["cat src/a.ts", "grep -r todo .", `ls ${q(userData)}`, `cat ${q(join(project, "plugin-data", "x.json"))}`]) { + assert.equal(check("Bash", { command }), null, command); + } + assert.equal(check("Read", { file_path: join(project, "src", "a.ts") }), null); +}); + test("file tools that name private data are refused; the agent's own account home is its own", () => { const check = (toolName, toolInput, extra = {}) => checkBaseProtection({ toolName, toolInput, root: project, home, privateData, ...extra }); assert.equal(check("Write", { file_path: join(control, "token-x"), content: "x" })?.rule, "app-private"); From 690ec9592cefbfdbb0918d1ed156aa70d558414f Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:33:22 +0000 Subject: [PATCH 23/28] feat(plugins): let card actions answer with a change review A card action can now answer with a structured, plain-text review of file changes grouped per agent, and the app window can call the action again with a bounded JSON input (pages, selected files, conflict choices). The host normalizes the review: at most 512 KiB, 24 groups and 400 files, text masked and stripped of control characters, file paths that masking would change are refused, and accept/reject action ids must be the plugin's own declared actions. The input is a JSON object of at most 1 MiB, copied before dispatch and never used as the session's identity. A service may declare up to 16 card actions. --- docs/canvastty-plugin.schema.json | 4 +- docs/plugin-api.d.ts | 56 ++++++++++++++- docs/plugins.md | 6 +- docs/plugins.ru.md | 6 +- docs/plugins.zh-CN.md | 6 +- src/main/ipc/registerIpc.ts | 4 +- src/main/services/PluginCards.ts | 11 ++- src/main/services/PluginChangeReviews.ts | 88 ++++++++++++++++++++++++ src/main/services/PluginManager.ts | 2 +- src/preload/index.ts | 4 +- src/shared/contracts.ts | 39 ++++++++++- tests/plugin-change-review.test.mjs | 67 ++++++++++++++++++ tests/plugin-tools-events-cards.test.mjs | 4 ++ 13 files changed, 276 insertions(+), 21 deletions(-) create mode 100644 src/main/services/PluginChangeReviews.ts create mode 100644 tests/plugin-change-review.test.mjs diff --git a/docs/canvastty-plugin.schema.json b/docs/canvastty-plugin.schema.json index 72f2e886..c881217f 100644 --- a/docs/canvastty-plugin.schema.json +++ b/docs/canvastty-plugin.schema.json @@ -148,8 +148,8 @@ "items": { "$ref": "#/$defs/agentTool" } }, "cardActions": { - "description": "Card actions (needs cards:decorate): items in the options menu of matching cards; the host calls canvastty.cards.invoke and shows the answer's message as a toast.", - "type": "array", "minItems": 1, "maxItems": 8, + "description": "Card actions (needs cards:decorate): items in the options menu of matching cards; the host calls canvastty.cards.invoke and shows the answer's message as a toast, or its review in the change review dialog.", + "type": "array", "minItems": 1, "maxItems": 16, "items": { "$ref": "#/$defs/cardAction" } }, "browserEngine": { "$ref": "#/$defs/browserEngine" } diff --git a/docs/plugin-api.d.ts b/docs/plugin-api.d.ts index 8ab1b19a..2e07c96a 100644 --- a/docs/plugin-api.d.ts +++ b/docs/plugin-api.d.ts @@ -173,7 +173,7 @@ export interface CanvasTTYPluginServiceManifestEntry { decide?: CanvasTTYServiceDecide; /** Agent tools in canvastty_agents; needs `tools:agents`. Up to 16; names unique within the plugin. */ tools?: CanvasTTYAgentTool[]; - /** Card actions; needs `cards:decorate`. Up to 8; ids unique within the plugin. */ + /** Card actions; needs `cards:decorate`. Up to 16; ids unique within the plugin. */ cardActions?: CanvasTTYCardAction[]; /** A browser engine for agents' background tabs; needs `browser:engine`. Ids unique within the plugin. */ browserEngine?: CanvasTTYBrowserEngine; @@ -260,10 +260,60 @@ export interface CanvasTTYCardActionInvocation { actionId: string; sessionId: string; session: CanvasTTYSessionSummary; + /** + * Only while the change review dialog is open (a JSON object, at most 1 MiB, from the app window): `{ offset }` or + * `{ agentSessionId, file, page }` for the review action, `{ agentSessionId?, files, resolutions? }` for its accept + * action and `{ agentSessionId? }` for its reject action. Never the session's identity: that is `sessionId`. + */ + input?: { + agentSessionId?: string; + files?: string[]; + resolutions?: Record; + offset?: number; + file?: string; + page?: number; + }; +} + +/** A page of text in a review (plain text, masked). */ +export interface CanvasTTYReviewTextPage { + label?: string; + text: string; + startLine: number; + totalLines: number; + hasMore: boolean; } -/** Answer to `canvastty.cards.invoke`: `message` (plain text, 2000 characters, masked) is a toast on the card. */ -export type CanvasTTYCardActionResult = { message?: string; tone?: CanvasTTYCardTone } | null; +/** + * A change review a card action can answer with (at most 512 KiB, 24 groups, 400 files; text cut at 16 KiB). + * `acceptActionId` and `rejectActionId` must be this plugin's own declared action ids. + */ +export interface CanvasTTYChangeReview { + title: string; + groups: { + sessionId: string; + title: string; + error?: string; + files: { + path: string; + diff: string; + status?: string; + page?: number; + hasMore?: boolean; + truncated?: boolean; + conflict?: { current: CanvasTTYReviewTextPage; agent: CanvasTTYReviewTextPage }; + }[]; + }[]; + acceptActionId?: string; + rejectActionId?: string; + nextOffset?: number; +} + +/** + * Answer to `canvastty.cards.invoke`: `message` (plain text, 2000 characters, masked) is a toast on the card; `review` + * opens the change review dialog instead. + */ +export type CanvasTTYCardActionResult = { message?: string; tone?: CanvasTTYCardTone; review?: CanvasTTYChangeReview } | null; export type CanvasTTYCardTone = "neutral" | "info" | "warn" | "error"; diff --git a/docs/plugins.md b/docs/plugins.md index 28732f39..bb7a6a61 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -370,7 +370,7 @@ A foreign or unknown id gets the same error, so a plugin cannot probe other card ### Card badges and actions (`cards:decorate`) -A service with `cards:decorate` can put a badge on any card and declare up to 8 `cardActions`: +A service with `cards:decorate` can put a badge on any card and declare up to 16 `cardActions`: ```json "permissions": ["cards:decorate"], @@ -381,7 +381,9 @@ A service with `cards:decorate` can put a badge on any card and declare up to 8 ``` - `cards.setBadge` `{ sessionId, badge: { text, tone?, tooltip? } | null }`: `text` is at most 24 characters, `tone` is `neutral` (default), `info`, `warn` or `error`, `tooltip` at most 200 characters; `null` removes the plugin's badge. At most 4 plugin badges per card. Badges are plain text, masked like agent text, and disappear with the card or when the plugin's trust is revoked. -- An action shows in the card's options menu on every card its `when` matches: `providers`, `environmentKinds` (any plugin's environment; a card outside an environment never matches) and `roles`, each optional; every listed key must match. Choosing it sends `canvastty.cards.invoke` `{ actionId, sessionId, session }` (host-only; `session` is the summary above) and waits at most 15 s. Answer `{ message?, tone? }`: the message (plain text, at most 2000 characters, masked) is shown as a toast on the card. A timeout or error shows an error toast. +- An action shows in the card's options menu on every card its `when` matches: `providers`, `environmentKinds` (any plugin's environment; a card outside an environment never matches) and `roles`, each optional; every listed key must match. Choosing it sends `canvastty.cards.invoke` `{ actionId, sessionId, session }` (host-only; `session` is the summary above) and waits at most 15 s. Answer `{ message?, tone?, review? }`: the message (plain text, at most 2000 characters, masked) is shown as a toast on the card. A timeout or error shows an error toast. +- An answer with `review` opens the change review dialog instead of a toast. `review` is `{ title, groups, acceptActionId?, rejectActionId?, nextOffset? }`; each group is `{ sessionId, title, files, error? }` (one per agent) and each file `{ path, diff, status?, page?, hasMore?, truncated?, conflict? }`, where `conflict` is `{ current, agent }`, two text pages `{ label?, text, startLine, totalLines, hasMore }`. The whole review is at most 512 KiB, 24 groups and 400 files; text fields are plain text, masked, stripped of control characters and cut at 16 KiB (the file is then marked truncated); a file path must be unchanged by masking. `acceptActionId` and `rejectActionId` must be action ids this plugin declared, or the answer is refused. +- While the dialog is open the host calls the same action again with an `input` object (a JSON object of at most 1 MiB, sent only by the app window, never taken as the session's identity): no input to refresh the review, `{ offset }` for the next page of files (`nextOffset`), `{ agentSessionId, file, page }` for a page of one file's diff (page 0 when the file is opened). Accept calls `acceptActionId` with `{ agentSessionId?, files, resolutions? }` (the chosen files; `resolutions` maps a conflicting path to `"current"` or `"agent"`; no `agentSessionId` for the whole task) and reject calls `rejectActionId` with `{ agentSessionId? }`. Accept and reject are sent only after the person confirms them in the dialog. Answer a mutation with the remaining review, or with a message. - No HTML anywhere: badges, titles and messages are rendered as text. The full example is [`examples/plugins/collect-demo`](../examples/plugins/collect-demo): the card action **Show changes** on cards in the `worktree` environment (from `env-worktree`) shows `git diff --stat` of the worktree and sets a "N changed" badge, and the tool `collect-demo__diffstat` gives orchestrators the same for their own folder or a subagent's, which it learns about from session events. diff --git a/docs/plugins.ru.md b/docs/plugins.ru.md index 558a4695..994f318a 100644 --- a/docs/plugins.ru.md +++ b/docs/plugins.ru.md @@ -348,7 +348,7 @@ interface PluginSessionEvent { ### Метки и действия на окнах (`cards:decorate`) -Сервис с `cards:decorate` может поставить метку на любое окно и объявить до 8 `cardActions`: +Сервис с `cards:decorate` может поставить метку на любое окно и объявить до 16 `cardActions`: ```json "permissions": ["cards:decorate"], @@ -359,7 +359,9 @@ interface PluginSessionEvent { ``` - `cards.setBadge` `{ sessionId, badge: { text, tone?, tooltip? } | null }`: `text` до 24 символов, `tone` — `neutral` (по умолчанию), `info`, `warn` или `error`, `tooltip` до 200 символов; `null` убирает метку плагина. Не больше 4 меток плагинов на окно. Метки — обычный текст, маскируются как текст агентов и исчезают вместе с окном или при снятии доверия к плагину. -- Действие появляется в меню параметров окна на каждом окне, которое подходит под `when`: `providers`, `environmentKinds` (среда любого плагина; окно без среды никогда не подходит) и `roles`, каждое необязательно; должны совпасть все указанные. Выбор отправляет `canvastty.cards.invoke` `{ actionId, sessionId, session }` (только от хоста; `session` — сводка выше) и ждёт не больше 15 с. Ответ — `{ message?, tone? }`: сообщение (обычный текст, до 2000 символов, замаскированный) показывается всплывающим уведомлением на окне. Таймаут или ошибка показывают уведомление об ошибке. +- Действие появляется в меню параметров окна на каждом окне, которое подходит под `when`: `providers`, `environmentKinds` (среда любого плагина; окно без среды никогда не подходит) и `roles`, каждое необязательно; должны совпасть все указанные. Выбор отправляет `canvastty.cards.invoke` `{ actionId, sessionId, session }` (только от хоста; `session` — сводка выше) и ждёт не больше 15 с. Ответ — `{ message?, tone?, review? }`: сообщение (обычный текст, до 2000 символов, замаскированный) показывается всплывающим уведомлением на окне. Таймаут или ошибка показывают уведомление об ошибке. +- Ответ с `review` открывает диалог проверки изменений вместо уведомления. `review` — `{ title, groups, acceptActionId?, rejectActionId?, nextOffset? }`; группа — `{ sessionId, title, files, error? }` (по одной на агента), файл — `{ path, diff, status?, page?, hasMore?, truncated?, conflict? }`, где `conflict` — `{ current, agent }`, две текстовые страницы `{ label?, text, startLine, totalLines, hasMore }`. Весь обзор — не больше 512 КиБ, 24 групп и 400 файлов; текст — обычный, замаскированный, без управляющих символов и обрезан до 16 КиБ (тогда файл помечается как сокращённый); путь файла не должен меняться от маскирования. `acceptActionId` и `rejectActionId` должны быть действиями, объявленными этим же плагином, иначе ответ отклоняется. +- Пока диалог открыт, хост снова вызывает то же действие с объектом `input` (JSON-объект до 1 МиБ, его присылает только окно приложения, он никогда не задаёт сессию): без `input` — обновить обзор, `{ offset }` — следующая страница файлов (`nextOffset`), `{ agentSessionId, file, page }` — страница diff одного файла (страница 0 при открытии файла). Принятие вызывает `acceptActionId` с `{ agentSessionId?, files, resolutions? }` (выбранные файлы; `resolutions` сопоставляет конфликтному пути `"current"` или `"agent"`; без `agentSessionId` — для всей задачи), отклонение — `rejectActionId` с `{ agentSessionId? }`. Принятие и отклонение отправляются только после подтверждения человеком в диалоге. На изменение отвечайте оставшимся обзором или сообщением. - Никакого HTML: метки, заголовки и сообщения выводятся как текст. Полный пример — [`examples/plugins/collect-demo`](../examples/plugins/collect-demo): действие **Show changes** на окнах в среде `worktree` (из `env-worktree`) показывает `git diff --stat` worktree и ставит метку «N changed», а инструмент `collect-demo__diffstat` даёт оркестраторам то же для своей папки или папки субагента, о котором плагин узнаёт из событий сессий. diff --git a/docs/plugins.zh-CN.md b/docs/plugins.zh-CN.md index 2b9db794..1fe577e3 100644 --- a/docs/plugins.zh-CN.md +++ b/docs/plugins.zh-CN.md @@ -346,7 +346,7 @@ interface PluginSessionEvent { ### 卡片标记与动作(`cards:decorate`) -具有 `cards:decorate` 的服务可以在任意卡片上设置标记,并声明最多 8 个 `cardActions`: +具有 `cards:decorate` 的服务可以在任意卡片上设置标记,并声明最多 16 个 `cardActions`: ```json "permissions": ["cards:decorate"], @@ -357,7 +357,9 @@ interface PluginSessionEvent { ``` - `cards.setBadge` `{ sessionId, badge: { text, tone?, tooltip? } | null }`:`text` 最多 24 个字符,`tone` 为 `neutral`(默认)、`info`、`warn` 或 `error`,`tooltip` 最多 200 个字符;`null` 移除该插件的标记。每张卡片最多 4 个插件标记。标记是纯文本,像 agent 文本一样被遮蔽,并随卡片关闭或插件信任撤销而消失。 -- 动作出现在所有匹配 `when` 的卡片的选项菜单中:`providers`、`environmentKinds`(任意插件的环境;不在环境中的卡片永远不匹配)和 `roles`,均为可选;列出的每一项都必须匹配。选择动作会发送 `canvastty.cards.invoke` `{ actionId, sessionId, session }`(仅宿主可发;`session` 为上面的摘要),最多等待 15 s。回答 `{ message?, tone? }`:消息(纯文本,最多 2000 个字符,经过遮蔽)以提示的形式显示在卡片上。超时或出错时显示错误提示。 +- 动作出现在所有匹配 `when` 的卡片的选项菜单中:`providers`、`environmentKinds`(任意插件的环境;不在环境中的卡片永远不匹配)和 `roles`,均为可选;列出的每一项都必须匹配。选择动作会发送 `canvastty.cards.invoke` `{ actionId, sessionId, session }`(仅宿主可发;`session` 为上面的摘要),最多等待 15 s。回答 `{ message?, tone?, review? }`:消息(纯文本,最多 2000 个字符,经过遮蔽)以提示的形式显示在卡片上。超时或出错时显示错误提示。 +- 带有 `review` 的回答会打开变更审查对话框,而不是提示。`review` 为 `{ title, groups, acceptActionId?, rejectActionId?, nextOffset? }`;每个分组为 `{ sessionId, title, files, error? }`(每个代理一个),每个文件为 `{ path, diff, status?, page?, hasMore?, truncated?, conflict? }`,其中 `conflict` 为 `{ current, agent }` 两个文本页 `{ label?, text, startLine, totalLines, hasMore }`。整个审查最多 512 KiB、24 个分组和 400 个文件;文本为纯文本、经过遮蔽、去除控制字符并截断到 16 KiB(此时文件标记为已截断);文件路径不得因遮蔽而改变。`acceptActionId` 和 `rejectActionId` 必须是本插件声明的动作 id,否则回答被拒绝。 +- 对话框打开期间,宿主会带着 `input` 对象(最多 1 MiB 的 JSON 对象,仅由应用窗口发送,从不作为会话身份)再次调用同一动作:不带 `input` 表示刷新审查,`{ offset }` 表示下一页文件(`nextOffset`),`{ agentSessionId, file, page }` 表示某个文件 diff 的一页(打开文件时为第 0 页)。接受会以 `{ agentSessionId?, files, resolutions? }` 调用 `acceptActionId`(所选文件;`resolutions` 把冲突路径映射为 `"current"` 或 `"agent"`;整个任务时不带 `agentSessionId`),拒绝会以 `{ agentSessionId? }` 调用 `rejectActionId`。接受和拒绝只在用户于对话框中确认后才发送。对修改请回答剩余的审查或一条消息。 - 任何地方都没有 HTML:标记、标题和消息都按文本渲染。 完整示例见 [`examples/plugins/collect-demo`](../examples/plugins/collect-demo):在 `worktree` 环境(来自 `env-worktree`)的卡片上,动作 **Show changes** 显示 worktree 的 `git diff --stat` 并设置“N changed”标记;工具 `collect-demo__diffstat` 为编排器提供同样的信息,针对它自己的文件夹或某个子 agent 的文件夹(插件通过会话事件得知子 agent)。 diff --git a/src/main/ipc/registerIpc.ts b/src/main/ipc/registerIpc.ts index 00d3feb6..e9706e60 100644 --- a/src/main/ipc/registerIpc.ts +++ b/src/main/ipc/registerIpc.ts @@ -532,13 +532,13 @@ export function registerIpc(ipcMain: IpcRegistrar, { assertMainRenderer(event, getMainWindow); return pluginCards.decorations(); }); - ipcMain.handle(IPC.pluginsInvokeCardAction, (event, pluginId: unknown, actionId: unknown, sessionId: unknown) => { + ipcMain.handle(IPC.pluginsInvokeCardAction, (event, pluginId: unknown, actionId: unknown, sessionId: unknown, input?: unknown) => { // Only the app window's own card menu invokes actions; plugin surfaces cannot reach this channel. assertMainRenderer(event, getMainWindow); if (typeof pluginId !== "string" || typeof actionId !== "string" || typeof sessionId !== "string") { throw new Error("Card action request is invalid."); } - return pluginCards.invoke(pluginId, actionId, sessionId); + return pluginCards.invoke(pluginId, actionId, sessionId, input); }); ipcMain.handle(IPC.pluginsLaunchFieldOptions, (event, pluginId: unknown, provider: unknown) => { // Only the app's own launcher asks; plugin surfaces cannot reach this channel. diff --git a/src/main/services/PluginCards.ts b/src/main/services/PluginCards.ts index 076397b0..ddf74ada 100644 --- a/src/main/services/PluginCards.ts +++ b/src/main/services/PluginCards.ts @@ -6,6 +6,7 @@ import type { PluginCardTone } from "../../shared/contracts.ts"; import { cardActionMatches } from "../../shared/pluginCardActions.ts"; +import { normalizeCardActionInput, normalizeCardReview } from "./PluginChangeReviews.ts"; import type { PluginSessionSummary } from "./PluginSessions.ts"; /** A trusted plugin service that declared `cardActions` (PluginManager.cardActionProviders). */ @@ -33,6 +34,7 @@ export interface CardActionInvocation { actionId: string; sessionId: string; session: PluginSessionSummary; + input?: Record; } const CARD_ACTION_TIMEOUT_MS = 15_000; @@ -126,7 +128,7 @@ export class PluginCards { } /** The person chose an action on a card. Errors and timeouts come back as an error toast, nothing else. */ - async invoke(pluginId: string, actionId: string, sessionId: string): Promise { + async invoke(pluginId: string, actionId: string, sessionId: string, input?: unknown): Promise { // Action ids are unique within a plugin, whichever of its services declares them. const provider = this.providers() .find((candidate) => candidate.pluginId === pluginId && candidate.actions.some((action) => action.id === actionId)); @@ -136,7 +138,8 @@ export class PluginCards { if (!session) throw new Error("No card has that session id."); if (!cardActionMatches(action.when, session)) throw new Error("That action does not apply to this card."); const timeoutMs = this.deps.timeoutMs ?? CARD_ACTION_TIMEOUT_MS; - const params: CardActionInvocation = { actionId, sessionId, session }; + const normalizedInput = normalizeCardActionInput(input); + const params: CardActionInvocation = { actionId, sessionId, session, ...(normalizedInput ? { input: normalizedInput } : {}) }; let timer: NodeJS.Timeout | undefined; try { const answer = await Promise.race([ @@ -148,7 +151,9 @@ export class PluginCards { const record = answer && typeof answer === "object" && !Array.isArray(answer) ? answer as Record : {}; const tone = TONES.has(record.tone as PluginCardTone) ? record.tone as PluginCardTone : "neutral"; const message = typeof record.message === "string" && record.message.trim() ? this.message(record.message) : undefined; - return { tone, ...(message ? { message } : {}) }; + const actions = new Set(this.providers().filter(row => row.pluginId === pluginId).flatMap(row => row.actions.map(action => action.id))); + const review = record.review === undefined ? undefined : normalizeCardReview(record.review, this.deps.redact, actions); + return { tone, ...(message ? { message } : {}), ...(review ? { review } : {}) }; } catch (error) { return { tone: "error", message: this.message(`${provider.pluginName}: ${error instanceof Error ? error.message : "the action failed."}`) }; } finally { diff --git a/src/main/services/PluginChangeReviews.ts b/src/main/services/PluginChangeReviews.ts new file mode 100644 index 00000000..c56f009a --- /dev/null +++ b/src/main/services/PluginChangeReviews.ts @@ -0,0 +1,88 @@ +import type { PluginChangeReview, PluginChangeReviewFile, PluginReviewTextPage } from "../../shared/contracts.ts"; + +const MAX_REVIEW_BYTES = 512 * 1024; +// A normalized review may repeat selected paths as conflict-resolution keys in its action. +// Conflict metadata consumes review space, keeping generated actions below the 1 MiB transport frame. +const MAX_ACTION_BYTES = 2 * MAX_REVIEW_BYTES; +const MAX_TEXT = 16_384; + +function record(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Plugin review data is invalid."); + return value as Record; +} + +function integer(value: unknown): number { + if (!Number.isSafeInteger(value) || (value as number) < 0) throw new Error("Plugin review page is invalid."); + return value as number; +} + +function boundedJson(value: unknown, limit: number): string { + let json: string | undefined; + try { json = JSON.stringify(value); } catch { throw new Error("Card action data must be JSON."); } + if (!json || Buffer.byteLength(json) > limit) throw new Error("Card action data is too large."); + return json; +} + +/** Only the app window can supply this payload. Keep it separate from the host's session identity. */ +export function normalizeCardActionInput(value: unknown): Record | undefined { + if (value === undefined) return undefined; + record(value); + return record(JSON.parse(boundedJson(value, MAX_ACTION_BYTES))); +} + +/** Plugins provide text and declared action ids, never HTML or instructions to execute. */ +export function normalizeCardReview(value: unknown, redact: (text: string) => string, actions: ReadonlySet): PluginChangeReview { + boundedJson(value, MAX_REVIEW_BYTES); + const review = record(value); + const text = (value: unknown, limit = MAX_TEXT): string => { + if (typeof value !== "string") throw new Error("Plugin review text is invalid."); + return redact(value.replace(/[\u0000-\u0008\u000B-\u001F\u007F]/gu, "")).slice(0, limit); + }; + const page = (value: unknown): PluginReviewTextPage => { + const source = record(value); + if (typeof source.hasMore !== "boolean") throw new Error("Plugin review page is invalid."); + return { ...(source.label === undefined ? {} : { label: text(source.label, 160) }), text: text(source.text), + startLine: integer(source.startLine), totalLines: integer(source.totalLines), hasMore: source.hasMore }; + }; + const action = (value: unknown): string | undefined => { + if (value === undefined) return undefined; + if (typeof value !== "string" || !actions.has(value)) throw new Error("Plugin review action is not declared by this plugin."); + return value; + }; + if (!Array.isArray(review.groups) || review.groups.length > 24) throw new Error("Plugin review groups are invalid."); + let fileCount = 0; + const groups = review.groups.map(value => { + const group = record(value); + if (typeof group.sessionId !== "string" || !/^[\w-]{1,160}$/u.test(group.sessionId)) throw new Error("Plugin review session is invalid."); + if (!Array.isArray(group.files) || (fileCount += group.files.length) > 400) throw new Error("Plugin review files are invalid."); + const files = group.files.map(value => { + const file = record(value); + if (typeof file.path !== "string" || !file.path || file.path.length > 1000 || /[\u0000-\u001F\u007F]/u.test(file.path)) throw new Error("Plugin review file path is invalid."); + if (redact(file.path) !== file.path) throw new Error("Plugin review file names contain protected data."); + const result: PluginChangeReviewFile = { path: file.path, diff: text(file.diff) }; + if (file.status !== undefined) result.status = text(file.status, 80); + if (file.page !== undefined) result.page = integer(file.page); + if (file.hasMore !== undefined) { + if (typeof file.hasMore !== "boolean") throw new Error("Plugin review page is invalid."); + result.hasMore = file.hasMore; + } + result.truncated = file.truncated === true || (typeof file.diff === "string" && file.diff.length > MAX_TEXT); + if (file.conflict !== undefined) { + const conflict = record(file.conflict); + result.conflict = { current: page(conflict.current), agent: page(conflict.agent) }; + if (result.conflict.current.text.length === MAX_TEXT || result.conflict.agent.text.length === MAX_TEXT) result.truncated = true; + } + return result; + }); + return { sessionId: group.sessionId, title: text(group.title, 200), files, + ...(group.error !== undefined ? { error: text(group.error, 2000) } : {}) }; + }); + const acceptActionId = action(review.acceptActionId), rejectActionId = action(review.rejectActionId); + const result: PluginChangeReview = { + title: text(review.title, 200), groups, + ...(acceptActionId ? { acceptActionId } : {}), ...(rejectActionId ? { rejectActionId } : {}), + ...(review.nextOffset !== undefined ? { nextOffset: integer(review.nextOffset) } : {}) + }; + boundedJson(result, MAX_REVIEW_BYTES); + return result; +} diff --git a/src/main/services/PluginManager.ts b/src/main/services/PluginManager.ts index bfedc269..ffef6bd9 100644 --- a/src/main/services/PluginManager.ts +++ b/src/main/services/PluginManager.ts @@ -1859,7 +1859,7 @@ function validateBrowserEngine(value: unknown): PluginBrowserEngine { return { id, title, ...(description ? { description } : {}), layout: value.layout === true }; } -const MAX_CARD_ACTIONS = 8; +const MAX_CARD_ACTIONS = 16; function validateCardActions(value: unknown): PluginCardAction[] { if (!Array.isArray(value) || value.length === 0 || value.length > MAX_CARD_ACTIONS) { diff --git a/src/preload/index.ts b/src/preload/index.ts index 32f38cf6..9b66ac8e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -212,8 +212,8 @@ const api: CanvasTTYApi = { onCardDecorations: (listener: (decorations: PluginCardDecorations) => void) => ( subscribe(IPC.pluginsCardDecorationsChanged, listener) ), - invokeCardAction: (pluginId: string, actionId: string, sessionId: string) => ( - ipcRenderer.invoke(IPC.pluginsInvokeCardAction, pluginId, actionId, sessionId) + invokeCardAction: (pluginId: string, actionId: string, sessionId: string, input?: Record) => ( + ipcRenderer.invoke(IPC.pluginsInvokeCardAction, pluginId, actionId, sessionId, input) ), launchFieldOptions: (pluginId: string, provider: ProviderId) => ( ipcRenderer.invoke(IPC.pluginsLaunchFieldOptions, pluginId, provider) diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index 12db40b0..0e798041 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -941,10 +941,45 @@ export interface PluginCardDecorations { actions: PluginCardActionEntry[]; } -/** What a card action answered, shown as a toast on the card. */ +export interface PluginReviewTextPage { + label?: string; + text: string; + startLine: number; + totalLines: number; + hasMore: boolean; +} + +export interface PluginChangeReviewFile { + path: string; + status?: string; + diff: string; + page?: number; + hasMore?: boolean; + truncated?: boolean; + conflict?: { current: PluginReviewTextPage; agent: PluginReviewTextPage }; +} + +export interface PluginChangeReviewGroup { + sessionId: string; + title: string; + files: PluginChangeReviewFile[]; + error?: string; +} + +/** Plain-text review supplied by an existing plugin; actions still require a human click. */ +export interface PluginChangeReview { + title: string; + groups: PluginChangeReviewGroup[]; + acceptActionId?: string; + rejectActionId?: string; + nextOffset?: number; +} + +/** What a card action answered, shown as a toast or a structured review on the card. */ export interface PluginCardActionResult { message?: string; tone: PluginCardTone; + review?: PluginChangeReview; } export type PluginDecisionEvent = "pre-tool"; @@ -1982,7 +2017,7 @@ export interface CanvasTTYApi { onServiceEvent(listener: (event: PluginServiceEvent) => void): () => void; cardDecorations(): Promise; onCardDecorations(listener: (decorations: PluginCardDecorations) => void): () => void; - invokeCardAction(pluginId: string, actionId: string, sessionId: string): Promise; + invokeCardAction(pluginId: string, actionId: string, sessionId: string, input?: Record): Promise; /** The service-provided choices of a plugin's `optionsFrom: "service"` launch fields for this agent; empty on any failure. */ launchFieldOptions(pluginId: string, provider: ProviderId): Promise; uninstall(pluginId: string): Promise; diff --git a/tests/plugin-change-review.test.mjs b/tests/plugin-change-review.test.mjs new file mode 100644 index 00000000..b9774a64 --- /dev/null +++ b/tests/plugin-change-review.test.mjs @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { PluginCards } from "../src/main/services/PluginCards.ts"; +import { normalizeCardActionInput, normalizeCardReview } from "../src/main/services/PluginChangeReviews.ts"; + +const textPage = (text, label) => ({ text, label, startLine: 0, totalLines: 1, hasMore: false }); +const review = () => ({ + title: "Task results", acceptActionId: "accept", rejectActionId: "reject", + groups: [{ sessionId: "worker", title: "Worker", files: [{ path: "source.txt", diff: "+ PRIVATE_KEY_VALUE", + conflict: { current: textPage("current PRIVATE_KEY_VALUE", "Agent: peer PRIVATE_KEY_VALUE"), agent: textPage("agent PRIVATE_KEY_VALUE", "Agent: worker") } }] }] +}); +const mask = text => text.replaceAll("PRIVATE_KEY_VALUE", "[MASKED]"); +const actions = new Set(["review", "accept", "reject"]); + +test("card actions retain their trusted session and mask structured file review and both conflict sides", async () => { + const calls = []; + const trustedSession = { id: "root", role: "orchestrator", provider: "codex" }; + const cards = new PluginCards({ + providers: () => [{ pluginId: "environments", pluginName: "Environments", serviceId: "results", actions: [...actions].map(id => ({ id, title: id })) }], + trustedPlugins: () => new Set(["environments"]), + session: id => id === "root" ? trustedSession : null, + call: async (_plugin, _service, _method, params) => { calls.push(params); return { tone: "info", review: review() }; }, + redact: mask, changed() {} + }); + const input = { agentSessionId: "worker", files: ["source.txt"], resolutions: { "source.txt": "agent" }, session: { id: "forged" } }; + const result = await cards.invoke("environments", "review", "root", input); + assert.deepEqual(calls[0].session, trustedSession); + assert.equal(calls[0].sessionId, "root"); + assert.deepEqual(calls[0].input, input); + assert.ok(result.review); + assert.doesNotMatch(JSON.stringify(result.review), /PRIVATE_KEY_VALUE/u); + assert.match(result.review.groups[0].files[0].conflict.current.text, /\[MASKED\]/u); + assert.match(result.review.groups[0].files[0].conflict.agent.text, /\[MASKED\]/u); + assert.match(result.review.groups[0].files[0].conflict.current.label, /\[MASKED\]/u); + assert.equal(result.review.groups[0].files[0].conflict.agent.label, "Agent: worker"); + assert.equal(result.review.acceptActionId, "accept"); +}); + +test("review cannot name another plugin's actions and bounds text, files and total size", () => { + const unknown = review(); unknown.acceptActionId = "undeclared"; + assert.throws(() => normalizeCardReview(unknown, mask, actions), /not declared/u); + const long = review(); long.groups[0].files[0].diff = "x".repeat(20_000); + const normalized = normalizeCardReview(long, mask, actions); + assert.equal(normalized.groups[0].files[0].diff.length, 16_384); + assert.equal(normalized.groups[0].files[0].truncated, true); + const many = review(); many.groups[0].files = Array.from({ length: 401 }, () => ({ path: "a", diff: "" })); + assert.throws(() => normalizeCardReview(many, mask, actions), /files are invalid/u); + const oversized = review(); oversized.groups[0].files[0].diff = "x".repeat(512 * 1024); + assert.throws(() => normalizeCardReview(oversized, mask, actions), /too large/u); +}); + +test("human card action input is a bounded JSON object and is copied before plugin dispatch", () => { + assert.equal(normalizeCardActionInput(undefined), undefined); + for (const input of [null, "wrong", ["wrong"]]) assert.throws(() => normalizeCardActionInput(input), /invalid/u); + const input = { files: ["source.txt"] }; + const copy = normalizeCardActionInput(input); + input.files.push("later.txt"); + assert.deepEqual(copy, { files: ["source.txt"] }); + const files = Array.from({ length: 400 }, (_, index) => `${index}/${"p".repeat(290)}.txt`); + const selection = { files, resolutions: Object.fromEntries(files.map(path => [path, "agent"])) }; + assert.ok(Buffer.byteLength(JSON.stringify(selection)) > 16 * 1024); + assert.doesNotThrow(() => normalizeCardActionInput(selection), "a valid 400-file selection fits the host action limit"); + assert.deepEqual(normalizeCardActionInput(selection), selection); + assert.throws(() => normalizeCardActionInput({ text: "x".repeat(1024 * 1024) }), /too large/u); + const cycle = {}; cycle.self = cycle; + assert.throws(() => normalizeCardActionInput(cycle), /must be JSON/u); +}); diff --git a/tests/plugin-tools-events-cards.test.mjs b/tests/plugin-tools-events-cards.test.mjs index 0824b05a..63572745 100644 --- a/tests/plugin-tools-events-cards.test.mjs +++ b/tests/plugin-tools-events-cards.test.mjs @@ -113,6 +113,10 @@ test("manifests: tools need tools:agents, card actions need cards:decorate, sche ...exampleManifest, services: [{ ...exampleManifest.services[0], cardActions: [{ id: "x", title: "X", when: { environmentKinds: ["Bad Kind"] } }] }] }), /environmentKinds/u); + const withActions=count=>({...exampleManifest,services:[{...exampleManifest.services[0], + cardActions:Array.from({length:count},(_,index)=>({id:`action-${index}`,title:`Action ${index}`}))}]}); + assert.equal(validatePluginManifest(withActions(16)).services[0].cardActions.length,16); + assert.throws(()=>validatePluginManifest(withActions(17)),/between 1 and 16/u); }); test("tool listing follows the session's role and agent; names are __", () => { From 56192d244075d53fe833c93bcb13829c00c67c16 Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:36:26 +0000 Subject: [PATCH 24/28] feat(isolation): keep a plugin worktree's Git metadata read-only An isolated agent whose project is a linked worktree inside plugin data (a plugin environment's worktree) now runs only when the host validates that worktree against the task's own repository: its .git pointer, its administration folder and common directory, and a branch only it has checked out. Otherwise the launch is refused. For a validated worktree the whole worker tree is the project. Its .git pointer, HEAD, administration folder and the shared repository stay read-only on macOS and Linux, their hooks are denied, and the other worktrees' administration folders are unreadable. A Plan launch refuses CLI state inside or above the read-only worktree. The agent is told in its environment that git add and git commit are unavailable there, and optional index locks are switched off. (bubblewrap cannot allow atomic ref updates to one branch without making its whole folder writable, so linked-worktree Git metadata stays read-only there.) --- src/main/services/TerminalManager.ts | 24 +- src/main/services/isolation/AgentIsolation.ts | 17 +- src/main/services/isolation/bubblewrap.ts | 5 +- src/main/services/isolation/isolationPaths.ts | 42 ++- .../services/isolation/worktreeGitAccess.ts | 259 +++++++++++++++++ tests/agent-isolation.test.mjs | 262 +++++++++++++++++- 6 files changed, 593 insertions(+), 16 deletions(-) create mode 100644 src/main/services/isolation/worktreeGitAccess.ts diff --git a/src/main/services/TerminalManager.ts b/src/main/services/TerminalManager.ts index 3afd86bf..224ab446 100644 --- a/src/main/services/TerminalManager.ts +++ b/src/main/services/TerminalManager.ts @@ -1800,7 +1800,8 @@ export class TerminalManager { try { spawn = this.wrapIsolated(id, provider, profile, planned, planned, { ...(contribution?.accountHome ? { accountHome: contribution.accountHome } : {}), - placedProjects: this.placedProjects(id, parentSessionId, planned.cwd, undefined) + placedProjects: this.placedProjects(id, parentSessionId, planned.cwd, undefined), + taskProjectRoot: this.taskProjectRoot(cwd, parentSessionId) }); } catch (error) { planned.cleanup(); @@ -1867,7 +1868,7 @@ export class TerminalManager { /** Wraps a planned launch in the isolation layer (throws when the layer cannot start: the launch is refused). */ private wrapIsolated(id: string, provider: ProviderId, profile: LaunchProfile, planned: { command: string; args: string[] | string; cwd: string; env: Record }, gateways: Pick, - placement: { accountHome?: string; placedProjects: readonly PlacedProject[] }): { command: string; args: string[]; env: Record } { + placement: { accountHome?: string; placedProjects: readonly PlacedProject[]; taskProjectRoot?: string }): { command: string; args: string[]; env: Record } { if (!this.isolation) throw new LaunchRefusal("agent isolation is not configured; the agent was not started without it."); if (typeof planned.args === "string") throw new LaunchRefusal("a Windows batch launcher cannot run inside agent isolation."); // Only the grant CanvasTTY handed this launch: a CANVASTTY_CONTROL_CONNECTION a plugin set reopens nothing. @@ -1882,6 +1883,7 @@ export class TerminalManager { profile, ...(placement.accountHome ? { accountHome: placement.accountHome } : {}), placedProjects: placement.placedProjects, + ...(placement.taskProjectRoot ? { taskProjectRoot: placement.taskProjectRoot } : {}), ...(grant ? { grantedPrivate: [grant] } : {}), // Only the sockets CanvasTTY's own gateways prepared; a CANVASTTY_*_ADDRESS from elsewhere is never bound in. gatewaySockets: gatewaySocketAddresses(gateways.agentBrowser?.environment, gateways.agentRuntime?.environment, @@ -2069,6 +2071,21 @@ export class TerminalManager { }; } + /** + * The task's original project: the folder of the top-level card a session descends from (its own folder when it has + * no parent). A plugin worktree is validated against this repository. + */ + private taskProjectRoot(cwd: string, parentSessionId?: string): string { + let root: ManagedSession | undefined; + for (let depth = 0, next = parentSessionId; next !== undefined && depth < 64; depth++) { + const current = this.sessions.get(next); + if (!current) break; + root = current; + next = current.metadata.parentSessionId; + } + return root?.metadata.cwd ?? cwd; + } + /** * For a subagent: the folder the person chose for the top-level agent it descends from, when that agent runs on this * computer, and this subagent's folder is it or inside it. The subagent's own real folder then needs no trust answer @@ -2334,7 +2351,8 @@ export class TerminalManager { try { spawn = { ...this.wrapIsolated(id, metadata.provider, decision.profile, spawn, planned, { ...(contribution?.accountHome ? { accountHome: contribution.accountHome } : {}), - placedProjects: this.placedProjects(id, metadata.parentSessionId, spawn.cwd, environment) + placedProjects: this.placedProjects(id, metadata.parentSessionId, spawn.cwd, environment), + taskProjectRoot: this.taskProjectRoot(metadata.cwd, metadata.parentSessionId) }), cwd: spawn.cwd }; } catch (error) { return refuse(error instanceof Error ? error.message : String(error)); diff --git a/src/main/services/isolation/AgentIsolation.ts b/src/main/services/isolation/AgentIsolation.ts index 58ee5b33..927981af 100644 --- a/src/main/services/isolation/AgentIsolation.ts +++ b/src/main/services/isolation/AgentIsolation.ts @@ -6,6 +6,7 @@ import type { ProviderId, SessionIsolation } from "../../../shared/contracts.ts" import { autoKind, PROFILE_RANK, type LaunchProfile } from "../../../shared/autoMode.ts"; import { LaunchRefusal } from "../launchRefusal.ts"; import { isolationPaths, type PlacedProject } from "./isolationPaths.ts"; +import { isPluginDataPath, worktreeGitAccess } from "./worktreeGitAccess.ts"; import { validateAccountHome } from "./accountHomeIsolation.ts"; import { seatbeltProfile } from "./seatbelt.ts"; import { bubblewrapArguments, projectHooks, type PathKind } from "./bubblewrap.ts"; @@ -21,6 +22,8 @@ export const BUBBLEWRAP_USERNS_DOCS = "docs/installing-and-security.md#linux-whe /** A failed bubblewrap check is repeated after this long, so allowing it takes effect without a restart. */ const BUBBLEWRAP_PROBE_RETRY_MS = 60_000; export const ISOLATION_NOTE = "CanvasTTY agent isolation: files can be written only inside the project folder, $TMPDIR and this CLI's own folders; SSH/cloud keys, other agents' credentials and CanvasTTY's tokens cannot be read; other processes, apps and daemons are out of reach. \"Operation not permitted\" outside that is this rule: do the work inside the project, or tell the person what you need."; +/** Added to ISOLATION_NOTE for an agent in a validated plugin worktree (its environment only; nothing on the card). */ +export const WORKTREE_GIT_NOTE = "A plugin worktree's Git metadata stays read-only under agent isolation to protect the shared repository and sibling worktrees. You can edit project files; git add and git commit are unavailable here. The host environment collects your file edits into a review for the user to accept."; export interface AgentIsolationOptions { /** CanvasTTY's userData folder (its private data is never readable inside the layer). */ @@ -71,6 +74,8 @@ export interface IsolationLaunch { sessionId: string; provider: ProviderId; cwd: string; + /** The task's original project root (the root agent's folder), for validating a linked worktree in plugin data. */ + taskProjectRoot?: string; command: string; args: readonly string[]; env: Record; @@ -190,12 +195,19 @@ export class AgentIsolation { chmodSync(folder, 0o700); const temp = join(folder, "tmp"); mkdirSync(temp, { mode: 0o700 }); + const requestedCwdInPluginData = isPluginDataPath(launch.cwd, this.options.userDataPath); const cwd = realpathSync(launch.cwd); + // A project in plugin data must be a linked worktree the host validates against the task's own repository. + const trustedWorktree = worktreeGitAccess(cwd, launch.taskProjectRoot ?? cwd, this.options.userDataPath); + if ((requestedCwdInPluginData || isPluginDataPath(cwd, this.options.userDataPath)) && !trustedWorktree) { + throw new LaunchRefusal("The plugin environment worktree could not be verified against the task's original Git repository. The agent was not started."); + } const accountHome = launch.accountHome === undefined ? undefined : validateAccountHome(this.options.userDataPath, launch.provider, launch.accountHome, launch.env); const granted = [...(launch.grantedPrivate ?? []), ...(accountHome ? [accountHome] : [])]; const paths = isolationPaths({ + ...(trustedWorktree ? { worktreeGitAccess: trustedWorktree } : {}), provider: launch.provider, cwd, sessionTemp: temp, @@ -212,10 +224,13 @@ export class AgentIsolation { const gitTemplate = join(folder, "git-template"); mkdirSync(gitTemplate, { mode: 0o500 }); // An agent that meets "Operation not permitted" can read why here, instead of trying other ways around it. - const env: Record = { ...launch.env, TMPDIR: `${temp}/`, TMP: temp, TEMP: temp, GIT_TEMPLATE_DIR: gitTemplate, [ISOLATION_ENV]: ISOLATION_NOTE }; + const isolationNote = trustedWorktree ? `${ISOLATION_NOTE} ${WORKTREE_GIT_NOTE}` : ISOLATION_NOTE; + const env: Record = { ...launch.env, TMPDIR: `${temp}/`, TMP: temp, TEMP: temp, GIT_TEMPLATE_DIR: gitTemplate, [ISOLATION_ENV]: isolationNote }; // Claude Code ignores TMPDIR for its Bash tool and editor files: `${CLAUDE_CODE_TMPDIR:-/tmp}/claude-`, which // all of the person's Claude sessions share and the layer keeps read-only. Its own folder is the launch's temp. if (launch.provider === "claude") env.CLAUDE_CODE_TMPDIR = temp; + // Read-only Git metadata: `git status` must not try to refresh the index (an optional lock it cannot take). + if (trustedWorktree) env.GIT_OPTIONAL_LOCKS = "0"; if (available.layer === "seatbelt") { const profilePath = join(folder, "profile.sb"); writeFileSync(profilePath, seatbeltProfile(paths), { mode: 0o600, flag: "wx" }); diff --git a/src/main/services/isolation/bubblewrap.ts b/src/main/services/isolation/bubblewrap.ts index c0950696..d7accb98 100644 --- a/src/main/services/isolation/bubblewrap.ts +++ b/src/main/services/isolation/bubblewrap.ts @@ -53,8 +53,11 @@ export function bubblewrapArguments( args.push("--ro-bind", path, path); for (const folder of seen) if (folder !== path && within(folder, path)) args.push("--bind", folder, folder); } + // The CLI's own writable single files (such as ~/.claude.json), each bound on its own unless a writable folder bound + // above already contains it. (Linked-worktree Git metadata is not among them: a bind cannot allow atomic ref updates + // to one branch without making its whole folder writable, so it stays read-only under bubblewrap.) for (const path of paths.writableFiles) { - if (exists(path) === "file") args.push("--bind", path, path); + if (exists(path) === "file" && ![...seen].some((folder) => within(path, folder))) args.push("--bind", path, path); } for (const path of paths.unreadable) { const kind = exists(path); diff --git a/src/main/services/isolation/isolationPaths.ts b/src/main/services/isolation/isolationPaths.ts index b9439014..7870a862 100644 --- a/src/main/services/isolation/isolationPaths.ts +++ b/src/main/services/isolation/isolationPaths.ts @@ -4,6 +4,7 @@ import type { ProviderId } from "../../../shared/contracts.ts"; import { AGENT_PROVIDERS } from "../../../shared/contracts.ts"; import { otherSpellings } from "../onDiskPath.ts"; import { openCodeConfigPaths } from "../inspectedConfig.ts"; +import type { WorktreeGitAccess } from "./worktreeGitAccess.ts"; /** * The folders one isolated agent may write, the ones it may not read, and the sockets it may connect to. Pure: the @@ -40,6 +41,8 @@ export interface IsolationPathInput { placedProjects?: readonly PlacedProject[]; /** Plan: the project is readable only (the CLI's own folders stay writable). */ readOnlyProject?: boolean; + /** Host-derived from cwd + task root after validating a linked worktree; never supplied by a plugin. */ + worktreeGitAccess?: WorktreeGitAccess | null; } /** @@ -251,18 +254,37 @@ export function isolationPaths(input: IsolationPathInput): IsolationPaths { // variable pointing at HOME or above the project would hide them, so it is not followed. .filter((folder) => !ownFolders.includes(folder) && !hides(folder)); const privateData = privateAppData(input.userDataPath); - const project = input.cwd; + // A validated linked worktree (a plugin environment's): the project is the whole registered worker tree, even when + // the agent starts in a subfolder of it. + const worktree = input.worktreeGitAccess && isWithin(input.cwd, input.worktreeGitAccess.cwd) ? input.worktreeGitAccess : null; + const project = worktree?.cwd ?? input.cwd; // A project inside plugin data (a plugin environment's worktree) stays this launch's project: that exact folder is // reopened, never its siblings, its plugin's other data or all plugin data. Only when the host placed the launch // there: a folder an agent asked for (a subagent's cwd) could otherwise be a plugin's account homes. const pluginDataRoot = join(input.userDataPath, PLUGIN_DATA_FOLDER); const projectDepth = pluginDataDepth(pathSpellings, pluginDataRoot, project); if (projectDepth >= 0 && projectDepth < 2) throw new Error(`The project ${project} is CanvasTTY's private plugin data.`); - if (projectDepth >= 2 && !(input.placedProjects ?? []).some((placed) => placesProject(pathSpellings, pluginDataRoot, placed, project))) { + // A validated worktree widens the project from the launch folder to its registered worker tree; the host placed the + // launch folder (a subagent can start in a subfolder of the worktree), so that is what must be placed. + const placedTarget = worktree ? input.cwd : project; + if (projectDepth >= 2 && !(input.placedProjects ?? []).some((placed) => placesProject(pathSpellings, pluginDataRoot, placed, placedTarget))) { throw new Error(`The project ${project} is inside CanvasTTY's private plugin data, and CanvasTTY did not place this launch there. Only a plugin environment's own folder or a folder the person chose is opened inside plugin data.`); } const grants = [...(input.grantedPrivate ?? []), join(input.userDataPath, "launch-runs", safeSegment(input.sessionId)), ...(projectDepth >= 2 ? [project] : [])]; + if (input.readOnlyProject && worktree) { + // Plan in a worktree: no writable CLI state may sit inside or above the read-only worker tree. + const projectPaths = pathSpellings(project); + const otherWritePaths = all([ + ...ownFolders, ...own.files, input.sessionTemp, + join(home, ".npm"), join(home, ".bun"), join(xdg.cache, "npm"), join(xdg.cache, "bun") + ]); + for (const path of otherWritePaths) { + if (projectPaths.some((root) => isWithin(root, path) || isWithin(path, root))) { + throw new Error(`Writable CLI state ${path} overlaps the read-only project ${project}.`); + } + } + } const trustedOwn = providerFolders(input.provider, {}, hostHome); const trustedOthers = AGENT_PROVIDERS.filter((provider) => provider !== input.provider).flatMap((provider) => { const defaults = providerFolders(provider, {}, hostHome); @@ -326,17 +348,23 @@ export function isolationPaths(input: IsolationPathInput): IsolationPaths { // Where git reads the shared config and hooks from. Protected where it exists: an empty placeholder (Linux) would // stop git from working; seatbelt also denies creating it (projectRoots). ...(existsSync(join(gitDir, "commondir")) ? [join(gitDir, "commondir")] : []), + // A linked worktree's Git metadata stays read-only: its `.git` pointer and its HEAD (the shared repository and + // the sibling worktrees' branches are below, in protectedDirectories). + ...(worktree ? [join(project, ".git"), join(worktree.adminDir, "HEAD")] : []), ...(input.provider === "codex" ? ownFolders.map((folder) => join(folder, "config.toml")) : []), ...(input.provider === "claude" ? ownFolders.flatMap((folder) => [join(folder, "settings.json"), join(folder, "settings.local.json")]) : []), ...configSources.jsonFiles ]), // Linked worktrees' records (their git folder, `commondir` and config): what git reads when the person works there. - protectedDirectories: all([...configSources.agentDirectories.flatMap((folder) => [join(folder, "agent"), join(folder, "agents")]), - ...(repository ? [join(gitDir, "worktrees")] : [])]), - gitHooks: all([join(project, ".git", "hooks")]), + protectedDirectories: all([ + ...configSources.agentDirectories.flatMap((folder) => [join(folder, "agent"), join(folder, "agents")]), + ...(repository ? [join(gitDir, "worktrees")] : []), + ...(worktree ? [worktree.adminDir, worktree.commonDir] : []) + ]), + gitHooks: all([join(project, ".git", "hooks"), ...(worktree ? [join(worktree.adminDir, "hooks"), join(worktree.commonDir, "hooks")] : [])]), projectRoots: input.readOnlyProject ? [] : all([project]), - unreadable: all([...sensitive, ...others, ...privateData]), - readableAgain: all([...grants, ...movedHomes]), + unreadable: all([...sensitive, ...others, ...privateData, ...(worktree ? worktree.otherAdminDirs : [])]), + readableAgain: all([...grants, ...movedHomes, ...(worktree ? [worktree.adminDir] : [])]), socketFolders: all(socketFolders), // The temporary folder a launch's own folder lives in (sessionTemp is /ctty-iso-…/tmp), and /tmp: where // CanvasTTY's gateways put their sockets when the userData path is too long for one. diff --git a/src/main/services/isolation/worktreeGitAccess.ts b/src/main/services/isolation/worktreeGitAccess.ts new file mode 100644 index 00000000..737cb0d5 --- /dev/null +++ b/src/main/services/isolation/worktreeGitAccess.ts @@ -0,0 +1,259 @@ +import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from "node:fs"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; + +const POINTER_LIMIT = 4096; + +/** Host-validated shared Git metadata for one linked worktree in the host's plugin-data tree. */ +export interface WorktreeGitAccess { + cwd: string; + commonDir: string; + adminDir: string; + otherAdminDirs: string[]; +} + +/** + * Validates the registered worktree boundary using only the host's resolved launch cwd, original task root, and + * userData. The returned paths are used to keep shared Git metadata read-only; no Git path or grant comes from a + * plugin. Any malformed, detached, shared-branch, or mismatched worktree is refused by returning null. + */ +export function worktreeGitAccess(cwd: string, taskRoot: string, userDataPath: string): WorktreeGitAccess | null { + try { + const launchCwd = real(cwd); + const userData = real(userDataPath); + const pluginData = resolve(userData, "plugin-data"); + if (!within(launchCwd, pluginData) || launchCwd === pluginData) return null; + // A provider may start in a project subfolder. Its Git boundary is the enclosing registered worktree, + // which is still validated below against the host task's shared repository and administration pointers. + const workingTree = nearestGitLayout(launchCwd)?.worktree; + if (!workingTree || !within(workingTree, pluginData) || workingTree === pluginData) return null; + + const task = real(taskRoot); + const taskLayout = nearestGitLayout(task); + if (!taskLayout || !within(task, taskLayout.worktree)) return null; + if (within(taskLayout.worktree, pluginData) && !isRegisteredLinkedWorktree(taskLayout)) return null; + if (workingTree === taskLayout.worktree) return null; + + const gitEntry = join(workingTree, ".git"); + const gitEntryStat = lstatSync(gitEntry); + if (!gitEntryStat.isFile()) return null; + const gitDirText = pointer(gitEntry, /^gitdir:\s*(.+?)\s*$/mu); + if (!gitDirText) return null; + const adminDir = real(isAbsolute(gitDirText) ? gitDirText : resolve(workingTree, gitDirText)); + const commonDir = real(readCommonDir(adminDir)); + if (commonDir !== taskLayout.commonDir) return null; + // The deny/reopen ordering deliberately hides plugin-data as a parent and reopens only the worker tree. If shared + // Git storage overlaps either path tree, that boundary cannot be expressed without broadening the reopen. + if (overlaps(commonDir, workingTree) || overlaps(commonDir, pluginData)) return null; + + const worktreesPath = join(commonDir, "worktrees"); + const worktreesDir = real(worktreesPath); + if (worktreesDir !== worktreesPath) return null; + if (dirname(adminDir) !== worktreesDir) return null; + if (!directory(adminDir)) return null; + + const adminGitdir = pointer(join(adminDir, "gitdir"), /^(.+?)\s*$/mu); + if (!adminGitdir) return null; + const registeredEntry = real(isAbsolute(adminGitdir) ? adminGitdir : resolve(adminDir, adminGitdir)); + if (registeredEntry !== gitEntry) return null; + const adminCommon = pointer(join(adminDir, "commondir"), /^(.+?)\s*$/mu); + if (!adminCommon) return null; + if (real(isAbsolute(adminCommon) ? adminCommon : resolve(adminDir, adminCommon)) !== commonDir) return null; + + const head = readRegular(join(adminDir, "HEAD")); + const branch = head === null ? null : /^ref:\s*(refs\/heads\/.+)\s*$/mu.exec(head)?.[1] ?? null; + if (!branch || !validBranchRef(branch)) return null; + if (!hasBranchRef(commonDir, branch)) return null; + const otherAdminDirs = otherAdministrations(worktreesDir, adminDir); + // The primary checkout is not listed under .git/worktrees. A forced linked worktree can share its branch, so + // include the host-validated task root's own Git directory HEAD in the single-writer check. + if (!otherAdminDirs || anotherWorktreeOwns([...otherAdminDirs, taskLayout.gitDir, commonDir], branch)) return null; + return { cwd: workingTree, commonDir, adminDir, otherAdminDirs }; + } catch { + return null; + } +} + +/** Does the launch cwd sit in the private plugin-data tree where only a verified project may be reopened? */ +export function isPluginDataPath(path: string, userDataPath: string): boolean { + try { + const lexical = resolve(path); + const lexicalRoot = resolve(userDataPath, "plugin-data"); + const realRoot = resolve(real(userDataPath), "plugin-data"); + return within(lexical, lexicalRoot) || within(lexical, realRoot) || within(real(path), realRoot); + } catch { + return false; + } +} + +interface GitLayout { worktree: string; gitDir: string; commonDir: string } + +function nearestGitLayout(path: string): GitLayout | null { + for (let current = path; ; current = dirname(current)) { + const layout = gitLayout(current); + if (layout) return layout; + const parent = dirname(current); + if (parent === current) return null; + } +} + +function gitLayout(worktree: string): GitLayout | null { + try { + const entry = join(worktree, ".git"); + const info = lstatSync(entry); + let gitDir: string; + if (info.isDirectory()) gitDir = real(entry); + else if (info.isFile()) { + const value = pointer(entry, /^gitdir:\s*(.+?)\s*$/mu); + if (!value) return null; + gitDir = real(isAbsolute(value) ? value : resolve(worktree, value)); + } else return null; + if (!directory(gitDir)) return null; + return { worktree, gitDir, commonDir: real(readCommonDir(gitDir)) }; + } catch { + return null; + } +} + +/** A task root can itself be a worker worktree; accept it only when its host-side Git pointers agree exactly. */ +function isRegisteredLinkedWorktree(layout: GitLayout): boolean { + try { + const entry = join(layout.worktree, ".git"); + const entryInfo = lstatSync(entry); + if (!entryInfo.isFile() || entryInfo.isSymbolicLink()) return false; + const gitDirText = pointer(entry, /^gitdir:\s*(.+?)\s*$/mu); + if (!gitDirText) return false; + const adminDir = real(isAbsolute(gitDirText) ? gitDirText : resolve(layout.worktree, gitDirText)); + const worktreesDir = join(layout.commonDir, "worktrees"); + if (real(worktreesDir) !== worktreesDir || dirname(adminDir) !== worktreesDir || adminDir !== layout.gitDir || !directory(adminDir)) return false; + const adminGitdir = pointer(join(adminDir, "gitdir"), /^(.+?)\s*$/mu); + if (!adminGitdir || real(isAbsolute(adminGitdir) ? adminGitdir : resolve(adminDir, adminGitdir)) !== entry) return false; + const adminCommon = pointer(join(adminDir, "commondir"), /^(.+?)\s*$/mu); + return Boolean(adminCommon && real(isAbsolute(adminCommon) ? adminCommon : resolve(adminDir, adminCommon)) === layout.commonDir); + } catch { + return false; + } +} + +function readCommonDir(gitDir: string): string { + const path = join(gitDir, "commondir"); + if (!existsSync(path)) return gitDir; + const value = pointer(path, /^(.+?)\s*$/mu); + if (!value) throw new Error("Invalid Git common directory pointer."); + return isAbsolute(value) ? value : resolve(gitDir, value); +} + +function otherAdministrations(worktreesDir: string, ownAdminDir: string): string[] | null { + const found: string[] = []; + for (const entry of readdirEntries(worktreesDir)) { + const admin = join(worktreesDir, entry); + const info = lstatSync(admin); + if (info.isSymbolicLink()) return null; + if (!info.isDirectory() || real(admin) !== admin) return null; + if (admin === ownAdminDir) continue; + found.push(admin); + } + return found; +} + +function anotherWorktreeOwns(otherAdmins: readonly string[], branch: string): boolean { + for (const admin of otherAdmins) { + const head = readRegular(join(admin, "HEAD")); + if (head !== null && /^ref:\s*(refs\/heads\/.+)\s*$/mu.exec(head)?.[1] === branch) return true; + } + return false; +} + +/** Checks an optional path below an expected real directory without following symlinked components. */ +function hasBranchRef(commonDir: string, branch: string): boolean { + const looseRef = join(commonDir, ...branch.split("/")); + const looseStatus = optionalRegularStatus(looseRef, commonDir); + if (looseStatus === null) return false; + if (looseStatus === "present") { + const oid = readRegular(looseRef); + return oid !== null && /^(?:[0-9a-f]{40}|[0-9a-f]{64})\s*$/iu.test(oid); + } + // pack-refs removes loose refs and may be used by a valid linked worktree. It is read only here; the complete + // common Git directory is protected by the OS layer, so this never opens a write grant. + const packedRefs = join(commonDir, "packed-refs"); + try { + const info = lstatSync(packedRefs); + if (!info.isFile() || info.isSymbolicLink() || info.size > 64 * 1024 * 1024) return false; + const lines = readFileSync(packedRefs, "utf8").split(/\r?\n/u); + const matches = lines.filter((line) => { + if (!line || line.startsWith("#") || line.startsWith("^")) return false; + const space = line.indexOf(" "); + return space > 0 && line.slice(space + 1) === branch; + }); + return matches.length === 1 && /^(?:[0-9a-f]{40}|[0-9a-f]{64})\s/iu.test(matches[0]); + } catch { + return false; + } +} + +/** Checks a loose-ref path without following symlinked components; absent refs are resolved through packed-refs. */ +function optionalRegularStatus(path: string, root: string): "present" | "missing" | null { + const rest = relative(root, path); + if (rest.startsWith("..") || isAbsolute(rest)) return null; + let current = root; + try { + const rootInfo = lstatSync(root); + if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink() || real(root) !== root) return null; + } catch { return null; } + const parts = rest.split(/[\\/]/u).filter(Boolean); + for (let index = 0; index < parts.length; index++) { + const part = parts[index]; + current = join(current, part); + try { + const info = lstatSync(current); + if (info.isSymbolicLink() || real(current) !== current) return null; + if (index === parts.length - 1) return info.isFile() ? "present" : null; + if (!info.isDirectory()) return null; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return "missing"; + return null; + } + } + return null; +} + +function readdirEntries(path: string): string[] { + return readdirSync(path); +} + +function pointer(path: string, pattern: RegExp): string | null { + const content = readRegular(path); + const match = content === null ? null : pattern.exec(content); + return match?.[1]?.trim() || null; +} + +function readRegular(path: string): string | null { + try { + const info = lstatSync(path); + if (!info.isFile() || info.isSymbolicLink() || info.size > POINTER_LIMIT) return null; + return readFileSync(path, "utf8"); + } catch { + return null; + } +} + +function directory(path: string): boolean { + try { return lstatSync(path).isDirectory(); } catch { return false; } +} + +function real(path: string): string { + return realpathSync.native(path); +} + +function validBranchRef(ref: string): boolean { + if (!/^refs\/heads\/[A-Za-z0-9._/-]+$/u.test(ref) || ref.includes("//") || ref.includes("..") || ref.includes("@{")) return false; + return ref.split("/").every((part) => part.length > 0 && !part.startsWith(".") && !part.endsWith(".") && !part.endsWith(".lock")); +} + +function within(path: string, root: string): boolean { + const rest = relative(root, path); + return rest === "" || (!rest.startsWith("..") && !isAbsolute(rest)); +} + +function overlaps(left: string, right: string): boolean { + return within(left, right) || within(right, left); +} diff --git a/tests/agent-isolation.test.mjs b/tests/agent-isolation.test.mjs index 2324b719..e71cb66e 100644 --- a/tests/agent-isolation.test.mjs +++ b/tests/agent-isolation.test.mjs @@ -10,6 +10,7 @@ import { AgentIsolation, gatewaySocketAddresses, ISOLATION_FOLDER_PREFIX, probeB import { isolationPaths } from "../src/main/services/isolation/isolationPaths.ts"; import { seatbeltProfile } from "../src/main/services/isolation/seatbelt.ts"; import { bubblewrapArguments } from "../src/main/services/isolation/bubblewrap.ts"; +import { worktreeGitAccess } from "../src/main/services/isolation/worktreeGitAccess.ts"; import { AgentControlService } from "../src/main/services/AgentControlService.ts"; import { codexInsideIsolation } from "../src/main/services/terminalLaunch.ts"; import { LaunchPipeline } from "../src/main/services/LaunchPipeline.ts"; @@ -19,6 +20,8 @@ import { availableRegistry, fakeSpawner } from "./helpers/terminal.mjs"; const at = { x: 0, y: 0 }; const mac = process.platform === "darwin" && existsSync("/usr/bin/sandbox-exec"); const onMac = { skip: mac ? false : "macOS seatbelt (sandbox-exec) only" }; +// Fixture repositories only: Windows has Git on PATH (Git for Windows), not at /usr/bin/git. +const GIT = process.platform === "win32" ? "git" : "/usr/bin/git"; /** A fake HOME with the files an escape would go for, a CanvasTTY userData folder and a project in NFD. */ async function world(t) { @@ -52,17 +55,53 @@ function isolation(w, options = {}) { } /** Runs `sh -c script` under the generated profile; returns stdout lines. */ -function run(w, script, { provider = "codex", env = {}, granted } = {}) { - const wrapped = isolation(w).wrap({ sessionId: "s1", provider, cwd: w.project.normalize("NFC"), command: "/bin/sh", args: ["-c", script], - env: { ...w.env, ...env }, ...(granted ? { grantedPrivate: granted } : {}) }); +function run(w, script, { provider = "codex", env = {}, granted, cwd = w.project, taskProjectRoot, placedProjects } = {}) { + const wrapped = isolation(w).wrap({ sessionId: "s1", provider, cwd: cwd.normalize("NFC"), command: "/bin/sh", args: ["-c", script], + env: { ...w.env, ...env }, ...(granted ? { grantedPrivate: granted } : {}), ...(taskProjectRoot ? { taskProjectRoot } : {}), + ...(placedProjects ? { placedProjects } : {}) }); try { - const result = spawnSync(wrapped.command, wrapped.args, { cwd: w.project, env: wrapped.env, encoding: "utf8", timeout: 20_000 }); + const result = spawnSync(wrapped.command, wrapped.args, { cwd, env: wrapped.env, encoding: "utf8", timeout: 20_000 }); return { lines: result.stdout.split("\n").filter(Boolean), stderr: result.stderr, env: wrapped.env }; } finally { wrapped.cleanup(); } } +async function linkedWorktree(w) { + const main = join(w.base, "main-repository"); + const worktrees = join(w.userData, "plugin-data", "canvastty-environments", "worktrees"); + const actor = join(worktrees, "actor"); + const sibling = join(worktrees, "sibling"); + await Promise.all([mkdir(main, { recursive: true }), mkdir(worktrees, { recursive: true })]); + const git = (cwd, args) => { + const result = spawnSync(GIT, args, { cwd, encoding: "utf8" }); + assert.equal(result.status, 0, `git ${args.join(" ")}: ${result.stderr}`); + }; + git(main, ["init", "-q"]); + git(main, ["config", "user.email", "fixture@example.invalid"]); + git(main, ["config", "user.name", "CanvasTTY fixture"]); + await writeFile(join(main, "tracked.txt"), "base\n"); + git(main, ["add", "tracked.txt"]); + git(main, ["commit", "-q", "-m", "base"]); + git(main, ["worktree", "add", "-q", "-b", "actor-branch", actor, "HEAD"]); + git(main, ["worktree", "add", "-q", "-b", "sibling-branch", sibling, "HEAD"]); + await writeFile(join(worktrees, "parent-marker"), "PARENT-SECRET\n"); + await writeFile(join(sibling, "sibling-marker"), "SIBLING-SECRET\n"); + return { main, worktrees, actor, sibling, common: join(main, ".git") }; +} + +/** What the host passes for a card the environments plugin placed in its worktree (TerminalManager.placedProjects). */ +const placedBy = (folder) => [{ folder, pluginId: "canvastty-environments" }]; + +function addPrimaryBranchDuplicate(fixture) { + const branch = spawnSync(GIT, ["-C", fixture.main, "branch", "--show-current"], { encoding: "utf8" }).stdout.trim(); + assert.ok(branch, "fixture primary checkout has a branch"); + const duplicate = join(fixture.worktrees, "primary-duplicate"); + const result = spawnSync(GIT, ["-C", fixture.main, "worktree", "add", "--force", "-q", duplicate, branch], { encoding: "utf8" }); + assert.equal(result.status, 0, `git worktree add --force ${branch}: ${result.stderr}`); + return duplicate; +} + test("the isolation decision: delegated and non-manual launches, the person's setting, missing layers and environments", () => { const on = new AgentIsolation({ userDataPath: "/u", enabled: () => true, platform: "darwin", exists: () => true }); assert.deepEqual(on.decide({ provider: "codex", profile: "normal", delegated: false }), { apply: false, profile: "normal" }, "manual: the person answers"); @@ -207,6 +246,166 @@ test("bubblewrap: CanvasTTY's gateway socket folders are read-only, a hidden soc assert.ok(pairs.includes(`--ro-bind /dev/null ${ssh}`)); }); +test("linked worktree Git metadata stays read-only on every OS layer while project files remain editable", async (t) => { + const w = await world(t); + const fixture = await linkedWorktree(w); + const access = worktreeGitAccess(fixture.actor, fixture.main, w.userData); + assert.ok(access, "the host recognizes the plugin worktree against its task root"); + const subfolder = join(fixture.actor, "src", "nested"); + await mkdir(subfolder, { recursive: true }); + const nestedAccess = worktreeGitAccess(subfolder, fixture.main, w.userData); + assert.ok(nestedAccess, "a delegated launch inside a registered worktree subfolder keeps its Git boundary"); + assert.equal(nestedAccess.cwd, fixture.actor, "isolation reopens the registered worker tree, not its private parent"); + assert.equal(nestedAccess.commonDir, access.commonDir); + assert.equal(nestedAccess.adminDir, access.adminDir); + // The environment placed the delegated launch in that subfolder: the validated worker tree around it is opened. + const fromSubfolder = isolationPaths({ provider: "codex", cwd: subfolder, sessionTemp: join(w.temp, "s-sub"), env: w.env, + userDataPath: w.userData, sessionId: "s-sub", worktreeGitAccess: nestedAccess, placedProjects: placedBy(subfolder) }); + assert.ok(fromSubfolder.readableAgain.includes(fixture.actor), "the placed subfolder's registered worker tree is reopened"); + assert.ok(!fromSubfolder.readableAgain.includes(fixture.sibling)); + assert.throws(() => isolationPaths({ provider: "codex", cwd: subfolder, sessionTemp: join(w.temp, "s-unplaced"), env: w.env, + userDataPath: w.userData, sessionId: "s-unplaced", worktreeGitAccess: nestedAccess, placedProjects: placedBy(fixture.sibling) }), + /did not place this launch there/u, "a validated worktree is still opened only where the host placed the launch"); + const common = isolationPaths({ provider: "codex", cwd: fixture.actor, sessionTemp: join(w.temp, "s"), env: w.env, + userDataPath: w.userData, sessionId: "s", worktreeGitAccess: access, placedProjects: placedBy(fixture.actor) }); + assert.ok(common.unreadable.includes(join(w.userData, "plugin-data")), "plugin data is hidden as a whole"); + assert.ok(common.readableAgain.includes(fixture.actor), "only this worker tree is reopened"); + for (const path of [access.adminDir, access.commonDir]) { + assert.ok(common.protectedDirectories.includes(path), `${path} is explicitly write-protected even if a CLI home is moved there`); + } + assert.ok(!common.writableFiles.some(path => path.startsWith(access.adminDir) || path.startsWith(access.commonDir))); + + const plan = isolationPaths({ provider: "codex", cwd: fixture.actor, sessionTemp: join(w.temp, "s"), env: w.env, + userDataPath: w.userData, sessionId: "s-plan", worktreeGitAccess: access, readOnlyProject: true, + placedProjects: placedBy(fixture.actor) }); + assert.ok(plan.protectedDirectories.includes(access.adminDir) && plan.protectedDirectories.includes(access.commonDir)); + + const linux = isolationPaths({ provider: "codex", cwd: fixture.actor, sessionTemp: join(w.temp, "s-linux"), env: w.env, + userDataPath: w.userData, sessionId: "s-linux", worktreeGitAccess: access, + placedProjects: placedBy(fixture.actor) }); + assert.ok(linux.protectedDirectories.includes(access.adminDir) && linux.protectedDirectories.includes(access.commonDir)); + assert.ok(linux.writable.includes(fixture.actor), "Linux still permits project file edits"); + const bound = new Map([[fixture.actor, "directory"], [access.adminDir, "directory"], [access.commonDir, "directory"], + [join(fixture.actor, ".git"), "file"], [join(access.adminDir, "index"), "file"]]); + const args = bubblewrapArguments(linux, { command: "/usr/bin/codex", args: [], cwd: fixture.actor }, (path) => bound.get(path) ?? null); + for (const path of [access.adminDir, access.commonDir, join(access.adminDir, "index")]) { + assert.ok(!args.some((arg, index) => arg === "--bind" && args[index + 1] === path), `${path} is not mounted writable`); + } + assert.ok(args.some((arg, index) => arg === "--ro-bind" && args[index + 1] === access.adminDir), "the required index remains visible read-only"); + assert.ok(args.some((arg, index) => arg === "--ro-bind" && args[index + 1] === access.commonDir), "shared objects and refs are remounted read-only"); +}); + +test("a validated plugin worktree tells the agent, in its environment only, that Git metadata is read-only", async (t) => { + const w = await world(t); + const fixture = await linkedWorktree(w); + const layer = isolation(w, { platform: "darwin", exists: () => true }); + const wrapped = layer.wrap({ sessionId: "note", provider: "codex", cwd: fixture.actor, taskProjectRoot: fixture.main, + placedProjects: placedBy(fixture.actor), + command: "/bin/sh", args: [], env: w.env }); + try { + assert.match(wrapped.env.CANVASTTY_ISOLATION, /git add and git commit are unavailable here/u); + assert.equal(wrapped.env.GIT_OPTIONAL_LOCKS, "0", "git status does not try to take the index lock it cannot write"); + assert.deepEqual(Object.keys(wrapped).sort(), ["args", "cleanup", "command", "env"], "nothing else reaches the card"); + } finally { + wrapped.cleanup(); + } + const plain = layer.wrap({ sessionId: "plain", provider: "codex", cwd: w.project, command: "/bin/sh", args: [], env: w.env }); + try { + assert.doesNotMatch(plain.env.CANVASTTY_ISOLATION, /worktree/u); + assert.equal(plain.env.GIT_OPTIONAL_LOCKS, undefined); + } finally { + plain.cleanup(); + } +}); + +test("host validation refuses a linked worktree sharing the primary checkout branch", async (t) => { + const w = await world(t); + const fixture = await linkedWorktree(w); + const duplicate = addPrimaryBranchDuplicate(fixture); + assert.equal(worktreeGitAccess(duplicate, fixture.main, w.userData), null); + const linux = isolation(w, { platform: "linux", bubblewrapPath: "/usr/bin/bwrap", bubblewrapProbe: () => null, exists: () => true }); + assert.throws(() => linux.wrap({ sessionId: "shared", provider: "codex", cwd: duplicate, taskProjectRoot: fixture.main, + command: "/usr/bin/codex", args: [], env: w.env }), /could not be verified against the task's original Git repository/u); + const outsideAlias = join(fixture.worktrees, "outside-alias"); + await symlink(fixture.main, outsideAlias, "dir"); + assert.throws(() => linux.wrap({ sessionId: "alias", provider: "codex", cwd: outsideAlias, taskProjectRoot: fixture.main, + command: "/usr/bin/codex", args: [], env: w.env }), /could not be verified against the task's original Git repository/u, + "a lexical plugin-data path that resolves outside is still rejected"); +}); + +test("host validation checks the common primary HEAD when the task root is itself a linked worktree", async (t) => { + const w = await world(t); + const fixture = await linkedWorktree(w); + const taskRoot = join(w.base, "task-root-worktree"); + const taskRootResult = spawnSync(GIT, ["-C", fixture.main, "worktree", "add", "-q", "-b", "task-root-branch", taskRoot, "HEAD"], { encoding: "utf8" }); + assert.equal(taskRootResult.status, 0, `git worktree add task root: ${taskRootResult.stderr}`); + const duplicate = addPrimaryBranchDuplicate(fixture); + assert.equal(worktreeGitAccess(duplicate, taskRoot, w.userData), null, + "the common repository HEAD is checked even though taskRoot's own admin HEAD has a different branch"); +}); + +test("a verified plugin worktree can host a nested worktree without reopening its private parent or siblings", async (t) => { + const w = await world(t); + const fixture = await linkedWorktree(w); + const nested = join(fixture.worktrees, "nested"); + const nestedResult = spawnSync(GIT, ["-C", fixture.actor, "worktree", "add", "-q", "-b", "nested-branch", nested, "HEAD"], { encoding: "utf8" }); + assert.equal(nestedResult.status, 0, nestedResult.stderr); + const access = worktreeGitAccess(nested, fixture.actor, w.userData); + assert.ok(access, "a host-validated linked worktree may be the task root for a nested worker"); + const paths = isolationPaths({ provider: "codex", cwd: nested, sessionTemp: join(w.temp, "s-nested"), env: w.env, + userDataPath: w.userData, sessionId: "nested", worktreeGitAccess: access, + placedProjects: placedBy(nested) }); + assert.ok(paths.unreadable.includes(join(w.userData, "plugin-data")), "the private parent remains hidden"); + assert.ok(paths.readableAgain.includes(nested), "only the nested worker worktree is reopened"); + assert.ok(!paths.readableAgain.includes(fixture.actor) && !paths.readableAgain.includes(fixture.sibling), + "the orchestrator worktree and its sibling receive no individual reopen"); +}); + +test("host validation accepts a packed worktree branch even when loose refs and reflog directories are absent", async (t) => { + const w = await world(t); + const fixture = await linkedWorktree(w); + const packed = spawnSync(GIT, ["-C", fixture.main, "pack-refs", "--all", "--prune"], { encoding: "utf8" }); + assert.equal(packed.status, 0, packed.stderr); + await rm(join(fixture.common, "logs"), { recursive: true, force: true }); + await rm(join(fixture.common, "refs"), { recursive: true, force: true }); + assert.equal(existsSync(join(fixture.common, "refs", "heads", "actor-branch")), false, "the branch is stored only in packed-refs"); + assert.ok(worktreeGitAccess(fixture.actor, fixture.main, w.userData), "read-only isolation does not depend on loose refs or reflogs"); +}); + +test("Plan refuses provider state inside or above the read-only project", async (t) => { + const w = await world(t); + const fixture = await linkedWorktree(w); + for (const home of [fixture.actor, fixture.worktrees, join(fixture.actor, "src")]) { + assert.throws(() => isolationPaths({ provider: "codex", cwd: fixture.actor, sessionTemp: join(w.temp, "plan-state"), + env: { ...w.env, CODEX_HOME: home }, userDataPath: w.userData, sessionId: "plan", readOnlyProject: true, + worktreeGitAccess: worktreeGitAccess(fixture.actor, fixture.main, w.userData), placedProjects: placedBy(fixture.actor) }), /overlaps the read-only project/u, home); + } +}); + +test("a moved CLI home cannot make shared worktree Git metadata writable", async (t) => { + const w = await world(t); + const fixture = await linkedWorktree(w); + const access = worktreeGitAccess(fixture.actor, fixture.main, w.userData); + assert.ok(access); + const paths = isolationPaths({ provider: "codex", cwd: fixture.actor, sessionTemp: join(w.temp, "git-home"), env: { ...w.env, CODEX_HOME: access.commonDir }, + userDataPath: w.userData, sessionId: "git-home", worktreeGitAccess: access, + placedProjects: placedBy(fixture.actor) }); + assert.ok(paths.writable.includes(access.commonDir), "the moved CLI state is nominally writable before the Git protection layer"); + assert.ok(paths.protectedDirectories.includes(access.commonDir), "the full shared Git directory is an explicit final deny/remount"); +}); + +test("seatbelt, for real: Plan refuses a provider home that overlaps its read-only worktree", onMac, async (t) => { + const w = await world(t); + const fixture = await linkedWorktree(w); + const original = await readFile(join(fixture.actor, "tracked.txt"), "utf8"); + const layer = isolation(w); + assert.throws(() => layer.wrap({ sessionId: "plan-overlap", provider: "codex", cwd: fixture.actor, taskProjectRoot: fixture.main, + placedProjects: placedBy(fixture.actor), + command: "/bin/sh", args: ["-c", "echo should-not-run >> tracked.txt"], profile: "plan", env: { ...w.env, CODEX_HOME: fixture.actor } }), + /overlaps the read-only project/u, "fail closed before the command could be launched with a writable project root"); + assert.equal(await readFile(join(fixture.actor, "tracked.txt"), "utf8"), original); +}); + test("the paths: another CLI's home moved by its own variable is unreadable, never this CLI's own", async (t) => { const w = await world(t); const custom = (name) => join(w.base, "creds", name); @@ -465,6 +664,61 @@ test("seatbelt, for real: writes stay in the project, secrets stay unread, nothi assert.notEqual(spawnSync("defaults", ["read", "ctty.isolation.probe"]).status, 0); }); +test("seatbelt, for real: a plugin worktree stays editable while Git metadata and shared objects stay read-only", onMac, async (t) => { + const w = await world(t); + const fixture = await linkedWorktree(w); + const actorGit = spawnSync(GIT, ["rev-parse", "--absolute-git-dir"], { cwd: fixture.actor, encoding: "utf8" }).stdout.trim(); + const objectOid = spawnSync(GIT, ["-C", fixture.actor, "rev-parse", "HEAD"], { encoding: "utf8" }).stdout.trim(); + const existingObject = join(fixture.common, "objects", objectOid.slice(0, 2), objectOid.slice(2)); + assert.ok(existsSync(existingObject), "the test uses a real loose object shared with the primary repository"); + await writeFile(join(fixture.main, "tracked.txt"), "base\nprimary-dirty\n"); + const primaryStatusBeforeCollection = spawnSync(GIT, ["-C", fixture.main, "status", "--short"], { encoding: "utf8" }).stdout; + const { lines, stderr } = run(w, [ + `cat "${join(fixture.worktrees, "parent-marker")}" 2>/dev/null || echo PARENT-READ-denied`, + `cat "${join(fixture.sibling, "sibling-marker")}" 2>/dev/null || echo SIBLING-READ-denied`, + `cat "${join(fixture.main, "tracked.txt")}" >/dev/null 2>&1 && echo PRIMARY-SOURCE-READ-ok || echo PRIMARY-SOURCE-READ-denied`, + `echo escape > "${join(fixture.main, "tracked.txt")}" 2>/dev/null && echo PRIMARY-SOURCE-WRITE-ok || echo PRIMARY-SOURCE-WRITE-denied`, + `echo escape > "${join(fixture.worktrees, "parent-write")}" 2>/dev/null && echo PARENT-WRITE-ok || echo PARENT-WRITE-denied`, + `echo escape > "${join(fixture.sibling, "sibling-write")}" 2>/dev/null && echo SIBLING-WRITE-ok || echo SIBLING-WRITE-denied`, + 'echo change >> tracked.txt', + 'git status --short >/dev/null && echo GIT-STATUS-ok || echo GIT-STATUS-denied', + 'git add tracked.txt >/dev/null 2>&1 && echo GIT-ADD-ok || echo GIT-ADD-denied', + 'git -c user.email=fixture@example.invalid -c user.name=CanvasTTY commit --all -q -m isolated >/dev/null 2>&1 && echo GIT-COMMIT-ok || echo GIT-COMMIT-denied', + `echo corrupt > "${existingObject}" 2>/dev/null && echo OBJECT-TRUNCATE-ok || echo OBJECT-TRUNCATE-denied`, + `rm "${existingObject}" 2>/dev/null && echo OBJECT-DELETE-ok || echo OBJECT-DELETE-denied`, + `ln "${existingObject}" object-alias 2>/dev/null && echo OBJECT-HARDLINK-ok || echo OBJECT-HARDLINK-denied`, + '[ -e object-alias ] && (echo corrupt > object-alias 2>/dev/null && echo OBJECT-ALIAS-WRITE-ok || echo OBJECT-ALIAS-WRITE-denied) || echo OBJECT-ALIAS-ABSENT', + `echo changed >> "${join(fixture.common, "config")}" 2>/dev/null && echo COMMON-CONFIG-WRITE-ok || echo COMMON-CONFIG-WRITE-denied`, + `echo evil > "${join(fixture.common, "hooks", "pre-commit")}" 2>/dev/null && echo COMMON-HOOK-WRITE-ok || echo COMMON-HOOK-WRITE-denied`, + `echo changed > "${join(fixture.common, "refs", "heads", "sibling-branch")}" 2>/dev/null && echo OTHER-REF-WRITE-ok || echo OTHER-REF-WRITE-denied`, + `echo changed > "${join(fixture.common, "refs", "heads", "actor-branch.evil")}" 2>/dev/null && echo SIBLING-REF-CREATE-ok || echo SIBLING-REF-CREATE-denied`, + `echo changed > "${join(fixture.common, "index")}" 2>/dev/null && echo COMMON-INDEX-WRITE-ok || echo COMMON-INDEX-WRITE-denied`, + `echo changed > "${join(actorGit, "HEAD")}" 2>/dev/null && echo WORKTREE-HEAD-WRITE-ok || echo WORKTREE-HEAD-WRITE-denied` + ].join("; "), { cwd: fixture.actor, taskProjectRoot: fixture.main, placedProjects: placedBy(fixture.actor), + env: { CODEX_HOME: fixture.common, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null" } }); + assert.deepEqual(lines, ["PARENT-READ-denied", "SIBLING-READ-denied", "PRIMARY-SOURCE-READ-ok", "PRIMARY-SOURCE-WRITE-denied", "PARENT-WRITE-denied", "SIBLING-WRITE-denied", + "GIT-STATUS-ok", "GIT-ADD-denied", "GIT-COMMIT-denied", "OBJECT-TRUNCATE-denied", "OBJECT-DELETE-denied", "OBJECT-HARDLINK-denied", + "OBJECT-ALIAS-ABSENT", "COMMON-CONFIG-WRITE-denied", "COMMON-HOOK-WRITE-denied", "OTHER-REF-WRITE-denied", + "SIBLING-REF-CREATE-denied", "COMMON-INDEX-WRITE-denied", "WORKTREE-HEAD-WRITE-denied"]); + assert.ok(stderr.split("\n").filter(Boolean).every((line) => line.includes("Operation not permitted")), + "only filesystem operations expected to be denied by seatbelt report errors"); + assert.equal(await readFile(join(fixture.actor, "tracked.txt"), "utf8"), "base\nchange\n", "only the worker's editable copy changes"); + assert.equal(await readFile(join(fixture.main, "tracked.txt"), "utf8"), "base\nprimary-dirty\n", "the primary checkout's dirty state is preserved"); + assert.equal(spawnSync(GIT, ["-C", fixture.actor, "cat-file", "-e", objectOid], { encoding: "utf8" }).status, 0, + "Git can still read its shared loose objects outside the sandbox"); + const hostStage = spawnSync(GIT, ["add", "tracked.txt"], { cwd: fixture.actor, encoding: "utf8" }); + assert.equal(hostStage.status, 0, hostStage.stderr); + const hostCommit = spawnSync(GIT, ["-c", "user.email=fixture@example.invalid", "-c", "user.name=CanvasTTY", "commit", "-q", "-m", "host collected"], + { cwd: fixture.actor, encoding: "utf8" }); + assert.equal(hostCommit.status, 0, hostCommit.stderr, "the trusted host can collect and commit the actor's edits"); + assert.equal(spawnSync(GIT, ["-C", fixture.main, "status", "--short"], { encoding: "utf8" }).stdout, primaryStatusBeforeCollection, + "host collection preserves the primary checkout's pre-existing dirty state"); + assert.equal(await readFile(join(fixture.common, "refs", "heads", "sibling-branch"), "utf8").then((value) => value.trim()), + spawnSync(GIT, ["-C", fixture.sibling, "rev-parse", "HEAD"], { encoding: "utf8" }).stdout.trim(), "the sibling branch stays unchanged"); + assert.equal(existsSync(join(fixture.worktrees, "parent-write")), false); + assert.equal(existsSync(join(fixture.sibling, "sibling-write")), false); +}); + test("seatbelt, for real: the launch writes the account home it was handed, not its permission settings, and no other home", onMac, async (t) => { const w = await world(t); const own = join(w.userData, "account-homes", "a1"); From 5b516fdfbb8c1c2850c9dc60bf152bd40c78965e Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:04:59 +0000 Subject: [PATCH 25/28] feat(orchestration): run subagents in plugin worktrees When the agent the person started works at the top of a Git project and a trusted environments plugin offers the worktree kind for the subagent's CLI, each subagent (spawn_agent or the control connection's create) runs in its own worktree from that plugin. The plugin's prepare now receives the task's project as projectRoot. spawn_agent gains isolate: "worktree" to ask for one explicitly; it is refused when no plugin provides it. The parent and the person's depth, child and live limits are checked again after the plugin answered, so subagents started together cannot pass the live limit while their environments resolve. Under isolation a project inside plugin data is first refused when CanvasTTY did not place the launch there, then when it is not a linked worktree validated against the task's repository. An agent card started in a Git-worktree environment keeps the folder the person chose (saved with the card as projectRoot, only with an environment); its launches and its subagents' are validated against that repository, not against the worktree itself. A folder inside plugin data that the person chose for a card is opened as placed and need not be a linked worktree. --- docs/agent-orchestration.md | 6 + docs/plugin-api.d.ts | 2 + docs/plugins.md | 4 +- docs/plugins.ru.md | 4 +- docs/plugins.zh-CN.md | 4 +- native/canvastty-helper/catalog.json | 8 +- src/agent-browser/orchestration-catalog.mjs | 3 +- src/main/index.ts | 8 +- src/main/services/AgentControlService.ts | 99 ++++++- src/main/services/EnvironmentRegistry.ts | 3 + src/main/services/SubagentWorktreeResolver.ts | 36 +++ src/main/services/TerminalManager.ts | 18 +- src/main/services/TerminalSessionStore.ts | 11 +- .../agent-browser/OrchestrationTools.ts | 4 +- src/main/services/isolation/AgentIsolation.ts | 26 +- tests/agent-delegation-invariants.test.mjs | 21 ++ tests/agent-isolation.test.mjs | 2 + tests/isolation-hardening.test.mjs | 27 +- tests/session-environments.test.mjs | 2 +- ...bagent-worktree-plugin-acceptance.test.mjs | 279 ++++++++++++++++++ tests/subagent-worktree-resolver.test.mjs | 64 ++++ tests/worktree-isolation-pipeline.test.mjs | 176 +++++++++++ 22 files changed, 774 insertions(+), 33 deletions(-) create mode 100644 src/main/services/SubagentWorktreeResolver.ts create mode 100644 tests/subagent-worktree-plugin-acceptance.test.mjs create mode 100644 tests/subagent-worktree-resolver.test.mjs create mode 100644 tests/worktree-isolation-pipeline.test.mjs diff --git a/docs/agent-orchestration.md b/docs/agent-orchestration.md index bf3c4a6b..c28ef2e8 100644 --- a/docs/agent-orchestration.md +++ b/docs/agent-orchestration.md @@ -74,6 +74,12 @@ Inside the layer, the files git reads its config and paths from are not writable A plugin environment declares in its manifest what it keeps (`keeps`): `launch` (the launch's arguments and environment reach the agent unchanged, so CanvasTTY's hooks and the profile's per-run settings work there), `isolated` (it does not run on this computer's files: a container or a remote host) and `confines`. Without `launch`, any profile but normal is refused and the card says that base protection and CanvasTTY's hooks do not reach the agent there. An `isolated` environment is not wrapped again (its own boundary applies; the card says so); a local one (a worktree) runs inside the layer with the worktree as its project. +### A worktree per subagent + +When the agent the person started works at the top of a Git project and a trusted environments plugin offers the `worktree` kind for the subagent's CLI, every subagent it starts (`spawn_agent`, `create`) runs in its own worktree from that plugin: the plugin's `prepare` gets the task's project as `projectRoot`. Without such a plugin, or outside the top of a Git project, subagents run in their folder as before. `spawn_agent` `isolate: "worktree"` asks for one explicitly and is refused when no plugin provides it; the answer then carries `isolationRequested: "worktree"`. The person's limits are checked again after the plugin answered, so subagents started at the same time cannot pass the live limit together. + +Under agent isolation a worktree in the plugin's data is the agent's project only when the host validates it as a linked worktree of the task's repository on a branch no other worktree has checked out; otherwise the launch is refused. Its Git metadata (`.git`, `HEAD`, the worktree's administration folder and the shared repository) is read-only to the agent and the other worktrees are unreadable, so `git add` and `git commit` fail there; the agent's environment says so. The plugin collects the agent's edits (for example into a change review a card action opens). + ## Enable and connect Turn on Settings → Agents → "Agent orchestration endpoint" (`agentControlEnabled`, off by default; it starts and stops the endpoint without a restart). A session launched from the desktop with the **Orchestrator** role receives `CANVASTTY_CONTROL_CONNECTION` (its own control connection, below) and `CANVASTTY_CONTROL_CLI` (the bundled CLI path) in its environment; ordinary sessions never do. For CI smoke, start the app with `--agent-control` or set `CANVASTTY_AGENT_CONTROL=1` for that invocation to force the endpoint on regardless of the setting. From a source checkout: diff --git a/docs/plugin-api.d.ts b/docs/plugin-api.d.ts index 2e07c96a..effa8367 100644 --- a/docs/plugin-api.d.ts +++ b/docs/plugin-api.d.ts @@ -375,6 +375,8 @@ export interface CanvasTTYEnvironmentPrepareParams { kind: string; provider: CanvasTTYProviderId; cwd: string; + /** The folder of the agent the person started (the task's project), for a worktree of that repository. */ + projectRoot?: string; options: Record; } export type CanvasTTYEnvironmentPrepareResult = diff --git a/docs/plugins.md b/docs/plugins.md index bb7a6a61..56e7733e 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -260,13 +260,13 @@ CanvasTTY keeps the card, the PTY, the saved record and the restore order; the s | Request | Params | Answer | Budget | |:--|:--|:--|:--| -| `canvastty.environment.prepare` | `sessionId, kind, provider, cwd, options` | `{ ref, label, cwd? }` or `{ refuse: { reason } }`. `ref` is opaque JSON of at most 4 KB saved with the card; `label` (80 characters) is the badge; `cwd` (an existing absolute folder) becomes the card's folder | 15 s | +| `canvastty.environment.prepare` | `sessionId, kind, provider, cwd, projectRoot?, options` | `{ ref, label, cwd? }` or `{ refuse: { reason } }`. `ref` is opaque JSON of at most 4 KB saved with the card; `label` (80 characters) is the badge; `cwd` (an existing absolute folder) becomes the card's folder | 15 s | | `canvastty.environment.wrap` | `sessionId, kind, ref, provider, command, args, env, secretEnvNames, cwd` | `{ command, args, env?, secretEnv?, cwd? }` or `{ refuse }` | 5 s | | `canvastty.environment.resume` | `sessionId, kind, ref` | `{ ok: true }` or `{ stopped: { reason } }` | 10 s | | `canvastty.environment.release` | `sessionId, kind, ref, keepData, reason` (`closed` or `quit`) | ignored | 10 s | | `canvastty.environment.describe` | `sessionId, kind, ref` | `{ label, detail? }` for the card badge and its tooltip | 3 s | -- `prepare` runs once, when the card first starts. `wrap` runs before every start (create, restart, restore) and turns what the host would spawn into what runs inside the environment, for example `ssh -tt host …`, `docker exec -it …`, or the same program in another folder. The host still spawns it with node-pty, so scrollback, status and orchestration work unchanged. +- `prepare` runs once, when the card first starts. `projectRoot` is the folder of the agent the person started (the task's project), for a worktree of that repository. A worktree environment's folder inside the plugin's data is the agent's project under agent isolation only when it is a linked worktree of that repository on a branch no other worktree has checked out; its Git metadata is read-only to the agent (no `git add` or `git commit`), so the plugin collects the agent's edits itself. `wrap` runs before every start (create, restart, restore) and turns what the host would spawn into what runs inside the environment, for example `ssh -tt host …`, `docker exec -it …`, or the same program in another folder. The host still spawns it with node-pty, so scrollback, status and orchestration work unchanged. - `wrap` output is checked: `command` must be an absolute path to an executable file or a bare program name that the host resolves on `PATH`; a command line, a relative path or shell syntax is refused, and nothing runs through a shell. `args` is an array (256 items, 8 KB each, no NUL). `env` and `secretEnv` follow the launch-contributor rules: reserved names are refused, and so is any name CanvasTTY or a launch option already sets for this launch. `secretEnv` values come from the plugin's own secrets (needs `secrets`) and are masked like launch secrets. - `wrap` receives the launch's own variables (from CanvasTTY and chosen launch options) without reserved `CANVASTTY_*` names and without secret values; `secretEnvNames` lists names whose values the spawned process gets from the host, so a wrapper can forward them by name (`docker exec -e NAME`). - Restore resumes every saved environment first, then starts parents before children. If the plugin is disabled, removed or untrusted, or `resume` answers `stopped`, the card comes back stopped with the reason and keeps its record; Restart asks `resume` again. A card is never started locally instead, and a timeout or error refuses, never falls back. diff --git a/docs/plugins.ru.md b/docs/plugins.ru.md index 994f318a..7ddc5afd 100644 --- a/docs/plugins.ru.md +++ b/docs/plugins.ru.md @@ -238,13 +238,13 @@ CanvasTTY владеет окном, PTY, сохранённой записью | Запрос | Параметры | Ответ | Лимит | |:--|:--|:--|:--| -| `canvastty.environment.prepare` | `sessionId, kind, provider, cwd, options` | `{ ref, label, cwd? }` или `{ refuse: { reason } }`. `ref` — непрозрачный JSON до 4 КБ, хранится с окном; `label` (80 символов) — бейдж; `cwd` (существующая абсолютная папка) становится папкой окна | 15 с | +| `canvastty.environment.prepare` | `sessionId, kind, provider, cwd, projectRoot?, options` | `{ ref, label, cwd? }` или `{ refuse: { reason } }`. `ref` — непрозрачный JSON до 4 КБ, хранится с окном; `label` (80 символов) — бейдж; `cwd` (существующая абсолютная папка) становится папкой окна | 15 с | | `canvastty.environment.wrap` | `sessionId, kind, ref, provider, command, args, env, secretEnvNames, cwd` | `{ command, args, env?, secretEnv?, cwd? }` или `{ refuse }` | 5 с | | `canvastty.environment.resume` | `sessionId, kind, ref` | `{ ok: true }` или `{ stopped: { reason } }` | 10 с | | `canvastty.environment.release` | `sessionId, kind, ref, keepData, reason` (`closed` или `quit`) | игнорируется | 10 с | | `canvastty.environment.describe` | `sessionId, kind, ref` | `{ label, detail? }` для бейджа окна и подсказки | 3 с | -- `prepare` вызывается один раз, при первом запуске окна. `wrap` вызывается перед каждым запуском (создание, перезапуск, восстановление) и превращает то, что хост запустил бы, в то, что работает внутри среды: `ssh -tt host …`, `docker exec -it …` или та же программа в другой папке. PTY по-прежнему создаёт хост через node-pty, поэтому прокрутка, статус и оркестрация работают как раньше. +- `prepare` вызывается один раз, при первом запуске окна. `projectRoot` — папка агента, которого запустил человек (проект задачи), для worktree этого репозитория. Папка worktree-среды внутри данных плагина становится проектом агента под изоляцией, только если это связанный worktree этого репозитория на ветке, которую не занимает другой worktree; метаданные Git для агента только для чтения (`git add` и `git commit` недоступны), поэтому правки агента собирает сам плагин. `wrap` вызывается перед каждым запуском (создание, перезапуск, восстановление) и превращает то, что хост запустил бы, в то, что работает внутри среды: `ssh -tt host …`, `docker exec -it …` или та же программа в другой папке. PTY по-прежнему создаёт хост через node-pty, поэтому прокрутка, статус и оркестрация работают как раньше. - Ответ `wrap` проверяется: `command` — абсолютный путь к исполняемому файлу или простое имя программы, которое хост находит в `PATH`; командная строка, относительный путь или синтаксис оболочки отклоняются, ничего не запускается через оболочку. `args` — массив (256 элементов, до 8 КБ, без NUL). `env` и `secretEnv` подчиняются правилам launch contributors: зарезервированные имена и имена, которые CanvasTTY или параметр запуска уже задают для этого запуска, отклоняются. Значения `secretEnv` берутся из собственных секретов плагина (нужно `secrets`) и маскируются так же, как секреты запуска. - `wrap` получает переменные самого запуска (от CanvasTTY и выбранных параметров запуска) без зарезервированных имён `CANVASTTY_*` и без значений секретов; `secretEnvNames` перечисляет имена, значения которых процесс получит от хоста, чтобы обёртка могла пробросить их по имени (`docker exec -e NAME`). - При восстановлении сначала возобновляются все сохранённые среды, затем запускаются родители, потом дочерние окна. Если плагин выключен, удалён или не доверен либо `resume` ответил `stopped`, окно возвращается остановленным с причиной и сохраняет запись; «Перезапуск» снова вызывает `resume`. Окно никогда не запускается локально вместо среды, а таймаут или ошибка отклоняют запуск без запасного варианта. diff --git a/docs/plugins.zh-CN.md b/docs/plugins.zh-CN.md index 1fe577e3..155e3e09 100644 --- a/docs/plugins.zh-CN.md +++ b/docs/plugins.zh-CN.md @@ -236,13 +236,13 @@ CanvasTTY 负责卡片、PTY、保存的记录和恢复顺序;服务回答五 | 请求 | 参数 | 应答 | 时限 | |:--|:--|:--|:--| -| `canvastty.environment.prepare` | `sessionId, kind, provider, cwd, options` | `{ ref, label, cwd? }` 或 `{ refuse: { reason } }`。`ref` 是不超过 4 KB 的不透明 JSON,随卡片保存;`label`(80 字符)是徽标;`cwd`(已存在的绝对路径文件夹)成为卡片的文件夹 | 15 秒 | +| `canvastty.environment.prepare` | `sessionId, kind, provider, cwd, projectRoot?, options` | `{ ref, label, cwd? }` 或 `{ refuse: { reason } }`。`ref` 是不超过 4 KB 的不透明 JSON,随卡片保存;`label`(80 字符)是徽标;`cwd`(已存在的绝对路径文件夹)成为卡片的文件夹 | 15 秒 | | `canvastty.environment.wrap` | `sessionId, kind, ref, provider, command, args, env, secretEnvNames, cwd` | `{ command, args, env?, secretEnv?, cwd? }` 或 `{ refuse }` | 5 秒 | | `canvastty.environment.resume` | `sessionId, kind, ref` | `{ ok: true }` 或 `{ stopped: { reason } }` | 10 秒 | | `canvastty.environment.release` | `sessionId, kind, ref, keepData, reason`(`closed` 或 `quit`) | 忽略 | 10 秒 | | `canvastty.environment.describe` | `sessionId, kind, ref` | `{ label, detail? }`,用于卡片徽标及其提示 | 3 秒 | -- `prepare` 只在卡片首次启动时调用一次。`wrap` 在每次启动(创建、重启、恢复)前调用,把宿主原本要启动的命令变成在环境中运行的命令,例如 `ssh -tt host …`、`docker exec -it …`,或在另一个文件夹中运行同一程序。PTY 仍由宿主通过 node-pty 创建,因此回滚、状态和编排照常工作。 +- `prepare` 只在卡片首次启动时调用一次。`projectRoot` 是用户启动的代理所在的文件夹(任务的项目),用于创建该仓库的 worktree。插件数据中的 worktree 环境文件夹只有在它是该仓库的链接 worktree、且所在分支没有被其他 worktree 检出时,才会在代理隔离下作为代理的项目;Git 元数据对代理只读(不能 `git add` 或 `git commit`),因此由插件自己收集代理的修改。`wrap` 在每次启动(创建、重启、恢复)前调用,把宿主原本要启动的命令变成在环境中运行的命令,例如 `ssh -tt host …`、`docker exec -it …`,或在另一个文件夹中运行同一程序。PTY 仍由宿主通过 node-pty 创建,因此回滚、状态和编排照常工作。 - `wrap` 的输出会被检查:`command` 必须是可执行文件的绝对路径,或由宿主在 `PATH` 中解析的纯程序名;命令行、相对路径或 shell 语法会被拒绝,任何内容都不经过 shell 运行。`args` 是数组(256 项,每项 8 KB,不含 NUL)。`env` 和 `secretEnv` 遵循启动贡献者的规则:保留名称以及 CanvasTTY 或启动选项已为此次启动设置的名称会被拒绝。`secretEnv` 的值来自插件自己的机密(需要 `secrets`),并像启动机密一样被遮蔽。 - `wrap` 收到此次启动自身的变量(来自 CanvasTTY 和所选启动选项),不含保留的 `CANVASTTY_*` 名称,也不含机密值;`secretEnvNames` 列出进程将从宿主获得值的名称,包装器可以按名称转发它们(`docker exec -e NAME`)。 - 恢复时先恢复所有保存的环境,再先启动父卡片、后启动子卡片。如果插件被禁用、删除或不受信任,或 `resume` 应答 `stopped`,卡片以停止状态返回并显示原因,记录保留;重启会再次调用 `resume`。卡片绝不会改为在本地启动,超时或错误会拒绝启动,不会回退。 diff --git a/native/canvastty-helper/catalog.json b/native/canvastty-helper/catalog.json index ab71c425..13250faf 100644 --- a/native/canvastty-helper/catalog.json +++ b/native/canvastty-helper/catalog.json @@ -964,7 +964,7 @@ }, { "name": "spawn_agent", - "description": "Launch another provider's agent as a CanvasTTY subagent of this session and optionally deliver a first prompt. Returns the new session id. provider must be an id from list_providers (known ids: codex, claude, qwen, kimi, opencode, hermes, grok, omp, pi, cursor, minimax, devin, antigravity); call list_providers first to see which are installed and signed in. Give each subagent one self-contained part of the task and an absolute cwd. If the person names a model, pass it as model in the format list_providers gives for that provider (OpenCode: provider/model); effort sets the reasoning effort where that CLI has one (list_providers shows its efforts). An unsupported model or effort is refused with the reason. profile: without it the subagent gets this session's launch profile, or the next lower one its CLI has; a subagent never gets more than this session (plan < normal < acceptEdits < auto) and never YOLO. \"auto\" lets it work without asking the person for each edit or command inside the project; CanvasTTY's outer layers (agent isolation, base protection) still stop writes outside the project, secrets and system changes. The answer says the profile it got. cwd must be this project's folder or a folder inside it. launchOptions passes plugin launch options exactly as a plugin tool gives them (for example the account a plugin picked), for plugins that allow an orchestrator to choose them. A plugin this call does not name keeps this session's own launch options (for example its model account), so the subagent never falls back silently to its CLI's default model; a subagent of another CLI is refused while this session's model comes from a plugin's options (spawn one of this CLI instead), and model next to inherited options that set the model is refused. The answer's servedBy says which CLI and requested model serve it and where each plugin's options came from. Refusals say why (a limit the person set, a folder outside the project, a profile above this session's): change the request instead of retrying it. Then call wait_for_agent and get_agent_result.", + "description": "Launch another provider's agent as a CanvasTTY subagent of this session and optionally deliver a first prompt. Returns the new session id. provider must be an id from list_providers (known ids: codex, claude, qwen, kimi, opencode, hermes, grok, omp, pi, cursor, minimax, devin, antigravity); call list_providers first to see which are installed and signed in. Give each subagent one self-contained part of the task and an absolute cwd. If the person names a model, pass it as model in the format list_providers gives for that provider (OpenCode: provider/model); effort sets the reasoning effort where that CLI has one (list_providers shows its efforts). An unsupported model or effort is refused with the reason. profile: without it the subagent gets this session's launch profile, or the next lower one its CLI has; a subagent never gets more than this session (plan < normal < acceptEdits < auto) and never YOLO. \"auto\" lets it work without asking the person for each edit or command inside the project; CanvasTTY's outer layers (agent isolation, base protection) still stop writes outside the project, secrets and system changes. The answer says the profile it got. cwd must be this project's folder or a folder inside it. isolate: \"worktree\" asks the installed environments plugin for a separate worktree (in a Git project with such a plugin, subagents get one anyway); it is refused when no plugin provides one. launchOptions passes plugin launch options exactly as a plugin tool gives them (for example the account a plugin picked), for plugins that allow an orchestrator to choose them. A plugin this call does not name keeps this session's own launch options (for example its model account), so the subagent never falls back silently to its CLI's default model; a subagent of another CLI is refused while this session's model comes from a plugin's options (spawn one of this CLI instead), and model next to inherited options that set the model is refused. The answer's servedBy says which CLI and requested model serve it and where each plugin's options came from. Refusals say why (a limit the person set, a folder outside the project, a profile above this session's): change the request instead of retrying it. Then call wait_for_agent and get_agent_result.", "inputSchema": { "type": "object", "properties": { @@ -1028,6 +1028,12 @@ "max" ] }, + "isolate": { + "type": "string", + "enum": [ + "worktree" + ] + }, "launchOptions": { "type": "object", "maxProperties": 16, diff --git a/src/agent-browser/orchestration-catalog.mjs b/src/agent-browser/orchestration-catalog.mjs index 3829b3f6..17c17023 100644 --- a/src/agent-browser/orchestration-catalog.mjs +++ b/src/agent-browser/orchestration-catalog.mjs @@ -61,7 +61,7 @@ export const ORCHESTRATION_TOOL_DEFINITIONS = Object.freeze([ ), tool( "spawn_agent", - `Launch another provider's agent as a CanvasTTY subagent of this session and optionally deliver a first prompt. Returns the new session id. provider must be an id from list_providers (known ids: ${AGENT_PROVIDER_IDS.join(", ")}); call list_providers first to see which are installed and signed in. Give each subagent one self-contained part of the task and an absolute cwd. If the person names a model, pass it as model in the format list_providers gives for that provider (OpenCode: provider/model); effort sets the reasoning effort where that CLI has one (list_providers shows its efforts). An unsupported model or effort is refused with the reason. profile: without it the subagent gets this session's launch profile, or the next lower one its CLI has; a subagent never gets more than this session (plan < normal < acceptEdits < auto) and never YOLO. "auto" lets it work without asking the person for each edit or command inside the project; CanvasTTY's outer layers (agent isolation, base protection) still stop writes outside the project, secrets and system changes. The answer says the profile it got. cwd must be this project's folder or a folder inside it. launchOptions passes plugin launch options exactly as a plugin tool gives them (for example the account a plugin picked), for plugins that allow an orchestrator to choose them. A plugin this call does not name keeps this session's own launch options (for example its model account), so the subagent never falls back silently to its CLI's default model; a subagent of another CLI is refused while this session's model comes from a plugin's options (spawn one of this CLI instead), and model next to inherited options that set the model is refused. The answer's servedBy says which CLI and requested model serve it and where each plugin's options came from. Refusals say why (a limit the person set, a folder outside the project, a profile above this session's): change the request instead of retrying it. Then call wait_for_agent and get_agent_result.`, + `Launch another provider's agent as a CanvasTTY subagent of this session and optionally deliver a first prompt. Returns the new session id. provider must be an id from list_providers (known ids: ${AGENT_PROVIDER_IDS.join(", ")}); call list_providers first to see which are installed and signed in. Give each subagent one self-contained part of the task and an absolute cwd. If the person names a model, pass it as model in the format list_providers gives for that provider (OpenCode: provider/model); effort sets the reasoning effort where that CLI has one (list_providers shows its efforts). An unsupported model or effort is refused with the reason. profile: without it the subagent gets this session's launch profile, or the next lower one its CLI has; a subagent never gets more than this session (plan < normal < acceptEdits < auto) and never YOLO. "auto" lets it work without asking the person for each edit or command inside the project; CanvasTTY's outer layers (agent isolation, base protection) still stop writes outside the project, secrets and system changes. The answer says the profile it got. cwd must be this project's folder or a folder inside it. isolate: "worktree" asks the installed environments plugin for a separate worktree (in a Git project with such a plugin, subagents get one anyway); it is refused when no plugin provides one. launchOptions passes plugin launch options exactly as a plugin tool gives them (for example the account a plugin picked), for plugins that allow an orchestrator to choose them. A plugin this call does not name keeps this session's own launch options (for example its model account), so the subagent never falls back silently to its CLI's default model; a subagent of another CLI is refused while this session's model comes from a plugin's options (spawn one of this CLI instead), and model next to inherited options that set the model is refused. The answer's servedBy says which CLI and requested model serve it and where each plugin's options came from. Refusals say why (a limit the person set, a folder outside the project, a profile above this session's): change the request instead of retrying it. Then call wait_for_agent and get_agent_result.`, { provider, cwd: string({ minLength: 1, maxLength: 4_096 }), @@ -70,6 +70,7 @@ export const ORCHESTRATION_TOOL_DEFINITIONS = Object.freeze([ profile: string({ enum: ["auto", "normal", "acceptEdits", "plan"] }), model: string({ minLength: 1, maxLength: 200 }), effort: string({ enum: [...REASONING_EFFORT_IDS] }), + isolate: string({ enum: ["worktree"] }), launchOptions }, ["provider", "cwd"] diff --git a/src/main/index.ts b/src/main/index.ts index f5d354a6..473182c7 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -64,6 +64,7 @@ import { ProviderSecretsService } from "./services/ProviderSecretsService"; import { listProviderDirectory, type ProviderDirectorySources } from "./services/providerDirectory"; import { ProviderModelCatalog } from "./services/providerModels"; import { AgentControlService } from "./services/AgentControlService"; +import { isGitProjectRoot, subagentWorktreeResolver } from "./services/SubagentWorktreeResolver"; import { AgentIsolation } from "./services/isolation/AgentIsolation"; import type { AgentProviderId, LaunchProfileId } from "../shared/contracts"; import { HermesHudService } from "./services/HermesHudService"; @@ -691,7 +692,12 @@ async function initializeServices(ipc: IpcRegistrar): Promise { const agentControlService = new AgentControlService(managedTerminals, { limits: () => ({ maxDepth: settings.get().orchestrationMaxDepth, maxSubagents: settings.get().orchestrationMaxSubagents }), containment: () => managedTerminals.containment(), - userDataPath + userDataPath, + // A subagent of a Git project runs in a worktree when a trusted environments plugin provides one. + resolveSubagentEnvironment: subagentWorktreeResolver({ + isGitProject: isGitProjectRoot, + providers: () => pluginManager?.environmentProviders() ?? [] + }) }); orchestrationGateway = new OrchestrationGateway({ runtimeDirectory: join(userDataPath, "orchestration", "runtime"), diff --git a/src/main/services/AgentControlService.ts b/src/main/services/AgentControlService.ts index b4464b67..2f200c7d 100644 --- a/src/main/services/AgentControlService.ts +++ b/src/main/services/AgentControlService.ts @@ -2,6 +2,7 @@ import type { AgentProviderId, CreateSessionRequest, LaunchProfileId, + SessionEnvironmentChoice, SessionMetadata, SessionSnapshot } from "../../shared/contracts.ts"; @@ -20,6 +21,7 @@ import { PLUGIN_DATA_FOLDER, privateAppData } from "./isolation/isolationPaths.t // Cards one parent may have in total, live or exited (the live limit is the person's setting). const MAX_CHILDREN_PER_PARENT = 16; +const NO_WORKTREE = "The environments plugin does not provide a worktree for this launch: no trusted environments plugin offers one for this agent in this Git project. Spawn it without isolate."; /** Defaults of the person's limits (Settings → Agents). */ export const DEFAULT_DELEGATION_LIMITS: DelegationLimits = { maxDepth: 2, maxSubagents: 8 }; @@ -52,6 +54,19 @@ export interface SpawnAgentRequest { /** The CLI's --model and reasoning effort for this subagent (checked by the launch for its CLI). */ model?: string; effort?: CreateSessionRequest["effort"]; + /** "worktree": run it in a worktree of the installed environments plugin; refused when none is available. */ + isolate?: "worktree"; +} + +/** What an environment resolver gets for one subagent (AgentControlOptions.resolveSubagentEnvironment). */ +export interface SubagentEnvironmentRequest { + provider: AgentProviderId; + parentSessionId: string; + /** The folder of the agent the person started: the task's project. */ + projectRoot: string; + cwd: string; + liveChildren: number; + isolate?: "worktree"; } export interface AgentObservation { @@ -103,6 +118,11 @@ export interface AgentControlOptions { containment?: () => boolean; /** CanvasTTY's userData folder: a subagent never works in its private data (tokens, account homes, plugin data). */ userDataPath?: string; + /** + * The environment a new subagent runs in (a trusted environments plugin's worktree), or null for this computer's + * folder. Absent: subagents run in their folder and `isolate: "worktree"` is refused. + */ + resolveSubagentEnvironment?: (request: SubagentEnvironmentRequest) => SessionEnvironmentChoice | null | Promise; } /** The longest wait one call may ask for (wait_for_agent's timeoutSeconds maximum). */ @@ -178,10 +198,6 @@ export class AgentControlService { const inheritance = this.subagentLaunchOptions(parent.id, request.provider, request.launchOptions, request.model); if (inheritance.launchOptions !== request.launchOptions) request = { ...request, launchOptions: inheritance.launchOptions }; - const children = this.children(parent.id); - if (children.length >= MAX_CHILDREN_PER_PARENT) { - throw new DelegationRefusal(`Session ${parent.id} already has ${MAX_CHILDREN_PER_PARENT} subagent cards; cancel_agent the finished ones first.`); - } const lineage = this.lineage(parent.id); const root = lineage.at(-1)!; // What the request asks for first (its folder, its profile), then the person's limits. @@ -189,6 +205,51 @@ export class AgentControlService { if ("error" in cwd) throw new DelegationRefusal(cwd.error); const profile = subagentProfile(parent.profile, request.provider, request.profile, this.containment()); if ("error" in profile) throw new DelegationRefusal(profile.error); + const { live } = this.spawnCapacity(parent.id); + const resolve = this.options.resolveSubagentEnvironment; + if (!resolve) { + if (request.isolate === "worktree") throw new DelegationRefusal(NO_WORKTREE); + return this.createSubagent(parent.id, request, cwd.cwd, profile.profile, null, signal, promptWaitMs); + } + const refusal = (error: unknown): DelegationRefusal => ( + new DelegationRefusal(`The environments plugin could not prepare this subagent: ${error instanceof Error ? error.message : "unknown error"}`) + ); + let environment: SessionEnvironmentChoice | null | Promise; + try { + environment = resolve({ + provider: request.provider, + parentSessionId: parent.id, + projectRoot: root.cwd, + cwd: cwd.cwd, + liveChildren: live, + ...(request.isolate ? { isolate: request.isolate } : {}) + }); + } catch (error) { + throw refusal(error); + } + if (environment instanceof Promise) { + // The delivery budget also covers the asynchronous environment lookup. + const startedAt = Date.now(); + return environment.then( + (choice) => this.createSubagent(parent.id, request, cwd.cwd, profile.profile, choice, signal, + promptWaitMs === undefined ? undefined : Math.max(0, promptWaitMs - (Date.now() - startedAt))), + (error: unknown) => { throw refusal(error); } + ); + } + return this.createSubagent(parent.id, request, cwd.cwd, profile.profile, environment, signal, promptWaitMs); + } + + /** + * Checks the person's limits for one more child of this parent (throws a DelegationRefusal); the live subagents of + * its orchestration otherwise. + */ + private spawnCapacity(parentId: string): { live: number } { + const children = this.children(parentId); + if (children.length >= MAX_CHILDREN_PER_PARENT) { + throw new DelegationRefusal(`Session ${parentId} already has ${MAX_CHILDREN_PER_PARENT} subagent cards; cancel_agent the finished ones first.`); + } + const lineage = this.lineage(parentId); + const root = lineage.at(-1)!; const limits = this.limits(); // The parent is at level lineage.length - 1 below its top-level agent; the new card one further down. if (lineage.length > limits.maxDepth) { @@ -198,13 +259,36 @@ export class AgentControlService { if (live >= limits.maxSubagents) { throw new DelegationRefusal(`This orchestration already runs ${live} live subagent${live === 1 ? "" : "s"}, its limit (Settings → Agents, set by the person). Wait for one to finish or cancel_agent one first.`); } + return { live }; + } + + private createSubagent( + parentId: string, + request: SpawnAgentRequest, + cwd: string, + profile: LaunchProfile, + environment: SessionEnvironmentChoice | null, + signal?: AbortSignal, + promptWaitMs?: number + ): Promise { + if (request.isolate === "worktree" && !environment) return Promise.reject(new DelegationRefusal(NO_WORKTREE)); // A call cancelled before its agent starts launches nothing. if (signal?.aborted) return Promise.reject(spawnCanceled()); - const cascade = children.length; + let parent: SessionMetadata; + let cascade: number; + try { + // An asynchronous environment lookup may race another spawn or the parent's end: the parent and the limits are + // checked again right before the synchronous card creation, which takes the live slot. + parent = this.requireSession(parentId); + this.spawnCapacity(parentId); + cascade = this.children(parentId).length; + } catch (error) { + return Promise.reject(error); + } const created = this.terminals.create({ provider: request.provider, - cwd: cwd.cwd, - profile: profile.profile, + cwd, + profile, position: { x: parent.position.x + CHILD_POSITION_STEP.x * (cascade + 1), y: parent.position.y + CHILD_POSITION_STEP.y * (cascade + 1) @@ -212,6 +296,7 @@ export class AgentControlService { ...(request.title !== undefined ? { title: request.title } : {}), role: "subagent", parentSessionId: parent.id, + ...(environment ? { environment } : {}), ...(request.launchOptions !== undefined ? { launchOptions: request.launchOptions } : {}), ...(request.model !== undefined ? { model: request.model } : {}), ...(request.effort !== undefined ? { effort: request.effort } : {}) diff --git a/src/main/services/EnvironmentRegistry.ts b/src/main/services/EnvironmentRegistry.ts index 7fd3c2f8..6295a231 100644 --- a/src/main/services/EnvironmentRegistry.ts +++ b/src/main/services/EnvironmentRegistry.ts @@ -137,6 +137,8 @@ export class EnvironmentRegistry { provider: ProviderId; cwd: string; choice: SessionEnvironmentChoice; + /** The task's original project (the folder of the agent the person started), for a worktree of that repository. */ + projectRoot?: string; }): Promise { const found = this.lookup(request.choice.pluginId, request.choice.kind); if (!found) return { ok: false, reason: `Environment ${request.choice.kind} from plugin ${request.choice.pluginId} is not available.` }; @@ -145,6 +147,7 @@ export class EnvironmentRegistry { kind: request.choice.kind, provider: request.provider, cwd: request.cwd, + ...(request.projectRoot ? { projectRoot: request.projectRoot } : {}), options: request.choice.options ?? {} }, undefined, { budgetMs: LATE_ANSWER_BUDGET_MS, diff --git a/src/main/services/SubagentWorktreeResolver.ts b/src/main/services/SubagentWorktreeResolver.ts new file mode 100644 index 00000000..0893e371 --- /dev/null +++ b/src/main/services/SubagentWorktreeResolver.ts @@ -0,0 +1,36 @@ +import { execFile } from "node:child_process"; +import { realpath } from "node:fs/promises"; +import { promisify } from "node:util"; +import type { AgentControlOptions } from "./AgentControlService.ts"; +import type { EnvironmentProvider } from "./EnvironmentRegistry.ts"; + +const exec = promisify(execFile); + +/** + * A subagent of a Git project gets a worktree from the first trusted environments plugin that offers the `worktree` + * kind for its CLI; the plugin creates it, CanvasTTY only chooses it. Without such a plugin, or outside the top of a + * Git project, the subagent runs in its folder (and an explicit `isolate: "worktree"` is refused by the caller). + */ +export function subagentWorktreeResolver(dependencies: { + isGitProject(projectRoot: string): Promise; + providers(): EnvironmentProvider[]; +}): NonNullable { + return async (request) => { + if (!await dependencies.isGitProject(request.projectRoot)) return null; + const provider = dependencies.providers().find((row) => row.kinds.some((kind) => kind.kind === "worktree" + && (!kind.appliesTo || kind.appliesTo.includes(request.provider)))); + return provider ? { pluginId: provider.pluginId, kind: "worktree" } : null; + }; +} + +/** The folder is the top of a Git working tree (not a folder inside one). No hooks or repository filters run. */ +export async function isGitProjectRoot(folder: string): Promise { + const env = { ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_"))), + GIT_OPTIONAL_LOCKS: "0", GIT_TERMINAL_PROMPT: "0" }; + try { + const { stdout } = await exec("git", ["--no-replace-objects", "-C", folder, "rev-parse", "--show-toplevel"], { env, timeout: 5_000 }); + return await realpath(stdout.trim()) === await realpath(folder); + } catch { + return false; + } +} diff --git a/src/main/services/TerminalManager.ts b/src/main/services/TerminalManager.ts index 224ab446..aaae3f8d 100644 --- a/src/main/services/TerminalManager.ts +++ b/src/main/services/TerminalManager.ts @@ -1461,6 +1461,7 @@ export class TerminalManager { ...(descriptor.environment ? { environment: descriptor.environment } : {}), ...(descriptor.environmentChoice && !descriptor.environment ? { environmentChoice: descriptor.environmentChoice } : {}), ...(descriptor.ownerPluginId ? { ownerPluginId: descriptor.ownerPluginId } : {}), + ...(descriptor.projectRoot && descriptor.environment ? { projectRoot: descriptor.projectRoot } : {}), ...(descriptor.gitAuditSince !== undefined ? { gitAuditSince: descriptor.gitAuditSince } : {}) }; @@ -2072,10 +2073,11 @@ export class TerminalManager { } /** - * The task's original project: the folder of the top-level card a session descends from (its own folder when it has - * no parent). A plugin worktree is validated against this repository. + * The task's original project: the folder the person chose for the top-level card a session descends from (its own + * when it has no parent), also after an environment placed that card in a worktree. A plugin worktree is validated + * against this repository. */ - private taskProjectRoot(cwd: string, parentSessionId?: string): string { + private taskProjectRoot(cwd: string, parentSessionId?: string, own?: PersistedSessionExtras): string { let root: ManagedSession | undefined; for (let depth = 0, next = parentSessionId; next !== undefined && depth < 64; depth++) { const current = this.sessions.get(next); @@ -2083,7 +2085,8 @@ export class TerminalManager { root = current; next = current.metadata.parentSessionId; } - return root?.metadata.cwd ?? cwd; + if (root) return root.extras.projectRoot ?? root.metadata.cwd; + return own?.projectRoot ?? cwd; } /** @@ -2217,7 +2220,8 @@ export class TerminalManager { const choice = session.extras.environmentChoice; if (choice && !session.extras.environment) { if (!environments) return refuse("plugin environments are not available."); - const placed = await environments.prepare({ sessionId: id, provider: metadata.provider, cwd: metadata.cwd, choice }); + const placed = await environments.prepare({ sessionId: id, provider: metadata.provider, cwd: metadata.cwd, choice, + projectRoot: this.taskProjectRoot(metadata.cwd, metadata.parentSessionId) }); if (!live()) { // An answer for a launch that no longer exists (the card was closed or restarted, or the app is quitting) // is never adopted or saved: nobody used it, so it is released at once and nothing is kept. @@ -2230,6 +2234,8 @@ export class TerminalManager { session.extras.environment = placed.environment; metadata.environment = environmentBadge(placed.environment); if (placed.cwd && placed.cwd !== metadata.cwd) { + // The folder the person chose stays the task's project: the placed worktree is validated against it. + session.extras.projectRoot = this.taskProjectRoot(metadata.cwd, metadata.parentSessionId); metadata.cwd = placed.cwd; session.lifecycle = this.lifecycleHooksEnabled ? createProviderLifecycleParser(metadata.provider, metadata.cwd) @@ -2352,7 +2358,7 @@ export class TerminalManager { spawn = { ...this.wrapIsolated(id, metadata.provider, decision.profile, spawn, planned, { ...(contribution?.accountHome ? { accountHome: contribution.accountHome } : {}), placedProjects: this.placedProjects(id, metadata.parentSessionId, spawn.cwd, environment), - taskProjectRoot: this.taskProjectRoot(metadata.cwd, metadata.parentSessionId) + taskProjectRoot: this.taskProjectRoot(metadata.cwd, metadata.parentSessionId, session.extras) }), cwd: spawn.cwd }; } catch (error) { return refuse(error instanceof Error ? error.message : String(error)); diff --git a/src/main/services/TerminalSessionStore.ts b/src/main/services/TerminalSessionStore.ts index 269c49bd..0f329cb0 100644 --- a/src/main/services/TerminalSessionStore.ts +++ b/src/main/services/TerminalSessionStore.ts @@ -1,4 +1,4 @@ -import { dirname, join } from "node:path"; +import { dirname, isAbsolute, join } from "node:path"; import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises"; import type { LaunchProfileId, @@ -54,6 +54,11 @@ export interface PersistedTerminalSession { environmentChoice?: SessionEnvironmentChoice; /** The plugin that started the card (EP-4 `sessions.create`); it keeps control after a restore. */ ownerPluginId?: string; + /** + * The folder the person chose for a card that an environment then placed elsewhere (its worktree): the task's + * original project, which the isolation layer validates that worktree against. + */ + projectRoot?: string; /** The model and reasoning effort its launches ask the CLI for (launchModel.ts). */ model?: string; effort?: ReasoningEffort; @@ -218,7 +223,7 @@ function normalizeStoredThreadId(provider: ProviderId, candidate: unknown): stri } /** What core keeps beside the live metadata: nothing here is scrollback, prompts or secrets. */ -export type PersistedSessionExtras = Pick & { +export type PersistedSessionExtras = Pick & { /** Overrides the derived state while a card is held stopped (its environment is unavailable). */ heldState?: PersistedLastState; }; @@ -250,6 +255,7 @@ export function persistedTerminalSession( ...(extras.environment ? { environment: structuredClone(extras.environment) } : {}), ...(extras.environmentChoice && !extras.environment ? { environmentChoice: structuredClone(extras.environmentChoice) } : {}), ...(extras.ownerPluginId ? { ownerPluginId: extras.ownerPluginId } : {}), + ...(extras.projectRoot && extras.environment ? { projectRoot: extras.projectRoot } : {}), ...(extras.gitAuditSince !== undefined ? { gitAuditSince: extras.gitAuditSince } : {}), ...(metadata.model !== undefined ? { model: metadata.model } : {}), ...(metadata.effort !== undefined ? { effort: metadata.effort } : {}) @@ -330,6 +336,7 @@ export function normalizePersistedTerminalSessions(candidate: unknown): Persiste ...(environment ? { environment } : {}), ...(environmentChoice ? { environmentChoice } : {}), ...(isPluginId(session.ownerPluginId) ? { ownerPluginId: session.ownerPluginId } : {}), + ...(environment && typeof session.projectRoot === "string" && isAbsolute(session.projectRoot) ? { projectRoot: session.projectRoot } : {}), ...(typeof session.gitAuditSince === "number" && Number.isFinite(session.gitAuditSince) && session.gitAuditSince > 0 ? { gitAuditSince: session.gitAuditSince } : {}), // A model or effort this CLI would not take is dropped: the card restores on the CLI's default. diff --git a/src/main/services/agent-browser/OrchestrationTools.ts b/src/main/services/agent-browser/OrchestrationTools.ts index 28566b78..7d4abd96 100644 --- a/src/main/services/agent-browser/OrchestrationTools.ts +++ b/src/main/services/agent-browser/OrchestrationTools.ts @@ -176,6 +176,7 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { ...(launchOptions !== undefined ? { launchOptions } : {}), ...(args.model !== undefined ? { model: args.model as string } : {}), ...(args.effort !== undefined ? { effort: args.effort as SpawnAgentRequest["effort"] } : {}), + ...(args.isolate === "worktree" ? { isolate: "worktree" as const } : {}), profile: profile.profile }, signal, Math.max(0, deadline - Date.now())).catch((error: unknown) => { if (signal?.aborted) throw canceledError(); @@ -201,7 +202,8 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { ...(created.effort !== undefined ? { effort: created.effort } : {}), // Which CLI serves it, the model it was asked for (null: its launch options or the CLI's own default decide), // and where each plugin's launch options came from. - servedBy: { provider: created.provider, model: created.model ?? null, launchOptions: inheritance.sources } + servedBy: { provider: created.provider, model: created.model ?? null, launchOptions: inheritance.sources }, + ...(args.isolate === "worktree" ? { isolationRequested: "worktree" } : {}) }; } diff --git a/src/main/services/isolation/AgentIsolation.ts b/src/main/services/isolation/AgentIsolation.ts index 927981af..c7079624 100644 --- a/src/main/services/isolation/AgentIsolation.ts +++ b/src/main/services/isolation/AgentIsolation.ts @@ -1,7 +1,7 @@ import { spawnSync } from "node:child_process"; import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, realpathSync, rmdirSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { delimiter, dirname, isAbsolute, join } from "node:path"; +import { delimiter, dirname, isAbsolute, join, relative } from "node:path"; import type { ProviderId, SessionIsolation } from "../../../shared/contracts.ts"; import { autoKind, PROFILE_RANK, type LaunchProfile } from "../../../shared/autoMode.ts"; import { LaunchRefusal } from "../launchRefusal.ts"; @@ -197,11 +197,11 @@ export class AgentIsolation { mkdirSync(temp, { mode: 0o700 }); const requestedCwdInPluginData = isPluginDataPath(launch.cwd, this.options.userDataPath); const cwd = realpathSync(launch.cwd); - // A project in plugin data must be a linked worktree the host validates against the task's own repository. + // A project in plugin data must be a linked worktree the host validates against the task's own repository, unless + // the person chose that folder (or one above it) for the card the launch descends from. const trustedWorktree = worktreeGitAccess(cwd, launch.taskProjectRoot ?? cwd, this.options.userDataPath); - if ((requestedCwdInPluginData || isPluginDataPath(cwd, this.options.userDataPath)) && !trustedWorktree) { - throw new LaunchRefusal("The plugin environment worktree could not be verified against the task's original Git repository. The agent was not started."); - } + const personChose = (launch.placedProjects ?? []).some((placed) => placed.pluginId === undefined && insideReal(cwd, placed.folder)); + const unverifiedWorktree = !personChose && (requestedCwdInPluginData || isPluginDataPath(cwd, this.options.userDataPath)) && !trustedWorktree; const accountHome = launch.accountHome === undefined ? undefined : validateAccountHome(this.options.userDataPath, launch.provider, launch.accountHome, launch.env); @@ -220,6 +220,11 @@ export class AgentIsolation { ...(launch.placedProjects ? { placedProjects: launch.placedProjects } : {}), ...(launch.profile === "plan" ? { readOnlyProject: true } : {}) }); + // isolationPaths refused a project in plugin data that CanvasTTY did not place; one it placed must also be a linked + // worktree validated against the task's own repository. + if (unverifiedWorktree) { + throw new LaunchRefusal("The plugin environment worktree could not be verified against the task's original Git repository. The agent was not started."); + } // `git init` and `git clone` copy git's template, sample hooks included: an empty one writes no hooks. const gitTemplate = join(folder, "git-template"); mkdirSync(gitTemplate, { mode: 0o500 }); @@ -334,3 +339,14 @@ export function controlGrantFolder(env: Readonly { + const { project } = await folders(t); + const { terminals } = managerWith(t); + const pending = []; + const control = new AgentControlService(terminals, { + limits: () => ({ maxDepth: 2, maxSubagents: 1 }), + resolveSubagentEnvironment: () => new Promise(resolve => pending.push(resolve)) + }); + const parent = terminals.create({ provider: "codex", profile: "normal", cwd: project, position: at, role: "orchestrator" }); + const request = { parentSessionId: parent.id, provider: "codex", cwd: project }; + const first = control.spawn(request); + const second = control.spawn(request); + const results = Promise.allSettled([first, second]); + assert.equal(pending.length, 2); + for (const resolve of pending) resolve(null); + const settled = await results; + assert.equal(settled.filter(result => result.status === "fulfilled").length, 1); + assert.match(settled.find(result => result.status === "rejected").reason.message, /already runs 1 live subagent/u); + assert.equal(control.children(parent.id).length, 1); +}); + test("YOLO is enforced in the main process: acknowledged by the person, never for a subagent", async (t) => { const { project } = await folders(t); const { terminals } = managerWith(t, { acknowledged: ["claude"] }); diff --git a/tests/agent-isolation.test.mjs b/tests/agent-isolation.test.mjs index e71cb66e..d40f7872 100644 --- a/tests/agent-isolation.test.mjs +++ b/tests/agent-isolation.test.mjs @@ -325,10 +325,12 @@ test("host validation refuses a linked worktree sharing the primary checkout bra assert.equal(worktreeGitAccess(duplicate, fixture.main, w.userData), null); const linux = isolation(w, { platform: "linux", bubblewrapPath: "/usr/bin/bwrap", bubblewrapProbe: () => null, exists: () => true }); assert.throws(() => linux.wrap({ sessionId: "shared", provider: "codex", cwd: duplicate, taskProjectRoot: fixture.main, + placedProjects: placedBy(duplicate), command: "/usr/bin/codex", args: [], env: w.env }), /could not be verified against the task's original Git repository/u); const outsideAlias = join(fixture.worktrees, "outside-alias"); await symlink(fixture.main, outsideAlias, "dir"); assert.throws(() => linux.wrap({ sessionId: "alias", provider: "codex", cwd: outsideAlias, taskProjectRoot: fixture.main, + placedProjects: placedBy(outsideAlias), command: "/usr/bin/codex", args: [], env: w.env }), /could not be verified against the task's original Git repository/u, "a lexical plugin-data path that resolves outside is still rejected"); }); diff --git a/tests/isolation-hardening.test.mjs b/tests/isolation-hardening.test.mjs index 06ca0aaa..f4786752 100644 --- a/tests/isolation-hardening.test.mjs +++ b/tests/isolation-hardening.test.mjs @@ -13,6 +13,7 @@ import { contributedAccountHome, validateAccountHome } from "../src/main/service import { bubblewrapArguments } from "../src/main/services/isolation/bubblewrap.ts"; import { isolationPaths } from "../src/main/services/isolation/isolationPaths.ts"; import { TerminalManager } from "../src/main/services/TerminalManager.ts"; +import { normalizePersistedTerminalSessions, persistedTerminalSession } from "../src/main/services/TerminalSessionStore.ts"; import { availableRegistry, fakeSpawner } from "./helpers/terminal.mjs"; // Plugin data and gateway sockets together: the account home a launch plugin selected stays open, a CANVASTTY_*_ADDRESS @@ -45,8 +46,21 @@ async function world(t) { const lifecycle = join(userData, "lifecycle", "runtime"); const project = join(base, "p"); const temp = join(base, "t"); - for (const dir of [home, join(home, ".ssh"), selected, sibling, worktree, otherWorktree, lifecycle, project, temp, + for (const dir of [home, join(home, ".ssh"), selected, sibling, worktrees, lifecycle, project, temp, join(userData, "agent-control")]) await mkdir(dir, { recursive: true }); + // The environment's worktrees are real linked worktrees of the project: the layer opens a project in plugin data only + // when it validates as one (and CanvasTTY placed the launch there). + const git = (...args) => { + const result = spawnSync("git", ["-C", project, "-c", "user.name=CanvasTTY test", "-c", "user.email=test@example.invalid", + "-c", "commit.gpgsign=false", ...args], { encoding: "utf8", env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null" } }); + assert.equal(result.status, 0, `git ${args.join(" ")}: ${result.stderr}`); + }; + git("init", "-q", "-b", "main"); + await writeFile(join(project, "README.md"), "project\n"); + git("add", "README.md"); + git("commit", "-qm", "base"); + git("worktree", "add", "-q", "-b", "env-w1", worktree, "HEAD"); + git("worktree", "add", "-q", "-b", "env-w2", otherWorktree, "HEAD"); await writeFile(join(selected, "auth.json"), "FAKE-SELECTED-TOKEN\n"); await writeFile(join(sibling, "auth.json"), "FAKE-SIBLING-TOKEN\n"); await writeFile(join(otherWorktree, "file.txt"), "other worktree\n"); @@ -232,6 +246,14 @@ test("an environment's worktree inside its own plugin data still runs inside the assert.deepEqual(mountFor(args, join(w.otherWorktree, "file.txt")), { target: w.pluginData, mode: "tmpfs" }); assert.deepEqual(mountFor(args, join(w.selected, "auth.json")), { target: w.pluginData, mode: "tmpfs" }); + // The card shows the worktree, and keeps the folder the person chose as its task's project across a restore. + const saved = persistedTerminalSession(own.card(placed.id), undefined, { environment: { pluginId: ENVIRONMENTS, kind: "worktree", ref: { id: "w1" }, label: "w1" }, + projectRoot: w.project }); + assert.equal(saved.cwd, w.worktree); + const restored = normalizePersistedTerminalSessions({ version: 2, sessions: [saved, { ...saved, id: "relative", projectRoot: "p" }, + { ...persistedTerminalSession({ ...own.card(placed.id), id: "local" }, undefined, { projectRoot: w.project }), projectRoot: w.project }] }).sessions; + assert.deepEqual(restored.map((record) => record.projectRoot ?? null), [w.project, null, null], "absolute, and only with an environment"); + // The environment plugin answers with the accounts plugin's homes: not its own data, so the launch is refused. const foreign = manager(t, w, { environments: environments(w.homes) }); const refused = create(foreign.terminals); @@ -323,7 +345,8 @@ except Exception: print('${label}-CONNECT-denied')"`; "echo x > new.txt && echo WORKTREE-WRITE-ok", `cat ${JSON.stringify(join(w.otherWorktree, "file.txt"))} >/dev/null 2>&1 || echo OTHER-WORKTREE-denied`, `cat ${JSON.stringify(join(w.selected, "auth.json"))} >/dev/null 2>&1 || echo ACCOUNT-denied` - ].join("; ")], env: w.env, profile: "acceptEdits", placedProjects: [{ folder: w.worktree, pluginId: ENVIRONMENTS }] }); + ].join("; ")], env: w.env, profile: "acceptEdits", taskProjectRoot: w.project, + placedProjects: [{ folder: w.worktree, pluginId: ENVIRONMENTS }] }); try { const result = spawnSync(placed.command, placed.args, { cwd: w.worktree, env: placed.env, encoding: "utf8", timeout: 20_000 }); assert.deepEqual(result.stdout.split("\n").filter(Boolean), ["WORKTREE-WRITE-ok", "OTHER-WORKTREE-denied", "ACCOUNT-denied"]); diff --git a/tests/session-environments.test.mjs b/tests/session-environments.test.mjs index f18da375..cded32e2 100644 --- a/tests/session-environments.test.mjs +++ b/tests/session-environments.test.mjs @@ -279,7 +279,7 @@ test("lifecycle: prepare, wrap, describe, saved ref; the environment never sees assert.equal(calls.length, 0, "the card waits for the environment"); await waitFor(() => calls.length === 1); assert.deepEqual(requests.slice(0, 2).map((request) => request.step), ["prepare", "wrap"]); - assert.deepEqual(requests[0].params, { sessionId: created.id, kind: "box", provider: "terminal", cwd, options: { name: "two" } }); + assert.deepEqual(requests[0].params, { sessionId: created.id, kind: "box", provider: "terminal", cwd, projectRoot:cwd, options: { name: "two" } }); const wrapParams = requests[1].params; assert.deepEqual(wrapParams.ref, { box: "b-1" }); assert.equal(Object.keys(wrapParams.env).some((key) => /^(CANVASTTY_|PATH$|TERM$)/u.test(key)), false); diff --git a/tests/subagent-worktree-plugin-acceptance.test.mjs b/tests/subagent-worktree-plugin-acceptance.test.mjs new file mode 100644 index 00000000..d6cb76e1 --- /dev/null +++ b/tests/subagent-worktree-plugin-acceptance.test.mjs @@ -0,0 +1,279 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { delimiter, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; +import { AgentControlService } from "../src/main/services/AgentControlService.ts"; +import { EnvironmentRegistry } from "../src/main/services/EnvironmentRegistry.ts"; +import { PluginServiceSupervisor } from "../src/main/services/PluginServiceSupervisor.ts"; +import { PluginSessions } from "../src/main/services/PluginSessions.ts"; +import { subagentWorktreeResolver } from "../src/main/services/SubagentWorktreeResolver.ts"; +import { TerminalManager } from "../src/main/services/TerminalManager.ts"; +import { fakeSpawner } from "./helpers/terminal.mjs"; + +const repoRoot = fileURLToPath(new URL("..", import.meta.url)); +const pluginRoot = process.env.CANVASTTY_ENVIRONMENTS_PLUGIN_DIR + ?? resolve(repoRoot, "../canvastty-work/canvastty-plugin-environments"); +const pluginPresent = existsSync(join(pluginRoot, "canvastty.plugin.json")); +const waitFor = async (predicate, timeoutMs = 15_000) => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await predicate()) return; + await new Promise(resolveDelay => setTimeout(resolveDelay, 20)); + } + throw new Error("Condition was not met in time."); +}; + +test("two delegated workers use the real worktree plugin, stay isolated, and only merge after an explicit choice", { + skip: !pluginPresent && "Set CANVASTTY_ENVIRONMENTS_PLUGIN_DIR to the environments plugin source checkout." +}, async t => { + const root = await realpath(await mkdtemp(join(tmpdir(), "canvastty-subagent-worktree-plugin-"))); + const project = join(root, "project"); + const pluginData = join(root, "plugin-data"); + await mkdir(project, { recursive: true }); + const git = (...args) => execFileSync("git", ["-C", project, "-c", "user.name=CanvasTTY test", "-c", "user.email=test@example.invalid", ...args], { + encoding: "utf8", env: { ...process.env, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1" } + }).trim(); + git("init", "-q", "-b", "main"); + // The real plugin commits in a child process, without this helper's -c flags. + git("config", "user.name", "CanvasTTY test"); + git("config", "user.email", "test@example.invalid"); + git("config", "commit.gpgsign", "false"); + await writeFile(join(project, "shared.txt"), "base version\n"); + git("add", "shared.txt"); + git("commit", "-qm", "base"); + const base = git("rev-parse", "HEAD"); + const serviceEnvironment = { PATH: process.env.PATH ?? "", HOME: root, TMPDIR: root, LC_ALL: "C" }; + const gitAddLog = join(root, "worktree-add.log"); + const gitAddOverlap = join(root, "worktree-add-overlap"); + if (process.platform !== "win32") { + const wrapperDir = join(root, "git-wrapper"); + await mkdir(wrapperDir); + const realGit = execFileSync("which", ["git"], { encoding: "utf8" }).trim(); + const shimConfig = JSON.stringify({ realGit, lock: join(root, "worktree-add.lock"), gitAddLog, gitAddOverlap }); + await writeFile(join(wrapperDir, "git"), `#!${process.execPath} +const { appendFileSync, mkdirSync, rmdirSync, writeFileSync } = require("node:fs"); +const { spawnSync } = require("node:child_process"); +const config = ${shimConfig}; +const args = process.argv.slice(2); +const isWorktreeAdd = args.some((arg, index) => arg === "worktree" && args[index + 1] === "add"); +const runGit = () => spawnSync(config.realGit, args, { stdio: "inherit", env: process.env }); +if (!isWorktreeAdd) { + const result = runGit(); + if (result.error) { console.error(result.error.message); process.exit(1); } + process.exit(result.status ?? 1); +} +appendFileSync(config.gitAddLog, "add\\n"); +try { mkdirSync(config.lock); } catch { + writeFileSync(config.gitAddOverlap, "A second git worktree add overlapped the first.\\n"); + console.error("overlapping git worktree add operations"); + process.exit(88); +} +try { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 750); + const result = runGit(); + if (result.error) { console.error(result.error.message); process.exitCode = 1; } + else process.exitCode = result.status ?? 1; +} finally { rmdirSync(config.lock); } +`, { mode: 0o755 }); + serviceEnvironment.PATH = `${wrapperDir}${delimiter}${process.env.PATH ?? ""}`; + } + const manifest = JSON.parse(await readFile(join(pluginRoot, "canvastty.plugin.json"), "utf8")); + const pluginId = manifest.id; + const calls = []; + let supervisor; + let pluginSessions; + const clis = { get: provider => ({ state: "available", provider, executable: process.execPath, launcher: "native", environment: { PATH: process.env.PATH ?? "" }, checked: [] }), snapshot: () => ({}) }; + const terminals = new TerminalManager((channel, payload) => pluginSessions?.observe(channel, payload), clis, undefined, undefined, true, + fakeSpawner(calls)); + const teardown = []; + t.after(async () => { + const failures = []; + for (const action of teardown.reverse()) { + try { await action(); } catch (error) { failures.push(error); } + } + try { await terminals.shutdown(); } catch (error) { failures.push(error); } + try { await supervisor?.dispose(); } catch (error) { failures.push(error); } + try { await rm(root, { recursive: true, force: true }); } catch (error) { failures.push(error); } + if (failures.length) throw new AggregateError(failures, "Worktree plugin fixture cleanup failed"); + }); + + pluginSessions = new PluginSessions({ terminals, notify: (id, service, method, params) => supervisor?.notify(id, service, method, params) ?? false }); + supervisor = new PluginServiceSupervisor({ + command: process.execPath, + environment: serviceEnvironment, + hostVersion: "9.9.9", + locale: () => "en", + requestTimeoutMs: 15_000, + stopGraceMs: 300, + host: { + storageGet: async () => null, + storageSet: async () => undefined, + emit: () => undefined, + sessions: (id, service, method, params, permissions) => pluginSessions.handle(id, service, method, params, permissions), + setBadge: () => null + } + }); + + const serviceSpec = async service => { + const entryPath = join(pluginRoot, service.entry); + return { + pluginId, serviceId: service.id, root: pluginRoot, entryPath, + sha256: createHash("sha256").update(await readFile(entryPath)).digest("hex"), + dataDir: pluginData, + permissions: service.id === "worktree" + ? ["environment:provide"] + : ["tools:agents", "cards:decorate", "sessions:events", "storage"] + }; + }; + const worktreeService = manifest.services.find(service => service.id === "worktree"); + const resultsService = manifest.services.find(service => service.id === "results"); + assert.ok(worktreeService?.environments?.some(kind => kind.kind === "worktree")); + await supervisor.sync([await serviceSpec(worktreeService), await serviceSpec(resultsService)]); + await waitFor(() => supervisor.report(pluginId).services.every(service => service.state === "running")); + + const envProvider = { pluginId, pluginName: manifest.name, serviceId: worktreeService.id, kinds: worktreeService.environments, secrets: false }; + const registry = new EnvironmentRegistry({ + providers: () => [envProvider], + call: (id, service, method, params, timeoutMs) => supervisor.hostCall(id, service, method, params, timeoutMs), + secret: async () => null + }); + terminals.configureEnvironments(registry); + const rootSession = terminals.create({ provider: "codex", profile: "normal", cwd: project, position: { x: 0, y: 0 }, role: "orchestrator" }); + const selected = []; + const resolveEnvironment = subagentWorktreeResolver({ + isGitProject: async folder => { try { return Boolean(execFileSync("git", ["-C", folder, "rev-parse", "--show-toplevel"], { encoding: "utf8" }).trim()); } catch { return false; } }, + providers: () => [envProvider] + }); + const control = new AgentControlService(terminals, { + waitTiming: { checkMs: 5, settleMs: 5, quietMs: 10 }, + resolveSubagentEnvironment: request => { selected.push(request.liveChildren); return resolveEnvironment(request); } + }); + const first = await control.spawn({ parentSessionId: rootSession.id, provider: "codex", cwd: project, + title: "First worker", profile: "plan" }); + const second = await control.spawn({ parentSessionId: rootSession.id, provider: "codex", cwd: project, title: "Second worker" }); + await waitFor(() => calls.length === 3 + && terminals.pluginContext(first.id)?.environment + && terminals.pluginContext(second.id)?.environment).catch(error => { + throw new Error(`${error.message}; cards=${JSON.stringify(terminals.listMetadata().map(session => ({ id: session.id, status: session.status, + failureDetails: session.failureDetails, environmentChoice: session.environmentChoice, environment: session.environment })))}` + + `; services=${JSON.stringify(supervisor.report(pluginId))}`); + }); + if (process.platform !== "win32") { + assert.deepEqual((await readFile(gitAddLog, "utf8")).trim().split("\n"), ["add", "add"], + "both plugin worktree additions pass through the serialized-operation fixture"); + assert.equal(existsSync(gitAddOverlap), false, "delegated workers never overlap git worktree add operations"); + } + assert.deepEqual(selected.sort(), [0, 1], "the first and subsequent worker both go through the resolver"); + + const firstContext = terminals.pluginContext(first.id); + const secondContext = terminals.pluginContext(second.id); + const firstRef = firstContext.environment.ref; + const secondRef = secondContext.environment.ref; + assert.equal(firstRef.repo, project); + assert.equal(secondRef.repo, project); + assert.equal(firstRef.base, base); + assert.equal(secondRef.base, base, "the second worker starts from the same project commit, not the first worker's edits"); + assert.equal(git("show", `${firstRef.branch}:shared.txt`), "base version"); + assert.equal(git("show", `${secondRef.branch}:shared.txt`), "base version"); + + await writeFile(join(firstRef.dir, "shared.txt"), "first worker version\n"); + assert.equal(await readFile(join(secondRef.dir, "shared.txt"), "utf8"), "base version\n", + "the second worker cannot see unmerged edits in the first isolated worktree"); + await writeFile(join(secondRef.dir, "shared.txt"), "second worker version\n"); + assert.equal(await readFile(join(project, "shared.txt"), "utf8"), "base version\n", + "neither worker writes through to the checked-out project"); + + for (const worker of [first, second]) { + assert.equal(terminals.decisionContext(worker.id).cwd, terminals.pluginContext(worker.id).workingDirectory, + "runtime decision hooks use the environment's actual worktree as their root"); + const pty = calls.findLast(call => call.options.cwd === terminals.pluginContext(worker.id).workingDirectory); + assert.ok(pty, `fake PTY launched for ${worker.title}`); + pty.process.emitExit(0); + } + await waitFor(() => [first, second].every(worker => terminals.getMetadata(worker.id)?.status === "done")); + + const rootSummary = () => pluginSessions.summary(rootSession.id); + const invoke = (actionId, input = {}) => supervisor.hostCall(pluginId, "results", "canvastty.cards.invoke", + { actionId, session: rootSummary(), input }, 15_000); + let review; + await waitFor(async () => { + const answer = await invoke("review-task-changes"); + if (answer.review) { review = answer.review; return true; } + return false; + }); + assert.deepEqual(review.groups.map(group => group.sessionId), [first.id, second.id]); + assert.equal(review.groups[0].files[0].conflict.current.text, "second worker version"); + assert.equal(review.groups[0].files[0].conflict.agent.text, "first worker version"); + assert.equal(git("rev-parse", "HEAD"), base, "review collection does not merge into the checked-out branch"); + + for (const worker of [first, second]) { + const detail = await invoke("review-task-changes", { agentSessionId: worker.id, file: "shared.txt", page: 0 }); + assert.ok(detail.review, JSON.stringify(detail)); + } + const accepted = await invoke("accept-task-changes", { + agentSessionId: first.id, files: ["shared.txt"], resolutions: { "shared.txt": "agent" } + }); + assert.equal(accepted.tone, "info", accepted.message); + assert.equal(git("show", "HEAD:shared.txt"), "first worker version"); + await waitFor(() => !existsSync(firstRef.dir)); + assert.equal(git("branch", "--list", firstRef.branch), "", "accept removes the completed source worktree and its branch"); + assert.ok(existsSync(secondRef.dir), "the other worker's source remains until its changes are resolved"); + assert.equal(git("show", "HEAD:shared.txt"), "first worker version", "only the chosen worker version was merged"); + + // The first acceptance changes the conflict's current side. Reopen the complete task review, + // inspect the second worker's updated diff, then explicitly keep the current first-worker version. + const afterFirstAcceptance = await invoke("review-task-changes"); + assert.ok(afterFirstAcceptance.review, JSON.stringify(afterFirstAcceptance)); + const secondConflict = await invoke("review-task-changes", { agentSessionId: second.id, file: "shared.txt", page: 0 }); + assert.ok(secondConflict.review, JSON.stringify(secondConflict)); + const secondSharedFile = secondConflict.review.groups.flatMap(group => group.files) + .find(file => file.path === "shared.txt"); + assert.equal(secondSharedFile.conflict.current.text, "first worker version"); + assert.equal(secondSharedFile.conflict.agent.text, "second worker version"); + const keepFirstVersion = await invoke("accept-task-changes", { + agentSessionId: second.id, files: ["shared.txt"], resolutions: { "shared.txt": "current" } + }); + assert.equal(keepFirstVersion.tone, "info", keepFirstVersion.message); + assert.equal(git("show", "HEAD:shared.txt"), "first worker version", "resolving current keeps the already accepted first-worker version"); + const afterCurrentResolution = await invoke("review-task-changes"); + assert.ok(afterCurrentResolution.review, JSON.stringify(afterCurrentResolution)); + assert.ok(!afterCurrentResolution.review.groups.some(group => group.sessionId === second.id + && group.files.some(file => file.path === "shared.txt")), + "the explicit current-side choice removes the discarded second-worker version from fresh review"); + await waitFor(() => !existsSync(secondRef.dir)); + assert.equal(git("branch", "--list", secondRef.branch), "", "resolving current releases the finished second-worker source"); + + // Reuse the resolved card ID for a new source tree. The prior resolution must not hide this new work. + const reused = await supervisor.hostCall(pluginId, "worktree", "canvastty.environment.prepare", + { sessionId: first.id, cwd: project, projectRoot: project, options: {} }, 15_000); + assert.ok(reused.ref, JSON.stringify(reused)); + await writeFile(join(reused.ref.dir, "new-turn.txt"), "new work on a reused card id\n"); + const previousSummary = pluginSessions.summary(first.id); + assert.ok(previousSummary?.environment); + await supervisor.notify(pluginId, "results", "canvastty.sessions.event", { + type: "status", session: { ...previousSummary, environment: { ...previousSummary.environment, ref: reused.ref } } + }); + + const remaining = await invoke("review-task-changes"); + assert.ok(remaining.review, JSON.stringify(remaining)); + assert.ok(!remaining.review.groups.some(group => group.sessionId === second.id && group.files.some(file => file.path === "shared.txt")), + "the explicit current-side resolution hides the second worker's discarded version"); + assert.ok(remaining.review.groups.find(group => group.sessionId === first.id)?.files.some(file => file.path === "new-turn.txt"), + "a new worktree ref on a previously resolved session ID is included again"); + const notReopened = await invoke("accept-task-changes"); + assert.equal(notReopened.tone, "error", "a reused actor's new files cannot inherit the earlier review approval"); + for (const group of remaining.review.groups) for (const file of group.files) { + const detail = await invoke("review-task-changes", { agentSessionId: group.sessionId, file: file.path, page: 0 }); + assert.ok(detail.review, JSON.stringify(detail)); + } + const acceptedTask = await invoke("accept-task-changes"); + assert.equal(acceptedTask.tone, "info", JSON.stringify({ message: acceptedTask.message, review: remaining.review })); + await waitFor(() => !existsSync(reused.ref.dir)); + assert.equal(git("branch", "--list", reused.ref.branch), "", "reused session worktree branch is removed after its new diff is reviewed"); + assert.equal(git("show", "HEAD:new-turn.txt"), "new work on a reused card id", "the newly reviewed work is accepted rather than skipped"); +}); diff --git a/tests/subagent-worktree-resolver.test.mjs b/tests/subagent-worktree-resolver.test.mjs new file mode 100644 index 00000000..ccb7a091 --- /dev/null +++ b/tests/subagent-worktree-resolver.test.mjs @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { subagentWorktreeResolver } from "../src/main/services/SubagentWorktreeResolver.ts"; + +const request = { provider: "codex", parentSessionId: "root", projectRoot: "/trusted-project", cwd: "/trusted-project/src", liveChildren: 0 }; +const provider = { pluginId: "environments", pluginName: "Environments", serviceId: "env", kinds: [{ kind: "worktree", label: "Worktree" }], secrets: false }; + +test("the first and subsequent workers choose separate worktrees from the trusted project", async () => { + const roots = []; + const resolve = subagentWorktreeResolver({ + isGitProject: async root => { roots.push(root); return true; }, + providers: () => [provider] + }); + const choice = { pluginId: "environments", kind: "worktree" }; + assert.deepEqual(await resolve(request), choice); + assert.deepEqual(await resolve({ ...request, liveChildren: 1 }), choice); + assert.deepEqual(roots, [request.projectRoot, request.projectRoot]); +}); + +test("without a trusted worktree provider or Git project selection stays local", async () => { + const unavailable = subagentWorktreeResolver({ isGitProject: async () => true, providers: () => [] }); + assert.equal(await unavailable({ ...request, isolate: "worktree" }), null); + const noGit = subagentWorktreeResolver({ isGitProject: async () => false, providers: () => [provider] }); + assert.equal(await noGit({ ...request, isolate: "worktree" }), null); +}); + +for (const compatible of [true,false]) test(`worktree selection skips an incompatible first provider (${compatible?'compatible fallback':'local fallback'})`,async()=>{ + const restricted={...provider,pluginId:'claude-only',kinds:[{kind:'worktree',label:'Claude',appliesTo:['claude']}]}; + const second={...provider,pluginId:'codex-only',kinds:[{kind:'worktree',label:'Codex',appliesTo:['codex']}]}; + const resolve=subagentWorktreeResolver({isGitProject:async()=>true,providers:()=>compatible?[restricted,second]:[restricted]}); + assert.deepEqual(await resolve(request),compatible?{pluginId:'codex-only',kind:'worktree'}:null); +}); + +import { AgentControlService } from '../src/main/services/AgentControlService.ts'; +import { TerminalManager } from '../src/main/services/TerminalManager.ts'; +import { availableRegistry, fakeSpawner } from './helpers/terminal.mjs'; + +test('worktree resolution receives the child provider and incompatible auto launches stay local while explicit isolation refuses',async t=>{ + const terminals=new TerminalManager(()=>undefined,availableRegistry(),undefined,undefined,true,fakeSpawner([]));t.after(()=>terminals.disposeAll()); + const parent=terminals.create({provider:'claude',profile:'normal',cwd:process.cwd(),role:'orchestrator',position:{x:0,y:0}}); + const seen=[],resolver=subagentWorktreeResolver({isGitProject:async()=>true,providers:()=>[{...provider,kinds:[{kind:'worktree',label:'Claude',appliesTo:['claude']}]}]}); + const control=new AgentControlService(terminals,{resolveSubagentEnvironment:input=>{seen.push(input);return resolver(input);}}); + const child=await control.spawn({parentSessionId:parent.id,provider:'codex',cwd:process.cwd()}); + assert.equal(seen[0].provider,'codex');assert.equal(child.environment,undefined); + await assert.rejects(control.spawn({parentSessionId:parent.id,provider:'codex',cwd:process.cwd(),isolate:'worktree'}),/does not provide a worktree/u); +}); + +import { execFileSync } from 'node:child_process'; +import { mkdir, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { isGitProjectRoot } from '../src/main/services/SubagentWorktreeResolver.ts'; + +test('only the top of a Git working tree counts as a Git project', async t => { + const root = await mkdtemp(join(tmpdir(), 'ctty-git-root-')); + t.after(() => rm(root, { recursive: true, force: true })); + const project = join(root, 'project'), inside = join(project, 'src'), plain = join(root, 'plain'); + await Promise.all([mkdir(inside, { recursive: true }), mkdir(plain)]); + execFileSync('git', ['init', '-q', project], { env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1' } }); + assert.equal(await isGitProjectRoot(project), true); + assert.equal(await isGitProjectRoot(inside), false, 'a folder inside a repository is not its top'); + assert.equal(await isGitProjectRoot(plain), false); + assert.equal(await isGitProjectRoot(join(root, 'missing')), false); +}); diff --git a/tests/worktree-isolation-pipeline.test.mjs b/tests/worktree-isolation-pipeline.test.mjs new file mode 100644 index 00000000..f9162705 --- /dev/null +++ b/tests/worktree-isolation-pipeline.test.mjs @@ -0,0 +1,176 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; +import { AgentControlService } from "../src/main/services/AgentControlService.ts"; +import { EnvironmentRegistry } from "../src/main/services/EnvironmentRegistry.ts"; +import { PluginServiceSupervisor } from "../src/main/services/PluginServiceSupervisor.ts"; +import { TerminalManager } from "../src/main/services/TerminalManager.ts"; +import { AgentIsolation, SANDBOX_EXEC } from "../src/main/services/isolation/AgentIsolation.ts"; +import { subagentWorktreeResolver } from "../src/main/services/SubagentWorktreeResolver.ts"; +import { fakeSpawner } from "./helpers/terminal.mjs"; + +const repoRoot = fileURLToPath(new URL("..", import.meta.url)); +const pluginRoot = process.env.CANVASTTY_ENVIRONMENTS_PLUGIN_DIR + ?? resolve(repoRoot, "../canvastty-work/canvastty-plugin-environments"); +const pluginPresent = existsSync(join(pluginRoot, "canvastty.plugin.json")); +const onMac = process.platform === "darwin"; + +const waitFor = async (predicate, timeoutMs = 15_000) => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await predicate()) return; + await new Promise(resolveDelay => setTimeout(resolveDelay, 20)); + } + throw new Error("Condition was not met in time."); +}; + +test("the real worktree environment wraps once before one active core seatbelt wrapper", { + skip: !pluginPresent ? "Set CANVASTTY_ENVIRONMENTS_PLUGIN_DIR to the environments plugin source checkout." + : !onMac ? "Native seatbelt profile evidence runs on macOS; Linux runtime containment is tested separately." + : false +}, async t => { + const root = await realpath(await mkdtemp(join(tmpdir(), "canvastty-worktree-isolation-pipeline-"))); + const project = join(root, "project"); + const userData = join(root, "app-data"); + const pluginData = join(userData, "plugin-data"); + const tempRoot = join(root, "tmp"); + await Promise.all([mkdir(project, { recursive: true }), mkdir(userData, { recursive: true }), mkdir(tempRoot, { recursive: true })]); + const git = (...args) => execFileSync("git", ["-C", project, "-c", "user.name=CanvasTTY test", "-c", "user.email=test@example.invalid", ...args], { + encoding: "utf8", env: { ...process.env, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1" } + }).trim(); + git("init", "-q", "-b", "main"); + await writeFile(join(project, "work.txt"), "base\n"); + await mkdir(join(project,"src","nested"),{recursive:true}); + await writeFile(join(project,"src","nested","module.txt"),"nested\n"); + git("add", "work.txt", "src/nested/module.txt"); + git("commit", "-qm", "base"); + + const manifest = JSON.parse(await readFile(join(pluginRoot, "canvastty.plugin.json"), "utf8")); + const pluginId = manifest.id; + const worktreeService = manifest.services.find(service => service.id === "worktree"); + assert.ok(worktreeService?.environments?.some(kind => kind.kind === "worktree")); + + const ptyCalls = []; + const wrapCalls = []; + let supervisor; + const clis = { get: provider => ({ state: "available", provider, executable: process.execPath, launcher: "native", + environment: { PATH: process.env.PATH ?? "" }, checked: [] }), snapshot: () => ({}) }; + const terminals = new TerminalManager(() => undefined, clis, undefined, undefined, false, fakeSpawner(ptyCalls)); + const failures = []; + t.after(async () => { + try { await terminals.shutdown(); } catch (error) { failures.push(error); } + try { await supervisor?.dispose(); } catch (error) { failures.push(error); } + try { await rm(root, { recursive: true, force: true }); } catch (error) { failures.push(error); } + if (failures.length) throw new AggregateError(failures, "Worktree isolation fixture cleanup failed"); + }); + + supervisor = new PluginServiceSupervisor({ + command: process.execPath, + hostVersion: "9.9.9", + locale: () => "en", + requestTimeoutMs: 15_000, + stopGraceMs: 300, + environment: { ...process.env, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1" }, + host: { + storageGet: async () => null, + storageSet: async () => undefined, + emit: () => undefined, + sessions: async () => { throw new Error("the worktree environment must not call session APIs"); }, + setBadge: () => null + } + }); + const entryPath = join(pluginRoot, worktreeService.entry); + await supervisor.sync([{ + pluginId, serviceId: worktreeService.id, root: pluginRoot, entryPath, + sha256: createHash("sha256").update(await readFile(entryPath)).digest("hex"), + // PluginManager's layout: each plugin's data is /plugin-data/ (an environment's folder is + // opened only inside its own plugin's data). + dataDir: join(pluginData, pluginId), permissions: ["environment:provide"] + }]); + const status = () => supervisor.report(pluginId); + await waitFor(() => status().services.every(service => service.state === "running")); + + const provider = { pluginId, pluginName: manifest.name, serviceId: worktreeService.id, + kinds: worktreeService.environments, secrets: false }; + const registry = new EnvironmentRegistry({ + providers: () => [provider], + call: (id, serviceId, method, params, timeoutMs) => { + if (method === "canvastty.environment.wrap") wrapCalls.push({ sessionId: params.sessionId, command: params.command, cwd: params.cwd }); + return supervisor.hostCall(id, serviceId, method, params, timeoutMs); + }, + secret: async () => null + }); + terminals.configureEnvironments(registry); + const isolation = new AgentIsolation({ userDataPath: userData, tempRoot, enabled: () => true, + platform: "darwin", exists: path => path === SANDBOX_EXEC || existsSync(path), + hostEnvironment: { HOME: process.env.HOME, PATH: process.env.PATH } }); + terminals.configureIsolation(isolation); + + const rootSession = terminals.create({ provider: "codex", profile: "normal", cwd: project, position: { x: 0, y: 0 }, role: "orchestrator" }); + const resolver = subagentWorktreeResolver({ + isGitProject: async folder => { + try { return Boolean(execFileSync("git", ["-C", folder, "rev-parse", "--show-toplevel"], { encoding: "utf8" }).trim()); } + catch { return false; } + }, + providers: () => [provider] + }); + const control = new AgentControlService(terminals, { resolveSubagentEnvironment: request => resolver(request) }); + const first = await control.spawn({ parentSessionId: rootSession.id, provider: "codex", cwd: project, title: "Isolation worker one" }); + const second = await control.spawn({ parentSessionId: rootSession.id, provider: "codex", cwd: join(project,"src","nested"), title: "Isolation worker from nested folder" }); + const workers = [first, second]; + await waitFor(() => workers.every(worker => terminals.pluginContext(worker.id)?.environment + && ptyCalls.some(call => call.options.cwd === terminals.pluginContext(worker.id).workingDirectory))).catch(error => { + throw new Error(`${error.message}; cards=${JSON.stringify(terminals.listMetadata().map(session => ({ id: session.id, status: session.status, + failureDetails: session.failureDetails, environmentChoice: session.environmentChoice, environment: session.environment, isolation: session.isolation })))}; ` + + `pty=${JSON.stringify(ptyCalls.map(call => ({ command: call.command, cwd: call.options.cwd })))}; wraps=${JSON.stringify(wrapCalls)}; ` + + `services=${JSON.stringify(status())}`); + }); + + assert.deepEqual(wrapCalls.map(call => call.sessionId).sort(), workers.map(worker => worker.id).sort(), + "the real environment service receives one wrap RPC for each delegated worker"); + assert.equal(ptyCalls.filter(call => call.command === SANDBOX_EXEC).length, workers.length, + "each worker's final PTY launch has exactly one native isolation wrapper"); + for (const worker of workers) { + const context = terminals.pluginContext(worker.id); + const environmentWrap = wrapCalls.find(call => call.sessionId === worker.id); + assert.equal(environmentWrap.command, process.execPath); + assert.equal(environmentWrap.cwd, context.workingDirectory, "the host pins environment wrapping to its returned working directory"); + const launch = ptyCalls.find(call => call.options.cwd === context.workingDirectory); + assert.ok(launch, `fake PTY captured ${worker.title}'s final launch`); + assert.equal(launch.command, SANDBOX_EXEC, "the core's native isolation wrapper is the final command"); + assert.equal(launch.args[0], "-f"); + assert.equal(launch.args.filter(argument => argument === process.execPath).length, 1, + "the original CLI appears once inside the single sandbox wrapper"); + assert.equal(terminals.getMetadata(worker.id).isolation.state, "on"); + assert.equal(terminals.getMetadata(worker.id).isolation.layer, "seatbelt"); + assert.equal(terminals.getMetadata(worker.id).isolation.reason, undefined, "the worktree note is for the agent, not a card label"); + assert.equal(launch.options.cwd, context.workingDirectory, "the wrapped launch stays in the registered worktree"); + assert.equal(launch.options.env.GIT_OPTIONAL_LOCKS, "0"); + assert.match(launch.options.env.CANVASTTY_ISOLATION, /Git metadata stays read-only/u); + + const profile = await readFile(launch.args[1], "utf8"); + assert.ok(profile.includes(`(subpath "${pluginData}")`), "the worktree parent is hidden"); + assert.ok(profile.includes(`(allow file-write* (subpath "${context.environment.ref.dir}")`), "the registered worker tree is reopened for edits, including nested launch folders"); + const commonDir = join(context.environment.ref.repo, ".git"); + const adminDir = execFileSync("git", ["-C", context.environment.ref.dir, "rev-parse", "--absolute-git-dir"], { encoding: "utf8" }).trim(); + const projectWriteReopen = profile.indexOf(`(allow file-write* (subpath "${context.environment.ref.dir}")`); + assert.ok(profile.includes(`(subpath "${commonDir}")`) && profile.lastIndexOf(`(subpath "${commonDir}")`) > projectWriteReopen, + "shared Git metadata is denied after the worker reopen"); + assert.ok(profile.includes(`(subpath "${adminDir}")`) && profile.lastIndexOf(`(subpath "${adminDir}")`) > projectWriteReopen, + "the linked-worktree administration directory stays protected after the worker reopen"); + } + assert.ok(wrapCalls.every(call => call.command === process.execPath), + "the environment wrapper runs on the host CLI command before core isolation wraps it"); + + for (const worker of workers) { + const launch = ptyCalls.find(call => call.options.cwd === terminals.pluginContext(worker.id).workingDirectory); + launch.process.emitExit(0); + } + await waitFor(() => workers.every(worker => terminals.getMetadata(worker.id)?.status === "done")); +}); From f05821671c5e5b59c36f348310532aeb0b7f1002 Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:06:12 +0000 Subject: [PATCH 26/28] feat(ui): show a card action's change review in a modal dialog A card action that answers with a review now opens it in a native modal dialog that App renders inside the themed tree, so it reads the theme tokens and root --ui-scale. Subtasks are a segmented row; the file list (virtualized, one all-files checkbox) and the diff sit side by side (stacked below 760px), added and removed lines tinted with the success and danger tokens. A conflicting file offers Current version / Agent version above both texts. - One primary action (Accept selected) and one secondary (Reject subtask); the task-wide and whole-subtask variants are in an overflow menu (first enabled row focused, Arrow Up/Down between enabled rows). - Accept and reject run only after an inline confirmation that holds the group, files and versions of the moment it opened; confirm re-checks them and asks again when they changed. - A group may carry active: true (its agent is still working): its files are not preselected, what the plugin refuses until the agent finishes is disabled with the reason, and the plugin's note is an informational notice. Disabled buttons stay readable on every theme. - Failed plugin calls show a localized line, details go to the log; Git statuses get localized labels. The orchestrator's own turns no longer refresh the review and drop the person's choices. - Card actions can be declared "menu": false (not listed in the card menu, refused without input), and may add "title.ru"/"title.en" (at most 40 characters) shown in the app's language. Schema, plugin-api.d.ts and docs/plugins (en/ru/zh-CN) document both. - Bidirectional formatting controls are removed from review text and refused in file names. While the dialog is open the native Browser is hidden and canvas shortcuts do not fire; focus returns to the card's options button. The dialog frame is a shared card-dialog base. The host and its Russian and English texts are a separate chunk, so the startup bundle carries none of them; App preloads that chunk once it is idle and renders the host directly, without a Suspense boundary (React holds a suspended reveal for up to 300 ms), so the dialog opens as soon as the plugin answers. Icons are the vendored Lucide set. --- docs/UI_CONTRACT.md | 1 + docs/UI_CONTRACT.ru.md | 1 + docs/UI_CONTRACT.zh-CN.md | 1 + docs/canvastty-plugin.schema.json | 6 + docs/plugin-api.d.ts | 5 + docs/plugins.md | 3 +- docs/plugins.ru.md | 3 +- docs/plugins.zh-CN.md | 3 +- src/main/services/PluginCards.ts | 6 +- src/main/services/PluginChangeReviews.ts | 10 +- src/main/services/PluginManager.ts | 16 +- src/renderer/src/App.tsx | 21 +- .../terminal/PluginChangesReviewDialog.tsx | 855 ++++++++++++++++++ .../src/features/terminal/TerminalCard.tsx | 16 +- .../src/features/terminal/cardDialogFocus.ts | 8 + .../features/terminal/cardDialogSurfaces.ts | 5 + .../src/features/terminal/pluginReviewText.ts | 97 ++ .../features/terminal/pluginReviewUiState.ts | 93 ++ .../features/workspace/WorkspaceCanvas.tsx | 12 +- src/renderer/src/lib/preloadedSurface.ts | 63 ++ src/renderer/src/styles/app.css | 73 ++ src/shared/contracts.ts | 9 + src/shared/pluginCardActions.ts | 8 +- tests/app-shortcuts.test.mjs | 2 +- tests/card-dialog-surfaces.test.mjs | 112 +++ tests/plugin-change-review.test.mjs | 69 ++ tests/plugin-review-mutation.test.mjs | 217 +++++ tests/plugin-review-ui-state.test.mjs | 76 ++ tests/review-dialogs-ui-contract.test.mjs | 84 ++ tests/usage-ui.test.mjs | 2 +- 30 files changed, 1854 insertions(+), 23 deletions(-) create mode 100644 src/renderer/src/features/terminal/PluginChangesReviewDialog.tsx create mode 100644 src/renderer/src/features/terminal/cardDialogFocus.ts create mode 100644 src/renderer/src/features/terminal/cardDialogSurfaces.ts create mode 100644 src/renderer/src/features/terminal/pluginReviewText.ts create mode 100644 src/renderer/src/features/terminal/pluginReviewUiState.ts create mode 100644 src/renderer/src/lib/preloadedSurface.ts create mode 100644 tests/card-dialog-surfaces.test.mjs create mode 100644 tests/plugin-review-mutation.test.mjs create mode 100644 tests/plugin-review-ui-state.test.mjs create mode 100644 tests/review-dialogs-ui-contract.test.mjs diff --git a/docs/UI_CONTRACT.md b/docs/UI_CONTRACT.md index 7acaf490..4f119c87 100644 --- a/docs/UI_CONTRACT.md +++ b/docs/UI_CONTRACT.md @@ -73,6 +73,7 @@ This contract preserves the approved MVP concept and prevents feature ownership - The white camera frame represents the full viewport and stays entirely inside the minimap's padded perimeter. It does not replace the viewport with a clipped rectangle or an edge marker. HOME and object projections retain their real proportions. - The canvas edge-pans RTS-style while the pointer rests within `56px` of a viewport edge over empty canvas; speed ramps linearly up to `900px/s` at the edge itself. Edge panning is off by default and enabled in Settings. Motion pauses over interactive surfaces (terminal cards, Home, controls) and while drag-panning. - Dialog close actions stay inside their own header/control row with a consistent inset; they never overlap a field, outline, or panel boundary. +- A plugin card action that answers with a change review opens it as a modal dialog; nothing on a default surface opens it. Its header shows the review title and the card's title or folder, with the close action. Several subtasks are a wrapping segmented row, never a native select. The file list is virtualized, has one all-files checkbox and highlights the open file; a conflicting file offers Current version / Agent version as a segmented choice above both texts. The footer holds one primary action (Accept selected) and one secondary action (Reject subtask); accepting all files of the subtask, the whole task result, and rejecting the whole task live in an overflow menu. Every accept or reject is sent only after an inline confirmation in the footer (destructive confirmations use the danger color); there is no native confirm. Escape and a backdrop click step back from the menu, then the confirmation, then close the dialog, except while an accept or reject runs. The native Browser is hidden and canvas shortcuts cannot fire while it is open, focus returns to the card's options button, and it uses theme tokens, official Lucide assets and root `--ui-scale`. - The radial quick launcher is opt-in under Agents → Quick launcher and defaults off for fresh profiles and existing profiles without an explicit preference. With it off, right-click opens the standard context menu. Disabling it preserves the configured action list. - Dropping local files onto a live terminal card pastes their quoted paths without pressing Enter. Quoting targets POSIX shells on Linux/macOS and PowerShell on Windows; nested shells and the Windows cmd.exe fallback may require manual quoting. Exited sessions and active title edits reject drops. Control characters and unavailable native paths produce an explicit error rather than partial input. diff --git a/docs/UI_CONTRACT.ru.md b/docs/UI_CONTRACT.ru.md index 33926a2d..f34ed096 100644 --- a/docs/UI_CONTRACT.ru.md +++ b/docs/UI_CONTRACT.ru.md @@ -59,6 +59,7 @@ - Мини-карта центрирует камеру по нажатию без изменения масштаба. В Controls выбирается **Клик** (по умолчанию, движение указателя игнорируется) или **Перетаскивание** (мини-карта захватывает указатель и непрерывно двигает камеру); клавиши-стрелки работают в обоих режимах. - Canvas двигается RTS-style, пока указатель находится в пределах `56px` от viewport edge над пустым canvas; скорость линейно возрастает до `900px/s` на самой границе. Edge panning выключен по умолчанию и включается в Settings. Motion останавливается над interactive surfaces и во время drag-pan. - Dialog close actions остаются внутри своей header/control row с одинаковым inset и не перекрывают field, outline или panel boundary. +- Card action плагина, который отвечает обзором изменений, открывает его модальным диалогом; на поверхностях по умолчанию ничего его не открывает. В заголовке — название обзора и название или папка окна, рядом действие закрытия. Несколько подзадач показываются переносящимся segmented-рядом, никогда не native select. Список файлов виртуализирован, имеет один чекбокс «все файлы» и подсвечивает открытый файл; у конфликтного файла выбор «Текущая версия» / «Версия агента» — segmented-переключатель над обоими текстами. В footer одно основное действие («Принять выбранные») и одно второстепенное («Отклонить подзадачу»); принять все файлы подзадачи, принять результат всей задачи и отклонить всю задачу — в overflow-меню. Каждое принятие или отклонение отправляется только после встроенного подтверждения в footer (разрушительные подтверждения — цветом danger); native confirm не используется. Escape и клик по фону по очереди закрывают меню, подтверждение, затем диалог, кроме времени, пока выполняется принятие или отклонение. Пока диалог открыт, native Browser скрыт и canvas shortcuts не срабатывают; фокус возвращается на кнопку параметров окна; диалог использует theme tokens, официальные ассеты Lucide и root `--ui-scale`. ## Приёмочные проверки diff --git a/docs/UI_CONTRACT.zh-CN.md b/docs/UI_CONTRACT.zh-CN.md index 9baea3f0..8858a567 100644 --- a/docs/UI_CONTRACT.zh-CN.md +++ b/docs/UI_CONTRACT.zh-CN.md @@ -58,6 +58,7 @@ - 在空白 canvas 上 drag 始终执行 pan。新配置中,空白 canvas 上的普通 scroll 会沿两个轴移动,pinch 与 `Cmd/Ctrl + scroll` 会以指针为中心缩放。**Use scroll wheel to zoom** 恢复历史 wheel zoom,并在迁移现有配置时自动启用。只有 focused input widget 会中断 canvas wheel/pinch;在 unfocused 或不可聚焦 widget 上,事件仍归 canvas。**Wheel/pinch capture over widgets** 有 Off、On、Key 三种模式:Off 保留 focused-widget ownership,On 始终只交给 canvas,Key 仅在按住绑定键时交给 canvas。因此,focused live Browser page 在 Off 或 Key 未按下时会由普通 scroll 原生滚动;unfocused Browser、On、激活的 Key binding、完整 override、pinch 与 `Cmd/Ctrl + scroll` 都归 canvas。Browser wheel sequence 开始时选定的 owner 会保持到 250 ms 没有 wheel event。Browser summary 与 placeholder 始终归 canvas。新配置默认使用 Key,在 macOS 为 `Command`,其他平台为 `Ctrl`。独立的 **Canvas navigation override**(macOS 默认 `Option`,其他平台默认 `Alt`)接管 wheel/pinch 与 drag,也允许单独 Command/Ctrl。按住完整 override 时所有 canvas surface 显示 `grab`;已开始的 pan 会显示 `grabbing`,并由 canvas 持有到 pointer up/cancel。 - 指针停在空白 canvas 的 viewport edge `56px` 范围内时,canvas 以 RTS 风格 edge-pan;速度线性增加,到边缘达到 `900px/s`。Edge panning 默认关闭,在 Settings 启用。指针位于 interactive surface 或正在 drag-pan 时暂停。 - Dialog close action 留在自己的 header/control row 内,使用一致 inset,不覆盖 field、outline 或 panel boundary。 +- 插件 card action 若以变更审查作答,会以模态对话框打开;默认界面上没有任何入口打开它。标题栏显示审查标题和卡片标题或文件夹,以及关闭操作。多个子任务以可换行的 segmented 行显示,绝不使用原生 select。文件列表虚拟化,只有一个“全部文件”复选框,并高亮当前打开的文件;冲突文件在两侧文本上方以 segmented 选择“当前版本”/“代理版本”。footer 只有一个主操作(接受所选)和一个次操作(拒绝子任务);接受子任务全部文件、接受整个任务结果、拒绝整个任务放在 overflow 菜单中。每次接受或拒绝都只在 footer 中的内联确认之后发送(破坏性确认使用 danger 颜色),不使用原生 confirm。Escape 与点击背景依次关闭菜单、确认、对话框;接受或拒绝执行期间除外。对话框打开时 native Browser 隐藏、canvas shortcuts 不会触发,关闭后焦点回到卡片的选项按钮;对话框使用 theme tokens、官方 Lucide 资源和根 `--ui-scale`。 ## 验收检查 diff --git a/docs/canvastty-plugin.schema.json b/docs/canvastty-plugin.schema.json index c881217f..44b5c511 100644 --- a/docs/canvastty-plugin.schema.json +++ b/docs/canvastty-plugin.schema.json @@ -193,6 +193,12 @@ "properties": { "id": { "type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$" }, "title": { "type": "string", "minLength": 1, "maxLength": 40 }, + "title.ru": { "description": "The title when the app is in Russian (like description.ru); title otherwise.", "type": "string", "minLength": 1, "maxLength": 40 }, + "title.en": { "description": "The title when the app is in English (like description.en); title otherwise.", "type": "string", "minLength": 1, "maxLength": 40 }, + "menu": { + "description": "false: not listed in the card menu; only a change review calls it, as its accept or reject action, always with input.", + "const": false + }, "when": { "description": "Cards the action shows on; every listed key must match. A card outside an environment never matches environmentKinds.", "type": "object", diff --git a/docs/plugin-api.d.ts b/docs/plugin-api.d.ts index effa8367..79d40d55 100644 --- a/docs/plugin-api.d.ts +++ b/docs/plugin-api.d.ts @@ -251,8 +251,13 @@ export interface CanvasTTYCardAction { id: string; /** Up to 40 characters, shown in the card's options menu. */ title: string; + /** The title when the app is in Russian / English (up to 40 characters, like `description.ru`); `title` otherwise. */ + "title.ru"?: string; + "title.en"?: string; /** Every listed key must match; a card outside an environment never matches `environmentKinds`. */ when?: { providers?: CanvasTTYProviderId[]; environmentKinds?: string[]; roles?: CanvasTTYSessionRole[] }; + /** `false`: not listed in the card menu; only a change review calls it, always with `input` (accept/reject). */ + menu?: false; } /** Params of the host request `canvastty.cards.invoke` (15 s). */ diff --git a/docs/plugins.md b/docs/plugins.md index 56e7733e..9d2a1d29 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -382,8 +382,9 @@ A service with `cards:decorate` can put a badge on any card and declare up to 16 - `cards.setBadge` `{ sessionId, badge: { text, tone?, tooltip? } | null }`: `text` is at most 24 characters, `tone` is `neutral` (default), `info`, `warn` or `error`, `tooltip` at most 200 characters; `null` removes the plugin's badge. At most 4 plugin badges per card. Badges are plain text, masked like agent text, and disappear with the card or when the plugin's trust is revoked. - An action shows in the card's options menu on every card its `when` matches: `providers`, `environmentKinds` (any plugin's environment; a card outside an environment never matches) and `roles`, each optional; every listed key must match. Choosing it sends `canvastty.cards.invoke` `{ actionId, sessionId, session }` (host-only; `session` is the summary above) and waits at most 15 s. Answer `{ message?, tone?, review? }`: the message (plain text, at most 2000 characters, masked) is shown as a toast on the card. A timeout or error shows an error toast. -- An answer with `review` opens the change review dialog instead of a toast. `review` is `{ title, groups, acceptActionId?, rejectActionId?, nextOffset? }`; each group is `{ sessionId, title, files, error? }` (one per agent) and each file `{ path, diff, status?, page?, hasMore?, truncated?, conflict? }`, where `conflict` is `{ current, agent }`, two text pages `{ label?, text, startLine, totalLines, hasMore }`. The whole review is at most 512 KiB, 24 groups and 400 files; text fields are plain text, masked, stripped of control characters and cut at 16 KiB (the file is then marked truncated); a file path must be unchanged by masking. `acceptActionId` and `rejectActionId` must be action ids this plugin declared, or the answer is refused. +- An answer with `review` opens the change review dialog instead of a toast. `review` is `{ title, groups, acceptActionId?, rejectActionId?, nextOffset? }`; each group is `{ sessionId, title, files, error?, active? }` (one per agent; `active: true` says its agent is still working, and the dialog then disables accepting all of its files, rejecting it and the task-wide actions, while a part of its files can still be accepted; its files start unselected and its `error` shows as a notice, not as an error) and each file `{ path, diff, status?, page?, hasMore?, truncated?, conflict? }`, where `conflict` is `{ current, agent }`, two text pages `{ label?, text, startLine, totalLines, hasMore }`. The whole review is at most 512 KiB, 24 groups and 400 files; text fields are plain text, masked, stripped of control characters and cut at 16 KiB (the file is then marked truncated); a file path must be unchanged by masking. `acceptActionId` and `rejectActionId` must be action ids this plugin declared, or the answer is refused. Declare them with `"menu": false`: such an action is not listed in the card menu (where it would run without `input`) and is refused when called without `input`. - While the dialog is open the host calls the same action again with an `input` object (a JSON object of at most 1 MiB, sent only by the app window, never taken as the session's identity): no input to refresh the review, `{ offset }` for the next page of files (`nextOffset`), `{ agentSessionId, file, page }` for a page of one file's diff (page 0 when the file is opened). Accept calls `acceptActionId` with `{ agentSessionId?, files, resolutions? }` (the chosen files; `resolutions` maps a conflicting path to `"current"` or `"agent"`; no `agentSessionId` for the whole task) and reject calls `rejectActionId` with `{ agentSessionId? }`. Accept and reject are sent only after the person confirms them in the dialog. Answer a mutation with the remaining review, or with a message. +- `title` is at most 40 characters. Like the manifest's `description.ru`/`description.en`, an action may add `"title.ru"` and `"title.en"` (each at most 40 characters): the card menu shows the one for the app's language, and `title` otherwise. - No HTML anywhere: badges, titles and messages are rendered as text. The full example is [`examples/plugins/collect-demo`](../examples/plugins/collect-demo): the card action **Show changes** on cards in the `worktree` environment (from `env-worktree`) shows `git diff --stat` of the worktree and sets a "N changed" badge, and the tool `collect-demo__diffstat` gives orchestrators the same for their own folder or a subagent's, which it learns about from session events. diff --git a/docs/plugins.ru.md b/docs/plugins.ru.md index 7ddc5afd..1aaed06b 100644 --- a/docs/plugins.ru.md +++ b/docs/plugins.ru.md @@ -360,8 +360,9 @@ interface PluginSessionEvent { - `cards.setBadge` `{ sessionId, badge: { text, tone?, tooltip? } | null }`: `text` до 24 символов, `tone` — `neutral` (по умолчанию), `info`, `warn` или `error`, `tooltip` до 200 символов; `null` убирает метку плагина. Не больше 4 меток плагинов на окно. Метки — обычный текст, маскируются как текст агентов и исчезают вместе с окном или при снятии доверия к плагину. - Действие появляется в меню параметров окна на каждом окне, которое подходит под `when`: `providers`, `environmentKinds` (среда любого плагина; окно без среды никогда не подходит) и `roles`, каждое необязательно; должны совпасть все указанные. Выбор отправляет `canvastty.cards.invoke` `{ actionId, sessionId, session }` (только от хоста; `session` — сводка выше) и ждёт не больше 15 с. Ответ — `{ message?, tone?, review? }`: сообщение (обычный текст, до 2000 символов, замаскированный) показывается всплывающим уведомлением на окне. Таймаут или ошибка показывают уведомление об ошибке. -- Ответ с `review` открывает диалог проверки изменений вместо уведомления. `review` — `{ title, groups, acceptActionId?, rejectActionId?, nextOffset? }`; группа — `{ sessionId, title, files, error? }` (по одной на агента), файл — `{ path, diff, status?, page?, hasMore?, truncated?, conflict? }`, где `conflict` — `{ current, agent }`, две текстовые страницы `{ label?, text, startLine, totalLines, hasMore }`. Весь обзор — не больше 512 КиБ, 24 групп и 400 файлов; текст — обычный, замаскированный, без управляющих символов и обрезан до 16 КиБ (тогда файл помечается как сокращённый); путь файла не должен меняться от маскирования. `acceptActionId` и `rejectActionId` должны быть действиями, объявленными этим же плагином, иначе ответ отклоняется. +- Ответ с `review` открывает диалог проверки изменений вместо уведомления. `review` — `{ title, groups, acceptActionId?, rejectActionId?, nextOffset? }`; группа — `{ sessionId, title, files, error?, active? }` (по одной на агента; `active: true` означает, что агент ещё работает: диалог тогда отключает принятие всех его файлов, отклонение и действия для всей задачи, а часть файлов принять можно; его файлы изначально не выбраны, а его `error` показывается как пояснение, а не как ошибка), файл — `{ path, diff, status?, page?, hasMore?, truncated?, conflict? }`, где `conflict` — `{ current, agent }`, две текстовые страницы `{ label?, text, startLine, totalLines, hasMore }`. Весь обзор — не больше 512 КиБ, 24 групп и 400 файлов; текст — обычный, замаскированный, без управляющих символов и обрезан до 16 КиБ (тогда файл помечается как сокращённый); путь файла не должен меняться от маскирования. `acceptActionId` и `rejectActionId` должны быть действиями, объявленными этим же плагином, иначе ответ отклоняется. Объявляйте их с `"menu": false`: такое действие не показывается в меню окна (оттуда оно запустилось бы без `input`) и отклоняется при вызове без `input`. - Пока диалог открыт, хост снова вызывает то же действие с объектом `input` (JSON-объект до 1 МиБ, его присылает только окно приложения, он никогда не задаёт сессию): без `input` — обновить обзор, `{ offset }` — следующая страница файлов (`nextOffset`), `{ agentSessionId, file, page }` — страница diff одного файла (страница 0 при открытии файла). Принятие вызывает `acceptActionId` с `{ agentSessionId?, files, resolutions? }` (выбранные файлы; `resolutions` сопоставляет конфликтному пути `"current"` или `"agent"`; без `agentSessionId` — для всей задачи), отклонение — `rejectActionId` с `{ agentSessionId? }`. Принятие и отклонение отправляются только после подтверждения человеком в диалоге. На изменение отвечайте оставшимся обзором или сообщением. +- `title` — до 40 символов. Как `description.ru`/`description.en` в манифесте, действие может добавить `"title.ru"` и `"title.en"` (каждый до 40 символов): меню окна показывает заголовок для языка приложения, иначе `title`. - Никакого HTML: метки, заголовки и сообщения выводятся как текст. Полный пример — [`examples/plugins/collect-demo`](../examples/plugins/collect-demo): действие **Show changes** на окнах в среде `worktree` (из `env-worktree`) показывает `git diff --stat` worktree и ставит метку «N changed», а инструмент `collect-demo__diffstat` даёт оркестраторам то же для своей папки или папки субагента, о котором плагин узнаёт из событий сессий. diff --git a/docs/plugins.zh-CN.md b/docs/plugins.zh-CN.md index 155e3e09..edd42dd8 100644 --- a/docs/plugins.zh-CN.md +++ b/docs/plugins.zh-CN.md @@ -358,8 +358,9 @@ interface PluginSessionEvent { - `cards.setBadge` `{ sessionId, badge: { text, tone?, tooltip? } | null }`:`text` 最多 24 个字符,`tone` 为 `neutral`(默认)、`info`、`warn` 或 `error`,`tooltip` 最多 200 个字符;`null` 移除该插件的标记。每张卡片最多 4 个插件标记。标记是纯文本,像 agent 文本一样被遮蔽,并随卡片关闭或插件信任撤销而消失。 - 动作出现在所有匹配 `when` 的卡片的选项菜单中:`providers`、`environmentKinds`(任意插件的环境;不在环境中的卡片永远不匹配)和 `roles`,均为可选;列出的每一项都必须匹配。选择动作会发送 `canvastty.cards.invoke` `{ actionId, sessionId, session }`(仅宿主可发;`session` 为上面的摘要),最多等待 15 s。回答 `{ message?, tone?, review? }`:消息(纯文本,最多 2000 个字符,经过遮蔽)以提示的形式显示在卡片上。超时或出错时显示错误提示。 -- 带有 `review` 的回答会打开变更审查对话框,而不是提示。`review` 为 `{ title, groups, acceptActionId?, rejectActionId?, nextOffset? }`;每个分组为 `{ sessionId, title, files, error? }`(每个代理一个),每个文件为 `{ path, diff, status?, page?, hasMore?, truncated?, conflict? }`,其中 `conflict` 为 `{ current, agent }` 两个文本页 `{ label?, text, startLine, totalLines, hasMore }`。整个审查最多 512 KiB、24 个分组和 400 个文件;文本为纯文本、经过遮蔽、去除控制字符并截断到 16 KiB(此时文件标记为已截断);文件路径不得因遮蔽而改变。`acceptActionId` 和 `rejectActionId` 必须是本插件声明的动作 id,否则回答被拒绝。 +- 带有 `review` 的回答会打开变更审查对话框,而不是提示。`review` 为 `{ title, groups, acceptActionId?, rejectActionId?, nextOffset? }`;每个分组为 `{ sessionId, title, files, error?, active? }`(每个代理一个;`active: true` 表示该代理仍在运行,此时对话框会禁用接受其全部文件、拒绝它以及整个任务的操作,但仍可接受其中一部分文件;其文件初始不被选中,其 `error` 显示为提示而非错误),每个文件为 `{ path, diff, status?, page?, hasMore?, truncated?, conflict? }`,其中 `conflict` 为 `{ current, agent }` 两个文本页 `{ label?, text, startLine, totalLines, hasMore }`。整个审查最多 512 KiB、24 个分组和 400 个文件;文本为纯文本、经过遮蔽、去除控制字符并截断到 16 KiB(此时文件标记为已截断);文件路径不得因遮蔽而改变。`acceptActionId` 和 `rejectActionId` 必须是本插件声明的动作 id,否则回答被拒绝。请用 `"menu": false` 声明它们:这样的动作不会出现在卡片菜单中(从菜单运行时没有 `input`),不带 `input` 调用时会被拒绝。 - 对话框打开期间,宿主会带着 `input` 对象(最多 1 MiB 的 JSON 对象,仅由应用窗口发送,从不作为会话身份)再次调用同一动作:不带 `input` 表示刷新审查,`{ offset }` 表示下一页文件(`nextOffset`),`{ agentSessionId, file, page }` 表示某个文件 diff 的一页(打开文件时为第 0 页)。接受会以 `{ agentSessionId?, files, resolutions? }` 调用 `acceptActionId`(所选文件;`resolutions` 把冲突路径映射为 `"current"` 或 `"agent"`;整个任务时不带 `agentSessionId`),拒绝会以 `{ agentSessionId? }` 调用 `rejectActionId`。接受和拒绝只在用户于对话框中确认后才发送。对修改请回答剩余的审查或一条消息。 +- `title` 最多 40 个字符。与清单中的 `description.ru`/`description.en` 一样,动作可以添加 `"title.ru"` 和 `"title.en"`(各最多 40 个字符):卡片菜单显示应用语言对应的标题,否则显示 `title`。 - 任何地方都没有 HTML:标记、标题和消息都按文本渲染。 完整示例见 [`examples/plugins/collect-demo`](../examples/plugins/collect-demo):在 `worktree` 环境(来自 `env-worktree`)的卡片上,动作 **Show changes** 显示 worktree 的 `git diff --stat` 并设置“N changed”标记;工具 `collect-demo__diffstat` 为编排器提供同样的信息,针对它自己的文件夹或某个子 agent 的文件夹(插件通过会话事件得知子 agent)。 diff --git a/src/main/services/PluginCards.ts b/src/main/services/PluginCards.ts index ddf74ada..a3c9bbcd 100644 --- a/src/main/services/PluginCards.ts +++ b/src/main/services/PluginCards.ts @@ -117,11 +117,14 @@ export class PluginCards { } return { badges, - actions: this.providers().flatMap((provider) => provider.actions.map((action) => ({ + // A review-only action (`menu: false`) is never listed: from the menu it would run without its review input. + actions: this.providers().flatMap((provider) => provider.actions.filter((action) => action.menu !== false).map((action) => ({ pluginId: provider.pluginId, pluginName: provider.pluginName, actionId: action.id, title: action.title, + ...(action["title.ru"] ? { "title.ru": action["title.ru"] } : {}), + ...(action["title.en"] ? { "title.en": action["title.en"] } : {}), ...(action.when ? { when: structuredClone(action.when) } : {}) }))) }; @@ -139,6 +142,7 @@ export class PluginCards { if (!cardActionMatches(action.when, session)) throw new Error("That action does not apply to this card."); const timeoutMs = this.deps.timeoutMs ?? CARD_ACTION_TIMEOUT_MS; const normalizedInput = normalizeCardActionInput(input); + if (action.menu === false && normalizedInput === undefined) throw new Error("That action runs only from a change review."); const params: CardActionInvocation = { actionId, sessionId, session, ...(normalizedInput ? { input: normalizedInput } : {}) }; let timer: NodeJS.Timeout | undefined; try { diff --git a/src/main/services/PluginChangeReviews.ts b/src/main/services/PluginChangeReviews.ts index c56f009a..fee4a830 100644 --- a/src/main/services/PluginChangeReviews.ts +++ b/src/main/services/PluginChangeReviews.ts @@ -5,6 +5,9 @@ const MAX_REVIEW_BYTES = 512 * 1024; // Conflict metadata consumes review space, keeping generated actions below the 1 MiB transport frame. const MAX_ACTION_BYTES = 2 * MAX_REVIEW_BYTES; const MAX_TEXT = 16_384; +// Bidirectional formatting controls can make a shown diff or file name read differently from what it is (Trojan +// Source): removed from text, refused in paths. +const BIDI_CONTROLS = /[\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/gu; function record(value: unknown): Record { if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Plugin review data is invalid."); @@ -36,7 +39,7 @@ export function normalizeCardReview(value: unknown, redact: (text: string) => st const review = record(value); const text = (value: unknown, limit = MAX_TEXT): string => { if (typeof value !== "string") throw new Error("Plugin review text is invalid."); - return redact(value.replace(/[\u0000-\u0008\u000B-\u001F\u007F]/gu, "")).slice(0, limit); + return redact(value.replace(/[\u0000-\u0008\u000B-\u001F\u007F]/gu, "").replace(BIDI_CONTROLS, "")).slice(0, limit); }; const page = (value: unknown): PluginReviewTextPage => { const source = record(value); @@ -57,7 +60,8 @@ export function normalizeCardReview(value: unknown, redact: (text: string) => st if (!Array.isArray(group.files) || (fileCount += group.files.length) > 400) throw new Error("Plugin review files are invalid."); const files = group.files.map(value => { const file = record(value); - if (typeof file.path !== "string" || !file.path || file.path.length > 1000 || /[\u0000-\u001F\u007F]/u.test(file.path)) throw new Error("Plugin review file path is invalid."); + if (typeof file.path !== "string" || !file.path || file.path.length > 1000 || /[\u0000-\u001F\u007F]/u.test(file.path) + || new RegExp(BIDI_CONTROLS.source, "u").test(file.path)) throw new Error("Plugin review file path is invalid."); if (redact(file.path) !== file.path) throw new Error("Plugin review file names contain protected data."); const result: PluginChangeReviewFile = { path: file.path, diff: text(file.diff) }; if (file.status !== undefined) result.status = text(file.status, 80); @@ -75,7 +79,7 @@ export function normalizeCardReview(value: unknown, redact: (text: string) => st return result; }); return { sessionId: group.sessionId, title: text(group.title, 200), files, - ...(group.error !== undefined ? { error: text(group.error, 2000) } : {}) }; + ...(group.error !== undefined ? { error: text(group.error, 2000) } : {}), ...(group.active === true ? { active: true } : {}) }; }); const acceptActionId = action(review.acceptActionId), rejectActionId = action(review.rejectActionId); const result: PluginChangeReview = { diff --git a/src/main/services/PluginManager.ts b/src/main/services/PluginManager.ts index ffef6bd9..03ba04da 100644 --- a/src/main/services/PluginManager.ts +++ b/src/main/services/PluginManager.ts @@ -1867,11 +1867,19 @@ function validateCardActions(value: unknown): PluginCardAction[] { } return value.map((candidate): PluginCardAction => { if (!isRecord(candidate)) throw new Error("Every plugin card action must be an object."); - assertOnlyKeys(candidate, ["id", "title", "when"], "Plugin card action"); + assertOnlyKeys(candidate, ["id", "title", "title.ru", "title.en", "when", "menu"], "Plugin card action"); const id = requiredString(candidate.id, "card action id", 64); if (!isContributionId(id)) throw new Error(`Plugin card action id is invalid: ${id}.`); - const title = requiredString(candidate.title, "card action title", 40); - if (candidate.when === undefined) return { id, title }; + const titleRu = optionalString(candidate["title.ru"], "card action title.ru", 40); + const titleEn = optionalString(candidate["title.en"], "card action title.en", 40); + const title = { + title: requiredString(candidate.title, "card action title", 40), + ...(titleRu ? { "title.ru": titleRu } : {}), + ...(titleEn ? { "title.en": titleEn } : {}) + }; + if (candidate.menu !== undefined && candidate.menu !== false) throw new Error(`Plugin card action ${id} menu must be false when present.`); + const menu = candidate.menu === false ? { menu: false as const } : {}; + if (candidate.when === undefined) return { id, ...title, ...menu }; if (!isRecord(candidate.when)) throw new Error(`Plugin card action ${id} when must be an object.`); assertOnlyKeys(candidate.when, ["providers", "environmentKinds", "roles"], "Plugin card action filter"); const list = (key: string, valid: (item: unknown) => boolean): string[] | undefined => { @@ -1890,7 +1898,7 @@ function validateCardActions(value: unknown): PluginCardAction[] { ...(environmentKinds ? { environmentKinds } : {}), ...(roles ? { roles: roles as PluginCardActionFilter["roles"] } : {}) }; - return { id, title, when }; + return { id, ...title, when, ...menu }; }); } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index cb72f648..b8647d51 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -71,6 +71,9 @@ import type { FileSessionOption } from "./features/files/FileBrowserCard"; import { GitRiskNotice } from "./features/terminal/GitRiskNotice"; import { UsageHistoryPanel } from "./features/usage/UsageHistoryPanel"; import { WorkspaceCanvas } from "./features/workspace/WorkspaceCanvas"; +import type { PluginReviewRequest } from "./features/terminal/pluginReviewUiState"; +import { pluginReviewHostSurface } from "./features/terminal/cardDialogSurfaces"; +import { preloadWhenIdle, usePreloadedSurface } from "./lib/preloadedSurface"; import { createCameraStore } from "./features/workspace/cameraStore"; import { isPixelSkinThemeId } from "./features/skins/skinCatalog"; import { isPixelSkinPackId } from "./features/skins/SkinAssets"; @@ -366,6 +369,13 @@ export function App(): React.JSX.Element { const [usageHistoryOpen, setUsageHistoryOpen] = useState(false); /** App root outside the canvas transform; overlays portal here to inherit the theme tokens. */ const [overlayHost, setOverlayHost] = useState(null); + // The change review a card action answered with (rendered here, inside the themed tree, as a modal dialog). + const [pluginReview, setPluginReview] = useState(null); + // Out of the startup bundle, preloaded once the app is idle, rendered without a Suspense wait (see preloadedSurface). + const PluginReviewHost = usePreloadedSurface(pluginReviewHostSurface, pluginReview !== null); + useEffect(() => preloadWhenIdle([pluginReviewHostSurface]), []); + // A modal dialog owns the keyboard: no canvas shortcut fires underneath it and the native Browser is hidden. + const modalDialogOpen = shortcutReferenceOpen || pluginReview !== null; const [homeEditDraft, setHomeEditDraft] = useState(null); const [activeSessionId, setActiveSessionId] = useState(null); const [browserSelected, setBrowserSelected] = useState(false); @@ -1881,7 +1891,7 @@ export function App(): React.JSX.Element { setRenamingSessionId(activeSessionId); }; const handleShortcut = (event: KeyboardEvent): void => { - if (shortcutReferenceOpen) return; + if (modalDialogOpen) return; if (handleMacNativeSelectAll(event, window.canvasTTY.window.isMacOS)) return; if (handoffRemarkIds !== null) return; if (shouldKeepNativeKeyboardInput(event.target, window.canvasTTY.window.isMacOS, event)) return; @@ -1907,7 +1917,7 @@ export function App(): React.JSX.Element { }; const handlePointerShortcut = (event: PointerEvent): void => { - if (shortcutReferenceOpen || usageHistoryOpen || handoffRemarkIds !== null) return; + if (modalDialogOpen || usageHistoryOpen || handoffRemarkIds !== null) return; if (isShortcutCaptureTarget(event.target) || isRenameInputTarget(event.target)) return; const action = matchesPointerShortcut(event, settings.shortcuts.home) ? "home" @@ -1932,7 +1942,7 @@ export function App(): React.JSX.Element { window.removeEventListener("pointerdown", handlePointerShortcut, true); stopTerminalFocus(); }; - }, [activeSessionId, fullscreenSessionId, goHome, handoffRemarkIds, homeEditDraft, launchProvider, pendingTerminalUrl, settings.locale, settings.shortcuts, settingsOpen, shortcutReferenceOpen, showToast, usageHistoryOpen, toggleSessionFullscreen]); + }, [activeSessionId, fullscreenSessionId, goHome, handoffRemarkIds, homeEditDraft, launchProvider, pendingTerminalUrl, settings.locale, settings.shortcuts, settingsOpen, modalDialogOpen, showToast, usageHistoryOpen, toggleSessionFullscreen]); const appearance = resolveAppearanceSettings(settings); const rootClasses = useMemo( @@ -1988,7 +1998,7 @@ export function App(): React.JSX.Element { limitsLoadState={limitsLoadState} plugins={plugins} browser={browser} - browserViewVisible={!settingsOpen && !usageHistoryOpen && !shortcutReferenceOpen && launchProvider === null && pendingTerminalUrl === null && handoffRemarkIds === null} + browserViewVisible={!settingsOpen && !usageHistoryOpen && !modalDialogOpen && launchProvider === null && pendingTerminalUrl === null && handoffRemarkIds === null} homeEditing={homeEditDraft !== null} camera={cameraStore} onCameraChange={changeCamera} @@ -1999,6 +2009,7 @@ export function App(): React.JSX.Element { onOpenTerminal={(position) => void openTerminal(position)} onOpenBrowser={openBrowserFromUi} onOpenTerminalUrl={(url) => void openTerminalUrl(url)} + onPluginReview={setPluginReview} onRequestMedia={requestMedia} onRemoveMedia={removeMedia} onHomeLayoutChange={changeHomeLayout} @@ -2080,6 +2091,8 @@ export function App(): React.JSX.Element { {shortcutReferenceOpen && setShortcutReferenceOpen(false)} />} + {pluginReview && PluginReviewHost && } ; + offset?: number; + file?: string; + page?: number; +}; + +export interface PluginChangesReviewDialogProps { + cardSessionId: string; + /** The card's title or folder, shown under the review title. */ + cardTitle?: string; + reviewActionId: string; + review: PluginChangeReview; + locale: LocaleId; + invokeAction(actionId: string, input?: PluginChangeReviewActionInput): Promise; + onReviewChange(review: PluginChangeReview): void; + onActionResult(result: PluginCardActionResult): void; + onClose(): void; +} + +type ConfirmationAction = "accept-selected" | "accept-all-group" | "accept-all-task" | "reject-group" | "reject-task"; +type Resolution = "current" | "agent"; +/** + * What the person confirms, fixed when the confirmation opens: the target group, the files and the chosen versions. + * Confirm sends exactly this, never the selection of a later moment. + */ +type Confirmation = { + action: ConfirmationAction; + groupId?: string; + files?: string[]; + resolutions: Record; +}; + +const CONFIRMATION_TEXT: Record = { + "accept-selected": "confirmAcceptSelected", + "accept-all-group": "confirmAcceptGroup", + "accept-all-task": "confirmAcceptTask", + "reject-group": "confirmRejectGroup", + "reject-task": "confirmRejectTask" +}; + +/** + * The change review a card action answered with (docs/plugins.md): rendered by App inside the themed tree as a native + * modal dialog. Accept and reject are sent to the plugin only after an explicit confirmation in the footer. + */ +export function PluginChangesReviewDialog({ + cardSessionId, + cardTitle, + reviewActionId, + review, + locale, + invokeAction, + onReviewChange, + onActionResult, + onClose +}: PluginChangesReviewDialogProps): React.JSX.Element { + const dialogRef = useRef(null); + const fileListRef = useRef(null); + const [selectedGroupId, setSelectedGroupId] = useState(review.groups[0]?.sessionId ?? ""); + const initialFileKeys = review.groups.flatMap((group) => group.files.map((file) => reviewFileKey(group.sessionId, file.path))); + const knownFileKeys = useRef(new Set(initialFileKeys)); + const [selectedFiles, setSelectedFiles] = useState>(() => preselectedFileKeys(review.groups)); + const firstGroup = review.groups[0]; + const firstFile = firstGroup?.files.find((file) => file.conflict) ?? firstGroup?.files[0]; + const [activeFileKey, setActiveFileKey] = useState(() => firstGroup && firstFile + ? reviewFileKey(firstGroup.sessionId, firstFile.path) : ""); + const [fileListScrollTop, setFileListScrollTop] = useState(0); + const [fileListViewportHeight, setFileListViewportHeight] = useState(280); + const [rowHeight, setRowHeight] = useState(DEFAULT_ROW_HEIGHT); + const [resolutions, setResolutions] = useState>({}); + const [confirmation, setConfirmation] = useState(null); + const [menuOpen, setMenuOpen] = useState(false); + const [busy, setBusy] = useState(false); + const [mutating, setMutating] = useState(false); + const [scopeStale, setScopeStale] = useState(false); + const [error, setError] = useState(""); + const requestInFlightRef = useRef(false); + const refreshPendingRef = useRef(false); + const refreshTimerRef = useRef(null); + const scopeEpochRef = useRef(0); + const confirmationEpochRef = useRef(0); + const mountedRef = useRef(true); + const sessionTrackerRef = useRef(new TaskReviewSessionTracker(cardSessionId)); + const reviewRef = useRef(review); + const invokeActionRef = useRef(invokeAction); + const onReviewChangeRef = useRef(onReviewChange); + const onActionResultRef = useRef(onActionResult); + reviewRef.current = review; + invokeActionRef.current = invokeAction; + onReviewChangeRef.current = onReviewChange; + onActionResultRef.current = onActionResult; + for (const candidate of review.groups) sessionTrackerRef.current.include(candidate.sessionId); + const text = pluginReviewText(locale); + const group = review.groups.find((candidate) => candidate.sessionId === selectedGroupId) ?? review.groups[0]; + const groupId = group?.sessionId ?? ""; + const groupFiles = group?.files ?? []; + const activeFile = groupFiles.find((file) => reviewFileKey(groupId, file.path) === activeFileKey); + const fileWindow = virtualFileWindow(groupFiles.length, fileListScrollTop, fileListViewportHeight, rowHeight, REVIEW_FILE_OVERSCAN); + const visibleFiles = groupFiles.slice(fileWindow.startIndex, fileWindow.endIndex); + const allFiles = useMemo(() => review.groups.flatMap((candidate) => candidate.files), [review.groups]); + const allTaskPathsUnique = new Set(allFiles.map((file) => file.path)).size === allFiles.length; + const hasMoreFileContent = (file: PluginChangeReviewFile): boolean => Boolean(file.hasMore + || file.conflict?.current.hasMore || file.conflict?.agent.hasMore); + const fileReviewLoaded = (file: PluginChangeReviewFile): boolean => file.page !== undefined; + const taskWideAcceptAllowed = Boolean(review.acceptActionId && review.nextOffset === undefined && allFiles.length > 0 && allTaskPathsUnique + && review.groups.every((candidate) => !candidate.error && candidate.active !== true && candidate.files.length > 0 + && candidate.files.every((file) => fileReviewLoaded(file) && !file.conflict && !hasMoreFileContent(file)))); + const selectedGroupFiles = groupFiles.filter((file) => selectedFiles.has(reviewFileKey(groupId, file.path))); + const hasUnresolvedSelectedConflict = selectedGroupFiles.some((file) => file.conflict && !resolutions[reviewFileKey(groupId, file.path)]); + const selectedGroupPaths = selectedGroupFiles.map((file) => file.path); + const hasUnloadedSelectedFile = selectedGroupFiles.some((file) => !fileReviewLoaded(file)); + const hasMoreSelectedContent = selectedGroupFiles.some(hasMoreFileContent); + const interactionBlocked = busy || scopeStale; + // While a confirmation is open the person confirms a fixed set: the group, the files and the versions stay put. + const selectionLocked = interactionBlocked || confirmation !== null; + // A group whose agent is still working (`active`): the plugin refuses accepting all of its files and rejecting it, + // so those actions are disabled here too; a part of its files can still be accepted. + const groupActive = group?.active === true; + const acceptsWholeActiveGroup = groupActive && groupFiles.length > 0 && selectedGroupPaths.length === groupFiles.length; + const acceptSelectedAllowed = !interactionBlocked && selectedGroupPaths.length > 0 && !hasUnloadedSelectedFile + && !hasUnresolvedSelectedConflict && !hasMoreSelectedContent && !acceptsWholeActiveGroup; + const acceptGroupAllowed = !interactionBlocked && !groupActive && groupFiles.length > 0 && groupFiles.every((file) => fileReviewLoaded(file) + && !hasMoreFileContent(file) && (!file.conflict || resolutions[reviewFileKey(groupId, file.path)])); + const rejectGroupAllowed = !interactionBlocked && !groupActive; + const rejectTaskAllowed = !interactionBlocked && review.groups.length > 0 && review.nextOffset === undefined + && !review.groups.some((item) => Boolean(item.error) || item.active === true); + const allSelected = groupFiles.length > 0 && selectedGroupPaths.length === groupFiles.length; + const someSelected = selectedGroupPaths.length > 0 && !allSelected; + + useEffect(() => { + setSelectedGroupId((current) => review.groups.some((candidate) => candidate.sessionId === current) + ? current : review.groups[0]?.sessionId ?? ""); + setSelectedFiles((current) => { + const available = new Set(review.groups.flatMap((candidate) => candidate.files.map((file) => reviewFileKey(candidate.sessionId, file.path)))); + const next = new Set([...current].filter((path) => available.has(path))); + for (const path of preselectedFileKeys(review.groups)) if (!knownFileKeys.current.has(path)) next.add(path); + knownFileKeys.current = available; + return next; + }); + setActiveFileKey((current) => { + const selectedGroup = review.groups.find((candidate) => candidate.sessionId === selectedGroupId) ?? review.groups[0]; + if (selectedGroup?.files.some((file) => reviewFileKey(selectedGroup.sessionId, file.path) === current)) return current; + const preferredFile = selectedGroup?.files.find((file) => file.conflict) ?? selectedGroup?.files[0]; + return selectedGroup && preferredFile ? reviewFileKey(selectedGroup.sessionId, preferredFile.path) : ""; + }); + }, [review]); + + useEffect(() => { + const element = dialogRef.current; + if (element && !element.open) element.showModal(); + return () => element?.close(); + }, []); + + useEffect(() => { + const list = fileListRef.current; + if (!list) return; + const measure = (): void => { + setFileListViewportHeight(list.clientHeight); + // Rows are sized in em, so their height follows --ui-scale: the window math uses the rendered height. + const row = list.querySelector("[data-file-index]"); + const height = row?.getBoundingClientRect().height; + if (height && height > 0) setRowHeight(height); + }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(list); + return () => observer.disconnect(); + }, [groupId, groupFiles.length > 0]); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + if (refreshTimerRef.current !== null) window.clearTimeout(refreshTimerRef.current); + }; + }, []); + + const setFileSelected = (path: string, selected: boolean): void => { + setSelectedFiles((current) => { + const next = new Set(current); + const key = reviewFileKey(groupId, path); + if (selected) next.add(key); + else next.delete(key); + return next; + }); + }; + + const setGroupSelection = (selected: boolean): void => { + if (!group) return; + setSelectedFiles((current) => { + const next = new Set(current); + for (const file of group.files) { + const key = reviewFileKey(group.sessionId, file.path); + if (selected) next.add(key); + else next.delete(key); + } + return next; + }); + }; + + const selectGroup = (nextGroup: PluginChangeReviewGroup | undefined): void => { + setSelectedGroupId(nextGroup?.sessionId ?? ""); + const nextFile = nextGroup?.files.find((file) => file.conflict) ?? nextGroup?.files[0]; + setActiveFileKey(nextGroup && nextFile ? reviewFileKey(nextGroup.sessionId, nextFile.path) : ""); + if (fileListRef.current) fileListRef.current.scrollTop = 0; + setFileListScrollTop(0); + }; + + const focusFileAt = (index: number): void => { + if (!group || groupFiles.length === 0) return; + const nextIndex = Math.max(0, Math.min(groupFiles.length - 1, index)); + setActiveFileKey(reviewFileKey(group.sessionId, groupFiles[nextIndex].path)); + const list = fileListRef.current; + if (list) { + list.scrollTop = Math.min(nextIndex * rowHeight, list.scrollHeight - list.clientHeight); + setFileListScrollTop(list.scrollTop); + } + window.requestAnimationFrame(() => { + const row = [...(fileListRef.current?.querySelectorAll("[data-file-index]") ?? [])] + .find((candidate) => Number(candidate.dataset.fileIndex) === nextIndex); + row?.querySelector(".plugin-review__file-open")?.focus({ preventScroll: true }); + }); + }; + + const onFileListKeyDown = (event: React.KeyboardEvent): void => { + if (!groupFiles.length) return; + const target = event.target instanceof Element ? event.target.closest("[data-file-index]") : null; + const activeIndex = target ? Number(target.dataset.fileIndex) + : groupFiles.findIndex((file) => reviewFileKey(groupId, file.path) === activeFileKey); + const current = activeIndex < 0 ? 0 : activeIndex; + const page = Math.max(1, Math.floor(fileListViewportHeight / rowHeight)); + let next = current; + if (event.key === "ArrowDown") next = current + 1; + else if (event.key === "ArrowUp") next = current - 1; + else if (event.key === "PageDown") next = current + page; + else if (event.key === "PageUp") next = current - page; + else if (event.key === "Home") next = 0; + else if (event.key === "End") next = groupFiles.length - 1; + else return; + event.preventDefault(); + focusFileAt(next); + }; + + /** A failed call to the plugin: the person gets a localized line, the details go to the log. */ + const failed = (reason: unknown): void => { + console.error("Change review request failed", reason); + setError(text.requestFailed); + }; + + const drainPendingRefreshRef = useRef<() => void>(() => undefined); + const invokeReview = async (input?: PluginChangeReviewActionInput, replaceScope = false): Promise => { + if (requestInFlightRef.current) { + if (replaceScope) refreshPendingRef.current = true; + return; + } + requestInFlightRef.current = true; + setBusy(true); + setError(""); + const requestEpoch = scopeEpochRef.current; + try { + const result = await invokeActionRef.current(reviewActionId, input); + if (!mountedRef.current || requestEpoch !== scopeEpochRef.current) return; + if (result.review) { + if (replaceScope) { + const available = new Set(result.review.groups.flatMap((candidate) => ( + candidate.files.map((file) => reviewFileKey(candidate.sessionId, file.path)) + ))); + knownFileKeys.current = available; + setSelectedFiles(preselectedFileKeys(result.review.groups)); + setResolutions({}); + setConfirmation(null); + setScopeStale(false); + selectGroup(result.review.groups.find((candidate) => candidate.sessionId === selectedGroupId) ?? result.review.groups[0]); + onReviewChangeRef.current(result.review); + } else { + onReviewChangeRef.current(mergeReview(reviewRef.current, result.review, input?.offset === undefined)); + } + } else if (replaceScope || result.tone === "error") setError(result.message ?? text.groupError); + else onActionResultRef.current(result); + } catch (reason) { + if (mountedRef.current && requestEpoch === scopeEpochRef.current) failed(reason); + } finally { + requestInFlightRef.current = false; + if (mountedRef.current && !refreshPendingRef.current) setBusy(false); + if (refreshPendingRef.current) drainPendingRefreshRef.current(); + } + }; + + const schedulePendingRefresh = (delayMs: number): void => { + if (refreshTimerRef.current !== null) window.clearTimeout(refreshTimerRef.current); + refreshTimerRef.current = window.setTimeout(() => { + refreshTimerRef.current = null; + if (!refreshPendingRef.current || !mountedRef.current || requestInFlightRef.current) return; + refreshPendingRef.current = false; + void invokeReview(undefined, true); + }, delayMs); + }; + + drainPendingRefreshRef.current = () => { + if (!refreshPendingRef.current || requestInFlightRef.current || !mountedRef.current) return; + schedulePendingRefresh(0); + }; + + // A subagent of this card finished or started a turn: the scope may have changed, so the review is fetched again + // and any pending confirmation is dropped. + const queueScopeRefresh = (): void => { + scopeEpochRef.current += 1; + confirmationEpochRef.current = -1; + setConfirmation(null); + setMenuOpen(false); + setResolutions({}); + setScopeStale(true); + setError(""); + refreshPendingRef.current = true; + setBusy(true); + schedulePendingRefresh(60); + }; + const queueScopeRefreshRef = useRef(queueScopeRefresh); + queueScopeRefreshRef.current = queueScopeRefresh; + + useEffect(() => { + let active = true; + const unsubscribe = window.canvasTTY.terminal.onSession(({ session }) => { + if (sessionTrackerRef.current.observe(session)) queueScopeRefreshRef.current(); + }); + void window.canvasTTY.terminal.list().then((sessions) => { + if (active) sessionTrackerRef.current.seedSubtree(sessions); + }).catch(() => undefined); + return () => { + active = false; + unsubscribe(); + }; + }, [cardSessionId]); + + useEffect(() => { + if (!group || !activeFile || activeFile.page !== undefined) return; + void invokeReview({ agentSessionId: group.sessionId, file: activeFile.path, page: 0 }); + }, [groupId, activeFile?.path, activeFile?.page]); + + const loadTaskPage = async (): Promise => { + if (reviewRef.current.nextOffset === undefined) return; + await invokeReview({ offset: reviewRef.current.nextOffset }); + }; + + const loadFilePage = async (file: PluginChangeReviewFile): Promise => { + if (!group) return; + await invokeReview({ agentSessionId: group.sessionId, file: file.path, page: file.page === undefined ? 0 : file.page + 1 }); + }; + + const fullGroupAction = confirmation?.action === "accept-all-group" || confirmation?.action === "reject-group" + || (confirmation?.action === "accept-selected" + && confirmation.files?.length === review.groups.find((candidate) => candidate.sessionId === confirmation.groupId)?.files.length); + const fullAction = fullGroupAction || confirmation?.action === "accept-all-task" || confirmation?.action === "reject-task"; + const confirmationMessage = confirmation + ? `${text[CONFIRMATION_TEXT[confirmation.action]]}${fullAction ? ` ${text.cleanupWarning}` : ""}` + : ""; + const confirmationDanger = confirmation?.action.startsWith("reject") === true; + + /** The files and versions an action applies to, from the selection of this moment. */ + const confirmationFor = (action: ConfirmationAction, targetGroupId?: string): Confirmation => { + const targetGroup = targetGroupId ? review.groups.find((candidate) => candidate.sessionId === targetGroupId) : undefined; + const accepting = action.startsWith("accept"); + const files = action === "accept-selected" + ? (targetGroup?.files ?? []).filter((file) => selectedFiles.has(reviewFileKey(targetGroup!.sessionId, file.path))).map((file) => file.path) + : accepting ? (action === "accept-all-task" ? allFiles.map((file) => file.path) : targetGroup?.files.map((file) => file.path) ?? []) + : undefined; + const chosen: Record = {}; + for (const file of targetGroup?.files ?? []) { + const resolution = resolutions[reviewFileKey(targetGroup!.sessionId, file.path)]; + if (file.conflict && resolution && files?.includes(file.path)) chosen[file.path] = resolution; + } + return { action, ...(targetGroupId ? { groupId: targetGroupId } : {}), ...(files ? { files } : {}), resolutions: chosen }; + }; + + const requestConfirmation = (action: ConfirmationAction, targetGroupId?: string): void => { + confirmationEpochRef.current = scopeEpochRef.current; + setMenuOpen(false); + setConfirmation(confirmationFor(action, targetGroupId)); + }; + + /** A confirmed accept still matches the review: its files exist and every conflict among them has a version. */ + const confirmationStillValid = (pending: Confirmation): boolean => { + if (!pending.action.startsWith("accept") || !pending.files) return true; + const scope = pending.action === "accept-all-task" ? allFiles + : review.groups.find((candidate) => candidate.sessionId === pending.groupId)?.files ?? []; + const byPath = new Map(scope.map((file) => [file.path, file])); + return pending.files.length > 0 && pending.files.every((path) => { + const file = byPath.get(path); + return file !== undefined && (!file.conflict || pending.resolutions[path] !== undefined); + }); + }; + + const confirm = async (): Promise => { + const pending = confirmation; + if (!pending) return; + if (confirmationEpochRef.current !== scopeEpochRef.current || requestInFlightRef.current || mutating) { + setConfirmation(null); + return; + } + if (!confirmationStillValid(pending)) { + setConfirmation(null); + setError(text.selectionChanged); + return; + } + const accepting = pending.action.startsWith("accept"); + const actionId = accepting ? review.acceptActionId : review.rejectActionId; + if (!actionId) { setError(text.actionUnavailable); return; } + const taskWide = pending.action.endsWith("task"); + const input: PluginChangeReviewActionInput = { + ...(pending.groupId && !taskWide ? { agentSessionId: pending.groupId } : {}), + ...(pending.files ? { files: pending.files } : {}), + ...(Object.keys(pending.resolutions).length > 0 ? { resolutions: pending.resolutions } : {}) + }; + requestInFlightRef.current = true; + setMutating(true); + setBusy(true); + setError(""); + const actionEpoch = scopeEpochRef.current; + try { + const result = await invokeActionRef.current(actionId, input); + if (!mountedRef.current || actionEpoch !== scopeEpochRef.current) return; + setConfirmation(null); + if (result.review) { + // A mutation answers with the authoritative remaining scope; only pagination merges into the old one. + const available = new Set(result.review.groups.flatMap((candidate) => + candidate.files.map((file) => reviewFileKey(candidate.sessionId, file.path)))); + knownFileKeys.current = available; + setSelectedFiles(preselectedFileKeys(result.review.groups)); + setResolutions({}); + selectGroup(result.review.groups.find((candidate) => candidate.sessionId === selectedGroupId) ?? result.review.groups[0]); + onReviewChangeRef.current(result.review); + } else if (result.tone === "error") setError(result.message ?? text.groupError); + else onActionResultRef.current(result); + } catch (reason) { + if (actionEpoch === scopeEpochRef.current) failed(reason); + } finally { + requestInFlightRef.current = false; + if (mountedRef.current) setMutating(false); + if (mountedRef.current && !refreshPendingRef.current) setBusy(false); + if (refreshPendingRef.current) drainPendingRefreshRef.current(); + } + }; + + // Escape and the backdrop step back one level: the overflow menu, then a pending confirmation, then the dialog. + const dismiss = (): void => { + if (mutating) return; + if (menuOpen) setMenuOpen(false); + else if (confirmation) setConfirmation(null); + else onClose(); + }; + + // The overflow menu takes focus on its first enabled row; Arrow Up/Down move between enabled rows. + const menuRows = (menu: HTMLElement): HTMLButtonElement[] => [...menu.querySelectorAll("[role=menuitem]:not(:disabled)")]; + const focusFirstMenuRow = (menu: HTMLDivElement | null): void => { + if (menu && !menu.contains(document.activeElement)) menuRows(menu)[0]?.focus(); + }; + const onMenuKeyDown = (event: React.KeyboardEvent): void => { + if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return; + event.preventDefault(); + const rows = menuRows(event.currentTarget); + if (rows.length === 0) return; + const index = rows.indexOf(document.activeElement as HTMLButtonElement); + const step = event.key === "ArrowDown" ? 1 : -1; + rows[index < 0 ? 0 : (index + step + rows.length) % rows.length]!.focus(); + }; + + const title = review.title || text.title; + const selectedCount = text.selected.replace("{count}", String(selectedGroupPaths.length)); + const activeKey = activeFile ? reviewFileKey(groupId, activeFile.path) : ""; + const activeSelected = Boolean(activeFile) && selectedFiles.has(activeKey); + + return ( + { event.preventDefault(); dismiss(); }} + onMouseDown={(event) => { + if (event.target === event.currentTarget) dismiss(); + else if (menuOpen && !(event.target instanceof Element && event.target.closest(".plugin-review__more"))) setMenuOpen(false); + }}> +
+
+
+

{title}

+ {cardTitle && !reviewTitleNamesCard(title, cardTitle, locale) &&

{cardTitle}

} +
+ +
+ + {review.groups.length > 1 && ( +
+ {review.groups.map((candidate) => ( + + ))} +
+ )} + + {group?.error && (groupActive + ?

{group.error}

+ :

{group.error}

)} + {groupFiles.length === 0 && !group?.error &&

{text.noFiles}

} +
+ {groupFiles.length > 0 && ( +
+ +
    setFileListScrollTop(event.currentTarget.scrollTop)} onKeyDown={onFileListKeyDown}> + {fileWindow.topSpacerHeight > 0 &&
  1. + setFileSelected(file.path, event.currentTarget.checked)} /> + + {file.status && {statusLabel(file.status, text)}} +
  2. + ); + })} + {fileWindow.bottomSpacerHeight > 0 &&
+ {review.nextOffset !== undefined && ( + + )} +
+ )} + + {activeFile && ( +
+
+ {activeFile.path} + {activeFile.status && {statusLabel(activeFile.status, text)}} +
+ {activeFile.conflict && ( +
+

+ + {text.conflict} +

+
+ {(["current", "agent"] as const).map((side) => { + const chosen = resolutions[activeKey] === side; + return ( + + ); + })} +
+
+ {(["current", "agent"] as const).map((side) => { + const page = activeFile.conflict![side]; + return ( +
+
+ {page.label || text[side === "current" ? "current" : "agent"]} + {" · "}{pageRange(page)} +
+
{page.text || text.conflictUnavailable}
+
+ ); + })} +
+
+ )} + {activeFile.diff && } + {activeFile.truncated &&

{text.truncated}

} + {!fileReviewLoaded(activeFile) && ( + + )} + {fileReviewLoaded(activeFile) && hasMoreFileContent(activeFile) && ( + + )} +
+ )} +
+ + {(error || scopeStale) && ( +
+ {error &&

{error}

} + {scopeStale && ( + + )} +
+ )} + + {confirmation ? ( +
+

{confirmationMessage}

+
+ {busy && } + + +
+
+ ) : ( +
+ {selectedCount} +
+ {busy && } + {(review.acceptActionId || review.rejectActionId) && group && ( +
+ + {menuOpen && ( +
+ {review.acceptActionId && <> + requestConfirmation("accept-all-group", group.sessionId)}> + {text.acceptGroup} + + requestConfirmation("accept-all-task")}> + {text.acceptTask} + + } + {review.rejectActionId && ( + requestConfirmation("reject-task")}> + {text.rejectTask} + + )} +
+ )} +
+ )} + {review.rejectActionId && group && ( + + )} + {review.acceptActionId && group && ( + + )} +
+
+ )} +
+
+ ); +} + +/** Git's short and long change statuses in the person's language; anything else is shown as the plugin wrote it. */ +const STATUS_TEXT: Record = { + A: "statusAdded", added: "statusAdded", + M: "statusModified", modified: "statusModified", + D: "statusDeleted", deleted: "statusDeleted", + R: "statusRenamed", renamed: "statusRenamed" +}; + +function statusLabel(status: string, text: PluginReviewText): string { + const key = STATUS_TEXT[status.trim()] ?? STATUS_TEXT[status.trim().toLowerCase()]; + return key ? text[key] : status; +} + +/** + * App's entry to the review (loaded with this module on demand): wires the request a card action answered with to the + * plugin call, keeps the review current and returns focus to the card's options button when the dialog closes. + */ +export function PluginReviewHost({ request, locale, setRequest, showToast }: { + request: PluginReviewRequest; + locale: LocaleId; + setRequest: React.Dispatch>; + showToast(message: string): void; +}): React.JSX.Element { + return ( + window.canvasTTY.plugins.invokeCardAction(request.pluginId, actionId, request.sessionId, input)} + onReviewChange={(review) => setRequest((current) => current && { ...current, review })} + onActionResult={(result) => { if (result.message) showToast(result.message); }} + onClose={() => { + setRequest(null); + focusCardOptions(request.sessionId); + }} /> + ); +} + +/** + * Whether the review title already names the card, so the card's title is not repeated under it. A plain terminal's + * default title starts with the English word ("Terminal · project"); a plugin may name it in the app's language. + */ +function reviewTitleNamesCard(title: string, cardTitle: string, locale: LocaleId): boolean { + return title.includes(cardTitle) || title.includes(cardTitle.replace(/^Terminal(?= · )/u, t(locale, "terminal"))); +} + +function pageRange(page: PluginReviewTextPage): string { + return `${page.startLine}–${page.startLine + Math.max(0, page.text.split("\n").length - 1)} / ${page.totalLines}`; +} + +function mergeReview(current: PluginChangeReview, next: PluginChangeReview, preserveTaskOffset: boolean): PluginChangeReview { + const groups = new Map(current.groups.map((group) => [group.sessionId, group])); + for (const incomingGroup of next.groups) { + const previousGroup = groups.get(incomingGroup.sessionId); + if (!previousGroup) { groups.set(incomingGroup.sessionId, incomingGroup); continue; } + const files = new Map(previousGroup.files.map((file) => [file.path, file])); + for (const incomingFile of incomingGroup.files) { + const previousFile = files.get(incomingFile.path); + if (!previousFile) { files.set(incomingFile.path, incomingFile); continue; } + const sameOrNextDiffPage = incomingFile.page === undefined || previousFile.page === undefined + || incomingFile.page <= previousFile.page || incomingFile.page === previousFile.page + 1; + const conflict = incomingFile.conflict && previousFile.conflict + ? { + current: mergeTextPage(previousFile.conflict.current, incomingFile.conflict.current), + agent: mergeTextPage(previousFile.conflict.agent, incomingFile.conflict.agent) + } + : incomingFile.conflict ?? previousFile.conflict; + files.set(incomingFile.path, { + ...previousFile, + ...incomingFile, + diff: incomingFile.page !== undefined && incomingFile.page > (previousFile.page ?? -1) && sameOrNextDiffPage + ? `${previousFile.diff}${incomingFile.diff}` : incomingFile.diff || previousFile.diff, + conflict, + hasMore: incomingFile.hasMore, + page: incomingFile.page ?? previousFile.page + }); + } + groups.set(incomingGroup.sessionId, { ...previousGroup, ...incomingGroup, files: [...files.values()] }); + } + return { + ...current, + ...next, + groups: [...groups.values()], + nextOffset: next.nextOffset ?? (preserveTaskOffset ? current.nextOffset : undefined) + }; +} + +function mergeTextPage(previous: PluginReviewTextPage, next: PluginReviewTextPage): PluginReviewTextPage { + if (next.startLine <= previous.startLine) return next; + const previousLines = previous.text.split("\n"); + const incomingLines = next.text.split("\n"); + const overlap = Math.max(0, previous.startLine + previousLines.length - next.startLine); + return { + ...next, + text: [...previousLines, ...incomingLines.slice(overlap)].join("\n"), + startLine: previous.startLine + }; +} + +function reviewFileKey(sessionId: string, path: string): string { + return `${sessionId}\0${path}`; +} + +/** + * The files selected when a review opens or its scope is replaced: every file, except those of a group whose agent + * is still working (`active`). All of them together could not be accepted yet, so the person picks the part to take. + */ +function preselectedFileKeys(groups: readonly PluginChangeReviewGroup[]): Set { + return new Set(groups.filter((group) => group.active !== true) + .flatMap((group) => group.files.map((file) => reviewFileKey(group.sessionId, file.path)))); +} + +/** A unified diff, each line tinted by its role: added, removed, hunk or file header, context. */ +function DiffText({ diff }: { diff: string }) { + const lines = useMemo(() => diffLines(diff), [diff]); + return ( +
+      
+        {lines.map(([kind, line], index) => (
+          {line}{"\n"}
+        ))}
+      
+    
+ ); +} + +type DiffLineKind = "meta" | "hunk" | "added" | "removed" | "context"; + +/** Inside a hunk a line's first character is its role; before the first hunk of a file every line is a header. */ +function diffLines(diff: string): Array<[DiffLineKind, string]> { + let inHunk = false; + return diff.replace(/\n$/u, "").split("\n").map((line): [DiffLineKind, string] => { + if (line.startsWith("diff ")) inHunk = false; + if (line.startsWith("@@")) { + inHunk = true; + return ["hunk", line]; + } + if (!inHunk) return ["meta", line]; + if (line.startsWith("+")) return ["added", line]; + if (line.startsWith("-")) return ["removed", line]; + return ["context", line]; + }); +} diff --git a/src/renderer/src/features/terminal/TerminalCard.tsx b/src/renderer/src/features/terminal/TerminalCard.tsx index b32ada7a..1bc3eabc 100644 --- a/src/renderer/src/features/terminal/TerminalCard.tsx +++ b/src/renderer/src/features/terminal/TerminalCard.tsx @@ -28,6 +28,7 @@ import { UiIcon } from "../../components/UiIcon"; import { t } from "../../lib/i18n"; import { matchesShortcut } from "../../lib/shortcuts"; import { GitRiskNotice } from "./GitRiskNotice"; +import type { PluginReviewRequest } from "./pluginReviewUiState"; import { isCustomTerminalBorderSkinId, terminalBorderSkinFallback } from "../../lib/skinStyles"; import { sessionStatusLabel } from "../../lib/sessionStatus"; import { attachTerminalMouseCoordinateAdapter, attachTerminalScrollbarCoordinateAdapter } from "./terminalMouseCoordinates"; @@ -52,6 +53,7 @@ import { createPinnedInputRefresh, limitPinnedTerminalInput, pinnedTerminalInput import { terminalLinkTarget } from "./terminalLinkTarget"; import { terminalFileLinkProvider } from "./terminalFileLinks"; import { parseTerminalFileLink } from "../../../../shared/terminalFileLink"; +import { cardActionTitle } from "../../../../shared/pluginCardActions"; import { constrainResize, snapMove, @@ -113,6 +115,8 @@ interface TerminalCardProps { /** Saving sessions is on, so the per-card "Don't restore" choice applies. */ restoreEnabled?: boolean; onOpenUrl(url: string): void; + /** A card action answered with a change review (App shows it in the review dialog). */ + onPluginReview(request: PluginReviewRequest): void; } interface DragState { @@ -184,6 +188,7 @@ function TerminalCardView({ onRestart, onDispose, onOpenUrl, + onPluginReview, restoreEnabled = false }: TerminalCardProps): React.JSX.Element { const borderSkin = terminalBorderSkinFallback(selectedBorderSkin); @@ -246,7 +251,14 @@ function TerminalCardView({ setOptionsOpen(false); setActionRunning(true); void window.canvasTTY.plugins.invokeCardAction(action.pluginId, action.actionId, session.id) - .then((result) => setActionToast({ tone: result.tone, message: result.message ?? `${action.title}: ${t(locale, "cardActionDone")}` })) + .then((result) => { + if (result.review) { + onPluginReview({ pluginId: action.pluginId, actionId: action.actionId, sessionId: session.id, + cardTitle: session.title || session.cwd, review: result.review }); + return; + } + setActionToast({ tone: result.tone, message: result.message ?? `${cardActionTitle(action, locale)}: ${t(locale, "cardActionDone")}` }); + }) .catch((error: unknown) => setActionToast({ tone: "error", message: error instanceof Error ? error.message : String(error) })) .finally(() => setActionRunning(false)); }; @@ -1319,7 +1331,7 @@ function TerminalCardView({ {pluginDecorations.actions.map((action, index) => ( ))} diff --git a/src/renderer/src/features/terminal/cardDialogFocus.ts b/src/renderer/src/features/terminal/cardDialogFocus.ts new file mode 100644 index 00000000..10f3e294 --- /dev/null +++ b/src/renderer/src/features/terminal/cardDialogFocus.ts @@ -0,0 +1,8 @@ +/** Focus goes back to the card whose options menu opened a dialog (the menu itself is closed by then). */ +export function focusCardOptions(sessionId: string): void { + window.requestAnimationFrame(() => { + const card = [...document.querySelectorAll("[data-session-id]")].find((element) => element.dataset.sessionId === sessionId); + card?.querySelector(".terminal-card__action--options")?.focus({ preventScroll: true }); + }); +} + diff --git a/src/renderer/src/features/terminal/cardDialogSurfaces.ts b/src/renderer/src/features/terminal/cardDialogSurfaces.ts new file mode 100644 index 00000000..71929bcb --- /dev/null +++ b/src/renderer/src/features/terminal/cardDialogSurfaces.ts @@ -0,0 +1,5 @@ +import { preloadedSurface } from "../../lib/preloadedSurface"; + +// The card dialogs exist only while they are open, so their code (and their texts) is not part of the startup bundle: +// App preloads it once it is idle, and an open before that loads its chunk at once. +export const pluginReviewHostSurface = preloadedSurface(() => import("./PluginChangesReviewDialog").then((module) => module.PluginReviewHost)); diff --git a/src/renderer/src/features/terminal/pluginReviewText.ts b/src/renderer/src/features/terminal/pluginReviewText.ts new file mode 100644 index 00000000..c5cd14c8 --- /dev/null +++ b/src/renderer/src/features/terminal/pluginReviewText.ts @@ -0,0 +1,97 @@ +import type { LocaleId } from "../../../../shared/contracts"; + +// The change review dialog's texts, loaded only with the lazily loaded dialog (like features/usage/usageText.ts), so +// they never weigh on the app's first paint. +const RU = { + title: "Изменения задачи", + subtasks: "Подзадачи", + files: "Файлы", + selectAll: "Все файлы подзадачи", + selected: "Выбрано файлов: {count}", + noFiles: "Нет файлов для проверки.", + groupError: "Не удалось получить изменения.", + conflict: "Конфликт: выберите версию перед принятием.", + keepVersion: "Какую версию оставить", + current: "Текущая версия", + agent: "Версия агента", + conflictUnavailable: "Для этой версии нет данных.", + truncated: "Показана сокращённая версия.", + loadDiff: "Загрузить изменения", + nextPage: "Следующая страница", + moreFiles: "Загрузить ещё файлы", + refresh: "Обновить", + acceptSelected: "Принять выбранные", + reject: "Отклонить подзадачу", + moreActions: "Другие действия", + acceptGroup: "Принять все файлы подзадачи", + acceptTask: "Принять результат всей задачи", + acceptTaskUnavailable: "Сначала загрузите все файлы и устраните конфликты и повторяющиеся пути.", + activeAcceptAll: "Агент ещё работает: все его файлы можно принять после завершения. Снимите выбор с части файлов, чтобы принять остальные сейчас.", + activeReject: "Агент ещё работает: отклонить его изменения можно после завершения.", + rejectTask: "Отклонить результат всей задачи", + confirmAcceptSelected: "Принять выбранные файлы? Изменения будут записаны в проект.", + confirmAcceptGroup: "Принять все файлы этой подзадачи? Изменения будут записаны в проект.", + confirmAcceptTask: "Принять изменения всех подзадач одним действием?", + confirmRejectGroup: "Отклонить изменения этой подзадачи?", + confirmRejectTask: "Отклонить изменения всей задачи?", + cleanupWarning: "Если агент уже завершился или упал, его отдельный worktree будет удалён вместе с оставшимся в нём локальным состоянием.", + confirm: "Подтвердить", + working: "Выполняется…", + actionUnavailable: "Это действие недоступно.", + selectionChanged: "Изменения обновились: проверьте выбор и подтвердите ещё раз.", + requestFailed: "Не удалось связаться с плагином. Подробности записаны в журнал.", + statusAdded: "добавлен", + statusModified: "изменён", + statusDeleted: "удалён", + statusRenamed: "переименован" +}; + +export type PluginReviewText = typeof RU; + +const EN: PluginReviewText = { + title: "Task changes", + subtasks: "Subtasks", + files: "Files", + selectAll: "All files of this subtask", + selected: "{count} files selected", + noFiles: "No files to review.", + groupError: "Could not read the changes.", + conflict: "Conflict: choose a version before accepting.", + keepVersion: "Version to keep", + current: "Current version", + agent: "Agent version", + conflictUnavailable: "No data for this version.", + truncated: "A shortened version is shown.", + loadDiff: "Load changes", + nextPage: "Next page", + moreFiles: "Load more files", + refresh: "Refresh", + acceptSelected: "Accept selected", + reject: "Reject subtask", + moreActions: "More actions", + acceptGroup: "Accept every file of this subtask", + acceptTask: "Accept the whole task result", + acceptTaskUnavailable: "Load every file first, then resolve conflicts and duplicate paths.", + activeAcceptAll: "This agent is still working: all of its files can be accepted once it finishes. Clear some files to accept the rest now.", + activeReject: "This agent is still working: its changes can be rejected once it finishes.", + rejectTask: "Reject the whole task result", + confirmAcceptSelected: "Accept the selected files? The changes are written to the project.", + confirmAcceptGroup: "Accept every file of this subtask? The changes are written to the project.", + confirmAcceptTask: "Accept the changes of every subtask in one action?", + confirmRejectGroup: "Reject this subtask's changes?", + confirmRejectTask: "Reject the whole task's changes?", + cleanupWarning: "If the agent has already finished or failed, its separate worktree is deleted together with the local state left in it.", + confirm: "Confirm", + working: "Working…", + actionUnavailable: "This action is unavailable.", + selectionChanged: "The changes were updated: check the selection and confirm again.", + requestFailed: "Could not reach the plugin. The details are in the log.", + statusAdded: "added", + statusModified: "modified", + statusDeleted: "deleted", + statusRenamed: "renamed" +}; + +export function pluginReviewText(locale: LocaleId): PluginReviewText { + return locale === "ru" ? RU : EN; +} diff --git a/src/renderer/src/features/terminal/pluginReviewUiState.ts b/src/renderer/src/features/terminal/pluginReviewUiState.ts new file mode 100644 index 00000000..5c9e8abd --- /dev/null +++ b/src/renderer/src/features/terminal/pluginReviewUiState.ts @@ -0,0 +1,93 @@ +import type { PluginChangeReview, SessionMetadata } from "../../../../shared/contracts"; + +/** A card action answered with a change review: App opens the review dialog for it. */ +export interface PluginReviewRequest { + pluginId: string; + actionId: string; + sessionId: string; + /** The card's title or folder. */ + cardTitle: string; + review: PluginChangeReview; +} + +export interface VirtualFileWindow { + startIndex: number; + endIndex: number; + topSpacerHeight: number; + bottomSpacerHeight: number; +} + +export function virtualFileWindow( + itemCount: number, + scrollTop: number, + viewportHeight: number, + rowHeight: number, + overscan = 4 +): VirtualFileWindow { + const count = Math.max(0, Math.floor(itemCount)); + const height = Math.max(1, rowHeight); + const firstVisible = Math.min(count, Math.floor(Math.max(0, scrollTop) / height)); + const visibleEnd = Math.min(count, Math.ceil((Math.max(0, scrollTop) + Math.max(0, viewportHeight)) / height)); + const startIndex = Math.max(0, firstVisible - Math.max(0, Math.floor(overscan))); + const endIndex = Math.min(count, Math.max(visibleEnd, firstVisible + 1) + Math.max(0, Math.floor(overscan))); + return { + startIndex, + endIndex, + topSpacerHeight: startIndex * height, + bottomSpacerHeight: (count - endIndex) * height + }; +} + +type SessionReviewState = Pick; + +/** Tracks only the card's known descendants (and their newly announced children), ignoring PTY/data traffic. */ +export class TaskReviewSessionTracker { + private readonly sessions = new Set(); + private readonly states = new Map(); + private readonly rootSessionId: string; + + constructor(rootSessionId: string) { + this.rootSessionId = rootSessionId; + this.sessions.add(rootSessionId); + } + + include(sessionId: string): void { + this.sessions.add(sessionId); + } + + seedSubtree(sessions: ReadonlyArray>): void { + let changed = true; + while (changed) { + changed = false; + for (const session of sessions) { + if (session.parentSessionId && this.sessions.has(session.parentSessionId) && !this.sessions.has(session.id)) { + this.sessions.add(session.id); + changed = true; + } + } + } + this.sessions.add(this.rootSessionId); + } + + observe(session: SessionMetadata): boolean { + const wasKnown = this.sessions.has(session.id); + const parentIsKnown = Boolean(session.parentSessionId && this.sessions.has(session.parentSessionId)); + if (!wasKnown && !parentIsKnown) return false; + this.sessions.add(session.id); + // The card's own session is the orchestrator working on the task: its turns do not change what its + // subagents left for review, and refreshing on them would drop the person's choices while it works. + if (session.id === this.rootSessionId) return false; + + const previous = this.states.get(session.id); + const next: SessionReviewState = { status: session.status, turnCompleted: session.turnCompleted }; + if (previous?.status === next.status && previous.turnCompleted === next.turnCompleted) return false; + this.states.set(session.id, next); + + const isTerminal = next.status === "done" || next.status === "failed"; + const wasTerminal = previous?.status === "done" || previous?.status === "failed"; + return (isTerminal && (previous === undefined || !wasTerminal)) + || (!isTerminal && next.turnCompleted === true && previous?.turnCompleted !== true) + || (previous !== undefined && next.status === "working" + && (previous.status !== "working" || previous.turnCompleted === true)); + } +} diff --git a/src/renderer/src/features/workspace/WorkspaceCanvas.tsx b/src/renderer/src/features/workspace/WorkspaceCanvas.tsx index 6fef8d6a..0644ed43 100644 --- a/src/renderer/src/features/workspace/WorkspaceCanvas.tsx +++ b/src/renderer/src/features/workspace/WorkspaceCanvas.tsx @@ -51,6 +51,7 @@ import { useRemarkDraft } from "../materials/useRemarkDraft"; import { stickyNoteAtPoint } from "../notes/stickyNoteBounds"; import { PluginCanvasCard } from "../plugins/PluginCanvasCard"; import { TerminalCard } from "../terminal/TerminalCard"; +import type { PluginReviewRequest } from "../terminal/pluginReviewUiState"; import { shouldTogglePixelSkinMasterView } from "../terminal/terminalShortcuts"; import { isPixelSkinThemeId } from "../skins/skinCatalog"; import { isPixelSkinPackId, usePixelSkinPackAssets } from "../skins/SkinAssets"; @@ -145,6 +146,7 @@ interface TerminalCardHandlers { restart(id: string, resume?: boolean): Promise; dispose(id: string, keepEnvironmentData?: boolean): void; openUrl(url: string): void; + pluginReview(request: PluginReviewRequest): void; } /** A group drag's commit basis, frozen once when the press activates: the pressed layer's start @@ -213,6 +215,8 @@ interface WorkspaceCanvasProps { onOpenTerminal(position?: Point): void; onOpenBrowser(position?: Point): void; onOpenTerminalUrl(url: string): void; + /** A card action answered with a change review. */ + onPluginReview(request: PluginReviewRequest): void; onFocusSession(session: SessionSnapshot): void; onResumeHistory(item: AgentChatHistoryItem, position: Point): Promise; activeSessionId: string | null; @@ -286,7 +290,7 @@ export function WorkspaceCanvas(props: WorkspaceCanvasProps): React.JSX.Element const { settings, mediaData, sessions, limits, limitsLoadState, plugins, browser, browserViewVisible, homeEditing, camera, onCameraChange, onGoHome, - onOpenSettings, onOpenAgent, onOpenTerminal, onOpenBrowser, onOpenTerminalUrl, onFocusSession, + onOpenSettings, onOpenAgent, onOpenTerminal, onOpenBrowser, onOpenTerminalUrl, onPluginReview, onFocusSession, activeSessionId, browserSelected, renamingSessionId, fullscreenSessionId, onToggleFullscreen, onSelectSession, onSelectBrowser, onClearCanvasSelection, onRenameSession, onRenameEnd, onRequestMedia, onRemoveMedia, onHomeLayoutChange, onHomeGridSizeChange, @@ -726,7 +730,8 @@ export function WorkspaceCanvas(props: WorkspaceCanvasProps): React.JSX.Element boundsChange: onSessionBoundsChange, restart: onRestartSession, dispose: onDisposeSession, - openUrl: onOpenTerminalUrl + openUrl: onOpenTerminalUrl, + pluginReview: onPluginReview }; const terminalCardCallbacks = useMemo(() => { const latest = terminalCardHandlers; @@ -735,7 +740,8 @@ export function WorkspaceCanvas(props: WorkspaceCanvasProps): React.JSX.Element onRenameEnd: () => latest.current!.renameEnd(), onRestart: (id: string, resume?: boolean) => latest.current!.restart(id, resume), onDispose: (id: string, keepEnvironmentData?: boolean) => latest.current!.dispose(id, keepEnvironmentData), - onOpenUrl: (url: string) => latest.current!.openUrl(url) + onOpenUrl: (url: string) => latest.current!.openUrl(url), + onPluginReview: (request: PluginReviewRequest) => latest.current!.pluginReview(request) }; return { canvas: { diff --git a/src/renderer/src/lib/preloadedSurface.ts b/src/renderer/src/lib/preloadedSurface.ts new file mode 100644 index 00000000..74d2a7fd --- /dev/null +++ b/src/renderer/src/lib/preloadedSurface.ts @@ -0,0 +1,63 @@ +import { useEffect, useState } from "react"; + +/** + * A surface (a menu, a palette) whose code is kept out of the startup bundle but still opens at once. Its chunk is + * loaded once, either ahead of time (`preloadWhenIdle`) or by the first open, and the loaded component is then + * rendered directly. It is not a `React.lazy` behind `Suspense`: React holds a suspended boundary's reveal for up to + * 300 ms, which delayed the first right-click and the first Cmd/Ctrl+K by that much. + */ +export interface PreloadedSurface { + /** Starts loading (once; a failed load is tried again next time) and settles with the loaded value. */ + load(): Promise; + /** The loaded value, or null while it has not loaded. */ + loaded(): T | null; +} + +export function preloadedSurface(load: () => Promise): PreloadedSurface { + let value: T | null = null; + let pending: Promise | null = null; + return { + load() { + pending ??= load().then((loaded) => { + value = loaded; + return loaded; + }, (error: unknown) => { + pending = null; + throw error; + }); + return pending; + }, + loaded: () => value + }; +} + +/** + * The surface's value for a render that shows it (`wanted`): the loaded one at once, or null while a load this open + * started is still running (the caller renders nothing until it arrives, then re-renders). + */ +export function usePreloadedSurface(surface: PreloadedSurface, wanted: boolean): T | null { + const [, setLoaded] = useState(0); + const value = surface.loaded(); + useEffect(() => { + if (!wanted || surface.loaded() !== null) return; + let live = true; + surface.load().then(() => { + if (live) setLoaded((count) => count + 1); + }, (error: unknown) => console.error("CanvasTTY could not load a menu", error)); + return () => { live = false; }; + }, [surface, wanted]); + return wanted ? value : null; +} + +/** Loads the surfaces once the app is idle after its first paint; returns a cancel for the pending start. */ +export function preloadWhenIdle(surfaces: readonly PreloadedSurface[]): () => void { + const start = (): void => { + for (const surface of surfaces) void surface.load().catch(() => undefined); + }; + if (typeof window.requestIdleCallback === "function") { + const handle = window.requestIdleCallback(start, { timeout: 2_000 }); + return () => window.cancelIdleCallback(handle); + } + const timer = window.setTimeout(start, 200); + return () => window.clearTimeout(timer); +} diff --git a/src/renderer/src/styles/app.css b/src/renderer/src/styles/app.css index f4786c22..da3569b8 100644 --- a/src/renderer/src/styles/app.css +++ b/src/renderer/src/styles/app.css @@ -345,6 +345,79 @@ button { border: 0; } .shortcut-reference-dialog__note { margin: 12px 0 0; color: var(--text-muted-on-dark); line-height: 1.4; text-wrap: pretty; } .shortcut-reference-dialog__contexts { padding-top: 14px; border-top: 1px solid color-mix(in srgb, var(--text) 12%, transparent); } .shortcut-reference-dialog__contexts .shortcut-reference { grid-template-columns: repeat(4, minmax(0, 1fr)); gap: clamp(14px, 2vw, 28px); } +/* Card dialogs: the shared frame of a native modal a card opens, inside .app, tokens and em only. */ +.card-dialog { width: min(60em, calc(100vw - 3em)); max-width: none; max-height: calc(100vh - 3em); padding: 0; overflow: hidden; border: 1px solid color-mix(in srgb, var(--text) 12%, transparent); border-radius: var(--radius-xl); color: var(--text); background: var(--surface); box-shadow: var(--shadow-lg); font-size: calc(13px * var(--ui-scale, 1)); } +.card-dialog::backdrop { background: color-mix(in srgb, var(--surface) 56%, transparent); backdrop-filter: blur(.8em); } +.card-dialog__panel { max-height: calc(100vh - 3em); padding: 1.5em; display: flex; flex-direction: column; gap: 1em; } +.card-dialog__header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1.5em; } +.card-dialog__header > div { min-width: 0; } +.card-dialog__header h2 { margin: 0; font-size: 1.45em; } +.card-dialog__header p { margin: .35em 0 0; overflow: hidden; color: var(--text-muted-on-dark); text-overflow: ellipsis; white-space: nowrap; } +.card-dialog__icon-button { width: 2.45em; height: 2.45em; flex: 0 0 auto; display: grid; place-items: center; border-radius: .7em; outline: 0; color: var(--text-muted-on-dark); background: var(--surface-soft); cursor: pointer; } +.card-dialog__icon-button:hover, .card-dialog__icon-button:focus-visible { color: var(--text); box-shadow: inset 0 0 0 .15em var(--secondary); } +.card-dialog__icon-button:disabled { cursor: not-allowed; opacity: .5; } +.card-dialog__muted { margin: 0; color: var(--text-muted-on-dark); font-size: .88em; } +.card-dialog__error { margin: 0; color: var(--danger); font-weight: 700; } +.card-dialog__footer { display: flex; align-items: center; justify-content: space-between; gap: 1em; padding-top: 1em; border-top: 1px solid color-mix(in srgb, var(--text) 12%, transparent); } +.card-dialog__footer--confirm p { margin: 0; flex: 1; line-height: 1.4; } +.card-dialog__actions { margin-left: auto; display: flex; align-items: center; justify-content: flex-end; gap: .6em; } +.card-dialog__busy { display: inline-flex; color: var(--text-muted-on-dark); } +.card-dialog__busy .ui-icon--working { animation: spin 1.2s linear infinite; } +.card-dialog__primary, .card-dialog__secondary, .card-dialog__danger { min-height: 2.6em; padding: .55em 1.05em; border-radius: .75em; outline: 0; cursor: pointer; font-weight: 750; white-space: nowrap; } +.card-dialog__primary { color: var(--text-dark); background: var(--primary); } +.card-dialog__primary:hover:not(:disabled) { background: var(--primary-hover); } +.card-dialog__secondary { color: var(--text); background: var(--surface-soft); } +.card-dialog__danger { color: var(--text-dark); background: var(--danger); } +.card-dialog__primary:focus-visible, .card-dialog__secondary:focus-visible, .card-dialog__danger:focus-visible { box-shadow: 0 0 0 .15em var(--surface), 0 0 0 .3em var(--secondary); } +/* Disabled actions stay readable on every palette: a quiet surface with muted text instead of a faded primary. */ +/* Disabled: quiet (no fill, a hairline, muted text) so it never reads as an available action, yet readable. */ +.card-dialog__primary:disabled, .card-dialog__secondary:disabled, .card-dialog__danger:disabled { cursor: not-allowed; color: var(--text-muted-on-dark); background: transparent; box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text) 16%, transparent); font-weight: 600; } +/* End of card dialogs. */ +/* Change review of a plugin card action (PluginChangesReviewDialog): the card dialog frame plus its file list and preview. */ +.plugin-review { width: min(68em, calc(100vw - 3em)); } +.plugin-review__groups { grid-auto-flow: row; grid-template-columns: repeat(auto-fill, minmax(12em, 1fr)); } +.plugin-review__groups .segmented__button { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.plugin-review__warning { line-height: 1.4; } +/* A running agent's group: an informational notice in the warning role, not an error. */ +.plugin-review__notice { margin: 0; color: var(--yellow); font-weight: 700; } +.plugin-review__body { min-height: 0; flex: 1; display: grid; grid-template-columns: minmax(15em, 1fr) minmax(0, 2.4fr); align-items: start; gap: 1em; } +.plugin-review__files { min-height: 0; display: flex; flex-direction: column; gap: .5em; } +.plugin-review__select-all { display: flex; align-items: center; gap: .6em; padding: .2em .55em; color: var(--text-muted-on-dark); cursor: pointer; } +.plugin-review input[type="checkbox"] { width: 1.15em; height: 1.15em; margin: 0; flex: 0 0 auto; accent-color: var(--primary); } +.plugin-review__file-list { min-height: 10em; max-height: 50vh; flex: 1; margin: 0; padding: .3em; overflow: auto; list-style: none; border-radius: var(--radius-md); background: color-mix(in srgb, var(--surface-soft) 60%, var(--surface)); } +.plugin-review__file-row { height: 3.2em; padding: 0 .55em; display: flex; align-items: center; gap: .6em; border-radius: .7em; } +.plugin-review__file-row--active { background: var(--surface-soft); } +.plugin-review__file-open { min-width: 0; flex: 1; padding: .45em .2em; display: flex; align-items: center; gap: .5em; border-radius: .5em; outline: 0; color: var(--text); background: transparent; cursor: pointer; text-align: left; } +.plugin-review__file-open:focus-visible { box-shadow: inset 0 0 0 .15em var(--secondary); } +.plugin-review__file-open code { min-width: 0; overflow: hidden; font: .92em/1.3 var(--font-mono); text-overflow: ellipsis; white-space: nowrap; } +.plugin-review__file-row small { flex: 0 0 auto; color: var(--text-muted-on-dark); font-size: .85em; } +.plugin-review__file-row .ui-icon--attention { color: var(--yellow); } +.plugin-review__preview { min-width: 0; min-height: 0; max-height: 58vh; display: flex; flex-direction: column; gap: .7em; overflow: auto; } +.plugin-review__preview:only-child { grid-column: 1 / -1; } +.plugin-review__preview > header { display: flex; align-items: baseline; gap: .8em; } +.plugin-review__preview > header code { min-width: 0; overflow-wrap: anywhere; font: 600 .95em/1.35 var(--font-mono); } +.plugin-review__preview > header span { color: var(--text-muted-on-dark); font-size: .85em; } +.plugin-review__conflict { display: flex; flex-direction: column; gap: .6em; } +.plugin-review__conflict-note { margin: 0; display: flex; align-items: center; gap: .5em; color: var(--yellow); font-weight: 700; } +.plugin-review__versions { width: min(24em, 100%); } +.plugin-review__sides { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .6em; } +.plugin-review__sides figure { min-width: 0; margin: 0; display: flex; flex-direction: column; gap: .35em; } +.plugin-review__sides figcaption { color: var(--text-muted-on-dark); font-size: .85em; } +.plugin-review pre { min-height: 4em; max-height: 22em; margin: 0; padding: .8em; overflow: auto; border-radius: var(--radius-sm); color: var(--text); background: color-mix(in srgb, var(--surface-soft) 60%, var(--surface)); font: .88em/1.45 var(--font-mono); white-space: pre; } +.plugin-review pre.plugin-review__diff { width: 100%; min-width: 100%; box-sizing: border-box; max-height: 46vh; } +.plugin-review__diff > code { min-width: 100%; display: inline-block; font: inherit; } +.plugin-review__line { display: block; margin: 0 -.8em; padding: 0 .8em; } +.plugin-review__line--added { background: color-mix(in srgb, var(--success) 18%, transparent); } +.plugin-review__line--removed { background: color-mix(in srgb, var(--danger) 18%, transparent); } +.plugin-review__line--hunk { color: var(--text-muted-on-dark); font-weight: 700; } +.plugin-review__line--meta { color: var(--text-muted-on-dark); } +.plugin-review__empty { margin: 0; color: var(--text-muted-on-dark); } +.plugin-review__status-row { display: flex; align-items: center; justify-content: space-between; gap: 1em; } +.plugin-review__more { position: relative; } +.plugin-review__menu { right: 0; bottom: calc(100% + .5em); min-width: 22em; } +@media (max-width: 760px) { .plugin-review__body, .plugin-review__sides { grid-template-columns: minmax(0, 1fr); } } +.plugin-review .card-dialog__secondary { align-self: flex-start; } +/* End of change review. */ .shortcut-reference { margin: 0; display: grid; gap: clamp(5px, 1vh, 10px); } .shortcut-reference > div { display: grid; align-content: start; gap: 4px; line-height: 1.35; } .shortcut-reference dt, .shortcut-reference dd { margin: 0; } diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index 0e798041..606c2cc0 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -914,7 +914,12 @@ export interface PluginCardActionFilter { export interface PluginCardAction { id: string; title: string; + /** The title in that UI language (like the manifest's `description.ru`); `title` otherwise. */ + "title.ru"?: string; + "title.en"?: string; when?: PluginCardActionFilter; + /** False: not listed in the card menu; only a change review calls it (as its accept or reject action). */ + menu?: false; } export type PluginCardTone = "neutral" | "info" | "warn" | "error"; @@ -932,6 +937,8 @@ export interface PluginCardActionEntry { pluginName: string; actionId: string; title: string; + "title.ru"?: string; + "title.en"?: string; when?: PluginCardActionFilter; } @@ -964,6 +971,8 @@ export interface PluginChangeReviewGroup { title: string; files: PluginChangeReviewFile[]; error?: string; + /** The group's agent is still working: accepting all of its files, rejecting it and the task-wide actions wait. */ + active?: boolean; } /** Plain-text review supplied by an existing plugin; actions still require a human click. */ diff --git a/src/shared/pluginCardActions.ts b/src/shared/pluginCardActions.ts index 5aa4a6c5..0426cda1 100644 --- a/src/shared/pluginCardActions.ts +++ b/src/shared/pluginCardActions.ts @@ -1,4 +1,4 @@ -import type { PluginCardActionFilter } from "./contracts.ts"; +import type { LocaleId, PluginCardActionEntry, PluginCardActionFilter } from "./contracts.ts"; /** Every key the filter lists must match; a card outside an environment never matches `environmentKinds`. */ export function cardActionMatches( @@ -11,3 +11,9 @@ export function cardActionMatches( if (when.environmentKinds && (!session.environment || !when.environmentKinds.includes(session.environment.kind))) return false; return true; } + +/** A card action's title in the UI language, as a manifest's `description.ru`/`description.en`; `title` otherwise. */ +export function cardActionTitle(action: Pick, locale: LocaleId): string { + const localized = locale === "ru" ? action["title.ru"] : locale === "en" ? action["title.en"] : undefined; + return localized || action.title; +} diff --git a/tests/app-shortcuts.test.mjs b/tests/app-shortcuts.test.mjs index 11e2aef6..81346f18 100644 --- a/tests/app-shortcuts.test.mjs +++ b/tests/app-shortcuts.test.mjs @@ -146,7 +146,7 @@ test("App restores native Command+A before its native-input guard without interc const actions = []; const window = { canvasTTY: { window: { isMacOS: true } } }; const settings = { shortcuts: { toggleFullscreen: "Meta+F", home: "Home", renameWindow: "F2" } }; - const createHandler = new Function("window", "settings", "performShortcut", "handleMacNativeSelectAll", "shouldKeepNativeKeyboardInput", "isShortcutCaptureTarget", "isRenameInputTarget", "matchesShortcut", "shortcutReferenceOpen", "handoffRemarkIds", "usageHistoryOpen", `return (event) => {${body}}`); + const createHandler = new Function("window", "settings", "performShortcut", "handleMacNativeSelectAll", "shouldKeepNativeKeyboardInput", "isShortcutCaptureTarget", "isRenameInputTarget", "matchesShortcut", "modalDialogOpen", "handoffRemarkIds", "usageHistoryOpen", `return (event) => {${body}}`); const handoffHandler = (handoffRemarkIds = null) => createHandler(window, settings, (action) => actions.push(action), handleMacNativeSelectAll, shouldKeepNativeKeyboardInput, isShortcutCaptureTarget, isRenameInputTarget, matchesShortcut, false, handoffRemarkIds, false); const handler = handoffHandler(); diff --git a/tests/card-dialog-surfaces.test.mjs b/tests/card-dialog-surfaces.test.mjs new file mode 100644 index 00000000..f487f488 --- /dev/null +++ b/tests/card-dialog-surfaces.test.mjs @@ -0,0 +1,112 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { build } from "esbuild"; + +// The card dialogs are kept out of the startup bundle, but a lazy() behind made their first open wait +// about 300 ms (React holds a suspended boundary's reveal that long). App preloads them once it is idle and renders +// them directly: once loaded, the very first render of an open dialog already contains it. Rendered here with the +// real React and react-dom/server, through the real dynamic imports of cardDialogSurfaces. +const root = fileURLToPath(new URL("..", import.meta.url)); +const LOADERS = { ".svg": "text", ".css": "empty", ".png": "empty", ".ttf": "empty", ".woff2": "empty", ".ico": "empty" }; +const SURFACES = [ + { name: "change review", surface: "pluginReviewHostSurface", chunk: /PluginChangesReviewDialog-/u, props: { + locale: "en", setRequest: () => undefined, showToast: () => undefined, + request: { pluginId: "plugin", actionId: "review", sessionId: "card", cardTitle: "Card", review: { + acceptActionId: "accept", rejectActionId: "reject", groups: [{ sessionId: "worker", title: "Worker", files: [{ path: "a.txt", diff: "+a", page: 0 }] }] + } } + } } +]; + +async function loadBundle(t) { + const outdir = await mkdtemp(join(tmpdir(), "canvastty-dialogs-")); + t.after(() => rm(outdir, { recursive: true, force: true })); + await build({ + stdin: { + contents: [ + 'export * from "./src/renderer/src/features/terminal/cardDialogSurfaces";', + 'export { usePreloadedSurface } from "./src/renderer/src/lib/preloadedSurface";', + 'export { createElement } from "react";', + 'export { renderToStaticMarkup } from "react-dom/server";' + ].join("\n"), + resolveDir: root, + loader: "ts" + }, + jsx: "automatic", bundle: true, platform: "node", format: "cjs", outfile: join(outdir, "dialogs.cjs"), logLevel: "silent", + define: { "process.env.NODE_ENV": '"production"' }, loader: LOADERS + }); + return createRequire(import.meta.url)(join(outdir, "dialogs.cjs")); +} + +for (const entry of SURFACES) test(`a preloaded ${entry.name} dialog renders on its first open, with no Suspense wait`, async (t) => { + const previousWindow = globalThis.window; + globalThis.window = { setTimeout, clearTimeout, requestAnimationFrame: () => 0, canvasTTY: { window: { isMacOS: false } } }; + t.after(() => { globalThis.window = previousWindow; }); + const bundle = await loadBundle(t); + const surface = bundle[entry.surface]; + const { createElement, renderToStaticMarkup, usePreloadedSurface } = bundle; + // App's own pattern: `{request && Host && }`. + const Host = ({ open }) => { + const Dialog = usePreloadedSurface(surface, open); + return open && Dialog ? createElement(Dialog, entry.props) : null; + }; + assert.equal(surface.loaded(), null, "nothing is loaded at startup"); + assert.equal(renderToStaticMarkup(createElement(Host, { open: true })), "", "an open before the preload renders nothing until its chunk arrives"); + await surface.load(); + assert.match(renderToStaticMarkup(createElement(Host, { open: true })), /]*class="card-dialog /u, + "the first render of the open dialog contains the dialog"); +}); + +test("App renders the card dialogs through preloaded surfaces, and their code stays out of the startup bundle", async (t) => { + const app = await readFile(join(root, "src/renderer/src/App.tsx"), "utf8"); + for (const entry of SURFACES) { + const host = entry.surface.replace(/Surface$/u, "").replace(/^./u, (first) => first.toUpperCase()); + assert.doesNotMatch(app, new RegExp(`const ${host} = lazy\\(`, "u"), `${host} is not a lazy() behind Suspense`); + assert.match(app, new RegExp(`const ${host} = usePreloadedSurface\\(${entry.surface}, `, "u")); + assert.match(app, new RegExp(`preloadWhenIdle\\(\\[[^\\]]*${entry.surface}`, "u"), `${host} is preloaded once the app is idle`); + } + const outdir = await mkdtemp(join(tmpdir(), "canvastty-dialogs-split-")); + t.after(() => rm(outdir, { recursive: true, force: true })); + const { metafile } = await build({ + entryPoints: [join(root, "src/renderer/src/App.tsx")], bundle: true, splitting: true, platform: "browser", format: "esm", + outdir, write: true, metafile: true, logLevel: "silent", loader: LOADERS + }); + const appPath = Object.keys(metafile.outputs).find((path) => path.endsWith("App.js")); + const eager = new Set(); + const visit = (path) => { + if (eager.has(path)) return; + eager.add(path); + for (const imported of metafile.outputs[path].imports) if (imported.kind === "import-statement") visit(imported.path); + }; + visit(appPath); + for (const entry of SURFACES) { + const chunk = Object.keys(metafile.outputs).find((path) => entry.chunk.test(path)); + assert.ok(chunk, `the ${entry.name} dialog is its own chunk`); + assert.ok(!eager.has(chunk), `the ${entry.name} dialog is reachable only through a dynamic import()`); + } +}); + +test("preloadWhenIdle loads once the app is idle (bounded); a failed load is tried again by the next open", async (t) => { + const { preloadedSurface, preloadWhenIdle } = await import("../src/renderer/src/lib/preloadedSurface.ts"); + let calls = 0, fail = true; + const surface = preloadedSurface(async () => { calls++; if (fail) throw new Error("chunk failed"); return "loaded"; }); + const idle = []; + const previousWindow = globalThis.window; + globalThis.window = { requestIdleCallback: (callback, options) => idle.push({ callback, options }), cancelIdleCallback: () => undefined }; + t.after(() => { globalThis.window = previousWindow; }); + preloadWhenIdle([surface]); + assert.equal(calls, 0, "nothing loads before the browser is idle"); + assert.ok(idle[0].options.timeout > 0); + idle[0].callback(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(calls, 1); + assert.equal(surface.loaded(), null); + fail = false; + assert.equal(await surface.load(), "loaded"); + assert.equal(surface.load(), surface.load(), "one load once it succeeded"); + assert.equal(calls, 2); +}); diff --git a/tests/plugin-change-review.test.mjs b/tests/plugin-change-review.test.mjs index b9774a64..787a4f85 100644 --- a/tests/plugin-change-review.test.mjs +++ b/tests/plugin-change-review.test.mjs @@ -65,3 +65,72 @@ test("human card action input is a bounded JSON object and is copied before plug const cycle = {}; cycle.self = cycle; assert.throws(() => normalizeCardActionInput(cycle), /must be JSON/u); }); + +test("review-only actions (menu: false) stay out of the card menu and never run without the review's input", async () => { + const { validatePluginManifest } = await import("../src/main/services/PluginManager.ts"); + const { readFile } = await import("node:fs/promises"); + const example = JSON.parse(await readFile(new URL("../examples/plugins/collect-demo/canvastty.plugin.json", import.meta.url), "utf8")); + const manifest = (cardActions) => ({ ...example, services: [{ ...example.services[0], cardActions }] }); + const validated = validatePluginManifest(manifest([{ id: "review", title: "Review" }, { id: "accept", title: "Accept", menu: false }])); + assert.deepEqual(validated.services[0].cardActions, [{ id: "review", title: "Review" }, { id: "accept", title: "Accept", menu: false }]); + assert.throws(() => validatePluginManifest(manifest([{ id: "accept", title: "Accept", menu: true }])), /menu must be false/u); + + const calls = []; + const cards = new PluginCards({ + providers: () => [{ pluginId: "reviews", pluginName: "Reviews", serviceId: "results", actions: validated.services[0].cardActions }], + trustedPlugins: () => new Set(["reviews"]), + session: (id) => id === "root" ? { id: "root", role: "orchestrator", provider: "codex" } : null, + call: async (_plugin, _service, _method, params) => { calls.push(params); return { tone: "info" }; }, + redact: (text) => text, changed() {} + }); + assert.deepEqual(cards.decorations().actions.map((action) => action.actionId), ["review"], "only the menu action is listed"); + await assert.rejects(cards.invoke("reviews", "accept", "root"), /only from a change review/u); + assert.equal(calls.length, 0); + await cards.invoke("reviews", "accept", "root", { files: ["a.txt"] }); + await cards.invoke("reviews", "accept", "root", {}); + assert.deepEqual(calls.map((call) => call.input), [{ files: ["a.txt"] }, {}]); +}); + +test("card action titles may be localized like the manifest description; the card menu shows the UI language's", async () => { + const { validatePluginManifest } = await import("../src/main/services/PluginManager.ts"); + const { cardActionTitle } = await import("../src/shared/pluginCardActions.ts"); + const { readFile } = await import("node:fs/promises"); + const example = JSON.parse(await readFile(new URL("../examples/plugins/collect-demo/canvastty.plugin.json", import.meta.url), "utf8")); + const manifest = (cardActions) => ({ ...example, services: [{ ...example.services[0], cardActions }] }); + const localized = { id: "review", title: "Review changes", "title.ru": "Проверить изменения", "title.en": "Review changes" }; + const validated = validatePluginManifest(manifest([localized, { id: "plain", title: "Plain" }])); + assert.deepEqual(validated.services[0].cardActions, [localized, { id: "plain", title: "Plain" }]); + assert.throws(() => validatePluginManifest(manifest([{ ...localized, "title.ru": "ж".repeat(41) }])), /title\.ru/u, "40 characters, like title"); + assert.throws(() => validatePluginManifest(manifest([{ ...localized, "title.ru": "" }])), /title\.ru/u); + assert.throws(() => validatePluginManifest(manifest([{ ...localized, "title.de": "Prüfen" }])), /title\.de/u, "only the app's languages"); + + const cards = new PluginCards({ + providers: () => [{ pluginId: "reviews", pluginName: "Reviews", serviceId: "results", actions: validated.services[0].cardActions }], + trustedPlugins: () => new Set(["reviews"]), session: () => null, call: async () => null, redact: (text) => text, changed() {} + }); + const [entry, plain] = cards.decorations().actions; + assert.equal(entry["title.ru"], "Проверить изменения", "the decorations carry the localized titles to the renderer"); + assert.equal(cardActionTitle(entry, "ru"), "Проверить изменения"); + assert.equal(cardActionTitle(entry, "en"), "Review changes"); + assert.equal(cardActionTitle(plain, "ru"), "Plain", "without a title for the language: title"); + assert.equal(cardActionTitle({ title: "Review", "title.en": "Review EN" }, "ru"), "Review"); + + // The card's options menu and its "done" toast show the localized title. + const card = await readFile(new URL("../src/renderer/src/features/terminal/TerminalCard.tsx", import.meta.url), "utf8"); + assert.match(card, /\{cardActionTitle\(action, locale\)\}\n\s*<\/button>/u); + assert.match(card, /`\$\{cardActionTitle\(action, locale\)\}: \$\{t\(locale, "cardActionDone"\)\}`/u); + assert.doesNotMatch(card, /\{action\.title\}/u); +}); + +test("bidirectional controls are removed from review text and refused in file names", () => { + const actions = new Set(["accept", "reject"]); + const value = review(); + value.title = "Res\u202Eults\u2066"; + value.groups[0].files[0].diff = "+ ok\u202E // tricky\u2069"; + const normalized = normalizeCardReview(value, mask, actions); + assert.equal(normalized.title, "Results"); + assert.equal(normalized.groups[0].files[0].diff, "+ ok // tricky"); + const named = review(); + named.groups[0].files[0].path = "src/\u202Etxt.exe"; + assert.throws(() => normalizeCardReview(named, mask, actions), /file path is invalid/u); +}); diff --git a/tests/plugin-review-mutation.test.mjs b/tests/plugin-review-mutation.test.mjs new file mode 100644 index 00000000..05037f4d --- /dev/null +++ b/tests/plugin-review-mutation.test.mjs @@ -0,0 +1,217 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import {readFile} from 'node:fs/promises'; +import {runInNewContext} from 'node:vm'; +import {normalizeCardActionInput,normalizeCardReview} from '../src/main/services/PluginChangeReviews.ts'; +import {PluginCards} from '../src/main/services/PluginCards.ts'; +import {importWithFakeReact,findAll,tick} from './helpers/fake-react.mjs'; + +const dialog=await importWithFakeReact('src/renderer/src/features/terminal/PluginChangesReviewDialog.tsx','PluginChangesReviewDialog'); +const file=path=>({path,diff:'+change',page:0}); +const ROW_HEIGHT=42; + +/** Renders the dialog with fake React; `click(label)` presses a button or overflow menu row by its visible text. */ +function harness(t,props){ + const oldWindow=globalThis.window; + globalThis.window={setTimeout,clearTimeout,requestAnimationFrame:()=>0,canvasTTY:{terminal:{onSession:()=>()=>{},list:async()=>[]}}}; + t.after(()=>{dialog.__unmount();globalThis.window=oldWindow;});dialog.__reset(); + let tree; + const render=()=>{dialog.__flush();tree=dialog.__render(dialog.PluginChangesReviewDialog,props);return tree;}; + const byLabel=label=>findAll(tree,node=>node.props?.children===label&&typeof node.props.onClick==='function')[0]; + const click=label=>{const target=byLabel(label);assert.ok(target,label);assert.equal(Boolean(target.props.disabled),false,label);target.props.onClick({currentTarget:{}});render();}; + const openMenu=()=>{const more=findAll(tree,node=>node.type==='button'&&node.props['aria-haspopup']==='menu')[0];assert.ok(more,'overflow menu');more.props.onClick();render();}; + return {render,click,openMenu,byLabel,tree:()=>tree}; +} + +for(const [action,viaMenu] of [['Accept selected',false],['Reject subtask',false],['Accept every file of this subtask',true]])test(`mutation replaces the remaining review scope: ${action}`,async t=>{ + const calls=[],remaining={acceptActionId:'accept',rejectActionId:'reject',groups:[{sessionId:'second',title:'Second',files:[file('remaining.txt')]}]}; + const props={cardSessionId:'root',reviewActionId:'review',locale:'en',review:{acceptActionId:'accept',rejectActionId:'reject',groups:[ + {sessionId:'first',title:'First',files:[file('removed.txt')]},{sessionId:'second',title:'Second',files:[file('remaining.txt')]}]}, + invokeAction:async(id,input)=>{calls.push({id,input});return {review:remaining};},onReviewChange:review=>{props.review=review;},onActionResult(){},onClose(){}}; + const ui=harness(t,props); + ui.render();await tick();ui.render(); + if(viaMenu)ui.openMenu(); + ui.click(action); + assert.equal(calls.length,0,'nothing is sent before the explicit confirmation'); + ui.click('Confirm');await tick();ui.render();ui.render(); + assert.equal(props.review,remaining,'mutation payload replaces cached groups');assert.equal(ui.byLabel('Confirm'),undefined); + assert.ok(!JSON.stringify(ui.tree()).includes('removed.txt')); + ui.click('Accept selected');ui.click('Confirm');await tick();ui.render(); + assert.deepEqual(calls[1],{id:'accept',input:{agentSessionId:'second',files:['remaining.txt']}}); +}); + +test('Escape steps back: the overflow menu, then the confirmation, then the dialog; a running mutation keeps it open',async t=>{ + let closed=0,release; + const props={cardSessionId:'root',reviewActionId:'review',locale:'en',review:{acceptActionId:'accept',rejectActionId:'reject',groups:[{sessionId:'w',title:'W',files:[file('a.txt')]}]}, + invokeAction:(id)=>id==='review'?Promise.resolve({tone:'info'}):new Promise(resolve=>{release=resolve;}),onReviewChange(){},onActionResult(){},onClose(){closed++;}}; + const ui=harness(t,props);ui.render();await tick();ui.render(); + const cancel=()=>{ui.tree().props.onCancel({preventDefault(){}});ui.render();}; + ui.openMenu();assert.ok(findAll(ui.tree(),node=>node.props?.role==='menu').length); + cancel();assert.equal(findAll(ui.tree(),node=>node.props?.role==='menu').length,0);assert.equal(closed,0); + ui.click('Reject subtask');cancel();assert.equal(ui.byLabel('Confirm'),undefined);assert.equal(closed,0); + ui.click('Reject subtask');ui.click('Confirm');cancel();assert.equal(closed,0,'a running accept/reject cannot be dismissed'); + release({tone:'info'});await tick();ui.render();cancel();assert.equal(closed,1); +}); + +test('a reject confirmation is a danger action; the dialog localizes its texts (ru)',async t=>{ + const props={cardSessionId:'root',cardTitle:'/project',reviewActionId:'review',locale:'ru',review:{title:'',rejectActionId:'reject',groups:[{sessionId:'w',title:'W',files:[file('a.txt')]}]}, + invokeAction:async()=>({tone:'info'}),onReviewChange(){},onActionResult(){},onClose(){}}; + const ui=harness(t,props);ui.render();await tick();ui.render(); + const json=JSON.stringify(ui.tree()); + assert.match(json,/Изменения задачи/u);assert.match(json,/\/project/u);assert.doesNotMatch(json,/Task changes|Reject subtask/u); + ui.click('Отклонить подзадачу'); + const confirm=ui.byLabel('Подтвердить');assert.equal(confirm.props.className,'card-dialog__danger'); +}); + +const supervisorSource=await readFile(new URL('../src/main/services/PluginServiceSupervisor.ts',import.meta.url),'utf8'); +const serializeFrame=supervisorSource.match(/frame = (JSON\.stringify\(\{ jsonrpc: "2.0", id, method, params: params === undefined \? null : params \}\));/u)?.[1]; +assert.ok(serializeFrame,'test uses the actual outbound plugin frame serialization'); +const frameLimit=Number(supervisorSource.match(/const PLUGIN_SERVICE_MAX_FRAME_BYTES = (\d+) \* (\d+)/u)?.[1])*Number(supervisorSource.match(/const PLUGIN_SERVICE_MAX_FRAME_BYTES = (\d+) \* (\d+)/u)?.[2]); +for(const [action,viaMenu,style,count,conflicts] of [ + ['Accept selected',false,'ascii',400,true], + ['Accept every file of this subtask',true,'escaped',350,true], + ['Accept the whole task result',true,'unicode',400,false] +])test(`large normalized review round-trips the real dialog ${action} with ${count} ${style} paths`,{timeout:20000},async t=>{ + const page={text:'',startLine:0,totalLines:0,hasMore:false}; + const files=Array.from({length:count},(_,i)=>({path:`${i}/`+(style==='ascii'?'a'.repeat(980):style==='escaped'?'"\\'.repeat(240):'界'.repeat(350)),diff:'+x',page:0,...(conflicts?{conflict:{current:page,agent:page}}:{})})); + const review=normalizeCardReview({title:'Large review',acceptActionId:'accept',groups:[{sessionId:'worker',title:'Worker',files}]},text=>text,new Set(['accept'])); + assert.ok(Buffer.byteLength(JSON.stringify(review))>256*1024);assert.ok(Buffer.byteLength(JSON.stringify(review))<=512*1024); + const calls=[],trustedSession={id:'root',provider:'codex',profile:'normal',role:'orchestrator',title:'Project review',cwd:'/project',position:{x:0,y:0},size:{width:800,height:600},status:'idle',startedAt:1,exitCode:null}; + const cards=new PluginCards({providers:()=>[{pluginId:'fixture',pluginName:'Fixture',serviceId:'review',actions:[{id:'accept',title:'Accept'}]}],trustedPlugins:()=>new Set(['fixture']),session:()=>trustedSession,redact:text=>text,changed(){},call:async(_plugin,_service,method,params)=>{ + const frame=runInNewContext(serializeFrame,{id:Number.MAX_SAFE_INTEGER,method,params});assert.ok(Buffer.byteLength(frame)cards.invoke('fixture',action,'root',input),onReviewChange(){},onActionResult(){},onClose(){}}; + const ui=harness(t,props);ui.render();await tick();ui.render(); + if(conflicts)for(let i=0;inode.type==='ol')[0].props.onScroll({currentTarget:{scrollTop:i*ROW_HEIGHT}});ui.render(); + const open=findAll(ui.tree(),node=>node.type==='button'&&node.props.title===files[i].path)[0];assert.ok(open);open.props.onClick();ui.render(); + const agent=findAll(ui.tree(),node=>node.type==='button'&&node.props.children==='Agent version')[0];assert.ok(agent);agent.props.onClick();ui.render(); + } + if(viaMenu)ui.openMenu(); + ui.click(action);ui.click('Confirm');await tick();ui.render();assert.equal(calls.length,1); + const input=calls[0].input;assert.equal(input.files.length,count);assert.deepEqual(normalizeCardActionInput(input),input);assert.ok(Buffer.byteLength(JSON.stringify(input))>256*1024); + if(conflicts){assert.equal(Object.keys(input.resolutions).length,count);assert.ok(Object.values(input.resolutions).every(value=>value==='agent'));} + else assert.equal(input.resolutions,undefined); +}); + +// F8: a confirmation is for the files and versions of the moment it opened. +const conflictPage={text:'x',startLine:1,totalLines:1,hasMore:false}; +const conflicted=path=>({...file(path),conflict:{current:conflictPage,agent:conflictPage}}); +const checkboxFor=(tree,path)=>findAll(tree,node=>node.type==='input'&&node.props['aria-label']===path)[0]; +const groupButton=(tree,title)=>findAll(tree,node=>node.type==='button'&&node.props['aria-pressed']!==undefined&&String(node.props.children?.[0]??node.props.children)===title)[0]; + +test('while a confirmation is open the group, the selection and the versions are locked, and confirm sends what was confirmed',async t=>{ + const calls=[]; + const props={cardSessionId:'root',reviewActionId:'review',locale:'en',review:{acceptActionId:'accept',rejectActionId:'reject',groups:[ + {sessionId:'first',title:'First',files:[file('one.txt'),file('two.txt')]},{sessionId:'second',title:'Second',files:[file('other.txt')]}]}, + invokeAction:async(id,input)=>{calls.push({id,input});return{tone:'info'};},onReviewChange(){},onActionResult(){},onClose(){}}; + const ui=harness(t,props);ui.render();await tick();ui.render(); + checkboxFor(ui.tree(),'two.txt').props.onChange({currentTarget:{checked:false}});ui.render(); + ui.click('Accept selected'); + assert.equal(groupButton(ui.tree(),'Second').props.disabled,true,'switching the group is locked'); + assert.equal(checkboxFor(ui.tree(),'two.txt').props.disabled,true,'the selection is locked'); + // Even a change that slips through (a stale handler) does not alter what is sent. + checkboxFor(ui.tree(),'two.txt').props.onChange({currentTarget:{checked:true}});ui.render(); + ui.click('Confirm');await tick();ui.render(); + assert.deepEqual(calls.filter(call=>call.id==='accept'),[{id:'accept',input:{agentSessionId:'first',files:['one.txt']}}]); +}); + +test('an unresolved conflict cannot join a pending confirmation, and a review that changed underneath is confirmed again',async t=>{ + const calls=[]; + const props={cardSessionId:'root',reviewActionId:'review',locale:'en',review:{acceptActionId:'accept',groups:[ + {sessionId:'w',title:'W',files:[file('plain.txt'),conflicted('clash.txt')]}]}, + invokeAction:async(id,input)=>{calls.push({id,input});return{tone:'info'};},onReviewChange(){},onActionResult(){},onClose(){}}; + const ui=harness(t,props);ui.render();await tick();ui.render(); + checkboxFor(ui.tree(),'clash.txt').props.onChange({currentTarget:{checked:false}});ui.render(); + ui.click('Accept selected'); + assert.equal(checkboxFor(ui.tree(),'clash.txt').props.disabled,true,'the unresolved conflict cannot be checked now'); + checkboxFor(ui.tree(),'clash.txt').props.onChange({currentTarget:{checked:true}});ui.render(); + ui.click('Confirm');await tick();ui.render(); + assert.deepEqual(calls.filter(call=>call.id==='accept').map(call=>call.input),[{agentSessionId:'w',files:['plain.txt']}],'the conflict is not sent unresolved'); + + // The plugin's review changes while a confirmation is open: the confirmed file is gone, nothing is sent. + props.review={acceptActionId:'accept',groups:[{sessionId:'w',title:'W',files:[file('plain.txt'),file('new.txt')]}]};ui.render();await tick();ui.render(); + ui.click('Accept selected'); + props.review={acceptActionId:'accept',groups:[{sessionId:'w',title:'W',files:[file('new.txt')]}]};ui.render(); + ui.click('Confirm');await tick();ui.render(); + assert.equal(calls.filter(call=>call.id==='accept').length,1,'a confirmation for files that are gone is not sent'); + assert.match(JSON.stringify(ui.tree()),/check the selection and confirm again/u); +}); + +test('plugin call failures are localized (details only in the log) and Git statuses get labels',async t=>{ + const logged=[];const oldError=console.error;console.error=(...args)=>logged.push(args);t.after(()=>{console.error=oldError;}); + const props={cardSessionId:'root',reviewActionId:'review',locale:'ru',review:{acceptActionId:'accept',groups:[{sessionId:'w',title:'W',files:[ + {...file('a.txt'),status:'A'},{...file('m.txt'),status:'modified'},{...file('x.txt'),status:'copied'}]}]}, + invokeAction:async()=>{throw new Error("Error invoking remote method 'plugins:invoke-card-action': secret detail");},onReviewChange(){},onActionResult(){},onClose(){}}; + const ui=harness(t,props);ui.render();await tick();ui.render(); + findAll(ui.tree(),node=>node.props?.className==='card-dialog__secondary'&&node.props.children==='Обновить')[0]?.props.onClick(); + ui.click('Принять выбранные');ui.click('Подтвердить');await tick();ui.render(); + const json=JSON.stringify(ui.tree()); + assert.match(json,/Не удалось связаться с плагином/u);assert.doesNotMatch(json,/secret detail|invoke-card-action/u); + assert.ok(logged.some(args=>String(args[1]).includes('secret detail')),'the details are logged'); + const labels=findAll(ui.tree(),node=>node.type==='small').map(node=>node.props.children); + assert.deepEqual(labels,['добавлен','изменён','copied']); +}); + +test('the overflow menu moves focus between enabled rows with Arrow Up/Down',async t=>{ + const rows=['a','b','c'].map(id=>{const row={id};row.focus=()=>{globalThis.document.activeElement=row;};return row;}); + const oldDocument=globalThis.document;globalThis.document={activeElement:null};t.after(()=>{globalThis.document=oldDocument;}); + const props={cardSessionId:'root',reviewActionId:'review',locale:'en',review:{acceptActionId:'accept',rejectActionId:'reject',groups:[{sessionId:'w',title:'W',files:[file('a.txt')]}]}, + invokeAction:async()=>({tone:'info'}),onReviewChange(){},onActionResult(){},onClose(){}}; + const ui=harness(t,props);ui.render();await tick();ui.render();ui.openMenu(); + const menu=findAll(ui.tree(),node=>node.props?.role==='menu')[0]; + const element={querySelectorAll:selector=>{assert.match(selector,/:not\(:disabled\)/u);return rows;},contains:node=>rows.includes(node)}; + menu.props.ref(element);assert.equal(globalThis.document.activeElement,rows[0],'the first enabled row takes focus'); + const key=k=>menu.props.onKeyDown({key:k,currentTarget:element,preventDefault(){}}); + key('ArrowDown');assert.equal(globalThis.document.activeElement,rows[1]); + key('ArrowUp');key('ArrowUp');assert.equal(globalThis.document.activeElement,rows[2],'wraps around'); +}); + +test('Accept selected is disabled while no file is selected and enabled once one is',async t=>{ + const props={cardSessionId:'root',reviewActionId:'review',locale:'en',review:{acceptActionId:'accept',rejectActionId:'reject',groups:[ + {sessionId:'w',title:'W',files:[file('a.txt'),file('b.txt')]}]}, + invokeAction:async()=>({tone:'info'}),onReviewChange(){},onActionResult(){},onClose(){}}; + const ui=harness(t,props);ui.render();await tick();ui.render(); + for(const path of ['a.txt','b.txt'])if(checkboxFor(ui.tree(),path).props.checked)checkboxFor(ui.tree(),path).props.onChange({currentTarget:{checked:false}}); + ui.render(); + assert.match(JSON.stringify(findAll(ui.tree(),node=>node.props?.className==='card-dialog__muted').map(node=>node.props.children)),/0/u); + assert.equal(ui.byLabel('Accept selected').props.disabled,true,'0 files selected'); + checkboxFor(ui.tree(),'a.txt').props.onChange({currentTarget:{checked:true}});ui.render(); + assert.equal(ui.byLabel('Accept selected').props.disabled,false,'one file selected'); +}); + +test('an active group: nothing is preselected, accepting all of its files and rejecting it are disabled, a part of its files can be accepted',async t=>{ + const calls=[]; + const props={cardSessionId:'root',reviewActionId:'review',locale:'en',review:{acceptActionId:'accept',rejectActionId:'reject',groups:[ + {sessionId:'w',title:'W',active:true,error:'This terminal is still running',files:[file('a.txt'),file('b.txt')]}]}, + invokeAction:async(id,input)=>{calls.push({id,input});return {tone:'info'};},onReviewChange(){},onActionResult(){},onClose(){}}; + const ui=harness(t,props);ui.render();await tick();ui.render(); + assert.equal(checkboxFor(ui.tree(),'a.txt').props.checked,false,'nothing is preselected in an active group'); + assert.equal(checkboxFor(ui.tree(),'b.txt').props.checked,false); + assert.equal(ui.byLabel('Accept selected').props.disabled,true,'nothing selected yet'); + const notice=findAll(ui.tree(),node=>node.type==='p'&&node.props?.children==='This terminal is still running')[0]; + assert.equal(notice.props.role,'status','an informational notice, not an error alert'); + assert.ok(!notice.props.className.includes('card-dialog__error')); + const selectAll=findAll(ui.tree(),node=>node.type==='input'&&node.props?.type==='checkbox'&&node.props['aria-label']===undefined)[0]; + selectAll.props.onChange();ui.render(); + const accept=ui.byLabel('Accept selected');assert.equal(accept.props.disabled,true,'every file selected is a full accept'); + assert.match(accept.props.title,/Clear some files/u); + const reject=ui.byLabel('Reject subtask');assert.equal(reject.props.disabled,true);assert.match(reject.props.title,/once it finishes/u); + ui.openMenu(); + for(const row of findAll(ui.tree(),node=>node.props?.role==='menuitem'))assert.equal(Boolean(row.props.disabled),true,'no whole-group or task-wide action'); + checkboxFor(ui.tree(),'b.txt').props.onChange({currentTarget:{checked:false}});ui.render(); + ui.click('Accept selected');ui.click('Confirm');await tick();ui.render(); + assert.deepEqual(calls.at(-1),{id:'accept',input:{agentSessionId:'w',files:['a.txt']}}); +}); + +test('the subtitle is left out when the review title already names the card',async t=>{ + const props={cardSessionId:'root',cardTitle:'Terminal · project',reviewActionId:'review',locale:'en',review:{title:'Changes from Terminal · project',groups:[{sessionId:'w',title:'W',files:[file('a.txt')]}]}, + invokeAction:async()=>({tone:'info'}),onReviewChange(){},onActionResult(){},onClose(){}}; + const ui=harness(t,props);ui.render();await tick();ui.render(); + assert.equal(JSON.stringify(ui.tree()).split('Terminal · project').length-1,1,'the card name shows once'); + // A plugin that names the card kind in the app's language: the English default title is not repeated either. + const ru=harness(t,{...props,locale:'ru',review:{...props.review,title:'Изменения: Терминал · project'}});ru.render();await tick();ru.render(); + assert.equal(JSON.stringify(ru.tree()).includes('Terminal · project'),false,'no English duplicate under a localized title'); + const other=harness(t,{...props,cardTitle:'my notes',review:{...props.review,title:'Changes from Terminal · project'}});other.render();await tick();other.render(); + assert.equal(JSON.stringify(other.tree()).includes('my notes'),true,'a title that names another card keeps the subtitle'); +}); diff --git a/tests/plugin-review-ui-state.test.mjs b/tests/plugin-review-ui-state.test.mjs new file mode 100644 index 00000000..bda295fb --- /dev/null +++ b/tests/plugin-review-ui-state.test.mjs @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { build } from "esbuild"; +import { fileURLToPath } from "node:url"; + +const helperPath = fileURLToPath(new URL("../src/renderer/src/features/terminal/pluginReviewUiState.ts", import.meta.url)); +const helperModule = await build({ + entryPoints: [helperPath], + bundle: true, + format: "esm", + platform: "node", + write: false, + logLevel: "silent" +}); +const { TaskReviewSessionTracker, virtualFileWindow } = await import( + `data:text/javascript;base64,${Buffer.from(helperModule.outputFiles[0].text).toString("base64")}` +); + +test("plugin review file window renders only nearby rows and preserves scroll geometry", () => { + const atStart = virtualFileWindow(400, 0, 320, 56, 3); + assert.equal(atStart.startIndex, 0); + assert.ok(atStart.endIndex < 20); + assert.equal(atStart.topSpacerHeight, 0); + assert.equal(atStart.topSpacerHeight + atStart.endIndex * 56 + atStart.bottomSpacerHeight, 400 * 56); + + const inMiddle = virtualFileWindow(400, 200 * 56, 320, 56, 3); + assert.ok(inMiddle.startIndex <= 200); + assert.ok(inMiddle.endIndex > 205); + assert.equal(inMiddle.topSpacerHeight, inMiddle.startIndex * 56); + assert.equal(inMiddle.topSpacerHeight + (inMiddle.endIndex - inMiddle.startIndex) * 56 + inMiddle.bottomSpacerHeight, 400 * 56); + + const atEnd = virtualFileWindow(400, 399 * 56, 320, 56, 3); + assert.equal(atEnd.endIndex, 400); + assert.equal(atEnd.bottomSpacerHeight, 0); + assert.deepEqual(virtualFileWindow(0, 0, 320, 56), { + startIndex: 0, endIndex: 0, topSpacerHeight: 0, bottomSpacerHeight: 0 + }); +}); + +test("plugin review session tracker refreshes once per relevant turn transition", () => { + const tracker = new TaskReviewSessionTracker("task-root"); + tracker.include("agent-one"); + tracker.seedSubtree([ + { id: "agent-one", parentSessionId: "task-root" }, + { id: "nested-existing", parentSessionId: "agent-one" }, + { id: "unrelated", parentSessionId: "other-root" } + ]); + const event = (id, status, parentSessionId, turnCompleted) => ({ id, status, parentSessionId, turnCompleted }); + + assert.equal(tracker.observe(event("unrelated", "done", "other-root")), false); + assert.equal(tracker.observe(event("agent-one", "working", "task-root", false)), false); + assert.equal(tracker.observe(event("agent-one", "working", "task-root", false)), false); + assert.equal(tracker.observe(event("agent-one", "working", "task-root", true)), true); + assert.equal(tracker.observe(event("agent-one", "working", "task-root", true)), false); + assert.equal(tracker.observe(event("agent-one", "working", "task-root", false)), true); + + // Once a child has been announced within the task subtree, its descendants are in scope too. + assert.equal(tracker.observe(event("nested-agent", "idle", "agent-one", false)), false); + assert.equal(tracker.observe(event("nested-agent", "failed", "agent-one", false)), true); + assert.equal(tracker.observe(event("nested-agent", "failed", "agent-one", false)), false); + assert.equal(tracker.observe(event("nested-existing", "done", "agent-one", false)), true); + assert.equal(tracker.observe(event("unrelated", "done", "other-root", false)), false); +}); + +test("the card's own session never refreshes the review: the orchestrator working does not drop the person's choices", () => { + const tracker = new TaskReviewSessionTracker("task-root"); + tracker.include("agent-one"); + const event = (id, status, parentSessionId, turnCompleted) => ({ id, status, parentSessionId, turnCompleted }); + assert.equal(tracker.observe(event("task-root", "working", undefined, false)), false); + assert.equal(tracker.observe(event("task-root", "working", undefined, true)), false); + assert.equal(tracker.observe(event("task-root", "idle", undefined, true)), false); + assert.equal(tracker.observe(event("task-root", "done", undefined, true)), false); + assert.equal(tracker.observe(event("agent-one", "done", "task-root", false)), true, "its subagents still do"); + assert.equal(tracker.observe(event("new-agent", "working", "task-root", false)), false); + assert.equal(tracker.observe(event("new-agent", "working", "task-root", true)), true, "children of the root are still followed"); +}); diff --git a/tests/review-dialogs-ui-contract.test.mjs b/tests/review-dialogs-ui-contract.test.mjs new file mode 100644 index 00000000..6ba312ed --- /dev/null +++ b/tests/review-dialogs-ui-contract.test.mjs @@ -0,0 +1,84 @@ +// docs/UI_CONTRACT.md for the dialogs a card opens: theme tokens and em sizes in their CSS, official icons instead of +// glyphs, no native select or confirm, every text from lib/i18n.ts in both app languages, and App owns them. +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const read = (path) => readFile(new URL(`../${path}`, import.meta.url), "utf8"); +const [styles, i18n, app] = await Promise.all([read("src/renderer/src/styles/app.css"), read("src/renderer/src/lib/i18n.ts"), read("src/renderer/src/App.tsx")]); + +const CARD_DIALOG_CSS = ["/* Card dialogs: the shared frame", "/* End of card dialogs. */"]; +const DIALOGS = [ + { name: "change review", source: "src/renderer/src/features/terminal/PluginChangesReviewDialog.tsx", + css: ["/* Change review of a plugin card action", "/* End of change review. */"], + textModule: "src/renderer/src/features/terminal/pluginReviewText.ts", textFunction: "pluginReviewText" } +]; + +function cssBlock([start, end]) { + const from = styles.indexOf(start); + const to = styles.indexOf(end); + assert.ok(from >= 0 && to > from, `${start} block exists in app.css`); + return styles.slice(from, to); +} + +for (const dialog of DIALOGS) { + test(`${dialog.name}: CSS uses theme tokens and scales with --ui-scale`, () => { + // Each dialog is the shared card dialog frame plus its own rules; both must follow the contract. + const block = cssBlock(CARD_DIALOG_CSS) + cssBlock(dialog.css); + assert.doesNotMatch(block, /#[0-9a-f]{3,8}\b/iu, "no hex colors"); + assert.doesNotMatch(block, /\b(rgba?|hsla?)\(/iu, "no literal rgb/hsl colors"); + assert.doesNotMatch(block, /:\s*(white|black)\b|,\s*(white|black)\)/iu, "no named colors"); + assert.doesNotMatch(block, /opacity:\s*\.[6-9]/u, "secondary text uses --text-muted-on-dark, not opacity"); + const pixels = [...block.matchAll(/(\d+(?:\.\d+)?)px/gu)].map((match) => match[0]); + assert.deepEqual([...new Set(pixels)].sort(), ["13px", "1px", "760px"].sort(), + "only hairline borders, the 13px × --ui-scale base and the narrow-window media query are in px"); + assert.match(block, /font-size: calc\(13px \* var\(--ui-scale, 1\)\)/u); + }); + + test(`${dialog.name}: official icons, no glyphs, no native select/confirm, no portal outside the themed tree`, async () => { + const source = await read(dialog.source); + assert.doesNotMatch(source, /[×✕✖↻⟳…]\s*<|>\s*[×✕✖↻⟳]/u, "no literal close or reload glyphs"); + assert.doesNotMatch(source, / { + const [source, texts] = await Promise.all([read(dialog.source), read(dialog.textModule)]); + const block = (name) => { + const start = texts.indexOf(`const ${name}`); + assert.ok(start >= 0, `${name} texts exist`); + return texts.slice(start, texts.indexOf("\n};", start)); + }; + const keys = (name) => new Set([...block(name).matchAll(/^ ([a-zA-Z]+):/gmu)].map((match) => match[1])); + const ru = keys("RU"), en = keys("EN"); + assert.ok(ru.size > 5, "the dialog's texts are in its text module"); + assert.deepEqual([...en].sort(), [...ru].sort(), "the same keys in ru and en"); + for (const [, key] of source.matchAll(/(? { + const rule = styles.match(/\.card-dialog__primary:disabled, \.card-dialog__secondary:disabled, \.card-dialog__danger:disabled \{([^}]*)\}/u); + assert.ok(rule, "one rule for every disabled dialog button"); + assert.match(rule[1], /background: transparent/u); + assert.match(rule[1], /color: var\(--text-muted-on-dark\)/u); + assert.match(rule[1], /cursor: not-allowed/u); + assert.match(styles, /\.card-dialog__primary:hover:not\(:disabled\)/u, "hover never lights a disabled button"); +}); + +test("App renders the card dialogs inside the themed tree, hides the native Browser and blocks canvas shortcuts", () => { + assert.match(app, /const modalDialogOpen = shortcutReferenceOpen \|\| pluginReview !== null/u); + assert.match(app, /browserViewVisible=\{[^}]*!modalDialogOpen/u); + assert.equal(app.match(/if \(modalDialogOpen[ )]/gu)?.length, 2, "keyboard and pointer shortcuts"); + assert.match(app, /\n\s* Date: Tue, 13 Oct 2026 12:46:53 +0000 Subject: [PATCH 27/28] feat(checkpoints): save rollback points of agent turns For a live agent in auto or YOLO whose folder is the top of a Git project, the host saves a rollback point once per turn: when the authenticated lifecycle hook reports a turn start (the runtime gateway holds that hook's answer for at most 750 ms, fail-open, cancelled by a newer signal, a disconnect or a revoke; the HTTP barrier always answers) and, when no turn is known, before a permission decision. A turn is the hook's turn id, or lasts until the agent stops or the person submits input while the card is not working. A failed attempt is not retried within its turn. A point is a git stash create snapshot (or HEAD) plus its index. Inside the barrier only the snapshot, its ref and the registry row are taken (~130 ms on a 2000-file, ~33 MiB repository); the trees are copied afterwards into host packs chained to the previous point (a complete pack every 16). All data lives in userData/checkpoints, private to the app and hidden from isolated agents. Retention: 50 per card, 1000 total, 2 GiB of packs; a new point's time is after every registered point's. Closing a card and cards known not to come back drop their points; folders that cannot be found (an unmounted volume) keep theirs; an unreadable registry is set aside, not fatal. Git runs per repository queue with a stripped environment and hardened config (--no-replace-objects, no hooks, no external diff, every filter driver blanked, attributes from a fixed tree). Projects whose files use a filter attribute (Git LFS, git-crypt) are refused, and the check runs again after the snapshot. Restore puts back tracked files and the index; HEAD and the branch stay. It is refused while any card in the folder runs or launches, for submodules, untracked files in the way, assume-unchanged/skip-worktree entries, cards related to an isolated plugin environment, and when the before-restore safety point cannot be taken. While it runs the folder's cards take no input (typed or replayed: the turn counters and this check live in writeInput, the one input write), nothing launches there, closes wait and card actions on the folder are refused. A case-only rename restores on case-insensitive file systems; an unset core.ignorecase reads as false. Previews run in their own lane, are bounded and show the restore direction (a/ is the file now, b/ after the restore). The main window reaches list, preview and restore over IPC; refusals are stable codes. --- docs/agent-orchestration.md | 12 + docs/plugins.md | 2 + src/main/index.ts | 33 +- src/main/ipc/checkpointIpc.ts | 59 ++ src/main/ipc/registerIpc.ts | 20 + src/main/services/GitCheckpoints.ts | 847 ++++++++++++++++++ src/main/services/PluginCards.ts | 6 + src/main/services/TerminalManager.ts | 201 ++++- src/main/services/TerminalSessionStore.ts | 25 +- .../services/agent-runtime/RuntimeGateway.ts | 98 +- src/main/services/checkpointRefusal.ts | 17 + src/main/services/checkpointTurnBarrier.ts | 142 +++ src/main/services/isolation/isolationPaths.ts | 4 +- src/main/services/safety/baseProtection.ts | 4 +- src/preload/index.ts | 3 + src/shared/contracts.ts | 42 + tests/agent-isolation.test.mjs | 16 +- tests/base-protection-app-private.test.mjs | 7 +- tests/checkpoint-cancellation.test.mjs | 95 ++ tests/checkpoint-hardening.test.mjs | 192 ++++ tests/checkpoint-ipc.test.mjs | 68 ++ tests/checkpoint-object-replacement.test.mjs | 266 ++++++ tests/checkpoint-restore-index.test.mjs | 43 + tests/checkpoint-restore-lease.test.mjs | 202 +++++ tests/checkpoint-safety.test.mjs | 159 ++++ tests/checkpoint-submodules.test.mjs | 56 ++ tests/runtime-checkpoint-barrier.test.mjs | 234 +++++ tests/session-restore-v2.test.mjs | 2 +- tests/worktree-checkpoints.test.mjs | 64 ++ 29 files changed, 2889 insertions(+), 30 deletions(-) create mode 100644 src/main/ipc/checkpointIpc.ts create mode 100644 src/main/services/GitCheckpoints.ts create mode 100644 src/main/services/checkpointRefusal.ts create mode 100644 src/main/services/checkpointTurnBarrier.ts create mode 100644 tests/checkpoint-cancellation.test.mjs create mode 100644 tests/checkpoint-hardening.test.mjs create mode 100644 tests/checkpoint-ipc.test.mjs create mode 100644 tests/checkpoint-object-replacement.test.mjs create mode 100644 tests/checkpoint-restore-index.test.mjs create mode 100644 tests/checkpoint-restore-lease.test.mjs create mode 100644 tests/checkpoint-safety.test.mjs create mode 100644 tests/checkpoint-submodules.test.mjs create mode 100644 tests/runtime-checkpoint-barrier.test.mjs create mode 100644 tests/worktree-checkpoints.test.mjs diff --git a/docs/agent-orchestration.md b/docs/agent-orchestration.md index c28ef2e8..8b8b92a9 100644 --- a/docs/agent-orchestration.md +++ b/docs/agent-orchestration.md @@ -80,6 +80,18 @@ When the agent the person started works at the top of a Git project and a truste Under agent isolation a worktree in the plugin's data is the agent's project only when the host validates it as a linked worktree of the task's repository on a branch no other worktree has checked out; otherwise the launch is refused. Its Git metadata (`.git`, `HEAD`, the worktree's administration folder and the shared repository) is read-only to the agent and the other worktrees are unreadable, so `git add` and `git commit` fail there; the agent's environment says so. The plugin collects the agent's edits (for example into a change review a card action opens). +### Rollback points + +For an agent in auto or YOLO whose folder is the top of a Git project, CanvasTTY saves a rollback point once per turn, when the agent's authenticated lifecycle hook reports that a turn starts. The hook's answer waits for it at most 750 ms; a slow or failed snapshot never blocks the agent and is not retried within that turn (a retry would save the turn's own edits). A turn is the hook's turn id when the CLI reports one; otherwise it lasts until the agent reports it stopped, or until the person submits input while the card is not working (pressing Enter to answer a prompt or menu inside a turn does not start a new one). A CLI without a turn-end hook stays "working": its next turn-start event without a turn id is a new turn when input was submitted (not as an answer to an approval) since the current turn's point. A permission decision inside the current turn waits (at most 750 ms) for that turn's point if it is still being taken and never takes a second one; for a card with no current turn (a CLI without a turn-start hook, or a turn the person moved past) it takes the point itself, bounded the same way. A reported turn id never revives a turn the person moved past. + +Inside that time only the snapshot is taken: `git stash create` (or HEAD when nothing changed), a ref under `refs/canvastty//`, and a row in the host registry (`checkpoints/checkpoints.json` in the app's data). On a repository of 2000 files and about 33 MiB this takes about 130 ms (about 320 ms for the first point after the app starts). Afterwards, in the background, the point's worktree and index trees are copied into host-owned packs (`checkpoints/checkpoint-objects`): only objects the repository's previous protected point does not already hold, chained to that point's packs; once a chain is 16 packs long the next point starts a new chain with a complete pack. Deleting refs, rewriting history and garbage-collecting the repository therefore does not lose a point. The whole `checkpoints` folder is unreadable to isolated agents. Captures and restores of one repository run in one queue; previews run in a separate lane per repository (so opening the dialog never delays a turn's point), hold the packs they read, and give up after two minutes. Repositories do not wait for each other. + +At most 50 points are kept per card, 1000 in total and 2 GiB of packs; the oldest go first, with their refs and packs. A point too large to copy under that cap is kept without a copy and relies on the repository's own objects. Closing a card removes its points; at start, points of saved cards known not to come back are removed. Nothing else is removed at start: not the points of a folder that cannot be found (it may be on a volume that is not mounted), and not when the saved cards could not be read, were written by a newer version, or restore is turned off. A registry that cannot be read (or one over 4 MiB) is set aside as `checkpoints.json.quarantined` (in the same folder) and rollback points start over; the packs it names are kept with it. A transient error reading the registry is retried at the next point instead of disabling points for the run, and a project folder that does not answer within 5 seconds (a hung network volume) gets no point instead of holding the queue. + +Git runs in the host process, never inside an agent's sandbox, with GIT_* variables removed, no hooks, no fsmonitor, no replace objects, no external diff or text conversion, no network (`protocol.allow=never`) and the global attributes file ignored; attributes are read from a fixed tree (HEAD's, or the saved tree when restoring) with Git 2.40 or later, and every filter driver Git knows there (the repository's own, and the person's global and system ones) is blanked. `--attr-source` does not cover `.git/info/attributes`; blanking every known driver keeps an attribute added there during a capture or restore from running one. A project whose tracked files (or the saved tree) use a filter attribute, such as Git LFS or git-crypt, gets no rollback points and its restores are refused: a raw copy of those files would not round-trip. The check runs again after the snapshot is taken, and a point is dropped when a filter attribute or a new driver appeared meanwhile. + +The card's **Rollback points…** menu item lists the points, shows a bounded preview of what the restore does (lines marked `+` come back, lines marked `-` are removed; long previews show their first lines and more on request), and restores one after a confirmation; the current work is saved as another rollback point first, and the restore is refused when that cannot be done (for example on a branch with no commit yet). On a case-insensitive file system a file renamed only in case since the point is restored under the point's name. A restore puts back the tracked files and the index; HEAD and the branch stay where they are (commits made since are not undone). It is refused while any card in that folder still runs or launches, when a related plugin environment ran isolated, when the current index or the saved point contains submodules, when a file the restore would write exists untracked in the folder (or one of its folders exists as a file), and when Git is told to ignore changes in some tracked files (assume-unchanged or skip-worktree): none of those would be in the saved point. While a restore runs, plugin card actions on that folder are refused as well. + ## Enable and connect Turn on Settings → Agents → "Agent orchestration endpoint" (`agentControlEnabled`, off by default; it starts and stops the endpoint without a restart). A session launched from the desktop with the **Orchestrator** role receives `CANVASTTY_CONTROL_CONNECTION` (its own control connection, below) and `CANVASTTY_CONTROL_CLI` (the bundled CLI path) in its environment; ordinary sessions never do. For CI smoke, start the app with `--agent-control` or set `CANVASTTY_AGENT_CONTROL=1` for that invocation to force the endpoint on regardless of the setting. From a source checkout: diff --git a/docs/plugins.md b/docs/plugins.md index 9d2a1d29..b518904f 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -256,6 +256,8 @@ An environment is where a card runs: a git worktree, a container, a remote host. `keeps` declares what of CanvasTTY's protection reaches the agent there: `launch` (the launch's arguments and environment reach the agent unchanged, so CanvasTTY's hooks and the profile's per-run settings work), `isolated` (the agent does not run on this computer's files: a container or a remote host) and `confines` (the environment itself confines the agent to the project). Undeclared means no: any profile but normal is refused without `launch`, and the card says that base protection does not reach the agent there. An `isolated` environment is not wrapped in CanvasTTY's agent isolation again (the card names the environment's own boundary); any other runs inside it, with the environment's folder as the project. +Rollback points (a card's **Rollback points…** menu item) restore files only in a folder where no card still runs, and never for a card related to an `isolated` environment: stopping its local SSH or container wrapper does not prove that the remote workload stopped. The host keeps, with each card, the folders an `isolated` environment ran for, independently of later plugin changes or app restarts; a placed card saved before this evidence existed, or with malformed evidence, counts as unknown scope: it refuses restores of its own folder, its task's project and any folder it cannot be resolved against, but not of unrelated folders. While a restore runs, card actions (`canvastty.cards.invoke`) on a card whose folder overlaps it are refused. Restoration also refuses repositories whose current index or saved rollback point contains submodules: the parent repository's snapshot does not keep dirty or untracked data inside nested repositories, so safeguard and restore them with Git separately. + CanvasTTY keeps the card, the PTY, the saved record and the restore order; the service answers five host-only requests (surfaces cannot send them): | Request | Params | Answer | Budget | diff --git a/src/main/index.ts b/src/main/index.ts index 473182c7..6ef0a51b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -65,6 +65,8 @@ import { listProviderDirectory, type ProviderDirectorySources } from "./services import { ProviderModelCatalog } from "./services/providerModels"; import { AgentControlService } from "./services/AgentControlService"; import { isGitProjectRoot, subagentWorktreeResolver } from "./services/SubagentWorktreeResolver"; +import { GitCheckpoints } from "./services/GitCheckpoints"; +import { createCheckpointTurnBarrier } from "./services/checkpointTurnBarrier"; import { AgentIsolation } from "./services/isolation/AgentIsolation"; import type { AgentProviderId, LaunchProfileId } from "../shared/contracts"; import { HermesHudService } from "./services/HermesHudService"; @@ -372,6 +374,14 @@ async function initializeServices(ipc: IpcRegistrar): Promise { // Secrets this app knows are masked in every text one agent reads from another (EP-8). const redaction = new SecretRedactionRegistry(); diagnostics.configureRedaction(text => redaction.redact(text)); + // Rollback points of agents' Git projects, kept in host-owned storage (userData/checkpoints: the registry, its + // temporary and set-aside copies, and the packs) that the isolation layer hides from agents. + const checkpoints = new GitCheckpoints((text) => redaction.redact(text), 50, join(userDataPath, "checkpoints", "checkpoints.json")); + const checkpointBarrier = createCheckpointTurnBarrier({ + terminals: () => terminalManager, + checkpoints, + redact: (text) => redaction.redact(text) + }); diagnosticContext = () => ({ servicesReady, settings: { locale: settings.get().locale, keyboardPreset: settings.get().keyboardPreset, @@ -531,7 +541,11 @@ async function initializeServices(ipc: IpcRegistrar): Promise { runtimeGateway = new RuntimeGateway({ runtimeDirectory: lifecycleRuntimeDirectory, windowsHostPath, + // An auto or yolo agent's turn start waits (at most 750 ms, fail-open) for its rollback point. + lifecycleGuard: (terminalSessionId) => checkpointBarrier.lifecycleGuard(terminalSessionId), + beforeLifecycle: (terminalSessionId, signal, cancellation) => checkpointBarrier.beforeLifecycle(terminalSessionId, signal, cancellation), onSignal: (terminalSessionId, signal) => { + checkpointBarrier.onSignal(terminalSessionId, signal.state); if (terminalManager) applyRuntimeSignal(terminalManager, terminalSessionId, signal); agentControl?.onSignal(terminalSessionId, signal); if (signal.lastAssistantMessage !== undefined && signal.answerCaptureGrantExpiresAt !== undefined) { @@ -544,7 +558,11 @@ async function initializeServices(ipc: IpcRegistrar): Promise { } }, onAnswerCaptureRevoked: (terminalSessionId) => evenG2?.clearAnswer(terminalSessionId), - onPermissionRequest: (terminalSessionId, request, signal) => decisionHooks.decide(terminalSessionId, request, signal), + onPermissionRequest: async (terminalSessionId, request, signal) => { + const refusal = await checkpointBarrier.beforePermission(terminalSessionId, signal); + if (refusal) return { behavior: "deny", message: refusal }; + return decisionHooks.decide(terminalSessionId, request, signal); + }, // Claude Code's lifecycle hooks go straight to a loopback listener where ClaudeHttpHookPolicy allows it. httpHooks: true }); @@ -626,6 +644,11 @@ async function initializeServices(ipc: IpcRegistrar): Promise { }).show(); } } else if (channel === IPC.terminalRemoved && "id" in payload) { + checkpointBarrier.forget(payload.id); + // A closed card's rollback points go with it; quitting keeps them for the restored card. + if (terminalManager && !terminalManager.isQuitting()) { + void checkpoints.forgetSession(payload.id).catch((error: unknown) => console.warn("Rollback points of a closed card could not be removed.", error instanceof Error ? error.message : "error")); + } diagnostics.record("info", "terminal", "session.closed", { id: payload.id }); notifiedAttentionStatus.delete(payload.id); } @@ -662,6 +685,7 @@ async function initializeServices(ipc: IpcRegistrar): Promise { trustedPlugins: () => new Set(pluginManager!.trustedServiceSpecs().map((spec) => spec.pluginId)), call: (pluginId, serviceId, method, params, timeoutMs) => pluginServices!.hostCall(pluginId, serviceId, method, params, timeoutMs), session: (sessionId) => sessionsForPlugins.summary(sessionId), + restoring: (folder) => terminalManager?.isRestoringFolder(folder) ?? false, redact: (text) => redaction.redact(text), changed: (decorations) => { if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.webContents.isDestroyed()) { @@ -730,7 +754,11 @@ async function initializeServices(ipc: IpcRegistrar): Promise { // launch options or an environment ask their plugin's service, so start services first. pluginServices.hostReady(); await pluginServicesStarted.catch(() => undefined); - await terminalManager.restorePersistedSessions(); + const notRestored = await terminalManager.restorePersistedSessions(); + // Rollback points of saved cards known not to come back (not restored, or a subagent whose parent is gone). Points + // of any other card stay (unreadable saved cards, a newer version's, or restore turned off): retention bounds them. + void checkpoints.forgetSessions(notRestored) + .catch((error: unknown) => console.warn("Rollback points could not be pruned.", error instanceof Error ? error.message : "error")); // The agent-control endpoint follows Settings → Agents → "Agent orchestration // endpoint"; the start flag / env var force it on for one launch (CI smoke) // regardless of the setting. Start and stop are serialised so a quick toggle @@ -844,6 +872,7 @@ async function initializeServices(ipc: IpcRegistrar): Promise { registerIpc(ipc, { mascots: mascotManager, settings, + checkpoints, recheckProviderClis: async () => { providerClis!.refresh(); agentBrowserBridge?.providerClisRefreshed(); diff --git a/src/main/ipc/checkpointIpc.ts b/src/main/ipc/checkpointIpc.ts new file mode 100644 index 00000000..d95fff4a --- /dev/null +++ b/src/main/ipc/checkpointIpc.ts @@ -0,0 +1,59 @@ +import type { CheckpointListResult, CheckpointPreviewResult, CheckpointRestoreResult } from "../../shared/contracts.ts"; +import { checkpointErrorCode, CheckpointRefusal } from "../services/checkpointRefusal.ts"; +import type { GitCheckpoints } from "../services/GitCheckpoints.ts"; +import type { TerminalManager } from "../services/TerminalManager.ts"; + +const CHECKPOINT_ID = /^refs\/canvastty\/[\w-]{1,100}\/\d{1,16}-[0-9a-f-]{36}$/u; + +/** + * The Rollback points dialog's three requests (the main window only, registerIpc). Answers carry a stable error code + * that the renderer localizes, never main-process text. + */ +export function checkpointIpcHandlers({ checkpoints, terminals }: { + checkpoints: Pick; + terminals: Pick; +}) { + const card = (id: unknown): { id: string; cwd: string } => { + if (typeof id !== "string") throw new CheckpointRefusal("missing-session", "Terminal session id is invalid."); + const row = terminals.getMetadata(id); + if (!row) throw new CheckpointRefusal("missing-session", "Terminal session does not exist."); + return { id, cwd: row.cwd }; + }; + const checkpointId = (value: unknown): string => { + if (typeof value !== "string" || !CHECKPOINT_ID.test(value)) throw new CheckpointRefusal("unknown-checkpoint", "Rollback point id is invalid."); + return value; + }; + const gitRoot = async (cwd: string): Promise => { + if (!await checkpoints.available(cwd)) throw new CheckpointRefusal("not-git-root", "The card's folder is not a Git project root."); + }; + const failed = (error: unknown): { ok: false; code: ReturnType } => { + if (!(error instanceof CheckpointRefusal)) console.warn("Rollback point action failed.", error instanceof Error ? error.name : "error"); + return { ok: false, code: checkpointErrorCode(error) }; + }; + return { + async list(id: unknown): Promise { + try { + const row = card(id); + await gitRoot(row.cwd); + return { ok: true, checkpoints: await checkpoints.list(row.id, row.cwd) }; + } catch (error) { return failed(error); } + }, + async preview(id: unknown, checkpoint: unknown): Promise { + try { + const row = card(id); + const ref = checkpointId(checkpoint); + await gitRoot(row.cwd); + return { ok: true, ...await checkpoints.preview(row.id, row.cwd, ref) }; + } catch (error) { return failed(error); } + }, + async restore(id: unknown, checkpoint: unknown): Promise { + try { + const row = card(id); + const ref = checkpointId(checkpoint); + await gitRoot(row.cwd); + await terminals.withCheckpointRestore(row.id, (cwd) => checkpoints.restore(row.id, cwd, ref)); + return { ok: true }; + } catch (error) { return failed(error); } + } + }; +} diff --git a/src/main/ipc/registerIpc.ts b/src/main/ipc/registerIpc.ts index e9706e60..ad2729b2 100644 --- a/src/main/ipc/registerIpc.ts +++ b/src/main/ipc/registerIpc.ts @@ -30,6 +30,8 @@ import { isCustomTerminalBorderSkinId, type SkinRegistry } from "../services/Ski import { isPixelSkinSlot, isPixelTerminalBorderSkinId, type PixelSkinPackRegistry } from "../services/PixelSkinPackRegistry"; import { providerCliAvailability, type ProviderCliRegistry } from "../services/providerCliRegistry"; import type { TerminalManager } from "../services/TerminalManager"; +import type { GitCheckpoints } from "../services/GitCheckpoints"; +import { checkpointIpcHandlers } from "./checkpointIpc"; import type { AgentChatHistoryService } from "../services/AgentChatHistoryService"; import { withoutAccountScope, type LimitsService } from "../services/LimitsService"; import type { UsageHistoryService } from "../services/UsageHistoryService"; @@ -65,6 +67,8 @@ interface Dependencies { settings: SettingsStore; recheckProviderClis(): Promise<{ availability: AgentCliAvailability; settings: AppSettings }>; terminals: TerminalManager; + /** Rollback points of agents' Git projects (main/index.ts); without it the dialog's requests are not served. */ + checkpoints?: GitCheckpoints; agentChatHistory: AgentChatHistoryService; limits: LimitsService; usageHistory: UsageHistoryService; @@ -205,6 +209,7 @@ export function registerIpc(ipcMain: IpcRegistrar, { settings, recheckProviderClis, terminals, + checkpoints, agentChatHistory, limits, usageHistory, @@ -972,6 +977,21 @@ export function registerIpc(ipcMain: IpcRegistrar, { if (typeof id !== "string") throw new Error("Terminal session id is invalid."); return terminals.setRestore(id, restore); }); + if (checkpoints) { + const rollback = checkpointIpcHandlers({ checkpoints, terminals }); + ipcMain.handle(IPC.terminalCheckpoints, (event, id: unknown) => { + assertMainRenderer(event, getMainWindow); + return rollback.list(id); + }); + ipcMain.handle(IPC.terminalCheckpointPreview, (event, id: unknown, checkpointId: unknown) => { + assertMainRenderer(event, getMainWindow); + return rollback.preview(id, checkpointId); + }); + ipcMain.handle(IPC.terminalCheckpointRestore, (event, id: unknown, checkpointId: unknown) => { + assertMainRenderer(event, getMainWindow); + return rollback.restore(id, checkpointId); + }); + } ipcMain.handle(IPC.terminalResolveGitRisk, (event, reportId: unknown, action: unknown) => { assertMainRenderer(event, getMainWindow); if (typeof reportId !== "string" || reportId.length > 64) throw new Error("Git warning id is invalid."); diff --git a/src/main/services/GitCheckpoints.ts b/src/main/services/GitCheckpoints.ts new file mode 100644 index 00000000..4c4631ea --- /dev/null +++ b/src/main/services/GitCheckpoints.ts @@ -0,0 +1,847 @@ +import { spawn } from "node:child_process"; +import { realpath, mkdir, mkdtemp, readdir, readFile, writeFile, rename, rm, rmdir, stat, lstat } from "node:fs/promises"; +import { createReadStream, existsSync } from "node:fs"; +import { PassThrough, Readable } from "node:stream"; +import { dirname, join } from "node:path"; +import { createHash, randomUUID } from "node:crypto"; +import type { CheckpointEntry } from "../../shared/contracts.ts"; +import { CheckpointRefusal } from "./checkpointRefusal.ts"; + +type CheckpointKind = CheckpointEntry["kind"]; +type TrustedCheckpoint = { + cwd: string; sessionId: string; kind: CheckpointKind; + /** The snapshot commit (what the convenience ref points at); restores use the two trees. */ + oid: string; tree: string; indexTree: string; + /** Host packs that together hold every object of both trees (oldest first); absent when none is kept. */ + packs?: string[]; +}; +type GitOptions = { + signal?: AbortSignal; timeoutMs?: number; input?: string | Readable; + /** Bytes of stdout kept; more is an error unless `truncate`, which stops Git and keeps the prefix. */ + limit?: number; truncate?: boolean; onTruncate?: () => void; + /** Read the repository's attributes from this tree (and nowhere else in the project), or as the person's Git would. */ + attributes?: string | "person"; + /** Streams stdout instead of collecting it. */ + onStdout?: (chunk: Buffer) => void; + extraConfig?: string[]; + /** Internal: false when this Git predates --attr-source (2.40). */ + attributeSource?: boolean; +}; + +const OID = /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/u; +const MAX_REGISTRY_BYTES = 4 * 1024 * 1024; +const DEFAULT_STDOUT_LIMIT = 64 * 1024 * 1024; +const PREVIEW_LIMIT = 2 * 1024 * 1024; +const MAX_PACK_CHAIN = 16; +const NULL_DEVICE = process.platform === "win32" ? "NUL" : "/dev/null"; +/** How long a file system call on a project folder may take (an unmounted or hung network volume never answers). */ +const FOLDER_TIMEOUT_MS = 5_000; +/** How long a preview may take in all (it may have to read a point's packs back into the repository). */ +const PREVIEW_TIMEOUT_MS = 120_000; +/** A set-aside registry larger than this is not read for the packs it references: no pack is swept then. */ +const MAX_QUARANTINED_BYTES = 64 * 1024 * 1024; +// Bidirectional formatting controls can make a shown diff or file name read differently from what it is. +const BIDI_CONTROLS = /[\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/gu; + +export interface GitCheckpointLimits { + /** Rollback points kept per card (oldest go first). */ + perSession?: number; + /** Rollback points kept for all cards together. */ + total?: number; + /** Bytes of protective packs kept for all cards together. */ + packBytes?: number; +} + +/** + * Rollback points of an agent's Git project, taken before its turns (auto and yolo cards) and before a restore. Each is + * a `git stash create` snapshot (or HEAD) plus its index, listed only from the host registry (`storagePath`, in + * userData/checkpoints; refs/canvastty//… are a convenience the agent may delete). After the turn may start, + * the snapshot's trees are copied into host-owned packs next to it (checkpoint-objects), chained to the repository's + * previous point so only what changed is packed. + * + * Git runs in the host (never in an agent's sandbox) with a stripped environment, without hooks, fsmonitor, replace + * objects, external diff or text conversion, network access, or the project's own filter drivers, and reads attributes + * only from a fixed tree. A project whose tracked files use a filter attribute (Git LFS, git-crypt) is refused instead: + * a raw copy of such files would not round-trip. All Git work for one repository runs in one queue. + */ +export class GitCheckpoints { + private redact: (text: string) => string; + private readonly limits: Required; + private readonly trusted = new Map(); + private loaded: Promise | null = null; + private writes: Promise = Promise.resolve(); + private readonly queues = new Map>(); + private readonly background = new Map>(); + /** Previews run in their own lane per repository: one never delays a turn's capture. */ + private readonly previews = new Map>(); + private attributeSource: Promise | null = null; + private readonly heldPacks = new Map(); + private readonly packSizes = new Map(); + private readonly forgotten = new Set(); + private readonly storagePath?: string; + constructor(redact: (text: string) => string, limits: number | GitCheckpointLimits = {}, storagePath?: string) { + this.redact = redact; + const given = typeof limits === "number" ? { perSession: limits } : limits; + this.limits = { perSession: given.perSession ?? 50, total: given.total ?? 1000, packBytes: given.packBytes ?? 2 * 1024 ** 3 }; + this.storagePath = storagePath; + } + + // ---- registry ------------------------------------------------------------------------------------------------- + + private load(): Promise { + // A failed load (a transient read error) is tried again by the next call instead of disabling points for the run. + this.loaded ??= this.loadOnce().catch((error: unknown) => { + this.trusted.clear(); this.loaded = null; + throw error; + }); + return this.loaded; + } + private async loadOnce(): Promise { + if (!this.storagePath) return; + let dropped = false; + try { + const text = await readFile(this.storagePath, "utf8"); + let values: unknown; + try { values = Buffer.byteLength(text) <= MAX_REGISTRY_BYTES ? JSON.parse(text) : undefined; } catch { values = undefined; } + if (!Array.isArray(values)) { + await this.quarantine("unreadable or too large"); + values = []; + } + for (const row of values as unknown[]) { + const parsed = parseRow(row); + if (!parsed) { dropped = true; continue; } + this.trusted.set(parsed.id, parsed.row); + } + if (dropped) await this.quarantine("invalid rows dropped", true); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + // A project folder that cannot be found now keeps its points: it may be on a volume that is not mounted. They go + // with their card or by retention. + if (dropped) await this.persist(); + await this.sweepPackStorage(); + } + /** A registry that cannot be read is kept aside (one copy) for diagnosis; rollback points start over. */ + private async quarantine(reason: string, copy = false): Promise { + const file = this.storagePath!; + const aside = `${file}.quarantined`; + try { + if (copy) await writeFile(aside, await readFile(file), { mode: 0o600 }); + else await rename(file, aside); + console.warn("Rollback point registry set aside:", reason); + } catch (error) { + console.warn("Rollback point registry could not be set aside.", (error as NodeJS.ErrnoException).code ?? "unknown error"); + if (!copy) await rm(file, { force: true }).catch(() => undefined); + } + } + private async persist(signal?: AbortSignal): Promise { + if (!this.storagePath) return; + const file = this.storagePath, text = JSON.stringify([...this.trusted].map(([id, row]) => ({ id, ...row }))); + const work = this.writes.catch(() => undefined).then(async () => { + signal?.throwIfAborted(); + await mkdir(dirname(file), { recursive: true, mode: 0o700 }); + const temp = `${file}.${randomUUID()}.tmp`; + try { + await writeFile(temp, text, { mode: 0o600, flag: "wx", signal }); signal?.throwIfAborted(); await rename(temp, file); + } catch (error) { + await rm(temp, { force: true }).catch(() => undefined); + throw error; + } + }); + this.writes = work; await work; + } + /** Removes packs no row references, empty project folders, and staging folders of dead captures. */ + private async sweepPackStorage(): Promise { + const root = this.objectsRoot(); + if (!root) return; + const projects = await readdir(root, { withFileTypes: true }).catch((error) => { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw error; + }); + const referenced = new Set([...this.trusted.values()].flatMap((row) => (row.packs ?? []).map((pack) => this.packPath(row.cwd, pack)))); + // Packs a set-aside registry names stay with it, so it can still be recovered by hand. + const aside = await this.quarantinedPacks(); + if (aside === null) return; + for (const project of projects) { + if (!project.isDirectory() || !/^[a-f0-9]{64}$/u.test(project.name)) continue; + const directory = join(root, project.name); + let remaining = 0; + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + const owner = /^\.pending-([1-9][0-9]{0,9})-[A-Za-z0-9]{6}$/u.exec(entry.name); + if (entry.isDirectory() && owner) { + const pid = Number(owner[1]); + let dead = pid <= 0x7fffffff && pid !== process.pid; + if (dead) { try { process.kill(pid, 0); dead = false; } catch (error) { dead = (error as NodeJS.ErrnoException).code === "ESRCH"; } } + // A live or inaccessible owner keeps its staging folder; only confirmed dead owners are removed. + if (dead) await this.removePackStaging(path); else remaining++; + } else if (entry.isFile() && /^pack-[a-f0-9]{40,64}\.pack$/u.test(entry.name) && !referenced.has(path) && !aside.has(entry.name.slice(5, -5))) { + await rm(path, { force: true }).catch(() => undefined); + } else remaining++; + } + if (remaining === 0) await rmdir(directory).catch(() => undefined); + } + } + /** The pack hashes a set-aside registry mentions (read leniently: it may not parse); null when it cannot be read. */ + private async quarantinedPacks(): Promise | null> { + const file = `${this.storagePath!}.quarantined`; + try { + if ((await stat(file)).size > MAX_QUARANTINED_BYTES) return null; + return new Set((await readFile(file, "latin1")).match(/[a-f0-9]{40}(?:[a-f0-9]{24})?/gu) ?? []); + } catch (error) { + return (error as NodeJS.ErrnoException).code === "ENOENT" ? new Set() : null; + } + } + private async removePackStaging(path: string): Promise { + await rm(path, { recursive: true, force: true }).catch((error) => { + // Cleanup failure must not turn an already published valid pack into an unregistered capture. + console.warn("Could not remove unfinished checkpoint files.", (error as NodeJS.ErrnoException).code ?? "unknown error"); + }); + } + + // ---- Git ------------------------------------------------------------------------------------------------------ + + private prefix(sessionId: string): string { + if (!/^[\w-]{1,100}$/u.test(sessionId)) throw new Error("Invalid checkpoint session."); + return `refs/canvastty/${sessionId}/`; + } + /** + * One hardened Git call, streamed. Never inherits GIT_* variables (GIT_DIR, GIT_CONFIG_PARAMETERS…), never fetches, + * never runs hooks or an fsmonitor, and reads attributes from `options.attributes` only. + */ + private async git(cwd: string, args: string[], options: GitOptions = {}): Promise { + if (options.attributes && options.attributes !== "person" && options.attributeSource === undefined) { + options = { ...options, attributeSource: await this.supportsAttributeSource() }; + } + return this.run(cwd, args, options); + } + /** + * Git 2.40 added --attr-source. An older Git reads the project's .gitattributes from the working tree instead; the + * project's own filter drivers are still blanked, so only the person's own (global or system) drivers could run. + */ + private supportsAttributeSource(): Promise { + this.attributeSource ??= this.run(process.cwd(), ["version"], {}).then((text) => { + const [major = 0, minor = 0] = (/git version (\d+)\.(\d+)/u.exec(text) ?? []).slice(1).map(Number); + return major > 2 || (major === 2 && minor >= 40); + }, () => false); + return this.attributeSource; + } + private run(cwd: string, args: string[], options: GitOptions): Promise { + const { signal } = options; + signal?.throwIfAborted(); + const env: NodeJS.ProcessEnv = { + ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_"))), + GIT_OPTIONAL_LOCKS: "0", GIT_TERMINAL_PROMPT: "0", GIT_NO_LAZY_FETCH: "1", GIT_NO_REPLACE_OBJECTS: "1", + ...(options.attributes === "person" ? {} : { GIT_ATTR_NOSYSTEM: "1" }) + }; + const config = ["core.hooksPath=" + NULL_DEVICE, "core.fsmonitor=false", "diff.external=", "protocol.allow=never", "gc.auto=0", + "maintenance.auto=false", "commit.gpgSign=false", "credential.helper=", "core.pager=cat", + ...(options.attributes === "person" ? [] : ["core.attributesFile=" + NULL_DEVICE]), ...(options.extraConfig ?? [])]; + const attributeSource = options.attributes && options.attributes !== "person" && options.attributeSource !== false ? [`--attr-source=${options.attributes}`] : []; + const argv = ["--no-pager", "--no-replace-objects", ...config.flatMap((value) => ["-c", value]), ...attributeSource, "-C", cwd, ...args]; + return new Promise((resolve, reject) => { + const child = spawn("git", argv, { env, stdio: [options.input === undefined ? "ignore" : "pipe", "pipe", "pipe"], windowsHide: true }); + const limit = options.limit ?? DEFAULT_STDOUT_LIMIT; + const chunks: Buffer[] = []; let size = 0, stderr = "", truncated = false, settled = false; + const timer = setTimeout(() => fail(Object.assign(new Error(`git ${args[0]} timed out.`), { code: "ETIMEDOUT" })), options.timeoutMs ?? 15_000); + const abort = (): void => fail(signal?.reason ?? new Error("Checkpoint cancelled.")); + signal?.addEventListener("abort", abort, { once: true }); + const finish = (): void => { settled = true; clearTimeout(timer); signal?.removeEventListener("abort", abort); }; + function fail(error: unknown): void { + if (settled) return; + finish(); child.kill("SIGKILL"); reject(error); + } + child.on("error", fail); + child.stdout!.on("data", (chunk: Buffer) => { + if (settled || truncated) return; + if (options.onStdout) { try { options.onStdout(chunk); } catch (error) { fail(error); } return; } + if (size + chunk.length > limit) { + if (!options.truncate) { fail(Object.assign(new Error(`git ${args[0]} output is too large.`), { code: "ERR_OUTPUT_LIMIT" })); return; } + // Keep whole lines only; the caller says the text is shortened. + const room = chunk.subarray(0, limit - size); + const end = room.lastIndexOf(10); + chunks.push(room.subarray(0, end + 1)); size += end + 1; + truncated = true; options.onTruncate?.(); child.kill("SIGKILL"); + return; + } + chunks.push(chunk); size += chunk.length; + }); + child.stderr!.on("data", (chunk: Buffer) => { if (stderr.length < 8192) stderr += chunk.toString("utf8"); }); + if (options.input !== undefined) { + child.stdin!.on("error", () => undefined); + if (typeof options.input === "string") child.stdin!.end(options.input); + else { options.input.on("error", fail); options.input.pipe(child.stdin!); } + } + child.on("close", (code) => { + if (settled) return; + finish(); + if (code === 0 || truncated) resolve(Buffer.concat(chunks).toString("utf8").replace(/\n$/u, "")); + else reject(Object.assign(new Error(`git ${args[0]} failed (${code}).`), { code, stderr: stderr.trim() })); + }); + }); + } + /** Streams the NUL-terminated records a Git command prints (paths are kept as bytes). */ + private async records(cwd: string, args: string[], onRecord: (record: Buffer) => void, options: GitOptions = {}): Promise { + let rest = Buffer.alloc(0); + await this.git(cwd, args, { ...options, onStdout: (chunk) => { + let data = rest.length ? Buffer.concat([rest, chunk]) : chunk, start = 0, end: number; + while ((end = data.indexOf(0, start)) !== -1) { onRecord(data.subarray(start, end)); start = end + 1; } + rest = Buffer.from(data.subarray(start)); + if (rest.length > 65_536) throw new Error("Git record is too long."); + data = rest; + } }); + } + /** A Git listing whose stdout feeds another Git call. */ + private listInto(cwd: string, args: string[], signal?: AbortSignal): { stdout: Readable; done: Promise; stop(): void } { + const stdout = new PassThrough(); + const done = this.git(cwd, args, { signal, onStdout: (chunk) => { stdout.write(chunk); } }) + .then(() => { stdout.end(); }, (error: unknown) => { stdout.destroy(error as Error); throw error; }); + done.catch(() => undefined); + return { stdout, done, stop: () => { if (!stdout.writableEnded) stdout.end(); } }; + } + /** The tracked paths (or a tree's paths) whose filter attribute is set, as the given attributes source sees them. */ + private async filteredPaths(cwd: string, attributes: string | "person", tree?: string, signal?: AbortSignal): Promise { + const list = this.listInto(cwd, tree ? ["ls-tree", "-r", "-z", "--name-only", "--full-tree", tree] : ["ls-files", "-z"], signal); + let filtered = 0, field = 0; + try { + await this.records(cwd, ["check-attr", "-z", "--stdin", "filter"], (record) => { + // Triples of path, attribute name and value. + if (field++ % 3 === 2) { const value = record.toString("utf8"); if (value !== "unspecified" && value !== "unset") filtered++; } + }, { signal, attributes, input: list.stdout, timeoutMs: 15_000 }); + await list.done; + } finally { list.stop(); } + return filtered; + } + /** + * Every filter driver Git would know here (the project's own config, which an agent may control, and the person's + * global and system ones): blanked for every call that could run one. `--attr-source` does not cover + * `$GIT_DIR/info/attributes`, so an attribute written there in a race could otherwise make the person's own Git LFS + * or git-crypt driver run on a raw snapshot (or on a restore). With every known driver blank, such an attribute does + * nothing; refusing projects whose files use a filter attribute keeps snapshots raw and round-trippable. + */ + private async filterOverrides(cwd: string, signal?: AbortSignal): Promise { + const names = new Set(); + const output = await this.git(cwd, ["config", "--show-scope", "-z", "--get-regexp", "^filter\\."], { signal }).catch((error: { code?: unknown }) => { + if (error.code === 1) return ""; throw error; + }); + const fields = output.split("\0"); + for (let index = 0; index + 1 < fields.length; index += 2) { + const key = fields[index + 1].split("\n", 1)[0]; + const name = /^filter\.(.+)\.[^.]+$/u.exec(key)?.[1]; + if (name === undefined) continue; + if (name.includes("=") || names.size >= 64) throw new CheckpointRefusal("filters", "The project configures filter drivers that cannot be neutralized."); + names.add(name); + } + return [...names].flatMap((name) => [`filter.${name}.clean=`, `filter.${name}.smudge=`, `filter.${name}.process=`, `filter.${name}.required=false`]); + } + /** Refuses when a filter attribute applies to the project's tracked files (or to a saved tree's files). */ + private async requireNoFilters(cwd: string, pinned: string, signal?: AbortSignal, tree?: string): Promise { + const counts = await Promise.all([ + this.filteredPaths(cwd, "person", undefined, signal), + this.filteredPaths(cwd, pinned, undefined, signal), + ...(tree ? [this.filteredPaths(cwd, tree, tree, signal)] : []) + ]); + if (counts.some((count) => count > 0)) { + throw new CheckpointRefusal("filters", "Files of this project use a Git filter (such as Git LFS or git-crypt); rollback points are unavailable."); + } + } + + // ---- queues --------------------------------------------------------------------------------------------------- + + /** Runs `job` after every earlier job of the same repository (only); a failure never blocks the next one. */ + private enqueue(repository: string, job: () => Promise, queues = this.queues): Promise { + const previous = queues.get(repository) ?? Promise.resolve(); + const work = previous.catch(() => undefined).then(job); + const tail = work.catch(() => undefined); + queues.set(repository, tail); + void tail.then(() => { if (queues.get(repository) === tail) queues.delete(repository); }); + return work; + } + /** Settles once every queued job (protective packs and pruning included) has finished. */ + async idle(): Promise { + while (this.queues.size > 0 || this.background.size > 0 || this.previews.size > 0) { + await Promise.all([...this.queues.values(), ...this.background.values(), ...this.previews.values()]); + } + await this.writes.catch(() => undefined); + } + + async available(cwd: string, signal?: AbortSignal): Promise { + try { return await withinTime(realpath(await this.git(cwd, ["rev-parse", "--show-toplevel"], { signal }))) === await withinTime(realpath(cwd)); } + catch { return false; } + } + + // ---- capture -------------------------------------------------------------------------------------------------- + + /** + * Takes a rollback point. With a `signal` (the turn barrier) it settles as soon as the point is registered (or the + * signal aborts); the protective pack is written afterwards in the repository's queue. + */ + async capture(sessionId: string, cwd: string, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + let root: string; + try { root = await withinTime(realpath(cwd)); } catch { return; } + let commit!: () => void; + const committed = new Promise((resolve) => { commit = resolve; }); + const work = this.enqueue(root, () => this.captureOne(sessionId, root, "before-turn", signal, commit)); + // Without a barrier signal the caller waits for the protective copy as well. + if (!signal) { await work; await this.background.get(root); return; } + let abort!: () => void; + const cancelled = new Promise((_resolve, reject) => { abort = () => reject(signal.reason ?? new Error("Checkpoint cancelled.")); }); + signal.addEventListener("abort", abort, { once: true }); + if (signal.aborted) abort(); + try { await Promise.race([work, committed, cancelled]); } + finally { signal.removeEventListener("abort", abort); } + } + /** + * Registers one rollback point; false when none could be taken here (no commit yet on the branch, the folder is not + * the project's top, or the card was closed). + */ + private async captureOne(sessionId: string, root: string, kind: CheckpointKind, signal?: AbortSignal, committed?: () => void): Promise { + signal?.throwIfAborted(); + if (this.forgotten.has(sessionId)) return false; + await this.load(); + const facts = (await this.git(root, ["rev-parse", "--show-toplevel", "HEAD^{commit}", "HEAD^{tree}"], { signal }).catch(() => "")).split("\n"); + if (facts.length !== 3 || !OID.test(facts[1]) || !OID.test(facts[2]) || await withinTime(realpath(facts[0])).catch(() => "") !== root) { + signal?.throwIfAborted(); return false; + } + const [, head, headTree] = facts; + const [overrides] = await Promise.all([this.filterOverrides(root, signal), this.requireNoFilters(root, headTree, signal)]); + const stash = await this.git(root, ["stash", "create"], { attributes: headTree, extraConfig: overrides, signal }); + if (stash && !OID.test(stash)) throw new Error("Invalid stash object."); + const oid = stash || head; + // A stash's second parent stores its index state. A clean checkpoint can itself be a merge commit, + // whose second parent is an unrelated branch and must not be used as the saved index. + const trees = stash ? (await this.git(root, ["rev-parse", `${stash}^{tree}`, `${stash}^2^{tree}`], { signal })).split("\n") : [headTree, headTree]; + if (trees.length !== 2 || !trees.every((tree) => OID.test(tree))) throw new Error("Invalid checkpoint trees."); + // Checked again after the snapshot: a filter attribute or driver that appeared while it was taken (for example in + // .git/info/attributes, which --attr-source does not cover) means the snapshot may not be the raw files. + const [after] = await Promise.all([this.filterOverrides(root, signal), this.requireNoFilters(root, headTree, signal, trees[0])]); + if (after.some((value) => !overrides.includes(value))) { + throw new CheckpointRefusal("filters", "A Git filter driver appeared while the rollback point was taken."); + } + const ref = `${this.prefix(sessionId)}${this.nextPointTime()}-${randomUUID()}`; + const row: TrustedCheckpoint = { cwd: root, sessionId, oid, tree: trees[0], indexTree: trees[1], kind }; + try { + signal?.throwIfAborted(); + await this.git(root, ["update-ref", "--no-deref", ref, oid, ""], { signal }); + signal?.throwIfAborted(); + if (this.forgotten.has(sessionId)) throw new Error("The card was closed."); + this.trusted.set(ref, row); + await this.persist(signal); + signal?.throwIfAborted(); + } catch (error) { + if (this.trusted.get(ref) === row) { + this.trusted.delete(ref); + // A cancellation can race the atomic registry replacement; write the previous contents back. + if (signal?.aborted) await this.persist().catch(() => undefined); + } + await this.git(root, ["update-ref", "-d", ref]).catch(() => undefined); + throw error; + } + // The point exists now: the turn may start. Copying its objects and pruning older points never touch the agent's + // files, so they run in the repository's background queue and never delay the next turn's capture. + committed?.(); + void this.enqueue(root, async () => { + await this.protect(ref, row).catch((error: unknown) => { + console.warn("Rollback point objects could not be copied.", error instanceof Error ? error.message : "error"); + }); + await this.prune(ref); + }, this.background).catch((error: unknown) => { + console.warn("Rollback points could not be pruned.", error instanceof Error ? error.message : "error"); + }); + return true; + } + /** + * Copies the point's two trees into host-owned packs, so deleting refs, rewriting history and garbage-collecting + * the repository loses nothing it needs. Only objects the repository's previous protected point does not already + * hold are packed (the files that changed, and their folders); its packs are referenced as well. A chain longer + * than MAX_PACK_CHAIN starts over with one complete pack. + */ + private async protect(ref: string, row: TrustedCheckpoint): Promise { + if (!this.storagePath || this.trusted.get(ref) !== row) return; + // The repository's newest protected point: its chain is extended, or, once that is MAX_PACK_CHAIN packs long, a + // complete pack starts a new chain (so no chain, and no pack holding everything since an old point, grows forever). + let previous: TrustedCheckpoint | undefined; + let newest = -1; + for (const [id, other] of this.trusted) { + const at = pointTime(id); + if (other !== row && other.cwd === row.cwd && other.packs && at > newest) { previous = other; newest = at; } + } + if (previous && previous.packs!.length >= MAX_PACK_CHAIN) previous = undefined; + const directory = dirname(this.packPath(row.cwd, "0".repeat(40))!); + await mkdir(directory, { recursive: true, mode: 0o700 }); + // Git can leave tmp_pack/partial index files when interrupted. Keep each invocation in its own + // private staging folder so failure cleanup never removes a pack retained by another checkpoint. + const staging = await mkdtemp(join(directory, `.pending-${process.pid}-`)); + const chain = previous?.packs ? [...previous.packs] : []; + const release = chain.map((pack) => this.holdPack(row.cwd, pack)); + try { + const input = [row.tree, row.indexTree, ...(previous ? [`^${previous.tree}`, `^${previous.indexTree}`] : [])].join("\n") + "\n"; + const hash = await this.git(row.cwd, ["pack-objects", "--revs", "--compression=1", join(staging, "pack")], { + input, timeoutMs: 300_000, extraConfig: ["pack.threads=1", "pack.writeBitmapHashCache=false"] + }); + if (!OID.test(hash)) throw new Error("Invalid checkpoint pack hash."); + // Restore streams each pack to index-pack, which creates its own repository index. Retaining + // Git's staging .idx would duplicate unused data; only the complete pack is published atomically. + const target = this.packPath(row.cwd, hash)!; + await rename(join(staging, `pack-${hash}.pack`), target); + if (this.trusted.get(ref) !== row || chain.some((pack) => !existsSync(this.packPath(row.cwd, pack)!))) { + await this.removePackIfUnreferenced(row.cwd, hash); + return; + } + row.packs = [...chain.filter((pack) => pack !== hash), hash]; + await this.persist(); + } finally { + for (const done of release) await done(); + await this.removePackStaging(staging); + } + } + private async packSize(cwd: string, pack: string): Promise { + const key = `${cwd}\0${pack}`; + let size = this.packSizes.get(key); + if (size === undefined) { + size = await stat(this.packPath(cwd, pack)!).then((info) => info.size, () => 0); + this.packSizes.set(key, size); + } + return size; + } + /** + * A new point's time: now, but always after every point already registered, so retention (which drops the oldest + * first, by this time) keeps the newest even for two points in one millisecond or after the clock went back. + */ + private nextPointTime(): number { + let at = Date.now(); + for (const id of this.trusted.keys()) at = Math.max(at, pointTime(id) + 1); + return at; + } + /** Keeps at most `perSession` points per card and `total`/`packBytes` overall, dropping the oldest first. */ + private async prune(newest?: string): Promise { + const rows = [...this.trusted].map(([id, row]) => ({ id, row, at: pointTime(id) })).sort((a, b) => b.at - a.at); + const drop = new Set(); + const perSession = new Map(); + for (const { id, row } of rows) { + const key = `${row.sessionId}\0${row.cwd}`, count = (perSession.get(key) ?? 0) + 1; + perSession.set(key, count); + if (count > this.limits.perSession) drop.add(id); + } + let kept = 0, bytes = 0; + const counted = new Set(); + let unprotect: TrustedCheckpoint | undefined; + for (const { id, row } of rows) { + if (drop.has(id)) continue; + const fresh = (row.packs ?? []).filter((pack) => !counted.has(`${row.cwd}\0${pack}`)); + let extra = 0; + for (const pack of fresh) extra += await this.packSize(row.cwd, pack); + if (id === newest && bytes + extra > this.limits.packBytes) { unprotect = row; kept++; continue; } + if (id !== newest && (kept + 1 > this.limits.total || bytes + extra > this.limits.packBytes)) { drop.add(id); continue; } + kept++; bytes += extra; + for (const pack of fresh) counted.add(`${row.cwd}\0${pack}`); + } + if (drop.size === 0 && !unprotect) return; + const removed = [...drop].map((id) => ({ id, row: this.trusted.get(id)! })); + for (const { id } of removed) this.trusted.delete(id); + const unprotected = unprotect?.packs ?? []; + // Too large to keep a copy of: the point stays, relying on the repository's own objects. + if (unprotect) delete unprotect.packs; + await this.persist(); + for (const pack of unprotected) await this.removePackIfUnreferenced(unprotect!.cwd, pack); + await this.discard(removed); + } + /** Removes dropped points' refs (in each repository's own queue) and their packs once nothing references them. */ + private async discard(removed: Array<{ id: string; row: TrustedCheckpoint }>): Promise { + const byRepository = new Map>(); + for (const entry of removed) byRepository.set(entry.row.cwd, [...(byRepository.get(entry.row.cwd) ?? []), entry]); + for (const [cwd, entries] of byRepository) { + for (const pack of new Set(entries.flatMap(({ row }) => row.packs ?? []))) await this.removePackIfUnreferenced(cwd, pack); + void this.enqueue(cwd, async () => { + try { await withinTime(stat(cwd)); } catch { return; } + await this.git(cwd, ["update-ref", "--stdin", "-z"], { + input: entries.map(({ id }) => `delete ${id}\0\0`).join("") + }).catch(() => undefined); + }); + } + } + /** A closed card's rollback points go with it. */ + async forgetSession(sessionId: string): Promise { + this.forgotten.add(sessionId); + await this.load(); + await this.drop(([, row]) => row.sessionId === sessionId); + } + /** + * After the saved cards came back: points of the saved cards known not to come back are dropped. Only cards named + * here go (never "everything not restored": the saved cards may be unreadable or from a newer version this run). + */ + async forgetSessions(sessionIds: Iterable): Promise { + const gone = new Set(sessionIds); + if (gone.size === 0) return; + for (const id of gone) this.forgotten.add(id); + await this.load(); + await this.drop(([, row]) => gone.has(row.sessionId)); + } + private async drop(match: (entry: [string, TrustedCheckpoint]) => boolean): Promise { + const removed = [...this.trusted].filter(match).map(([id, row]) => ({ id, row })); + if (removed.length === 0) return; + for (const { id } of removed) this.trusted.delete(id); + await this.persist(); + await this.discard(removed); + } + private async removePackIfUnreferenced(cwd: string, pack?: string): Promise { + if (!pack || this.heldPacks.has(`${cwd}\0${pack}`) || [...this.trusted.values()].some((row) => row.cwd === cwd && row.packs?.includes(pack))) return; + const path = this.packPath(cwd, pack); + this.packSizes.delete(`${cwd}\0${pack}`); + if (path) await rm(path, { force: true }); + } + private holdPack(cwd: string, pack?: string): () => Promise { + if (!pack) return async () => undefined; + const key = `${cwd}\0${pack}`; this.heldPacks.set(key, (this.heldPacks.get(key) ?? 0) + 1); + let released = false; + return async () => { + if (released) return; released = true; + const count = this.heldPacks.get(key) ?? 0; + if (count > 1) this.heldPacks.set(key, count - 1); + else { this.heldPacks.delete(key); await this.removePackIfUnreferenced(cwd, pack); } + }; + } + private objectsRoot(): string | undefined { + return this.storagePath ? join(dirname(this.storagePath), "checkpoint-objects") : undefined; + } + private packPath(cwd: string, hash: string): string | undefined { + const root = this.objectsRoot(); + return root ? join(root, createHash("sha256").update(cwd).digest("hex"), `pack-${hash}.pack`) : undefined; + } + + // ---- list, preview, restore ------------------------------------------------------------------------------------ + + async list(sessionId: string, cwd: string): Promise { + await this.load(); + if (!await this.available(cwd)) return []; + const root = await withinTime(realpath(cwd)); + const prefix = this.prefix(sessionId); + // Only the protected host registry decides which snapshots exist; refs are an additional repository convenience. + return [...this.trusted].filter(([id, row]) => id.startsWith(prefix) && row.cwd === root && row.sessionId === sessionId) + .map(([id, row]) => ({ id, at: pointTime(id), kind: row.kind })) + .sort((a, b) => b.at - a.at); + } + /** The registered row, with its objects back in the repository (from the host packs when some are missing). */ + private async requirePoint(sessionId: string, root: string, id: string, signal?: AbortSignal): Promise { + if (!(await this.list(sessionId, root)).some((entry) => entry.id === id)) throw new CheckpointRefusal("unknown-checkpoint", "Checkpoint does not belong to this session."); + const row = this.trusted.get(id)!; + await this.ensureObjects(root, row, signal); + return row; + } + /** Imports the point's packs only when the repository misses one of its objects (no pack piles up per preview). */ + private async ensureObjects(root: string, row: TrustedCheckpoint, signal?: AbortSignal): Promise { + if (await this.complete(root, row, signal)) return; + for (const pack of row.packs ?? []) { + const source = createReadStream(this.packPath(row.cwd, pack)!); + try { + await this.git(root, ["index-pack", "--stdin"], { input: source, timeoutMs: 300_000, limit: 4096, signal }); + } catch (error) { + // A missing pack leaves the objects to the repository; the completeness check below decides. + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } finally { source.destroy(); } + } + signal?.throwIfAborted(); + if (!await this.complete(root, row, signal)) throw new CheckpointRefusal("unknown-checkpoint", "The rollback point's objects are no longer in the repository."); + } + private async complete(root: string, row: TrustedCheckpoint, signal?: AbortSignal): Promise { + let missing = false; + try { + await this.git(root, ["rev-list", "--objects", "--no-object-names", "--missing=print", row.tree, row.indexTree], { + timeoutMs: 120_000, signal, onStdout: (chunk) => { if (chunk.includes(63)) missing = true; } + }); + } catch { signal?.throwIfAborted(); return false; } + return !missing; + } + /** + * What a restore of the point would change, as a diff from the current files to the point (lines marked `+` come + * back, lines marked `-` go). Runs in the repository's own preview lane, never ahead of or behind a capture, holds + * the point's packs while it reads them, and gives up after PREVIEW_TIMEOUT_MS. + */ + async preview(sessionId: string, cwd: string, id: string): Promise<{ text: string; changedFiles: string[]; truncated: boolean }> { + const root = await withinTime(realpath(cwd)); + return this.enqueue(root, async () => { + const signal = AbortSignal.timeout(PREVIEW_TIMEOUT_MS); + await this.load(); + const initial = this.trusted.get(id); + const holds = (initial?.packs ?? []).map((pack) => this.holdPack(initial!.cwd, pack)); + try { + const row = await this.requirePoint(sessionId, root, id, signal); + const pinned = { ...await this.pinnedAttributes(root, signal), signal }; + let truncated = false; + const options: GitOptions = { ...pinned, limit: PREVIEW_LIMIT, truncate: true, onTruncate: () => { truncated = true; } }; + // The restore direction (current files → the point). -R swaps the a/ b/ prefixes too, so they are swapped back: + // the headers read a/ (now) → b/ (after the restore), as in any diff. + const text = await this.git(root, ["diff", "-R", "--src-prefix=b/", "--dst-prefix=a/", "--no-ext-diff", "--no-textconv", "--no-color", row.tree, "--", "."], options); + const names: string[] = []; + let bytes = 0; + await this.records(root, ["diff", "-R", "--name-only", "-z", "--no-ext-diff", "--no-textconv", row.tree, "--", "."], (record) => { + if ((bytes += record.length) > PREVIEW_LIMIT) { truncated = true; return; } + names.push(record.toString("utf8")); + }, pinned); + // A preview is cut at the output limit (the restore still uses the complete snapshot); the dialog says so. + return { text: visible(this.redact(text)), changedFiles: names.map((name) => visible(this.redact(name))), truncated }; + } finally { for (const done of holds) await done(); } + }, this.previews); + } + /** The attributes source and filter overrides for calls that compare with or write the working tree. */ + private async pinnedAttributes(root: string, signal?: AbortSignal): Promise> { + const tree = await this.git(root, ["rev-parse", "--verify", "HEAD^{tree}"], { signal }).catch(() => ""); + return { attributes: OID.test(tree) ? tree : await this.emptyTree(root), extraConfig: await this.filterOverrides(root, signal) }; + } + private async emptyTree(root: string): Promise { + const empty = await this.git(root, ["hash-object", "-t", "tree", "--stdin"], { input: "" }); + if (!OID.test(empty)) throw new Error("Invalid empty tree."); + return empty; + } + /** + * Restores tracked files and the index (HEAD and the branch stay where they are); the current work is saved first + * as a "before-restore" rollback point. Refused when a file the restore would write exists untracked, or when Git + * is told to ignore changes in some tracked files: neither would be in that saved point. + */ + async restore(sessionId: string, cwd: string, id: string): Promise<{ ok: true }> { + const root = await withinTime(realpath(cwd)); + return this.enqueue(root, () => this.restoreOne(sessionId, root, id)); + } + private async inspectIndex(root: string): Promise<{ paths: Set; submodules: boolean; flagged: boolean }> { + const paths = new Set(); + let submodules = false, flagged = false; + await this.records(root, ["ls-files", "-z", "-v", "--stage"], (record) => { + // " \t": a lowercase tag is assume-unchanged, S/s skip-worktree. + const tab = record.indexOf(9); + const head = record.subarray(0, tab).toString("latin1"); + const tag = head[0]; + if (tag === "S" || (tag >= "a" && tag <= "z")) flagged = true; + if (head.slice(2, 8) === "160000") submodules = true; + paths.add(record.subarray(tab + 1).toString("latin1")); + }); + return { paths, submodules, flagged }; + } + private async treeEntries(root: string, tree: string, onEntry: (mode: string, path: Buffer) => void): Promise { + await this.records(root, ["ls-tree", "-r", "-z", "--full-tree", tree], (record) => { + const tab = record.indexOf(9); + onEntry(record.subarray(0, 6).toString("latin1"), record.subarray(tab + 1)); + }); + } + private async restoreOne(sessionId: string, root: string, id: string): Promise<{ ok: true }> { + const initial = this.trusted.get(id); + // Hold the point's packs: the safety capture's pruning may drop the point itself at a retention of one. + const holds = (initial?.packs ?? []).map((pack) => this.holdPack(initial!.cwd, pack)); + const release = async (): Promise => { for (const done of holds) await done(); }; + try { + const row = await this.requirePoint(sessionId, root, id); + const index = await this.inspectIndex(root); + // Inspect modes only at NUL-delimited record starts: paths can contain newlines, tabs or "160000". + // The parent snapshot does not retain dirty/untracked nested repository data for a safe recursive restore. + let submodules = index.submodules; + const incoming: Buffer[] = []; + const target = new Set(); + for (const tree of new Set([row.tree, row.indexTree])) { + await this.treeEntries(root, tree, (mode, path) => { + if (mode === "160000") submodules = true; + if (tree !== row.tree) return; + const key = path.toString("latin1"); + target.add(key); + if (!index.paths.has(key)) incoming.push(Buffer.from(path)); + }); + } + // A case-insensitive file system (macOS, Windows): a tracked path that differs from an incoming one only in case + // is the same file. It is not an untracked collision, but Git would write the incoming file and then delete the + // tracked one (the same file), so the tracked one is removed first (its content is in the safety point). + // Unset (Git leaves it so on a case-sensitive file system) makes `git config` exit with 1: that is false. + const ignoreCase = await this.git(root, ["config", "--type=bool", "core.ignorecase"]).then((value) => value === "true", () => false); + const folded = new Map(); + if (ignoreCase) for (const path of index.paths) folded.set(foldPath(path), path); + const caseRenamed = new Set(); + if (ignoreCase) { + for (const path of incoming) { + const tracked = folded.get(foldPath(path.toString("latin1"))); + if (tracked !== undefined && !target.has(tracked)) caseRenamed.add(tracked); + } + } + if (submodules) { + throw new CheckpointRefusal("submodules", "Submodule contents are not captured by checkpoints. Restoration is unavailable when the current index or saved checkpoint contains submodules; safeguard the nested repositories and restore them with Git separately."); + } + if (index.flagged) throw new CheckpointRefusal("worktree-conflict", "Git is told to ignore changes in some tracked files (assume-unchanged or skip-worktree)."); + if (await this.untrackedCollision(root, incoming, (key) => index.paths.has(key) || (ignoreCase && folded.has(foldPath(key))))) { + throw new CheckpointRefusal("worktree-conflict", "A file the restore would write exists untracked in the folder."); + } + const head = await this.git(root, ["rev-parse", "--verify", "HEAD^{tree}"]).catch(() => ""); + await this.requireNoFilters(root, OID.test(head) ? head : await this.emptyTree(root), undefined, row.tree); + // Never restore over work that could not be saved first (for example a branch without a commit yet). + if (!await this.captureOne(sessionId, root, "before-restore")) { + throw new CheckpointRefusal("no-safety-point", "The current work could not be saved as a rollback point first."); + } + // Capture may prune this checkpoint at a retention limit of one. Re-import its held private pack + // after capture, immediately before restore, in case Git garbage-collected the earlier import. + await this.ensureObjects(root, row); + const options = { attributes: row.tree, extraConfig: await this.filterOverrides(root) }; + if (caseRenamed.size > 0) { + const input = Readable.from([Buffer.concat([...caseRenamed].map((path) => Buffer.from(`${path}\0`, "latin1")))]); + await this.git(root, ["--literal-pathspecs", "rm", "-q", "-f", "--pathspec-from-file=-", "--pathspec-file-nul"], { ...options, input, timeoutMs: 300_000 }); + } + // Keep the current index until Git removes tracked paths absent from the target worktree. + // Restoring the index first would turn post-checkpoint additions into untouched untracked files. + await this.git(root, ["restore", `--source=${row.tree}`, "--worktree", "--", "."], { ...options, timeoutMs: 300_000 }); + await this.git(root, ["restore", `--source=${row.indexTree}`, "--staged", "--", "."], { ...options, timeoutMs: 300_000 }); + return { ok: true }; + } finally { await release(); } + } + /** A path the restore would create (or one of its folders) already exists without being tracked. */ + private async untrackedCollision(root: string, incoming: Buffer[], tracked: (path: string) => boolean): Promise { + const checked = new Set(); + const base = Buffer.from(root + "/"); + for (const path of incoming) { + const parts: number[] = []; + for (let index = path.indexOf(47); index !== -1; index = path.indexOf(47, index + 1)) parts.push(index); + for (const end of [...parts, path.length]) { + const part = path.subarray(0, end), key = part.toString("latin1"); + if (checked.has(key)) continue; + checked.add(key); + if (tracked(key)) continue; + let entry; + try { entry = await lstat(Buffer.concat([base, part])); } catch { break; } + if (end === path.length || !entry.isDirectory()) return true; + } + } + return false; + } +} + +/** Settles like `work`, or fails once FOLDER_TIMEOUT_MS passed (a hung mount: the call itself cannot be cancelled). */ +function withinTime(work: Promise, ms = FOLDER_TIMEOUT_MS): Promise { + let timer: ReturnType | undefined; + const late = new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(Object.assign(new Error("The folder did not answer in time."), { code: "ETIMEDOUT" })), ms); + timer.unref?.(); + }); + return Promise.race([work, late]).finally(() => clearTimeout(timer)); +} + +/** A path (bytes as latin1) as a case-insensitive file system compares it. */ +function foldPath(path: string): string { + return Buffer.from(path, "latin1").toString("utf8").normalize("NFC").toLowerCase(); +} + +/** Text shown in the dialog, without bidirectional formatting controls. */ +function visible(text: string): string { + return text.replace(BIDI_CONTROLS, ""); +} + +function pointTime(id: string): number { + return Number(id.slice(id.lastIndexOf("/") + 1).split("-", 1)[0]); +} + +function parseRow(value: unknown): { id: string; row: TrustedCheckpoint } | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const row = value as Record; + if (typeof row.id !== "string" || !/^refs\/canvastty\/[\w-]{1,100}\/\d{1,16}-[0-9a-f-]{36}$/u.test(row.id)) return null; + if (typeof row.cwd !== "string" || !row.cwd || row.cwd.includes("\0") || typeof row.sessionId !== "string" || !row.id.startsWith(`refs/canvastty/${row.sessionId}/`)) return null; + for (const key of ["oid", "tree", "indexTree"]) if (typeof row[key] !== "string" || !OID.test(row[key] as string)) return null; + if (row.packs !== undefined && (!Array.isArray(row.packs) || row.packs.length > MAX_PACK_CHAIN + 1 + || !row.packs.every((pack) => typeof pack === "string" && OID.test(pack)))) return null; + const kind = row.kind ?? "before-turn"; + if (kind !== "before-turn" && kind !== "before-restore") return null; + return { id: row.id, row: { cwd: row.cwd, sessionId: row.sessionId, kind, oid: row.oid as string, tree: row.tree as string, + indexTree: row.indexTree as string, ...(row.packs ? { packs: [...row.packs as string[]] } : {}) } }; +} diff --git a/src/main/services/PluginCards.ts b/src/main/services/PluginCards.ts index a3c9bbcd..16c2afc6 100644 --- a/src/main/services/PluginCards.ts +++ b/src/main/services/PluginCards.ts @@ -23,6 +23,8 @@ export interface PluginCardsDependencies { trustedPlugins(): ReadonlySet; call(pluginId: string, serviceId: string, method: "canvastty.cards.invoke", params: unknown, timeoutMs: number): Promise; session(sessionId: string): PluginSessionSummary | null; + /** A rollback point restore is writing files in (or above, or below) this folder. */ + restoring?(folder: string): boolean; redact(text: string): string; /** Pushes the new decorations to the window. */ changed(decorations: PluginCardDecorations): void; @@ -140,6 +142,10 @@ export class PluginCards { const session = this.deps.session(sessionId); if (!session) throw new Error("No card has that session id."); if (!cardActionMatches(action.when, session)) throw new Error("That action does not apply to this card."); + // A card action may change the card's files (apply a worktree, discard changes): never while a restore writes them. + if (this.deps.restoring && [session.cwd, session.workingDirectory].some((folder) => this.deps.restoring!(folder))) { + throw new Error("Workspace rollback point restoration is in progress. Try the action again after it finishes."); + } const timeoutMs = this.deps.timeoutMs ?? CARD_ACTION_TIMEOUT_MS; const normalizedInput = normalizeCardActionInput(input); if (action.menu === false && normalizedInput === undefined) throw new Error("That action runs only from a change review."); diff --git a/src/main/services/TerminalManager.ts b/src/main/services/TerminalManager.ts index aaae3f8d..6fc96607 100644 --- a/src/main/services/TerminalManager.ts +++ b/src/main/services/TerminalManager.ts @@ -70,6 +70,7 @@ import { controlGrantFolder, gatewaySocketAddresses } from "./isolation/AgentIso import { auditRepositories, neutralizeRepositories, type GitRiskRepository } from "./isolation/gitAudit.ts"; import { providerHomeVariables, type PlacedProject } from "./isolation/isolationPaths.ts"; import { LaunchRefusal } from "./launchRefusal.ts"; +import { CheckpointRefusal } from "./checkpointRefusal.ts"; import { configuredMode } from "./configuredMode.ts"; import { envKey, RESERVED_ENV, type LaunchPipeline, type PreparedLaunch } from "./LaunchPipeline.ts"; import type { EnvironmentRegistry } from "./EnvironmentRegistry.ts"; @@ -190,6 +191,13 @@ interface ManagedSession { /** When this launch's first lifecycle hook stops waiting for inputReady (INPUT_READY_HOOK_GRACE_MS after it). */ hookReadyAt?: number; hookReadyTimer?: ReturnType; + /** + * Bumped by every submitted input (and restart): a pre-turn rollback point belongs to one generation. Separate from + * inputGeneration, which ties a subagent's answer to its prompt. + */ + checkpointGeneration?: number; + /** Bumped by every input submitted while no approval is pending, even inside a turn (see submittedInputs). */ + submittedInputs?: number; } type EnvironmentService = Pick; closes: Map; done: Promise } | null = null; + // Cards whose plugin launch (options, environment) is still being prepared: their final folder is not known yet. + private readonly pendingLaunches = new Set(); constructor( emit: Emit, @@ -476,16 +489,22 @@ export class TerminalManager { this.sessionRestoreMode = mode; } - async restorePersistedSessions(): Promise { + /** + * Brings the saved cards back. Returns the ids of saved cards known not to come back (so data kept for them, such as + * rollback points, may go); empty when the saved cards could not be read, are from a newer version, or restore is off. + */ + async restorePersistedSessions(): Promise { const store = this.sessionStore; - if (!store) return; + if (!store) return []; const persisted = await store.load(); if (this.sessionRestoreMode === "off") { if (persisted.length > 0) await store.clear(); - return; + return []; } await this.restoreSessionRecords(persisted, this.sessionRestoreMode); + if (store.loadProblem) return []; + return persisted.map((record) => record.id).filter((id) => !this.sessions.has(id)); } private async restoreSessionRecords(persisted: PersistedTerminalSession[], mode: SessionRestoreMode): Promise { @@ -537,6 +556,7 @@ export class TerminalManager { } async shutdown(): Promise { + if (this.checkpointRestore) await this.checkpointRestore.done; if (this.persistenceTimer !== null) { clearTimeout(this.persistenceTimer); this.persistenceTimer = null; @@ -650,6 +670,134 @@ export class TerminalManager { return { promptSent: mark !== undefined, turnStartedSincePrompt: mark !== undefined && (session.turnStarts ?? 0) > mark }; } + /** A rollback point restore holds this card: no input, launch or close reaches it until the restore ends. */ + isCheckpointRestoreActive(id: string): boolean { + return this.checkpointRestore?.ids.has(id) ?? false; + } + + /** The app is quitting: closing cards now keeps their rollback points for the next start. */ + isQuitting(): boolean { + return this.quitting; + } + + /** A rollback point restore is writing files in (or above, or below) this folder. */ + isRestoringFolder(path: string): boolean { + const lease = this.checkpointRestore; + if (!lease) return false; + let folder: string; + try { folder = realpathSync(path); } catch { folder = path; } + return isPathInside(lease.cwd, folder) || isPathInside(folder, lease.cwd); + } + + /** A pre-turn rollback point may be taken for this card now. */ + canCaptureCheckpoint(id: string): boolean { + const session = this.sessions.get(id); + return Boolean(session && session.metadata.exitCode === null && !this.isCheckpointRestoreActive(id)); + } + + /** + * A host-only freshness check for a pre-turn rollback point: false once the card was replaced, received new submitted + * input, ended, or a restore holds it. + */ + providerSignalGuard(id: string): () => boolean { + const session = this.sessions.get(id); + const generation = session?.checkpointGeneration ?? 0; + return () => Boolean(session && this.sessions.get(id) === session && (session.checkpointGeneration ?? 0) === generation + && session.metadata.status !== "done" && session.metadata.status !== "failed" && !this.isCheckpointRestoreActive(id)); + } + + /** + * How many inputs were submitted to this card (not counting answers to an approval). A CLI without a turn-end hook + * keeps its card "working", so the turn barrier compares this count to tell a new prompt from the running turn. + */ + submittedInputs(id: string): number { + return this.sessions.get(id)?.submittedInputs ?? 0; + } + + /** No launch may start in (or above, or below) a folder whose files are being restored, nor any plugin launch. */ + private assertCheckpointLaunchAllowed(cwd: string, contributed = false): void { + const lease = this.checkpointRestore; + if (!lease) return; + let folder: string; + try { folder = realpathSync(cwd); } catch { folder = cwd; } + if (contributed || isPathInside(lease.cwd, folder) || isPathInside(folder, lease.cwd)) { + throw new LaunchRefusal("Workspace rollback point restoration is in progress. Try the launch again after it finishes."); + } + } + + /** + * Restores a card's files under a lease over every card whose folder overlaps it. Refused (CheckpointRefusal) while + * any of them is still running or launching, or when a related plugin environment ran isolated (stopping its local + * wrapper does not prove the remote or container workload stopped). While it runs, those cards take no input, no + * launch starts in the folder, and closing one waits until the restore ends. + */ + async withCheckpointRestore(id: string, restore: (cwd: string) => Promise): Promise { + if (this.checkpointRestore) throw new CheckpointRefusal("restore-in-progress", "Another rollback point restoration is in progress."); + const target = this.sessions.get(id); + if (!target) throw new CheckpointRefusal("missing-session", "Terminal session does not exist."); + if (this.pendingLaunches.size > 0) throw new CheckpointRefusal("pending-launch", "Wait for pending agent launches before restoring files."); + let cwd: string; + try { cwd = realpathSync(this.launchContexts.get(id)?.cwd ?? target.metadata.cwd); } + catch { throw new CheckpointRefusal("not-git-root", "The card's folder is unavailable."); } + const targetScopes = new Set([cwd]); + let targetScopeAmbiguous = false; + for (const path of [target.metadata.cwd, this.taskProjectRoot(target.metadata.cwd, target.metadata.parentSessionId)]) { + try { targetScopes.add(realpathSync(path)); } catch { targetScopeAmbiguous = true; } + } + const overlaps = (path: string): boolean => [...targetScopes].some((scope) => isPathInside(path, scope) || isPathInside(scope, path)); + const taskRoot = (session: ManagedSession): string => { + let root = session; + for (let depth = 0; root.metadata.parentSessionId && depth < 64; depth++) { + const parent = this.sessions.get(root.metadata.parentSessionId); + if (!parent) break; + root = parent; + } + return root.metadata.id; + }; + const targetTask = taskRoot(target); + const affected: ManagedSession[] = []; + for (const session of this.sessions.values()) { + const isolated = session.extras.isolatedEnvironmentScopes; + if (isolated && (isolated.roots.length > 0 || isolated.ambiguous)) { + // Recorded folders decide. Evidence that is incomplete (ambiguous) also counts the card's own folders and its + // task: an unrelated card elsewhere never blocks this restore, an unknown one in the same task or folder does. + let related = targetScopeAmbiguous || isolated.roots.some(overlaps); + if (!related && isolated.ambiguous) { + let known = 0; + for (const path of [session.metadata.cwd, this.taskProjectRoot(session.metadata.cwd, session.metadata.parentSessionId)]) { + try { if (overlaps(realpathSync(path))) related = true; known++; } catch { /* unknown folder */ } + } + if (known === 0 || taskRoot(session) === targetTask) related = true; + } + if (related) throw new CheckpointRefusal("isolated-environment", "Rollback point restoration is unavailable for a related isolated environment."); + } + let folder: string; + try { folder = realpathSync(this.launchContexts.get(session.metadata.id)?.cwd ?? session.metadata.cwd); } + catch { if (session.metadata.exitCode !== null) continue; folder = session.metadata.cwd; } + if (!isPathInside(cwd, folder) && !isPathInside(folder, cwd)) continue; + if (session.metadata.exitCode === null) { + throw new CheckpointRefusal("agent-running", "Close or exit every agent and terminal in this folder before restoring files."); + } + affected.push(session); + } + let complete!: () => void; + const lease = { cwd, ids: new Set(affected.map((session) => session.metadata.id)), closes: new Map(), + done: new Promise((resolve) => { complete = resolve; }) }; + // Closing an ancestor closes its children and may release their environment: the whole close waits. + for (const session of affected) { + let parent = session.metadata.parentSessionId; + while (parent && !lease.ids.has(parent)) { lease.ids.add(parent); parent = this.sessions.get(parent)?.metadata.parentSessionId; } + } + this.checkpointRestore = lease; + try { + return await restore(cwd); + } finally { + this.checkpointRestore = null; + complete(); + for (const [closed, options] of lease.closes) this.dispose(closed, options); + } + } + /** The session's output offset without copying its scrollback; null when it does not exist. */ outputOffset(id: string): number | null { return this.sessions.get(id)?.outputOffset ?? null; @@ -678,6 +826,7 @@ export class TerminalManager { control: { captureResult?: boolean; answerCaptureGrantExpiresAt?: number; origin?: LaunchOrigin; ownerPluginId?: string } = {} ): SessionSnapshot { assertCreateRequest(request, this.containment()); + this.assertCheckpointLaunchAllowed(request.cwd, Boolean(request.environment || request.launchOptions)); const origin: LaunchOrigin = request.role === "subagent" ? "subagent" : control.origin ?? "person"; if (request.profile === "yolo" && request.provider !== "terminal") { // YOLO is the person's decision, made in the launcher for that CLI; nothing else starts it on their behalf. @@ -844,6 +993,9 @@ export class TerminalManager { const session = this.sessions.get(id); if (!session) throw new Error("Terminal session does not exist."); if (session.metadata.exitCode === null) throw new Error("Terminal session is still running."); + if (this.isCheckpointRestoreActive(id)) throw new LaunchRefusal("Workspace rollback point restoration is in progress."); + this.assertCheckpointLaunchAllowed(session.metadata.cwd, + Boolean(session.extras.environment || session.extras.environmentChoice || session.extras.options)); const environment = session.extras.environment; if (environment && !this.environmentUsable(environment)) { // Never run a placed session locally instead of where it belongs. @@ -857,6 +1009,7 @@ export class TerminalManager { if (missingPlugins.length > 0) throw new Error(`Launch refused: ${missingLaunchPlugins(missingPlugins)}`); delete session.extras.heldState; delete session.metadata.restoreNote; + session.checkpointGeneration = (session.checkpointGeneration ?? 0) + 1; // Input queued for the launch that ended never reaches this one. session.launchEpoch += 1; this.wakeLaunchWaiters(session); @@ -1082,8 +1235,16 @@ export class TerminalManager { */ private writeInput(id: string, session: ManagedSession, process: IPty, data: string): boolean { if (this.sessions.get(id) !== session || session.process !== process || session.metadata.exitCode !== null) return false; + // A rollback point restore holds the card: neither typed nor replayed input reaches it until the restore ends. + if (this.isCheckpointRestoreActive(id)) return false; const written = tryPtyOperation(() => process.write(data)); if (written) this.noteSubmittedInput(session, data); + // Input submitted while the agent is not in a turn starts the next one: an earlier turn's rollback point is not + // this one's. An Enter inside a running turn (answering a prompt or a menu) never does. + if (written && data.includes("\r") && session.metadata.status !== "working" && session.metadata.status !== "needs_approval") { + session.checkpointGeneration = (session.checkpointGeneration ?? 0) + 1; + } + if (written && data.includes("\r") && session.metadata.status !== "needs_approval") session.submittedInputs = (session.submittedInputs ?? 0) + 1; if (written && ANSWERS_PROMPT.test(data)) this.settleAnsweredPrompt(id, session); return written; } @@ -1362,6 +1523,7 @@ export class TerminalManager { * answer to "Keep environment data?" (kept unless they said no). Quitting releases nothing. */ dispose(id: string, options: { keepEnvironmentData?: boolean } = {}): void { + if (this.checkpointRestore?.ids.has(id)) { this.checkpointRestore.closes.set(id, options); return; } const session = this.sessions.get(id); if (!session) return; // A subagent belongs to its parent: closing the parent closes its subagents first (deepest first), so none keeps @@ -1457,6 +1619,7 @@ export class TerminalManager { ...(descriptor.effort !== undefined ? { effort: descriptor.effort } : {}) }; const extras: PersistedSessionExtras = { + ...(descriptor.isolatedEnvironmentScopes ? { isolatedEnvironmentScopes: structuredClone(descriptor.isolatedEnvironmentScopes) } : {}), ...(descriptor.options ? { options: descriptor.options } : {}), ...(descriptor.environment ? { environment: descriptor.environment } : {}), ...(descriptor.environmentChoice && !descriptor.environment ? { environmentChoice: descriptor.environmentChoice } : {}), @@ -1810,6 +1973,7 @@ export class TerminalManager { } } try { + this.assertCheckpointLaunchAllowed(planned.cwd); const process = this.spawnPty(spawn.command, spawn.args, { name: "xterm-256color", cols, rows, cwd: planned.cwd, env: spawn.env }); @@ -2176,8 +2340,12 @@ export class TerminalManager { // Each launch decides its model again; until it is prepared no plugin has. session.modelSetBy = []; const tasks = session.launchTasks ??= new Set(); + this.pendingLaunches.add(id); // Register before invoking plugin code, which may synchronously close or restart its card. - const task = Promise.resolve().then(() => this.runContributedLaunch(id, session, token, resume, restoring, answerCaptureGrantExpiresAt)) + const task = Promise.resolve().then(() => { + this.assertCheckpointLaunchAllowed(metadata.cwd, true); + return this.runContributedLaunch(id, session, token, resume, restoring, answerCaptureGrantExpiresAt); + }) .catch((error: unknown): LaunchOutcome => { metadata.failureDetails = this.redactSecrets(`Launch refused: ${error instanceof Error ? error.message : String(error)}`); return "failed"; @@ -2191,7 +2359,11 @@ export class TerminalManager { this.emitSession(metadata, outcome === "failed" ? failureOrigin : null); this.schedulePersistence(); }) - .finally(() => { tasks.delete(task); this.wakeLaunchWaiters(session); }); + .finally(() => { + tasks.delete(task); + if (session.launchToken === token || this.sessions.get(id) !== session) this.pendingLaunches.delete(id); + this.wakeLaunchWaiters(session); + }); tasks.add(task); void task.catch(() => undefined); } @@ -2217,6 +2389,8 @@ export class TerminalManager { // 1. Place a new session where the person chose. The choice stays saved with the card until the plugin has // prepared it, so a card whose preparation was cut short (quit, crash) or failed never restores locally. + // The folder asked for, before an environment may move the card: an isolated environment's evidence keeps it. + const requestedCwd = metadata.cwd; const choice = session.extras.environmentChoice; if (choice && !session.extras.environment) { if (!environments) return refuse("plugin environments are not available."); @@ -2295,6 +2469,7 @@ export class TerminalManager { // 4. What the environment keeps of CanvasTTY's protection, and the isolation layer for this launch. const keeps = environment ? this.environments?.keeps?.(environment) ?? {} : {}; + const isolatedEnvironment = Boolean(environment && keeps.isolated === true); if (environment && keeps.launch !== true && metadata.profile !== "normal") { return refuse(`${environment.label} does not pass the launch on unchanged (the plugin does not declare it), so the ${metadata.profile} profile's settings and CanvasTTY's hooks would not reach the agent there. Launch it in normal, or use an environment that keeps them.`); } @@ -2366,6 +2541,7 @@ export class TerminalManager { } let process: IPty; try { + this.assertCheckpointLaunchAllowed(spawn.cwd); process = this.spawnPty(spawn.command, spawn.args, { name: "xterm-256color", cols: session.cols, rows: session.rows, cwd: spawn.cwd, env: spawn.env }); @@ -2375,6 +2551,21 @@ export class TerminalManager { return "failed"; } session.process = process; + if (environment) session.extras.isolatedEnvironmentScopes ??= { roots: [], ambiguous: false }; + if (isolatedEnvironment) { + // The folders an isolated environment ran for, kept with the card: a later restore never re-reads a plugin that + // changed or was removed. + const evidence = session.extras.isolatedEnvironmentScopes!; + for (const path of [requestedCwd, this.taskProjectRoot(requestedCwd, metadata.parentSessionId), metadata.cwd, spawn.cwd]) { + try { + const root = realpathSync(path); + if (!evidence.roots.includes(root)) { + if (evidence.roots.length < 32) evidence.roots.push(root); + else evidence.ambiguous = true; + } + } catch { evidence.ambiguous = true; } + } + } if (planned.launchEnvironment.CANVASTTY_CODEX_KEYBOARD) { metadata.nativeEditor = JSON.parse(planned.launchEnvironment.CANVASTTY_CODEX_KEYBOARD); } else { diff --git a/src/main/services/TerminalSessionStore.ts b/src/main/services/TerminalSessionStore.ts index 0f329cb0..a7ca6f6f 100644 --- a/src/main/services/TerminalSessionStore.ts +++ b/src/main/services/TerminalSessionStore.ts @@ -1,4 +1,4 @@ -import { dirname, isAbsolute, join } from "node:path"; +import { dirname, isAbsolute, join, normalize } from "node:path"; import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises"; import type { LaunchProfileId, @@ -25,6 +25,11 @@ export const MAX_PLUGIN_SLOT_BYTES = 4_096; const MAX_OPTION_PLUGINS = 16; export interface PersistedTerminalSession { + /** + * Host-only: the folders a plugin environment that declared `isolated` ran this card for (rollback point restores + * refuse them). A placed card saved without it counts as unknown scope. + */ + isolatedEnvironmentScopes?: { roots: string[]; ambiguous: boolean }; id: string; provider: ProviderId; profile: LaunchProfileId; @@ -223,7 +228,7 @@ function normalizeStoredThreadId(provider: ProviderId, candidate: unknown): stri } /** What core keeps beside the live metadata: nothing here is scrollback, prompts or secrets. */ -export type PersistedSessionExtras = Pick & { +export type PersistedSessionExtras = Pick & { /** Overrides the derived state while a card is held stopped (its environment is unavailable). */ heldState?: PersistedLastState; }; @@ -251,6 +256,7 @@ export function persistedTerminalSession( lastState, ...(lastState !== "running" ? { exitCode: metadata.exitCode } : {}), restore: metadata.skipRestore !== true, + ...(extras.isolatedEnvironmentScopes ? { isolatedEnvironmentScopes: structuredClone(extras.isolatedEnvironmentScopes) } : {}), ...(extras.options ? { options: structuredClone(extras.options) } : {}), ...(extras.environment ? { environment: structuredClone(extras.environment) } : {}), ...(extras.environmentChoice && !extras.environment ? { environmentChoice: structuredClone(extras.environmentChoice) } : {}), @@ -262,6 +268,19 @@ export function persistedTerminalSession( }; } +/** Invalid or missing evidence on a placed card never regains local restore authority: it reads as unknown scope. */ +function normalizeIsolationEvidence(value: unknown, placed: boolean): PersistedTerminalSession["isolatedEnvironmentScopes"] { + if (value === undefined) return placed ? { roots: [], ambiguous: true } : undefined; + const invalid = { roots: [], ambiguous: true }; + if (!value || typeof value !== "object" || Array.isArray(value)) return invalid; + const evidence = value as { roots?: unknown; ambiguous?: unknown }; + if (Object.keys(value).some((key) => key !== "roots" && key !== "ambiguous") || typeof evidence.ambiguous !== "boolean" + || !Array.isArray(evidence.roots) || evidence.roots.length > 32) return invalid; + if (evidence.roots.some((root) => typeof root !== "string" || root.length === 0 || root.length > 4096 || root.includes("\0") + || !isAbsolute(root) || normalize(root) !== root)) return invalid; + return { roots: [...new Set(evidence.roots as string[])], ambiguous: evidence.ambiguous }; +} + export function normalizePersistedTerminalSessions(candidate: unknown): PersistedTerminalSessionState { if (!candidate || typeof candidate !== "object") return structuredClone(EMPTY_STATE); const source = candidate as { version?: unknown; sessions?: unknown }; @@ -314,6 +333,7 @@ export function normalizePersistedTerminalSessions(candidate: unknown): Persiste // Likewise a launch whose environment was chosen but not prepared yet. const environmentChoice = environment ? undefined : normalizeEnvironmentChoice(session.environmentChoice); if (!environment && session.environmentChoice !== undefined && !environmentChoice) continue; + const isolation = normalizeIsolationEvidence(session.isolatedEnvironmentScopes, Boolean(environment)); sessions.push({ id: session.id, provider: session.provider as ProviderId, @@ -334,6 +354,7 @@ export function normalizePersistedTerminalSessions(candidate: unknown): Persiste restore: session.restore !== false, ...(options ? { options } : {}), ...(environment ? { environment } : {}), + ...(isolation ? { isolatedEnvironmentScopes: isolation } : {}), ...(environmentChoice ? { environmentChoice } : {}), ...(isPluginId(session.ownerPluginId) ? { ownerPluginId: session.ownerPluginId } : {}), ...(environment && typeof session.projectRoot === "string" && isAbsolute(session.projectRoot) ? { projectRoot: session.projectRoot } : {}), diff --git a/src/main/services/agent-runtime/RuntimeGateway.ts b/src/main/services/agent-runtime/RuntimeGateway.ts index 42a27aad..a208e8d9 100644 --- a/src/main/services/agent-runtime/RuntimeGateway.ts +++ b/src/main/services/agent-runtime/RuntimeGateway.ts @@ -91,6 +91,8 @@ interface RuntimeLease { /** How long a decision may take for this session (sized at launch from the decision services' budgets). */ gatewayMs: number; checks: Set; + lifecycleChecks: Set; + lifecycleRevision: number; } /** One decision hook call (permission-gate.mjs). The tool input is agent-influenced data, never instructions. */ @@ -132,6 +134,12 @@ export interface RuntimeGatewayOptions { windowsPipeHostFactory?: (options: WindowsPipeHostTransportOptions) => WindowsPipeHostTransport; /** A connection must send its one message within this time (default 5 s). */ firstMessageTimeoutMs?: number; + /** Captures a synchronous host freshness token, checked even if checkpoint cancellation settles later. */ + lifecycleGuard?(terminalSessionId: string, signal: RuntimeLifecycleSignal): () => boolean; + /** Barrier for authenticated working events only; UI delivery stays asynchronous. Failures/timeouts fail open. */ + beforeLifecycle?(terminalSessionId: string, signal: RuntimeLifecycleSignal, cancellation: AbortSignal): Promise | boolean | void; + /** Must remain below the unchanged 1 s JS/native hook deadline. Tests may shorten it. */ + lifecycleBarrierMs?: number; onSignal?(terminalSessionId: string, signal: RuntimeLifecycleSignal): void; onAnswerCaptureRevoked?(terminalSessionId: string): void; /** @@ -152,6 +160,9 @@ export class RuntimeGateway { private readonly requestedRuntimeDirectory: string | undefined; private readonly windowsHostPath: string | undefined; private readonly windowsPipeHostFactory: (options: WindowsPipeHostTransportOptions) => WindowsPipeHostTransport; + private readonly lifecycleGuard: RuntimeGatewayOptions["lifecycleGuard"]; + private readonly beforeLifecycle: RuntimeGatewayOptions["beforeLifecycle"]; + private readonly lifecycleBarrierMs: number; private readonly onSignal: RuntimeGatewayOptions["onSignal"]; private readonly onAnswerCaptureRevoked: RuntimeGatewayOptions["onAnswerCaptureRevoked"]; private readonly onPermissionRequest: RuntimeGatewayOptions["onPermissionRequest"]; @@ -181,6 +192,9 @@ export class RuntimeGateway { ?? ((transportOptions) => new WindowsPipeHostTransport(transportOptions)); this.firstMessageTimeoutMs = options.firstMessageTimeoutMs ?? FIRST_MESSAGE_TIMEOUT_MS; this.onSignal = options.onSignal; + this.beforeLifecycle = options.beforeLifecycle; + this.lifecycleGuard = options.lifecycleGuard; + this.lifecycleBarrierMs = Math.max(1, Math.min(750, options.lifecycleBarrierMs ?? 750)); this.onAnswerCaptureRevoked = options.onAnswerCaptureRevoked; this.onPermissionRequest = options.onPermissionRequest; this.now = options.now ?? Date.now; @@ -290,6 +304,7 @@ export class RuntimeGateway { : null, decisions, gatewayMs: permissionGateTimings(decisionBudgetMs).gatewayMs, + lifecycleChecks: new Set(), lifecycleRevision: 0, checks: new Set() }); return { address: this.endpoint, terminalSessionId, provider, capabilityToken }; @@ -310,6 +325,7 @@ export class RuntimeGateway { lease.tokenDigest.fill(0); this.leases.delete(terminalSessionId); for (const check of lease.checks) check.abort(); + for (const check of lease.lifecycleChecks) check.abort(); if (lease.answerCaptureGrantExpiresAt !== null) { this.onAnswerCaptureRevoked?.(terminalSessionId); } @@ -345,6 +361,7 @@ export class RuntimeGateway { for (const lease of this.leases.values()) { lease.tokenDigest.fill(0); for (const check of lease.checks) check.abort(); + for (const check of lease.lifecycleChecks) check.abort(); if (lease.answerCaptureGrantExpiresAt !== null) { this.onAnswerCaptureRevoked?.(lease.terminalSessionId); } @@ -409,10 +426,18 @@ export class RuntimeGateway { answerCapture })}\n`, "utf8")); } else { - // The ack goes out before the app reacts: the hook (and the agent behind it) waits only for the check. const delivery = this.handleLifecycle(value); - socket.write(Buffer.from(`${JSON.stringify({ v: RUNTIME_PROTOCOL_VERSION, type: "ack" })}\n`, "utf8")); - this.deliverLater(delivery); + const acknowledge = (accepted: Delivery | null): void => { + socket.write(Buffer.from(`${JSON.stringify({ v: RUNTIME_PROTOCOL_VERSION, type: "ack" })}\n`, "utf8")); + this.deliverLater(accepted); + const timeout = setTimeout(close, 1_000); timeout.unref(); + }; + if (this.beforeLifecycle && delivery?.signal.state === "working") { + void this.waitBeforeLifecycle(delivery, socket).then(acknowledge).catch(close); + return; + } + acknowledge(delivery); + return; } const timeout = setTimeout(close, 1_000); timeout.unref(); @@ -504,6 +529,9 @@ export class RuntimeGateway { if (message.lastAssistantMessage !== undefined && lease.answerCaptureGrantExpiresAt !== null) { signal.answerCaptureGrantExpiresAt = lease.answerCaptureGrantExpiresAt; } + // A newer accepted signal supersedes an in-flight pre-turn barrier, including its snapshot work. + for (const check of lease.lifecycleChecks) check.abort(); + lease.lifecycleRevision++; // Captured text is delivered once and never stored in the lifecycle lease. lease.latest = { state: signal.state, @@ -511,7 +539,39 @@ export class RuntimeGateway { turnId: signal.turnId, ...(signal.threadId === undefined ? {} : { threadId: signal.threadId }) }; - return { terminalSessionId: message.terminalSessionId, signal }; + return { terminalSessionId: message.terminalSessionId, signal, lease, revision: lease.lifecycleRevision }; + } + + private async waitBeforeLifecycle(delivery: Delivery, connection: { on(event: "close", listener: () => void): unknown }): Promise { + const controller = new AbortController(), { lease } = delivery; + const hostCurrent = this.lifecycleGuard?.(delivery.terminalSessionId, delivery.signal) ?? (() => true); + const current = (): boolean => this.leases.get(delivery.terminalSessionId) === lease && lease.lifecycleRevision === delivery.revision && hostCurrent(); + delivery.current = () => this.leases.get(delivery.terminalSessionId) === lease && hostCurrent(); + let listening = true; + const abort = (): void => { if (listening) controller.abort(); }; + connection.on("close", abort); + lease.lifecycleChecks.add(controller); + let cancel!: () => void; + const cancelled = new Promise(resolve => { cancel = resolve; }); + controller.signal.addEventListener("abort", cancel, { once: true }); + const timeout = setTimeout(abort, this.lifecycleBarrierMs); + timeout.unref(); + try { + if (!current()) return null; + const work = Promise.resolve().then(() => this.beforeLifecycle?.(delivery.terminalSessionId, delivery.signal, controller.signal)); + const accepted = await Promise.race([work, cancelled]); + // The callback must observe cancellation before publishing a checkpoint. A timeout remains fail-open. + if (controller.signal.aborted) console.warn("Pre-turn checkpoint unavailable: lifecycle barrier cancelled or timed out."); + return accepted !== false && current() ? delivery : null; + } catch (error) { + console.warn("Pre-turn checkpoint unavailable:", error instanceof Error ? error.message : String(error)); + return current() ? delivery : null; + } finally { + clearTimeout(timeout); + listening = false; + controller.signal.removeEventListener("abort", cancel); + lease.lifecycleChecks.delete(controller); + } } /** Runs the app's reaction after the current I/O callback, in arrival order; a failure there never reaches a hook. */ @@ -519,6 +579,7 @@ export class RuntimeGateway { const onSignal = this.onSignal; if (!delivery || !onSignal) return; setImmediate(() => { + if (delivery.current && !delivery.current()) return; try { onSignal(delivery.terminalSessionId, delivery.signal); } catch (error) { @@ -614,16 +675,19 @@ export class RuntimeGateway { } catch { delivery = null; } - // An oversized body is still being sent: answer once it has arrived (read and dropped), so the connection is - // closed after it rather than reset under the sender, which would lose the answer (ECONNRESET). - if (oversized && !request.complete) { - request.resume(); - request.once("end", () => finish(200, true)); - request.once("close", () => finish(200, true)); - } else { - finish(200, oversized); - } - this.deliverLater(delivery); + const acknowledge = (accepted: Delivery | null): void => { + // Drain an oversized request before closing its connection, preserving the helper's fail-open contract. + if (oversized && !request.complete) { + request.resume(); + request.once("end", () => finish(200, true)); + request.once("close", () => finish(200, true)); + } else finish(200, oversized); + this.deliverLater(accepted); + }; + if (this.beforeLifecycle && delivery?.signal.state === "working") { + // waitBeforeLifecycle never rejects today; a failure still answers the hook (fail-open) instead of leaving it. + void this.waitBeforeLifecycle(delivery, response).then(acknowledge).catch(() => acknowledge(null)); + } else acknowledge(delivery); }; if (oversized) return complete(); request.on("data", (chunk: Buffer) => { @@ -709,6 +773,9 @@ export class RuntimeGateway { } interface Delivery { + current?: () => boolean; + lease: RuntimeLease; + revision: number; terminalSessionId: string; signal: RuntimeLifecycleSignal; } @@ -882,7 +949,8 @@ function parseLifecycleMessage(value: unknown): ParsedLifecycleMessage { return value as unknown as ParsedLifecycleMessage; } -function isTurnStart(event: string): boolean { +/** A hook event that means a prompt was just submitted (a turn starts). */ +export function isTurnStart(event: string): boolean { return event === "UserPromptSubmit" || event === "TurnStarted" || event === "pre_llm_call" diff --git a/src/main/services/checkpointRefusal.ts b/src/main/services/checkpointRefusal.ts new file mode 100644 index 00000000..30af5e0b --- /dev/null +++ b/src/main/services/checkpointRefusal.ts @@ -0,0 +1,17 @@ +import type { CheckpointErrorCode } from "../../shared/contracts.ts"; + +/** A rollback point action that cannot run; `code` is what the renderer localizes. */ +export class CheckpointRefusal extends Error { + readonly code: CheckpointErrorCode; + + constructor(code: CheckpointErrorCode, message: string) { + super(message); + this.name = "CheckpointRefusal"; + this.code = code; + } +} + +/** The stable code of any error from a checkpoint action ("failed" when it is not a refusal). */ +export function checkpointErrorCode(error: unknown): CheckpointErrorCode { + return error instanceof CheckpointRefusal ? error.code : "failed"; +} diff --git a/src/main/services/checkpointTurnBarrier.ts b/src/main/services/checkpointTurnBarrier.ts new file mode 100644 index 00000000..c997ad91 --- /dev/null +++ b/src/main/services/checkpointTurnBarrier.ts @@ -0,0 +1,142 @@ +import type { GitCheckpoints } from "./GitCheckpoints.ts"; +import type { TerminalManager } from "./TerminalManager.ts"; +import { isTurnStart } from "./agent-runtime/RuntimeGateway.ts"; + +type BarrierTerminals = Pick; + +/** The longest a turn start or a permission decision waits for its rollback point (below the hooks' 1 s deadline). */ +export const CHECKPOINT_BARRIER_MS = 750; + +export interface CheckpointTurnBarrierDependencies { + /** The terminal manager, once it exists (the runtime gateway is created first). */ + terminals(): BarrierTerminals | null; + checkpoints: Pick; + redact(text: string): string; + warn?(message: string, detail: string): void; + /** Tests may shorten the bound (a permission decision's wait, and a capture's longest run). */ + barrierMs?: number; +} + +type Turn = { + pending: Promise; turnId: string | null; current: () => boolean; submitted: number; + /** Cancels the capture; `waiting` counts the barriers (lifecycle or permission) still waiting for it. */ + capture: AbortController; waiting: number; +}; + +/** + * When rollback points are taken: once per agent turn of a live auto or yolo card, before its turn starts (the + * runtime gateway holds the authenticated `working` hook's acknowledgement, bounded and fail-open). A turn is the + * hook's own turn id when it reports one; otherwise it lasts until an accepted non-working signal, or until the person + * submits input while the card is not working (TerminalManager's input generation). A permission decision takes a + * point only when no current turn is known for the card (a CLI without a turn-start hook, or a turn the person moved + * past), bounded the same way; inside the current turn it waits (bounded) for that turn's point and never takes a + * second one. A failed or cancelled attempt is not retried within the same turn (a later + * retry would snapshot the turn's own edits as if they came before it). A CLI without a turn-end hook keeps its card + * "working": a turn-start event without a turn id is a new turn when input was submitted since the current turn's point. + * The capture belongs to its turn, not to the barrier that started it: a newer signal of the same turn ends the first + * barrier's wait (RuntimeGateway supersedes it) and waits for the same capture. The capture is cancelled once no + * barrier waits for it any more, one barrier after it started, or when its turn ends or is replaced. + */ +export function createCheckpointTurnBarrier(dependencies: CheckpointTurnBarrierDependencies) { + const turns = new Map(); + const warn = dependencies.warn ?? ((message: string, detail: string) => console.warn(message, detail)); + const barrierMs = dependencies.barrierMs ?? CHECKPOINT_BARRIER_MS; + + const startTurn = (id: string, turnId: string | null, signal?: AbortSignal, turnStart = false): Promise => { + const terminals = dependencies.terminals(); + const row = terminals?.getMetadata(id); + if (!terminals || !row || row.exitCode !== null || (row.profile !== "auto" && row.profile !== "yolo")) return Promise.resolve(); + if (!terminals.canCaptureCheckpoint(id)) return Promise.resolve(); + const known = turns.get(id); + const submitted = terminals.submittedInputs(id); + // The host's freshness check decides first: a reported turn id never revives a turn the person moved past. + const newPrompt = turnStart && !turnId && known !== undefined && submitted > known.submitted; + if (known && known.current() && !newPrompt && (!turnId || !known.turnId || known.turnId === turnId)) return waitFor(known, signal); + if (known) cancel(known); + const turn: Turn = { pending: Promise.resolve(), turnId, current: terminals.providerSignalGuard(id), submitted, capture: new AbortController(), waiting: 0 }; + // Never longer than one barrier from its start, however many signals of the turn wait for it. + const deadline = setTimeout(() => turn.capture.abort(new Error("Rollback point timed out.")), barrierMs); + deadline.unref?.(); + turn.pending = dependencies.checkpoints.capture(id, row.cwd, turn.capture.signal).catch((error: unknown) => { + warn("Rollback point unavailable", dependencies.redact(String(error))); + }).finally(() => clearTimeout(deadline)); + turns.set(id, turn); + return waitFor(turn, signal); + }; + const cancel = (turn: Turn): void => turn.capture.abort(new Error("The agent turn ended before its rollback point was taken.")); + /** + * One barrier's wait for its turn's point, until the point settles or `signal` aborts. A capture no barrier waits for + * any more is cancelled, after the current I/O callback: a newer signal of the same turn, which superseded this + * barrier, takes over the wait first. + */ + const waitFor = (turn: Turn, signal?: AbortSignal): Promise => { + if (!signal) return turn.pending; + turn.waiting++; + return new Promise((resolve) => { + let open = true; + const done = (): void => { + if (!open) return; + open = false; + signal.removeEventListener("abort", done); + turn.waiting--; + if (signal.aborted) setImmediate(() => { if (turn.waiting === 0) turn.capture.abort(new Error("Rollback point timed out.")); }); + resolve(); + }; + signal.addEventListener("abort", done, { once: true }); + if (signal.aborted) done(); + void turn.pending.then(done); + }); + }; + + return { + /** RuntimeGateway.lifecycleGuard: the host's freshness check for a held turn start. */ + lifecycleGuard(id: string): () => boolean { + return dependencies.terminals()?.providerSignalGuard(id) ?? (() => false); + }, + /** RuntimeGateway.beforeLifecycle: false when the turn went stale while its rollback point was taken. */ + async beforeLifecycle(id: string, signal: { turnId?: string | null; event?: string }, cancellation: AbortSignal): Promise { + const terminals = dependencies.terminals(); + if (!terminals) return false; + const current = terminals.providerSignalGuard(id); + if (!current()) return false; + await startTurn(id, signal.turnId ?? null, cancellation, isTurnStart(signal.event ?? "")); + return current(); + }, + /** An accepted lifecycle signal: anything but working or a pending approval ends the turn. */ + onSignal(id: string, state: string): void { + if (state === "working" || state === "needs_approval") return; + const known = turns.get(id); + if (known) cancel(known); + turns.delete(id); + }, + /** + * Before a permission decision: a reason (to deny with) when a restore holds the card or its turn changed while a + * rollback point was taken; null to go on to the decision. + */ + async beforePermission(id: string, signal?: AbortSignal): Promise { + const terminals = dependencies.terminals(); + if (terminals?.isCheckpointRestoreActive(id)) return "Workspace rollback point restoration is in progress."; + const current = terminals?.providerSignalGuard(id); + const known = turns.get(id); + const bound = new AbortController(); + const stop = (): void => bound.abort(signal?.reason ?? new Error("Rollback point timed out.")); + const timer = setTimeout(stop, barrierMs); + signal?.addEventListener("abort", stop, { once: true }); + if (signal?.aborted) stop(); + try { + // Inside the current turn: wait (bounded) for its point if it is still being taken; a settled one, even a + // failed one, is not taken again. A stale or missing turn takes its point now. + if (known && known.current()) { + await Promise.race([known.pending, new Promise((resolve) => bound.signal.addEventListener("abort", () => resolve(), { once: true }))]); + } else await startTurn(id, null, bound.signal); + } finally { clearTimeout(timer); signal?.removeEventListener("abort", stop); } + if (!current?.() || terminals?.isCheckpointRestoreActive(id)) return "The agent turn changed while preparing its rollback point."; + return null; + }, + forget(id: string): void { + const known = turns.get(id); + if (known) cancel(known); + turns.delete(id); + } + }; +} diff --git a/src/main/services/isolation/isolationPaths.ts b/src/main/services/isolation/isolationPaths.ts index 7870a862..7ccbeafa 100644 --- a/src/main/services/isolation/isolationPaths.ts +++ b/src/main/services/isolation/isolationPaths.ts @@ -186,7 +186,9 @@ export const PLUGIN_DATA_FOLDER = "plugin-data"; * runtime folders stay readable: they hold token-authenticated sockets and per-run hook settings the CLI must read. */ export function privateAppData(userDataPath: string): string[] { - return ["agent-control", "provider-secrets.bin", "plugin-secrets", "account-homes", "github-oauth.json", "launch-runs", PLUGIN_DATA_FOLDER] + return ["agent-control", "provider-secrets.bin", "plugin-secrets", "account-homes", "github-oauth.json", "launch-runs", PLUGIN_DATA_FOLDER, + // Rollback points live in "checkpoints"; the two older names stay private in case an earlier build left them. + "checkpoints", "checkpoints.json", "checkpoint-objects"] .map((name) => join(userDataPath, name)); } diff --git a/src/main/services/safety/baseProtection.ts b/src/main/services/safety/baseProtection.ts index ee1116da..10befc40 100644 --- a/src/main/services/safety/baseProtection.ts +++ b/src/main/services/safety/baseProtection.ts @@ -166,10 +166,12 @@ function readAction(toolName: string, raw: Record | null, previ */ export function canvasTtyPrivateData(userDataPath: string): PrivateData { const names = ['agent-control', join('browser', 'runtime'), join('lifecycle', 'runtime'), join('orchestration', 'runtime'), - 'provider-secrets.bin', 'plugin-secrets', 'account-homes', 'github-oauth.json', 'launch-runs', 'claude-draft', 'plugin-data']; + 'provider-secrets.bin', 'plugin-secrets', 'account-homes', 'github-oauth.json', 'launch-runs', 'claude-draft', 'plugin-data', 'checkpoints', 'checkpoints.json', 'checkpoint-objects']; return { appRoots: [userDataPath], paths: names.map(name => join(userDataPath, name)), + // Rollback point storage is matched by its real path above only: "checkpoints" is a common word in projects + // (CanvasTTY's own among them), so it is no marker for code that builds a path from pieces. markers: ['agent-control', 'provider-secrets', 'plugin-secrets', 'account-homes', 'github-oauth', 'claude-draft'] }; } diff --git a/src/preload/index.ts b/src/preload/index.ts index 9b66ac8e..e00debb4 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -316,6 +316,9 @@ const api: CanvasTTYApi = { setBounds: (id: string, bounds: SessionBounds) => ipcRenderer.send(IPC.terminalBounds, id, bounds), rename: (id: string, title: string) => ipcRenderer.invoke(IPC.terminalRename, id, title), setRestore: (id: string, restore: boolean) => ipcRenderer.invoke(IPC.terminalSetRestore, id, restore), + checkpoints: (id: string) => ipcRenderer.invoke(IPC.terminalCheckpoints, id), + previewCheckpoint: (id: string, checkpointId: string) => ipcRenderer.invoke(IPC.terminalCheckpointPreview, id, checkpointId), + restoreCheckpoint: (id: string, checkpointId: string) => ipcRenderer.invoke(IPC.terminalCheckpointRestore, id, checkpointId), dispose: (id: string, options?: { keepEnvironmentData?: boolean }) => ipcRenderer.invoke(IPC.terminalDispose, id, options), setVisible: (id: string, visible: boolean) => ipcRenderer.send(IPC.terminalSetVisible, id, visible), onData: (listener: (event: TerminalDataEvent) => void, id?: string) => terminalData.subscribe(listener, id), diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index 606c2cc0..8011de91 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -984,6 +984,37 @@ export interface PluginChangeReview { nextOffset?: number; } +/** + * Why a rollback point could not be listed, shown or restored: a stable code the renderer localizes (main-process text + * never reaches the dialog). + */ +export type CheckpointErrorCode = + | "not-git-root" + | "unknown-checkpoint" + | "submodules" + | "filters" + | "worktree-conflict" + | "no-safety-point" + | "restore-in-progress" + | "agent-running" + | "pending-launch" + | "isolated-environment" + | "missing-session" + | "failed"; + +/** A rollback point taken before an agent turn ("before-turn") or before a restore ("before-restore"). */ +export interface CheckpointEntry { + id: string; + at: number; + kind: "before-turn" | "before-restore"; +} + +export type CheckpointListResult = { ok: true; checkpoints: CheckpointEntry[] } | { ok: false; code: CheckpointErrorCode }; +export type CheckpointPreviewResult = + | { ok: true; text: string; changedFiles: string[]; truncated: boolean } + | { ok: false; code: CheckpointErrorCode }; +export type CheckpointRestoreResult = { ok: true } | { ok: false; code: CheckpointErrorCode }; + /** What a card action answered, shown as a toast or a structured review on the card. */ export interface PluginCardActionResult { message?: string; @@ -2108,6 +2139,14 @@ export interface CanvasTTYApi { setBounds(id: string, bounds: SessionBounds): void; rename(id: string, title: string): Promise; setRestore(id: string, restore: boolean): Promise; + /** The card's rollback points (its folder must be a Git project root), newest first. */ + checkpoints(id: string): Promise; + previewCheckpoint(id: string, checkpointId: string): Promise; + /** + * Refused while any card in the folder still runs; the current work is saved as another rollback point first. Files + * and the index go back; HEAD and the branch stay where they are. + */ + restoreCheckpoint(id: string, checkpointId: string): Promise; /** `keepEnvironmentData` answers "Keep environment data?" for a card that runs in a plugin environment. */ dispose(id: string, options?: { keepEnvironmentData?: boolean }): Promise; /** Report whether the card renders live output; hidden cards keep history but skip streaming. */ @@ -2304,6 +2343,9 @@ export const IPC = { terminalBounds: "terminal:bounds", terminalRename: "terminal:rename", terminalSetRestore: "terminal:set-restore", + terminalCheckpoints: "terminal:checkpoints", + terminalCheckpointPreview: "terminal:checkpoint-preview", + terminalCheckpointRestore: "terminal:checkpoint-restore", terminalDispose: "terminal:dispose", terminalData: "terminal:data", /** Main -> renderer: the TerminalDataEvents of one output flush, in order (see TerminalRendererOutbox). */ diff --git a/tests/agent-isolation.test.mjs b/tests/agent-isolation.test.mjs index d40f7872..840f2b15 100644 --- a/tests/agent-isolation.test.mjs +++ b/tests/agent-isolation.test.mjs @@ -46,6 +46,14 @@ async function world(t) { await writeFile(join(userData, "agent-control", "token-app"), "APP-TOKEN"); await writeFile(join(userData, "agent-control", "sessions", "own", "connection.json"), "{}"); await writeFile(join(userData, "provider-secrets.bin"), "SECRETS"); + await mkdir(join(userData, "checkpoints", "checkpoint-objects", "fixture"), { recursive: true }); + await writeFile(join(userData, "checkpoints", "checkpoints.json"), "IMMUTABLE-CHECKPOINT-REGISTRY"); + await writeFile(join(userData, "checkpoints", "checkpoints.json.quarantined"), "SET-ASIDE-CHECKPOINT-REGISTRY"); + await writeFile(join(userData, "checkpoints", "checkpoint-objects", "fixture", "pack.pack"), "HOST-SNAPSHOT-OBJECTS"); + // Names an earlier build used, directly in userData. + await writeFile(join(userData, "checkpoints.json"), "LEGACY-CHECKPOINT-REGISTRY"); + await mkdir(join(userData, "checkpoint-objects"), { recursive: true }); + await writeFile(join(userData, "checkpoint-objects", "pack.pack"), "LEGACY-SNAPSHOT-OBJECTS"); const env = { HOME: home, PATH: "/usr/bin:/bin:/usr/sbin:/sbin", LANG: "en_US.UTF-8" }; return { base, home, userData, project, temp, env }; } @@ -186,7 +194,7 @@ test("the paths: the project and the CLI's own folders writable; other CLIs' cre assert.ok(paths.writable.includes(join(w.home, ".codex"))); assert.ok(!paths.writable.includes(join(w.home, ".claude"))); for (const secret of [join(w.home, ".ssh"), join(w.home, ".aws"), join(w.home, ".claude"), join(w.home, ".local", "share", "opencode"), - join(w.home, ".grok"), join(w.userData, "agent-control"), join(w.userData, "provider-secrets.bin"), join(w.userData, "account-homes")]) { + join(w.home, ".grok"), join(w.userData, "agent-control"), join(w.userData, "provider-secrets.bin"), join(w.userData, "account-homes"), join(w.userData, "checkpoints"), join(w.userData, "checkpoints.json"), join(w.userData, "checkpoint-objects")]) { assert.ok(paths.unreadable.includes(secret), secret); } assert.ok(!paths.unreadable.includes(join(w.home, ".codex")), "its own folder stays readable"); @@ -644,6 +652,10 @@ test("seatbelt, for real: writes stay in the project, secrets stay unread, nothi 'cat "$HOME/.claude/.credentials.json" 2>/dev/null || echo R-other-cli-denied', `cat "${join(w.userData, "agent-control", "token-app")}" 2>/dev/null || echo R-app-token-denied`, `cat "${join(w.userData, "provider-secrets.bin")}" 2>/dev/null || echo R-secret-store-denied`, + `cat "${join(w.userData, "checkpoints", "checkpoints.json")}" 2>/dev/null || echo R-checkpoints-denied`, + `cat "${join(w.userData, "checkpoints", "checkpoints.json.quarantined")}" 2>/dev/null || echo R-checkpoints-aside-denied`, + `cat "${join(w.userData, "checkpoints", "checkpoint-objects", "fixture", "pack.pack")}" 2>/dev/null || echo R-checkpoint-pack-denied`, + `cat "${join(w.userData, "checkpoints.json")}" "${join(w.userData, "checkpoint-objects", "pack.pack")}" 2>/dev/null || echo R-legacy-checkpoints-denied`, `cat "${join(own, "connection.json")}" >/dev/null && echo R-own-grant-ok`, 'echo t > "$TMPDIR/t" && echo W-session-tmp-ok', 'echo c > "$HOME/.codex/state" && echo W-own-cli-ok', @@ -656,7 +668,7 @@ test("seatbelt, for real: writes stay in the project, secrets stay unread, nothi 'launchctl submit -l ctty.isolation.probe -- /usr/bin/true 2>/dev/null || echo LAUNCHD-denied' ].join("; "), { granted: [own] }); assert.deepEqual(lines, ["W-project-ok", "W-subfolder-ok", "W-home-denied", "W-tmp-denied", "W-usr-local-denied", "RM-home-denied", "R-ssh-denied", - "R-aws-denied", "R-other-cli-denied", "R-app-token-denied", "R-secret-store-denied", "R-own-grant-ok", "W-session-tmp-ok", "W-own-cli-ok", + "R-aws-denied", "R-other-cli-denied", "R-app-token-denied", "R-secret-store-denied", "R-checkpoints-denied", "R-checkpoints-aside-denied", "R-checkpoint-pack-denied", "R-legacy-checkpoints-denied", "R-own-grant-ok", "W-session-tmp-ok", "W-own-cli-ok", "W-own-cli-config-denied", "GIT-ok", "W-git-hook-denied", "SIGNAL-denied", "PREFS-denied", "APPLE-EVENTS-denied", "LAUNCHD-denied"]); assert.equal(await readFile(join(w.home, "victim", "deep", "file"), "utf8"), "keep me"); assert.equal(existsSync(join(w.home, "outside")), false); diff --git a/tests/base-protection-app-private.test.mjs b/tests/base-protection-app-private.test.mjs index b7a46ed0..f1d278d7 100644 --- a/tests/base-protection-app-private.test.mjs +++ b/tests/base-protection-app-private.test.mjs @@ -62,6 +62,8 @@ const DENY = [ "echo $CANVASTTY_RUNTIME_CAPABILITY", // Interpreter one-liners and heredocs. `python3 -c "print(open('${token}').read())"`, + `cat ${q(join(userData, "checkpoints", "checkpoints.json"))}`, + `python3 -c "print(open('${join(userData, "checkpoints", "checkpoints.json")}').read())"`, "python3 -c \"import os;p=os.path.join(os.path.expanduser('~'),'Library','Application Support','canvastty','agent-control');print(os.listdir(p))\"", "node -e \"console.log(require('fs').readFileSync(process.env.HOME + '/Library/Application Support/canvastty/plugin-secrets/x', 'utf8'))\"", `python3 - <<'EOF'\nprint(open("${token}").read())\nEOF`, @@ -96,7 +98,10 @@ const ALLOW = [ "cat ~/.config/other/token", "node -e \"console.log(1)\"", "curl https://example.com/agent-control/token-1", - "echo hi > out.txt" + "echo hi > out.txt", + // Ordinary work in a project that mentions the app and "checkpoints" (CanvasTTY's own repository among them). + "node -e \"require('./src/canvastty/checkpoints/index.js')\"", + "python3 -c \"import os;print(os.path.join('canvastty','checkpoints','README'))\"" ]; test("CanvasTTY's own tokens, secret stores and sockets are refused for every reader and client", () => { diff --git a/tests/checkpoint-cancellation.test.mjs b/tests/checkpoint-cancellation.test.mjs new file mode 100644 index 00000000..55cb7de8 --- /dev/null +++ b/tests/checkpoint-cancellation.test.mjs @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import {execFileSync} from 'node:child_process'; +import {realpathSync} from 'node:fs'; +import {mkdtemp,writeFile,rm,readdir,readFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {GitCheckpoints} from '../src/main/services/GitCheckpoints.ts'; +const deferred=()=>{let resolve;const promise=new Promise(done=>resolve=done);return{promise,resolve};}; +async function fixture(t){ + const root=await mkdtemp(join(tmpdir(),'ctty-cancel-point-')),project=join(root,'project'); + const git=(...args)=>execFileSync('git',args,{cwd:root,encoding:'utf8',env:{...process.env,GIT_CONFIG_GLOBAL:process.platform==='win32'?'NUL':'/dev/null',GIT_CONFIG_NOSYSTEM:'1'}}).trim(); + git('init','-q',project);git('-C',project,'config','user.name','Fixture');git('-C',project,'config','user.email','fixture@example.invalid');git('-C',project,'config','core.autocrlf','false');git('-C',project,'config','core.eol','lf'); + await writeFile(join(project,'file.txt'),'base');git('-C',project,'add','.');git('-C',project,'commit','-qm','base'); + const checkpoints=new GitCheckpoints(text=>text,1,join(root,'registry.json')); + t.after(async()=>{await checkpoints.idle();await rm(root,{recursive:true,force:true});}); + return{root,project,checkpoints,git}; +} +for(const stage of ['stash','ref'])test(`aborted ${stage} capture never registers a late checkpoint or removes the older one`,{timeout:10000},async t=>{ + const f=await fixture(t);await f.checkpoints.capture('worker',f.project);const previous=await f.checkpoints.list('worker',f.project); + await writeFile(join(f.project,'file.txt'),'pending change');const entered=deferred(),release=deferred(),controller=new AbortController();t.after(()=>release.resolve()); + const original=f.checkpoints.git.bind(f.checkpoints); + f.checkpoints.git=async(cwd,args,options)=>{ + if(stage==='stash'&&args[0]==='stash'){entered.resolve();await release.promise;} + const value=await original(cwd,args,options); + if(stage==='ref'&&args[0]==='update-ref'&&args[1]==='--no-deref'){entered.resolve();await release.promise;} + return value; + }; + const pending=f.checkpoints.capture('worker',f.project,controller.signal);await entered.promise;controller.abort(); + await assert.rejects(pending);await writeFile(join(f.project,'file.txt'),'agent already started');release.resolve();await f.checkpoints.idle(); + assert.deepEqual(await f.checkpoints.list('worker',f.project),previous); + const refs=f.git('-C',f.project,'for-each-ref','--format=%(refname)','refs/canvastty/').split('\n');assert.deepEqual(refs,previous.map(row=>row.id)); + const packs=(await readdir(join(f.root,'checkpoint-objects'),{recursive:true})).filter(path=>path.endsWith('.pack'));assert.equal(packs.length,1); + const reloaded=new GitCheckpoints(text=>text,1,join(f.root,'registry.json'));assert.deepEqual(await reloaded.list('worker',f.project),previous); +}); + +test('the barrier is answered once the point is registered: copying objects and pruning run after it, and a later abort keeps the point',{timeout:10000},async t=>{ + const f=await fixture(t);await f.checkpoints.capture('worker',f.project);const before=await f.checkpoints.list('worker',f.project); + await writeFile(join(f.project,'file.txt'),'new checkpoint');const packing=deferred(),release=deferred(),controller=new AbortController();t.after(()=>release.resolve()); + const git=f.checkpoints.git.bind(f.checkpoints),order=[];f.checkpoints.git=async(cwd,args,...rest)=>{ + order.push(args[0]);if(args[0]==='pack-objects'){packing.resolve();await release.promise;} + return git(cwd,args,...rest); + }; + await f.checkpoints.capture('worker',f.project,controller.signal); + assert.ok(!order.includes('pack-objects')||order.indexOf('pack-objects')>order.indexOf('update-ref'),'nothing is packed before the point exists'); + await packing.promise;controller.abort(); + const during=await f.checkpoints.list('worker',f.project);assert.equal(during.length,2,'the new point is listed while its objects are copied'); + release.resolve();await f.checkpoints.idle(); + const after=await f.checkpoints.list('worker',f.project);assert.equal(after.length,1);assert.notEqual(after[0].id,before[0].id); + const loaded=new GitCheckpoints(text=>text,1,join(f.root,'registry.json'));assert.deepEqual(await loaded.list('worker',f.project),after); + assert.ok(JSON.parse(await readFile(join(f.root,'registry.json'),'utf8'))[0].packs.length>=1); + assert.equal(f.git('-C',f.project,'for-each-ref','--format=%(refname)','refs/canvastty/'),after[0].id,'the pruned point\'s ref is deleted'); +}); + +test('an aborted queued checkpoint never begins snapshotting after the current job finishes',{timeout:10000},async t=>{ + const f=await fixture(t),entered=deferred(),release=deferred(),controller=new AbortController();t.after(()=>release.resolve());let stashes=0; + const original=f.checkpoints.git.bind(f.checkpoints);f.checkpoints.git=async(cwd,args,...rest)=>{if(args[0]==='stash'){stashes++;entered.resolve();await release.promise;}return original(cwd,args,...rest);}; + const first=f.checkpoints.capture('first',f.project);await entered.promise;const second=f.checkpoints.capture('second',f.project,controller.signal);controller.abort();await assert.rejects(second); + release.resolve();await first;await f.checkpoints.idle();assert.equal(stashes,1);assert.deepEqual(await f.checkpoints.list('second',f.project),[]); +}); + +test('captures of different repositories do not wait for each other',{timeout:10000},async t=>{ + const f=await fixture(t),g=await fixture(t),entered=deferred(),release=deferred();t.after(()=>release.resolve()); + const original=f.checkpoints.git.bind(f.checkpoints);f.checkpoints.git=async(cwd,args,...rest)=>{if(args[0]==='stash'&&cwd===realpathSync(f.project)){entered.resolve();await release.promise;}return original(cwd,args,...rest);}; + await writeFile(join(f.project,'file.txt'),'x');const held=f.checkpoints.capture('a',f.project);await entered.promise; + // Same instance, other repository: completes while the first one is held. + g.checkpoints=f.checkpoints;await f.checkpoints.capture('b',g.project);assert.equal((await f.checkpoints.list('b',g.project)).length,1); + release.resolve();await held;await f.checkpoints.idle(); +}); + +test('restore serializes safety capture and both restores against queued captures and later restores',{timeout:15000},async t=>{ + const f=await fixture(t);await writeFile(join(f.project,'file.txt'),'saved uncommitted change');await f.checkpoints.capture('worker',f.project);const [point]=await f.checkpoints.list('worker',f.project); + await writeFile(join(f.project,'file.txt'),'changed');const entered=deferred(),release=deferred();t.after(()=>release.resolve()); + const original=f.checkpoints.git.bind(f.checkpoints),operations=[];let hold=true; + f.checkpoints.git=async(cwd,args,...rest)=>{if(args[0]==='stash'||args[0]==='restore')operations.push(args[0]==='stash'?'capture':args.includes('--staged')?'index':'worktree');if(args[0]==='restore'&&args.includes('--worktree')&&hold){hold=false;entered.resolve();await release.promise;}return original(cwd,args,...rest);}; + const restoring=f.checkpoints.restore('worker',f.project,point.id);await entered.promise; + const captured=f.checkpoints.capture('later',f.project);assert.deepEqual(operations,['capture','worktree']); + release.resolve();await Promise.all([restoring,captured]);assert.deepEqual(operations,['capture','worktree','index','capture']); + const [later]=await f.checkpoints.list('later',f.project);assert.equal((await f.checkpoints.preview('later',f.project,later.id)).text,''); +}); + +test('large real Git preview is truncated and redacted while the complete checkpoint remains restorable',{timeout:15000},async t=>{ + const f=await fixture(t);await writeFile(join(f.project,'file.txt'),'saved text\n');await f.checkpoints.capture('worker',f.project);const [point]=await f.checkpoints.list('worker',f.project); + f.checkpoints.redact=text=>text.replaceAll('PREVIEW_SECRET','[REDACTED]'); + await writeFile(join(f.project,'file.txt'),('PREVIEW_SECRET '+ 'x'.repeat(180)+'\n').repeat(16000)); + const preview=await f.checkpoints.preview('worker',f.project,point.id); + assert.equal(preview.truncated,true,'the dialog says the preview is cut');assert.match(preview.text,/\[REDACTED\]/);assert.doesNotMatch(preview.text,/PREVIEW_SECRET/);assert.ok(Buffer.byteLength(preview.text)<2*1024*1024+512);assert.deepEqual(preview.changedFiles,['file.txt']); + await f.checkpoints.restore('worker',f.project,point.id);assert.equal(await readFile(join(f.project,'file.txt'),'utf8'),'saved text\n'); +}); + +test('preview still rejects real Git command failures instead of calling them truncation',{timeout:10000},async t=>{ + const f=await fixture(t);await f.checkpoints.capture('worker',f.project);const [point]=await f.checkpoints.list('worker',f.project),original=f.checkpoints.git.bind(f.checkpoints); + f.checkpoints.git=(cwd,args,...rest)=>original(cwd,args[0]==='diff'?[...args.slice(0,1),'--definitely-invalid-checkpoint-option',...args.slice(1)]:args,...rest); + await assert.rejects(f.checkpoints.preview('worker',f.project,point.id),error=>error.code!==0&&/invalid|usage|unknown/i.test(error.stderr)); +}); diff --git a/tests/checkpoint-hardening.test.mjs b/tests/checkpoint-hardening.test.mjs new file mode 100644 index 00000000..e0747311 --- /dev/null +++ b/tests/checkpoint-hardening.test.mjs @@ -0,0 +1,192 @@ +// GitCheckpoints against real repositories: the turn barrier's time budget, filter attributes (Git LFS-like), +// the host's Git environment, restore refusals, and how the registry and its packs stay bounded. +// Run with a fake HOME and GIT_CONFIG_GLOBAL=/dev/null (the fixture also isolates the Git config it uses itself). +import assert from 'node:assert/strict'; +import test from 'node:test'; +import {execFileSync} from 'node:child_process'; +import {existsSync} from 'node:fs'; +import {mkdtemp,mkdir,writeFile,readFile,readdir,rm,stat,realpath} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {randomBytes} from 'node:crypto'; +import {GitCheckpoints} from '../src/main/services/GitCheckpoints.ts'; + +const NULL=process.platform==='win32'?'NUL':'/dev/null'; +const env={...process.env,GIT_CONFIG_GLOBAL:NULL,GIT_CONFIG_NOSYSTEM:'1'}; +async function fixture(t,{files={file:'saved\n'}}={}){ + const root=await realpath(await mkdtemp(join(tmpdir(),'ctty-checkpoint-hardening-'))),project=join(root,'project'); + // Background pack writes of every store made here finish before the folder goes. + const made=[];t.after(async()=>{await Promise.all(made.map(checkpoints=>checkpoints.idle().catch(()=>undefined)));await rm(root,{recursive:true,force:true});}); + const git=(...args)=>execFileSync('git',['-C',project,...args],{env,encoding:'utf8',maxBuffer:64*1024*1024}).trimEnd(); + await mkdir(project);git('init','-q','-b','main');git('config','user.name','Fixture');git('config','user.email','fixture@example.invalid');git('config','core.autocrlf','false'); + for(const [name,content] of Object.entries(files)){await mkdir(join(project,name,'..'),{recursive:true});await writeFile(join(project,name),content);} + git('add','-A');git('commit','-qm','base'); + const storage=join(root,'private','checkpoints.json'); + const make=(limits=50)=>{const checkpoints=new GitCheckpoints(text=>text,limits,storage);made.push(checkpoints);return checkpoints;}; + return{root,project,git,storage,make}; +} +const code=expected=>error=>error.code===expected; +const packsOf=async root=>(await readdir(join(root,'private','checkpoint-objects'),{recursive:true}).catch(()=>[])).filter(name=>name.endsWith('.pack')); + +test('a medium repository (2000 files, tens of MB, dirty) gets its rollback point within the turn barrier',{timeout:120000},async t=>{ + const files={}; + for(let index=0;index<2000;index++)files[`src/${index%40}/file-${index}.txt`]=`line ${index}\n`.repeat(200+(index%50)*10); + for(let index=0;index<12;index++)files[`assets/blob-${index}.bin`]=randomBytes(2*1024*1024).toString('base64'); + const f=await fixture(t,{files}); + const total=Number(f.git('count-objects','-v').match(/size-pack: (\d+)|size: (\d+)/u)?.slice(1).find(Boolean)??0); + // An agent's previous turn changed 60 files, one of them large, and staged some. + for(let index=0;index<60;index++)await writeFile(join(f.project,`src/${index%40}/file-${index}.txt`),`changed ${index}\n`.repeat(400)); + await writeFile(join(f.project,'assets/blob-0.bin'),randomBytes(3*1024*1024).toString('base64')); + f.git('add','src/0'); + const checkpoints=f.make(); + await checkpoints.list('warmup',f.project); + const timings=[]; + for(let round=0;round<3;round++){ + await writeFile(join(f.project,'src/1/file-1.txt'),`round ${round}\n`); + const controller=new AbortController(),timer=setTimeout(()=>controller.abort(new Error('barrier timed out')),750); + const started=performance.now(); + await checkpoints.capture('worker',f.project,controller.signal); + timings.push(performance.now()-started);clearTimeout(timer); + const packStarted=performance.now();await checkpoints.idle();timings.push(-(performance.now()-packStarted)); + } + const barrier=timings.filter(value=>value>=0),packing=timings.filter(value=>value<0).map(value=>-value); + t.diagnostic(`repository ~${Math.round(total/1024)} MiB in the object store; barrier ${barrier.map(value=>value.toFixed(0)).join('/')} ms; protective packs after it ${packing.map(value=>value.toFixed(0)).join('/')} ms`); + assert.equal((await checkpoints.list('worker',f.project)).length,3); + for(const value of barrier)assert.ok(value<750,`capture took ${value.toFixed(0)} ms inside the barrier`); + const sizes=await Promise.all((await packsOf(f.root)).map(name=>stat(join(f.root,'private','checkpoint-objects',name)).then(info=>info.size))); + sizes.sort((a,b)=>b-a);assert.equal(sizes.length,3); + t.diagnostic(`pack sizes ${sizes.map(size=>Math.round(size/1024)+' KiB').join(', ')}`); + assert.ok(sizes[1]*20{ + const f=await fixture(t,{files:{file:'saved\n','asset.bin':'binary\n'}}); + const checkpoints=f.make();await checkpoints.capture('worker',f.project);const [point]=await checkpoints.list('worker',f.project); + const marker=join(f.root,'filter-ran'); + f.git('config','filter.lfs.clean',`sh -c 'touch ${marker}; cat'`);f.git('config','filter.lfs.smudge',`sh -c 'touch ${marker}; cat'`);f.git('config','filter.lfs.required','true'); + await writeFile(join(f.project,'.gitattributes'),'*.bin filter=lfs diff=lfs merge=lfs -text\n');f.git('add','.gitattributes');f.git('commit','-qm','lfs'); + await writeFile(join(f.project,'asset.bin'),'changed\n'); + await rm(marker,{force:true});// the fixture's own git commands may have run the driver + await assert.rejects(checkpoints.capture('worker',f.project),code('filters')); + assert.equal((await checkpoints.list('worker',f.project)).length,1,'no point for a filtered project'); + await assert.rejects(checkpoints.restore('worker',f.project,point.id),code('filters')); + assert.equal(await readFile(join(f.project,'asset.bin'),'utf8'),'changed\n');assert.equal(existsSync(marker),false); + // An attribute only in .git/info/attributes (agent-writable outside isolation) counts as well. + await rm(join(f.project,'.gitattributes'));f.git('commit','-qam','drop'); + await writeFile(join(f.project,'.git','info','attributes'),'* filter=lfs\n');await rm(marker,{force:true}); + await assert.rejects(checkpoints.capture('worker',f.project),code('filters'));assert.equal(existsSync(marker),false); +}); + +test('host Git ignores inherited GIT_* variables, hooks and fsmonitor, and blanks the project\'s own filter drivers',{timeout:30000},async t=>{ + const f=await fixture(t);const other=await fixture(t,{files:{other:'other\n'}});const marker=join(f.root,'ran'); + f.git('config','core.fsmonitor',`sh -c 'touch ${marker}-fsmonitor'`); + await writeFile(join(f.project,'.git','hooks','reference-transaction'),`#!/bin/sh\ntouch ${marker}-hook\n`,{mode:0o755}); + // A driver for a name no tracked file uses yet: blanked anyway (an attribute could appear in a race). + f.git('config','filter.evil.clean',`sh -c 'touch ${marker}-filter; cat'`); + const saved={...process.env};t.after(()=>{for(const key of ['GIT_DIR','GIT_WORK_TREE','GIT_CONFIG_PARAMETERS','GIT_INDEX_FILE'])if(key in saved)process.env[key]=saved[key];else delete process.env[key];}); + Object.assign(process.env,{GIT_DIR:join(other.project,'.git'),GIT_WORK_TREE:other.project,GIT_INDEX_FILE:join(f.root,'index'),GIT_CONFIG_PARAMETERS:`'core.hooksPath'='${f.root}'`}); + const checkpoints=f.make();await writeFile(join(f.project,'file'),'changed\n'); + await checkpoints.capture('worker',f.project);const [point]=await checkpoints.list('worker',f.project); + await writeFile(join(f.project,'file'),'later\n');await checkpoints.preview('worker',f.project,point.id);await checkpoints.restore('worker',f.project,point.id); + assert.equal(await readFile(join(f.project,'file'),'utf8'),'changed\n'); + for(const suffix of ['fsmonitor','hook','filter'])assert.equal(existsSync(`${marker}-${suffix}`),false,suffix); + assert.equal(execFileSync('git',['-C',other.project,'for-each-ref','refs/canvastty/'],{env:{...env,GIT_DIR:undefined}}).toString(),'','another repository is never touched'); +}); + +test('restore refuses to overwrite an untracked file and files Git ignores changes in; HEAD stays put',{timeout:30000},async t=>{ + const f=await fixture(t,{files:{file:'saved\n','added.txt':'saved added\n'}}); + const checkpoints=f.make();await checkpoints.capture('worker',f.project);const [point]=await checkpoints.list('worker',f.project); + f.git('rm','-q','--cached','added.txt');await writeFile(join(f.project,'added.txt'),'precious untracked work\n'); + await assert.rejects(checkpoints.restore('worker',f.project,point.id),code('worktree-conflict')); + assert.equal(await readFile(join(f.project,'added.txt'),'utf8'),'precious untracked work\n'); + await rm(join(f.project,'added.txt')); + // An untracked file where the saved tree has a folder blocks it as well. + f.git('reset','-q','--hard');await mkdir(join(f.project,'dir'));await writeFile(join(f.project,'dir','inner.txt'),'tracked\n');f.git('add','dir'); + await checkpoints.capture('worker',f.project);const [withFolder]=await checkpoints.list('worker',f.project); + f.git('rm','-q','-r','--cached','dir');await rm(join(f.project,'dir'),{recursive:true});await writeFile(join(f.project,'dir'),'a file now\n'); + await assert.rejects(checkpoints.restore('worker',f.project,withFolder.id),code('worktree-conflict')); + await rm(join(f.project,'dir')); + f.git('update-index','--assume-unchanged','file');await writeFile(join(f.project,'file'),'hidden change\n'); + await assert.rejects(checkpoints.restore('worker',f.project,point.id),code('worktree-conflict')); + f.git('update-index','--no-assume-unchanged','file'); + const head=f.git('rev-parse','HEAD');await writeFile(join(f.project,'later.txt'),'x');f.git('add','later.txt');f.git('commit','-qm','later');const moved=f.git('rev-parse','HEAD'); + await checkpoints.restore('worker',f.project,point.id); + assert.equal(f.git('rev-parse','HEAD'),moved,'HEAD and the branch stay where they are');assert.notEqual(moved,head); + assert.equal(await readFile(join(f.project,'file'),'utf8'),'saved\n');assert.equal(existsSync(join(f.project,'later.txt')),false); +}); + +test('chained packs survive rewritten history and immediate garbage collection',{timeout:30000},async t=>{ + const f=await fixture(t,{files:{a:'a\n',b:'b\n',c:'c\n'}});const checkpoints=f.make(); + await writeFile(join(f.project,'a'),'first\n');await checkpoints.capture('worker',f.project); + await writeFile(join(f.project,'b'),'second\n');await checkpoints.capture('worker',f.project); + const [second,first]=await checkpoints.list('worker',f.project); + const rows=JSON.parse(await readFile(f.storage,'utf8'));assert.equal(rows.find(row=>row.id===second.id).packs.length,2,'the second point references the first one\'s pack'); + f.git('checkout','-q','--orphan','other');f.git('rm','-q','-rf','.');await writeFile(join(f.project,'z'),'z\n');f.git('add','z');f.git('commit','-qm','unrelated'); + f.git('branch','-D','main');f.git('for-each-ref','--format=delete %(refname)','refs/canvastty/'); + for(const ref of f.git('for-each-ref','--format=%(refname)','refs/canvastty/').split('\n').filter(Boolean))f.git('update-ref','-d',ref); + f.git('reflog','expire','--expire=now','--all');f.git('gc','-q','--prune=now'); + const restarted=new GitCheckpoints(text=>text,50,f.storage);t.after(()=>restarted.idle()); + await rm(join(f.project,'z'));f.git('rm','-q','--cached','z'); + await restarted.restore('worker',f.project,second.id); + assert.equal(await readFile(join(f.project,'a'),'utf8'),'first\n');assert.equal(await readFile(join(f.project,'b'),'utf8'),'second\n');assert.equal(await readFile(join(f.project,'c'),'utf8'),'c\n'); + assert.ok(first); +}); + +test('retention is bounded per card, in total and in pack bytes; dropped points lose their refs and packs',{timeout:60000},async t=>{ + const f=await fixture(t);const checkpoints=f.make({perSession:3,total:5}); + for(let index=0;index<5;index++){await writeFile(join(f.project,'file'),`a ${index}\n`);await checkpoints.capture('a',f.project);} + assert.equal((await checkpoints.list('a',f.project)).length,3); + for(let index=0;index<3;index++){await writeFile(join(f.project,'file'),`b ${index}\n`);await checkpoints.capture('b',f.project);} + await checkpoints.idle(); + const a=await checkpoints.list('a',f.project),b=await checkpoints.list('b',f.project); + assert.equal(a.length+b.length,5,'five in total');assert.equal(b.length,3); + assert.deepEqual(f.git('for-each-ref','--format=%(refname)','refs/canvastty/').split('\n').sort(),[...a,...b].map(row=>row.id).sort()); + const rows=JSON.parse(await readFile(f.storage,'utf8'));const referenced=new Set(rows.flatMap(row=>row.packs??[])); + assert.equal((await packsOf(f.root)).length,referenced.size,'no pack outlives the points that need it'); + const tight=new GitCheckpoints(text=>text,{packBytes:1},join(f.root,'tight','checkpoints.json'));t.after(()=>tight.idle()); + await writeFile(join(f.project,'file'),'big\n'.repeat(1000));await tight.capture('c',f.project); + assert.equal((await tight.list('c',f.project)).length,1,'a point too large to copy is kept, relying on the repository'); + assert.equal(JSON.parse(await readFile(join(f.root,'tight','checkpoints.json'),'utf8'))[0].packs,undefined); +}); + +test('closing a card and cards known not to come back remove their points; a missing folder keeps them; a broken registry is set aside',{timeout:60000},async t=>{ + const f=await fixture(t);const gone=await fixture(t); + const checkpoints=new GitCheckpoints(text=>text,50,f.storage);t.after(()=>checkpoints.idle()); + for(const id of ['kept','closed','stale']){await writeFile(join(f.project,'file'),id);await checkpoints.capture(id,f.project);} + await writeFile(join(gone.project,'file'),'x');await checkpoints.capture('elsewhere',gone.project); + await checkpoints.forgetSession('closed');await checkpoints.idle(); + assert.deepEqual(await checkpoints.list('closed',f.project),[]); + assert.equal(f.git('for-each-ref','refs/canvastty/closed/'),''); + await checkpoints.forgetSessions(['stale']);await checkpoints.idle(); + assert.deepEqual(await checkpoints.list('stale',f.project),[]);assert.equal((await checkpoints.list('kept',f.project)).length,1); + await checkpoints.capture('closed',f.project);assert.deepEqual(await checkpoints.list('closed',f.project),[],'a closed card never gets a point again'); + await rm(gone.project,{recursive:true,force:true}); + const restarted=new GitCheckpoints(text=>text,50,f.storage);t.after(()=>restarted.idle());await restarted.list('kept',f.project); + const rows=JSON.parse(await readFile(f.storage,'utf8'));assert.deepEqual(rows.map(row=>row.sessionId).sort(),['elsewhere','kept'],'a missing folder\'s points stay (it may be an unmounted volume)'); + assert.equal((await packsOf(f.root)).length,new Set(rows.flatMap(row=>row.packs??[])).size); + // One invalid row is dropped (a copy is set aside); the rest survives. + await writeFile(f.storage,JSON.stringify([...rows,{id:'refs/canvastty/x/1-bad',cwd:f.project,sessionId:'x',oid:'nope'}])); + const partial=new GitCheckpoints(text=>text,50,f.storage);t.after(()=>partial.idle()); + assert.equal((await partial.list('kept',f.project)).length,1);assert.ok(existsSync(`${f.storage}.quarantined`)); + // Unreadable or oversized: set aside, and rollback points start over instead of failing every capture. + for(const broken of ['{not json',' '.repeat(4*1024*1024+1)]){ + await writeFile(f.storage,broken); + const fresh=new GitCheckpoints(text=>text,50,f.storage);t.after(()=>fresh.idle()); + assert.deepEqual(await fresh.list('kept',f.project),[]); + await writeFile(join(f.project,'file'),'again');await fresh.capture('kept',f.project);assert.equal((await fresh.list('kept',f.project)).length,1); + assert.equal(await readFile(`${f.storage}.quarantined`,'utf8'),broken); + } +}); + +test('points taken in one millisecond, or after the clock went back, keep their order: retention drops the oldest',{timeout:60000},async t=>{ + const f=await fixture(t);const checkpoints=f.make({perSession:2,total:10}); + const realNow=Date.now;let frozen=realNow();Date.now=()=>frozen;t.after(()=>{Date.now=realNow;}); + for(let index=0;index<3;index++){await writeFile(join(f.project,'file'),`same ms ${index}\n`);await checkpoints.capture('a',f.project);} + frozen-=60_000;await writeFile(join(f.project,'file'),'clock went back\n');await checkpoints.capture('a',f.project); + await checkpoints.idle(); + const kept=await checkpoints.list('a',f.project);assert.equal(kept.length,2); + assert.ok(kept[0].at>kept[1].at,'strictly ordered'); + await checkpoints.restore('a',f.project,kept[0].id);await checkpoints.idle(); + assert.equal(await readFile(join(f.project,'file'),'utf8'),'clock went back\n','the newest point is the last one taken'); +}); + diff --git a/tests/checkpoint-ipc.test.mjs b/tests/checkpoint-ipc.test.mjs new file mode 100644 index 00000000..676c1ff2 --- /dev/null +++ b/tests/checkpoint-ipc.test.mjs @@ -0,0 +1,68 @@ +// The Rollback points dialog's requests answer with stable codes the renderer localizes, never main-process text. +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import test from "node:test"; +import { checkpointIpcHandlers } from "../src/main/ipc/checkpointIpc.ts"; +import { GitCheckpoints } from "../src/main/services/GitCheckpoints.ts"; +import { TerminalManager } from "../src/main/services/TerminalManager.ts"; +import { availableRegistry, fakeSpawner } from "./helpers/terminal.mjs"; + +const run = promisify(execFile); + +async function fixture(t) { + const root = await realpath(await mkdtemp(join(tmpdir(), "ctty-checkpoint-ipc-"))); + const project = join(root, "project"); + const env = { ...process.env, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1" }; + await run("git", ["init", "-q", project], { env }); + const git = (...args) => run("git", ["-C", project, "-c", "user.name=t", "-c", "user.email=t@example.invalid", ...args], { env }); + await writeFile(join(project, "file.txt"), "base\n"); + await git("add", "file.txt"); + await git("commit", "-qm", "base"); + const calls = []; + const terminals = new TerminalManager(() => undefined, availableRegistry(), undefined, undefined, true, fakeSpawner(calls)); + const checkpoints = new GitCheckpoints((text) => text.replaceAll("SECRET", "[MASKED]"), 50, join(root, "app", "checkpoints.json")); + t.after(async () => { terminals.disposeAll(); await rm(root, { recursive: true, force: true }); }); + return { root, project, terminals, checkpoints, calls, handlers: checkpointIpcHandlers({ checkpoints, terminals }) }; +} + +test("list, preview and restore answer with data or a code; a live card in the folder refuses the restore", async (t) => { + const f = await fixture(t); + const card = f.terminals.create({ provider: "codex", profile: "yolo", cwd: f.project, position: { x: 0, y: 0 } }); + assert.deepEqual(await f.handlers.list(card.id), { ok: true, checkpoints: [] }); + await writeFile(join(f.project, "file.txt"), "before turn SECRET\n"); + await f.checkpoints.capture(card.id, f.project); + const listed = await f.handlers.list(card.id); + assert.equal(listed.ok, true); + assert.equal(listed.checkpoints[0].kind, "before-turn"); + await writeFile(join(f.project, "file.txt"), "agent edit\n"); + const preview = await f.handlers.preview(card.id, listed.checkpoints[0].id); + assert.equal(preview.ok, true); + assert.deepEqual(preview.changedFiles, ["file.txt"]); + assert.match(preview.text, /\[MASKED\]/u); + assert.equal(preview.truncated, false); + assert.deepEqual(await f.handlers.restore(card.id, listed.checkpoints[0].id), { ok: false, code: "agent-running" }); + assert.equal(await readFile(join(f.project, "file.txt"), "utf8"), "agent edit\n", "nothing was restored"); + f.calls[0].process.emitExit(0); + assert.deepEqual(await f.handlers.restore(card.id, listed.checkpoints[0].id), { ok: true }); + assert.equal(await readFile(join(f.project, "file.txt"), "utf8"), "before turn SECRET\n"); + assert.deepEqual((await f.handlers.list(card.id)).checkpoints.map((entry) => entry.kind).sort(), ["before-restore", "before-turn"]); +}); + +test("refusals are codes: unknown card, invalid or foreign rollback point, a folder that is not a Git project root", async (t) => { + const f = await fixture(t); + assert.deepEqual(await f.handlers.list("missing"), { ok: false, code: "missing-session" }); + assert.deepEqual(await f.handlers.list(42), { ok: false, code: "missing-session" }); + const card = f.terminals.create({ provider: "codex", profile: "yolo", cwd: f.project, position: { x: 0, y: 0 } }); + assert.deepEqual(await f.handlers.preview(card.id, "../../etc"), { ok: false, code: "unknown-checkpoint" }); + assert.deepEqual(await f.handlers.preview(card.id, "refs/canvastty/other/1-00000000-0000-0000-0000-000000000000"), + { ok: false, code: "unknown-checkpoint" }); + const plain = f.terminals.create({ provider: "terminal", profile: "normal", cwd: f.root, position: { x: 0, y: 0 } }); + assert.deepEqual(await f.handlers.list(plain.id), { ok: false, code: "not-git-root" }); + const broken = checkpointIpcHandlers({ checkpoints: { available: async () => { throw new Error("internal detail /Users/x"); } }, terminals: f.terminals }); + const warn = console.warn; console.warn = () => undefined; + try { assert.deepEqual(await broken.list(card.id), { ok: false, code: "failed" }); } finally { console.warn = warn; } +}); diff --git a/tests/checkpoint-object-replacement.test.mjs b/tests/checkpoint-object-replacement.test.mjs new file mode 100644 index 00000000..5417c27f --- /dev/null +++ b/tests/checkpoint-object-replacement.test.mjs @@ -0,0 +1,266 @@ +import assert from 'node:assert/strict'; +import { execFile, spawn } from 'node:child_process'; +import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; +import { once } from 'node:events'; +import test from 'node:test'; +import { GitCheckpoints } from '../src/main/services/GitCheckpoints.ts'; +const exec = promisify(execFile); + +async function gitFixture(t, prefix, { branch, content = 'saved\n', message = 'saved', project = false, executable = 'git' } = {}) { + const root = await mkdtemp(join(tmpdir(), prefix)); + t.after(() => rm(root, { recursive: true, force: true })); + const dir = project ? join(root, 'project') : root; + if (project) await mkdir(dir); + const git = async (...args) => (await exec(executable, ['-C', dir, '-c', 'user.name=Test', '-c', 'user.email=test@example.invalid', '-c', 'commit.gpgsign=false', ...args])).stdout.trim(); + await git('init', ...(branch ? ['-b', branch] : []), '-q'); + // These fixtures compare exact bytes; pin the repository against a global core.autocrlf (Windows runners set true). + await git('config', 'core.autocrlf', 'false'); + if (content !== null) { + await writeFile(join(dir, 'file'), content); + await git('add', 'file'); + await git('commit', '-qm', message); + } + return { root, dir, git }; +} + +test('replacement refs cannot change checkpoint preview or restored tracked content', async t => { + const { dir, git } = await gitFixture(t, 'ctty-checkpoint-replace-', { content: 'TRUSTED-CONTENT\n', message: 'trusted' }); + const trustedOid = await git('rev-parse', 'HEAD'); + const checkpoints = new GitCheckpoints(text => text); + await checkpoints.capture('session', dir); + const saved = (await checkpoints.list('session', dir))[0]; + await writeFile(join(dir, 'file'), 'AGENT-SUBSTITUTION\n'); + await git('add', 'file'); await git('commit', '-qm', 'replacement'); + const replacement = await git('rev-parse', 'HEAD'); + await git('replace', trustedOid, replacement); + await writeFile(join(dir, 'file'), 'CURRENT-CONTENT\n'); + const preview = await checkpoints.preview('session', dir, saved.id); + assert.match(preview.text, /\+TRUSTED-CONTENT/u); + assert.doesNotMatch(preview.text, /AGENT-SUBSTITUTION/u); + await checkpoints.restore('session', dir, saved.id); + assert.equal(await readFile(join(dir, 'file'), 'utf8'), 'TRUSTED-CONTENT\n'); +}); + +test('a CRLF working copy in a core.autocrlf repository restores with its CRLF line endings', async t => { + const { dir, git } = await gitFixture(t, 'ctty-checkpoint-autocrlf-', { content: null }); + await git('config', 'core.autocrlf', 'true'); + await writeFile(join(dir, 'file'), 'first\r\nsecond\r\n'); + await git('add', 'file'); await git('commit', '-qm', 'crlf'); + await writeFile(join(dir, 'file'), 'edited\r\nsecond\r\n'); + const checkpoints = new GitCheckpoints(text => text); + await checkpoints.capture('session', dir); + const saved = (await checkpoints.list('session', dir))[0]; + await writeFile(join(dir, 'file'), 'later\r\n'); + await checkpoints.restore('session', dir, saved.id); + assert.equal(await readFile(join(dir, 'file'), 'utf8'), 'edited\r\nsecond\r\n', 'Git normalizes on capture and converts back on restore'); + assert.equal(await git('status', '--porcelain'), 'M file'); +}); + +test('protected checkpoint packs survive deleted source refs and immediate Git garbage collection', async t => { + const { root, dir, git } = await gitFixture(t, 'ctty-checkpoint-gc-', { project: true }); + const storage=join(root,'private','checkpoints.json'); + const checkpoints=new GitCheckpoints(text=>text,50,storage);await checkpoints.capture('session',dir); + const saved=(await checkpoints.list('session',dir))[0]; + const oldBranch=await git('branch','--show-current');await git('checkout','--orphan','replacement'); + await writeFile(join(dir,'file'),'replacement\n');await git('add','file');await git('commit','-qm','unrelated'); + await git('branch','-D',oldBranch);await git('update-ref','-d',saved.id); + await git('reflog','expire','--expire=now','--all');await git('gc','--prune=now'); + const restarted=new GitCheckpoints(text=>text,50,storage); + await assert.doesNotReject(async()=>{ + assert.match((await restarted.preview('session',dir,saved.id)).text,/\+saved/u); + await restarted.restore('session',dir,saved.id); + assert.equal(await readFile(join(dir,'file'),'utf8'),'saved\n'); + },'host checkpoint survives ref deletion and immediate Git garbage collection'); + await restarted.idle();await checkpoints.idle(); +}); + +test('deleting an agent-writable checkpoint ref cannot hide the host-registered checkpoint', async t => { + const { dir, git } = await gitFixture(t, 'ctty-checkpoint-ref-delete-'); + const checkpoints = new GitCheckpoints(text => text); + await checkpoints.capture('session', dir); + const saved = (await checkpoints.list('session', dir))[0]; + await git('update-ref', '-d', saved.id); + await writeFile(join(dir, 'file'), 'later\n'); + assert.equal((await checkpoints.list('session', dir))[0].id, saved.id); + await checkpoints.restore('session', dir, saved.id); + assert.equal(await readFile(join(dir, 'file'), 'utf8'), 'saved\n'); +}); + +test('repository reference hooks cannot block host checkpoint capture', async t => { + const { dir, git } = await gitFixture(t, 'ctty-checkpoint-hooks-', { branch: 'main' }); + await writeFile(join(dir, '.git', 'hooks', 'reference-transaction'), '#!/bin/sh\nexit 73\n', { mode: 0o755 }); + const head = await git('rev-parse', 'HEAD'); + await assert.rejects(git('update-ref', 'refs/test/hook-check', head), undefined, 'fixture hook should be active for ordinary Git commands'); + + const checkpoints = new GitCheckpoints(text => text); + await checkpoints.capture('session', dir); + assert.equal((await checkpoints.list('session', dir)).length, 1); +}); + +test('restoring a clean merge-HEAD checkpoint keeps the merge tree in the index', async t => { + const { dir, git } = await gitFixture(t, 'ctty-checkpoint-merge-head-', { branch: 'main', content: null }); + await writeFile(join(dir, 'base.txt'), 'base\n'); + await git('add', 'base.txt'); await git('commit', '-qm', 'base'); + await git('checkout', '-b', 'feature'); + await writeFile(join(dir, 'feature.txt'), 'feature\n'); + await git('add', 'feature.txt'); await git('commit', '-qm', 'feature'); + await git('checkout', 'main'); + await writeFile(join(dir, 'main.txt'), 'main\n'); + await git('add', 'main.txt'); await git('commit', '-qm', 'main'); + await git('merge', '--no-ff', 'feature', '-m', 'merge feature'); + + const checkpoints = new GitCheckpoints(text => text); + await checkpoints.capture('session', dir); + const saved = (await checkpoints.list('session', dir))[0]; + await writeFile(join(dir, 'main.txt'), 'changed after checkpoint\n'); + await checkpoints.restore('session', dir, saved.id); + + assert.equal(await readFile(join(dir, 'main.txt'), 'utf8'), 'main\n'); + assert.equal(await git('diff', '--cached', '--name-only'), '', 'restored index should match the clean merge checkpoint'); + assert.equal(await git('write-tree'), await git('rev-parse', `${saved.id}^{tree}`)); +}); + +test('restore holds a pruned checkpoint pack through Git GC with retention set to one', async t => { + const { root, dir, git } = await gitFixture(t, 'ctty-checkpoint-restore-lease-', { branch: 'main', project: true }); + const storage = join(root, 'private', 'checkpoints.json'); + const checkpoints = new GitCheckpoints(text => text, 1, storage); + await checkpoints.capture('session', dir); + const saved = (await checkpoints.list('session', dir))[0]; + + const oldBranch = await git('branch', '--show-current'); + await git('checkout', '--orphan', 'replacement'); + await writeFile(join(dir, 'file'), 'replacement\n'); + await git('add', 'file'); await git('commit', '-qm', 'unrelated'); + await git('branch', '-D', oldBranch); + + const capture = checkpoints.capture.bind(checkpoints); + checkpoints.capture = async (session, cwd) => { + await capture(session, cwd); + await git('reflog', 'expire', '--expire=now', '--all'); + await git('gc', '--prune=now'); + }; + await assert.doesNotReject(checkpoints.restore('session', dir, saved.id), 'restore must retain its source pack through capture-time pruning and GC'); + assert.equal(await readFile(join(dir, 'file'), 'utf8'), 'saved\n'); + // Retention runs in the repository's background queue after the safety point is registered. + await checkpoints.idle(); + assert.equal((await checkpoints.list('session', dir)).length, 1, 'the new pre-restore checkpoint should remain under retention one'); +}); + +test('a failed registry replacement keeps retained checkpoint packs and refs intact', async t => { + const { root, dir, git } = await gitFixture(t, 'ctty-checkpoint-retention-atomic-', { branch: 'main', project: true }); + const storage = join(root, 'private', 'checkpoints.json'); + const checkpoints = new GitCheckpoints(text => text, 1, storage); + await checkpoints.capture('session', dir); + await checkpoints.idle(); + const saved = (await checkpoints.list('session', dir))[0]; + const registry = await readFile(storage, 'utf8'); + const packDirectory = join(root, 'private', 'checkpoint-objects', (await readdir(join(root, 'private', 'checkpoint-objects')))[0]); + const packs = (await readdir(packDirectory)).filter(name => name.endsWith('.pack')); + assert.equal(packs.length, 1, 'the protective pack is written after the point is registered'); + + // A directory at the registry filename makes the atomic rename fail. + await rm(storage); + await mkdir(storage); + await writeFile(join(dir, 'file'), 'new checkpoint\n'); + await assert.rejects(checkpoints.capture('session', dir)); + await checkpoints.idle(); + + assert.deepEqual((await checkpoints.list('session', dir)).map(row => row.id), [saved.id]); + assert.deepEqual((await git('for-each-ref', '--format=%(refname)', 'refs/canvastty/session')).split('\n'), [saved.id]); + assert.deepEqual((await readdir(packDirectory)).filter(name => name.endsWith('.pack')), packs); + + await rm(storage, { recursive: true, force: true }); + await writeFile(storage, registry, { mode: 0o600 }); + const restarted = new GitCheckpoints(text => text, 1, storage); + await restarted.restore('session', dir, saved.id); + assert.equal(await readFile(join(dir, 'file'), 'utf8'), 'saved\n'); + await restarted.idle(); + assert.ok((await readdir(packDirectory)).every(name => name.endsWith('.pack')), 'only complete packs are kept'); +}); + +test('an interrupted pack leaves no temporary files and preserves the last checkpoint', async t => { + if (process.platform === 'win32') return t.skip('the interrupted Git fixture uses a POSIX shell'); + const realGit = (await exec('/bin/sh', ['-c', 'command -v git'])).stdout.trim(); + const { root, dir, git } = await gitFixture(t, 'ctty-checkpoint-pack-interrupt-', { branch: 'main', project: true, executable: realGit }); + const storage = join(root, 'private', 'checkpoints.json'); + const bin = join(root, 'bin'); + await mkdir(bin); + const checkpoints = new GitCheckpoints(text => text, 50, storage); + await checkpoints.capture('session', dir); + await checkpoints.idle(); + const saved = (await checkpoints.list('session', dir))[0]; + const packFolders = await readdir(join(root, 'private', 'checkpoint-objects')); + assert.equal(packFolders.length, 1); + const packDirectory = join(root, 'private', 'checkpoint-objects', packFolders[0]); + const existingFiles = (await readdir(packDirectory)).sort(); + await writeFile(join(dir, 'file'), 'changed\n'); + await writeFile(join(bin, 'git'), `#!/bin/sh +interrupted=false +for argument in "$@"; do + [ "$argument" = pack-objects ] && interrupted=true + prefix="$argument" +done +if [ "$interrupted" = true ]; then + while IFS= read -r object; do :; done + printf 'unfinished pack' > "$(dirname "$prefix")/tmp_pack_interrupted" + printf 'unfinished index' > "$prefix.partial.idx" + printf 'fixture pack interrupted\\n' >&2 + exit 73 +fi +exec "$CTTY_REAL_GIT" "$@" +`, { mode: 0o755 }); + // Keep the PATH override inside a child so other tests and host commands always use real Git. + const code = ` + import assert from 'node:assert/strict'; + import { readFile, readdir } from 'node:fs/promises'; + import { GitCheckpoints } from ${JSON.stringify(new URL('../src/main/services/GitCheckpoints.ts', import.meta.url).href)}; + const checkpoints = new GitCheckpoints(text => text, 50, ${JSON.stringify(storage)}); + // The point is registered before its objects are copied; a failed copy leaves it relying on the repository. + await checkpoints.capture('session', ${JSON.stringify(dir)}); + await checkpoints.idle(); + assert.deepEqual((await readdir(${JSON.stringify(packDirectory)})).sort(), ${JSON.stringify(existingFiles)}, 'failed pack creation must not leave temporary or partial pack files'); + const rows = JSON.parse(await readFile(${JSON.stringify(storage)}, 'utf8')); + assert.equal(rows.length, 2); + assert.equal(rows.find(row => row.id !== ${JSON.stringify(saved.id)}).packs, undefined); + process.env.PATH = process.env.CTTY_ORIGINAL_PATH; + await checkpoints.capture('session', ${JSON.stringify(dir)}); + assert.equal((await checkpoints.list('session', ${JSON.stringify(dir)})).length, 3, 'a later capture still works after the interrupted one'); + await checkpoints.restore('session', ${JSON.stringify(dir)}, ${JSON.stringify(saved.id)}); + assert.equal(await readFile(${JSON.stringify(join(dir, 'file'))}, 'utf8'), 'saved\\n'); + `; + const result = await exec(process.execPath, ['--experimental-strip-types', '--input-type=module', '--eval', code], { + env: { ...process.env, PATH: `${bin}:${process.env.PATH ?? ''}`, CTTY_REAL_GIT: realGit, CTTY_ORIGINAL_PATH: process.env.PATH ?? '' }, + timeout: 15_000 + }); + // Node 22/23 announce type stripping as an ExperimentalWarning; only that notice (and Node's trace hint) is tolerated. + const nodeExperimentalNotice = /^\(node:\d+\) ExperimentalWarning: [^\n]*\n(?:\(Use `node --trace-warnings \.\.\.` to show where the warning was created\)\n)?/gmu; + assert.equal(result.stderr.replace(nodeExperimentalNotice, '').replace(/^Rollback point objects could not be copied\. [^\n]*\n/mu, ''), ''); + assert.ok((await readdir(packDirectory)).every(name => name.endsWith('.pack')), 'new checkpoints retain only complete packs, without unused indexes or staging folders'); +}); + +test('startup removes dead owners\' staging and packs no point references, keeping live captures', async t => { + const root = await mkdtemp(join(tmpdir(), 'ctty-checkpoint-staging-owner-')); + t.after(() => rm(root, { recursive: true, force: true })); + const storage = join(root, 'private', 'checkpoints.json'); + const projectPacks = join(root, 'private', 'checkpoint-objects', 'a'.repeat(64)); + await mkdir(projectPacks, { recursive: true }); + const owner = spawn(process.execPath, ['-e', 'process.exit(0)']); + await once(owner, 'exit'); + assert.ok(Number.isSafeInteger(owner.pid)); + const deadFolder = join(projectPacks, `.pending-${owner.pid}-aBc123`); + const liveFolder = join(projectPacks, `.pending-${process.pid}-xYz456`); + for (const folder of [deadFolder, liveFolder]) { + await mkdir(folder); + await writeFile(join(folder, 'tmp_pack'), 'incomplete'); + } + const packName = `pack-${'b'.repeat(40)}.pack`; + await writeFile(join(projectPacks, packName), 'pack no rollback point references'); + const checkpoints = new GitCheckpoints(text => text, 50, storage); + // load runs even for a nonrepository and with no registry: first-capture failures leave no row. + assert.deepEqual(await checkpoints.list('session', root), []); + assert.deepEqual((await readdir(projectPacks)).sort(), [liveFolder.slice(projectPacks.length + 1)], 'dead staging and unreferenced packs go'); + assert.equal(await readFile(join(liveFolder, 'tmp_pack'), 'utf8'), 'incomplete'); +}); diff --git a/tests/checkpoint-restore-index.test.mjs b/tests/checkpoint-restore-index.test.mjs new file mode 100644 index 00000000..9a67719b --- /dev/null +++ b/tests/checkpoint-restore-index.test.mjs @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import {execFileSync} from 'node:child_process'; +import {mkdtemp,writeFile,readFile,rm,stat} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {GitCheckpoints} from '../src/main/services/GitCheckpoints.ts'; + +async function fixture(t){ + const root=await mkdtemp(join(tmpdir(),'ctty-checkpoint-index-')); + const git=(...args)=>execFileSync('git',args,{cwd:root,encoding:'utf8',env:{...process.env,GIT_CONFIG_GLOBAL:process.platform==='win32'?'NUL':'/dev/null',GIT_CONFIG_NOSYSTEM:'1'}}); + git('init','-q');git('config','user.name','Fixture');git('config','user.email','fixture@example.invalid');git('config','core.autocrlf','false');git('config','core.eol','lf'); + for(const name of ['file.txt','remove.txt','rename-old.txt','unstaged-delete.txt'])await writeFile(join(root,name),`base ${name}\n`); + git('add','.');git('commit','-qm','base');const checkpoints=new GitCheckpoints(text=>text); + t.after(async()=>{await checkpoints.idle();await rm(root,{recursive:true,force:true});}); + return{root,git,checkpoints}; +} + +for(const change of ['addition','removal','rename','combined'])test(`checkpoint restore reverses staged ${change} and can round-trip through its safety checkpoint`,{timeout:30000},async t=>{ + const {root,git,checkpoints}=await fixture(t); + // Save an index/worktree split plus a path that will later be absent from the current index. + await writeFile(join(root,'file.txt'),'target index\n');await writeFile(join(root,'target-added.txt'),'target added index\n');git('add','file.txt','target-added.txt'); + await writeFile(join(root,'file.txt'),'target worktree\n');await writeFile(join(root,'target-added.txt'),'target added worktree\n'); + await checkpoints.capture('worker',root);const [target]=await checkpoints.list('worker',root); + const savedIndex=git('diff','--cached','--binary'),savedWorktree=git('diff','--binary'); + if(change==='addition'||change==='combined'){await writeFile(join(root,'new.txt'),'new staged file\n');git('add','new.txt');await writeFile(join(root,'new.txt'),'new unstaged contents\n');} + if(change==='removal'||change==='combined')git('rm','-q','remove.txt'); + if(change==='rename'||change==='combined')git('mv','rename-old.txt','renamed-new.txt'); + git('rm','-q','-f','target-added.txt');await writeFile(join(root,'file.txt'),'later index\n');git('add','file.txt');await writeFile(join(root,'file.txt'),'later worktree\n');await rm(join(root,'unstaged-delete.txt')); + await writeFile(join(root,'unrelated.txt'),'untracked unrelated\n'); + const beforeIndex=git('diff','--cached','--binary'),beforeWorktree=git('diff','--binary'),beforeStatus=git('status','--porcelain=v1'); + await checkpoints.restore('worker',root,target.id); + if(change==='addition'||change==='combined')await assert.rejects(stat(join(root,'new.txt')),{code:'ENOENT'},'post-checkpoint staged addition must be removed from disk'); + if(change==='rename'||change==='combined')await assert.rejects(stat(join(root,'renamed-new.txt')),{code:'ENOENT'},'post-checkpoint rename destination must not survive as an untracked file'); + assert.equal(git('diff','--cached','--binary'),savedIndex);assert.equal(git('diff','--binary'),savedWorktree); + assert.equal(await readFile(join(root,'target-added.txt'),'utf8'),'target added worktree\n');assert.equal(git('show',':target-added.txt'),'target added index\n'); + assert.equal(await readFile(join(root,'unrelated.txt'),'utf8'),'untracked unrelated\n'); + const safety=(await checkpoints.list('worker',root)).find(row=>row.id!==target.id);assert.ok(safety); + await checkpoints.restore('worker',root,safety.id); + assert.equal(git('diff','--cached','--binary'),beforeIndex);assert.equal(git('diff','--binary'),beforeWorktree);assert.equal(git('status','--porcelain=v1'),beforeStatus); + if(change==='addition'||change==='combined'){assert.equal(git('show',':new.txt'),'new staged file\n');assert.equal(await readFile(join(root,'new.txt'),'utf8'),'new unstaged contents\n');} + assert.equal(await readFile(join(root,'unrelated.txt'),'utf8'),'untracked unrelated\n'); +}); diff --git a/tests/checkpoint-restore-lease.test.mjs b/tests/checkpoint-restore-lease.test.mjs new file mode 100644 index 00000000..c5b159b6 --- /dev/null +++ b/tests/checkpoint-restore-lease.test.mjs @@ -0,0 +1,202 @@ +// TerminalManager.withCheckpointRestore: a rollback point restore never runs under a live card of its folder, holds +// the folder's cards while Git restores, and refuses related isolated environments (docs/plugins.md). +import assert from 'node:assert/strict'; +import test from 'node:test'; +import {mkdtemp,mkdir,rm,realpath,readFile,writeFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {TerminalManager} from '../src/main/services/TerminalManager.ts'; +import {TerminalSessionStore} from '../src/main/services/TerminalSessionStore.ts'; +import {EnvironmentRegistry} from '../src/main/services/EnvironmentRegistry.ts'; +import {availableRegistry,fakeSpawner} from './helpers/terminal.mjs'; + +const deferred=()=>{let resolve;const promise=new Promise(done=>resolve=done);return{promise,resolve};}; +const tick=()=>new Promise(resolve=>setImmediate(resolve)); +const waitFor=async(predicate,timeoutMs=3000)=>{const end=Date.now()+timeoutMs;while(!predicate()){if(Date.now()>end)throw new Error('timed out');await new Promise(r=>setTimeout(r,5));}}; +const code=expected=>error=>error.code===expected; + +async function fixture(t){ + const root=await realpath(await mkdtemp(join(tmpdir(),'ctty-restore-lease-'))),other=join(root,'other'),project=join(root,'project');await mkdir(other);await mkdir(project); + const calls=[],writes=[]; + const terminal=new TerminalManager(()=>{},availableRegistry(),undefined,undefined,true,fakeSpawner(calls,{onWrite:value=>writes.push(value)})); + const create=(cwd=project,provider='codex')=>{const row=terminal.create({provider,profile:'normal',cwd,position:{x:0,y:0}});terminal.resize(row.id,80,24);return row;}; + const exit=row=>calls.find(call=>call.options.cwd===terminal.getMetadata(row.id)?.cwd&&!call.exited&&(call.exited=true))?.process.emitExit(0); + t.after(async()=>{terminal.disposeAll();await rm(root,{recursive:true,force:true});}); + return{root,terminal,project,other,create,calls,writes,exit}; +} + +test('restore is refused while any card of the folder still runs; a card elsewhere does not block it',async t=>{ + const f=await fixture(t),a=f.create(),b=f.create(),elsewhere=f.create(f.other);let ran=false; + await assert.rejects(f.terminal.withCheckpointRestore(a.id,async()=>{ran=true;}),code('agent-running')); + f.calls[0].process.emitExit(0); + await assert.rejects(f.terminal.withCheckpointRestore(a.id,async()=>{ran=true;}),code('agent-running'),'a sibling card in the same folder still runs'); + f.calls[1].process.emitExit(0); + assert.equal(f.terminal.getMetadata(elsewhere.id).exitCode,null); + assert.equal(await f.terminal.withCheckpointRestore(a.id,async cwd=>{assert.equal(cwd,f.project);ran=true;return 7;}),7); + assert.equal(ran,true); + assert.equal(f.terminal.getMetadata(b.id).exitCode,0); +}); + +test('while it restores: no input, launch or restart in the folder, closes wait; other folders work',async t=>{ + const f=await fixture(t),a=f.create(),b=f.create(),other=f.create(f.other); + f.calls[0].process.emitExit(0);f.calls[1].process.emitExit(0); + const entered=deferred(),release=deferred(); + const restore=f.terminal.withCheckpointRestore(a.id,async()=>{entered.resolve();await release.promise;});await entered.promise; + assert.equal(f.terminal.isCheckpointRestoreActive(a.id),true); + assert.equal(f.terminal.inputChecked(a.id,'x'),false); + assert.throws(()=>f.create(),/restoration is in progress/); + assert.throws(()=>f.create(join(f.project,'..')),/restoration is in progress/,'a folder above the restored one is refused too'); + assert.throws(()=>f.terminal.restart(a.id),/restoration is in progress/); + await assert.rejects(f.terminal.withCheckpointRestore(b.id,async()=>assert.fail()),code('restore-in-progress')); + const elsewhere=f.create(f.other);assert.ok(elsewhere.id,'another folder still launches'); + assert.equal(f.terminal.inputChecked(other.id,'next\r'),true); + f.terminal.dispose(b.id);assert.ok(f.terminal.getMetadata(b.id),'the close waits for the restore'); + release.resolve();await restore; + assert.equal(f.terminal.getMetadata(b.id),null,'the deferred close ran'); + assert.equal(f.terminal.isCheckpointRestoreActive(a.id),false); + f.terminal.restart(a.id);assert.equal(f.terminal.getMetadata(a.id).exitCode,null); +}); + +test('a plugin launch still preparing refuses the restore, and a restore refuses plugin launches',async t=>{ + const f=await fixture(t),a=f.create();f.calls[0].process.emitExit(0); + const wrap=deferred(); + const registry=new EnvironmentRegistry({providers:()=>[{pluginId:'fixture.env',pluginName:'Env',serviceId:'env',secrets:false,kinds:[{kind:'box',label:'Box',keeps:{launch:true}}]}], + secret:async()=>null,call:async(_p,_s,method)=>{if(method.endsWith('.prepare'))return{ref:{id:'x'},label:'Box'};if(method.endsWith('.wrap')){await wrap.promise;return{command:process.execPath,args:[]};}return{};}}); + f.terminal.configureEnvironments(registry); + const placed=f.terminal.create({provider:'codex',profile:'normal',cwd:f.other,position:{x:0,y:0},environment:{pluginId:'fixture.env',kind:'box'}}); + await tick(); + await assert.rejects(f.terminal.withCheckpointRestore(a.id,async()=>assert.fail()),code('pending-launch')); + wrap.resolve();await waitFor(()=>f.terminal.getMetadata(placed.id)?.exitCode===null&&f.calls.length===2); + f.calls[1].process.emitExit(0);await tick(); + const entered=deferred(),release=deferred(); + const restore=f.terminal.withCheckpointRestore(a.id,async()=>{entered.resolve();await release.promise;});await entered.promise; + assert.throws(()=>f.terminal.create({provider:'codex',profile:'normal',cwd:f.other,position:{x:0,y:0},environment:{pluginId:'fixture.env',kind:'box'}}),/restoration is in progress/); + release.resolve();await restore; +}); + +test('shutdown waits for a running restore before it disposes the cards',async t=>{ + const f=await fixture(t),a=f.create();f.calls[0].process.emitExit(0);const entered=deferred(),release=deferred(); + const restore=f.terminal.withCheckpointRestore(a.id,async()=>{entered.resolve();await release.promise;});await entered.promise; + const shutdown=f.terminal.shutdown();await tick();assert.ok(f.terminal.getMetadata(a.id));release.resolve();await restore;await shutdown;assert.equal(f.terminal.getMetadata(a.id),null); +}); + +test('a missing folder of an unrelated exited card does not prevent the restore',async t=>{ + const f=await fixture(t),a=f.create();f.create(f.other);f.calls[0].process.emitExit(0);f.calls[1].process.emitExit(0);await rm(f.other,{recursive:true});let restored=false; + await f.terminal.withCheckpointRestore(a.id,async()=>{restored=true;});assert.equal(restored,true); +}); + +async function environmentFixture(t,{isolated=true,relocate=false,kind='box',removeProjectDuringWrap=false,persist=false}={}){ + const f=await fixture(t),provider={pluginId:'fixture.environment',pluginName:'Environment',serviceId:'environment',secrets:false,kinds:[{kind,label:'Fixture',keeps:{launch:true,...(isolated===null?{}:{isolated})}}]}; + let providers=[provider]; + if(persist)f.terminal.configureSessionPersistence(new TerminalSessionStore(f.root),'continue'); + const registry=new EnvironmentRegistry({providers:()=>providers,secret:async()=>null,call:async(_plugin,_service,method)=>{ + if(method.endsWith('.prepare'))return{ref:{id:'fixture'},label:'Fixture',...(relocate?{cwd:f.other}:{})}; + if(method.endsWith('.wrap')){if(removeProjectDuringWrap)await rm(f.project,{recursive:true});return{command:process.execPath,args:['fake-environment'],cwd:f.other};} + if(method.endsWith('.resume'))return{ok:true}; + return{}; + }}); + f.terminal.configureEnvironments(registry); + const launched=f.terminal.create({provider:'codex',profile:'normal',cwd:f.project,position:{x:0,y:0},environment:{pluginId:provider.pluginId,kind}}); + await waitFor(()=>f.calls.length===1); + return{...f,launched,provider,registry,remove:()=>{providers=[];}}; +} + +for(const mutation of ['unchanged','flip','remove'])test(`an isolated environment refuses the restore even after its wrapper exited (${mutation})`,{timeout:5000},async t=>{ + const f=await environmentFixture(t);f.calls[0].process.emitExit(0); + if(mutation==='flip')f.provider.kinds[0].keeps.isolated=false; + if(mutation==='remove')f.remove(); + let ran=false; + await assert.rejects(f.terminal.withCheckpointRestore(f.launched.id,async()=>{ran=true;}),code('isolated-environment'));assert.equal(ran,false); +}); + +test('an isolated sibling hides no project: a card of the same project is refused, a distinct project is not',{timeout:5000},async t=>{ + const f=await environmentFixture(t,{relocate:true});f.calls[0].process.emitExit(0); + const sibling=f.create();f.calls[1].process.emitExit(0); + await assert.rejects(f.terminal.withCheckpointRestore(sibling.id,async()=>assert.fail()),code('isolated-environment')); + const distinct=join(f.root,'distinct');await mkdir(distinct);const local=f.create(distinct);f.calls[2].process.emitExit(0);let restored=false; + await f.terminal.withCheckpointRestore(local.id,async()=>{restored=true;});assert.equal(restored,true); +}); + +test('an unverifiable isolated launch scope refuses every restore',{timeout:5000},async t=>{ + const f=await environmentFixture(t,{removeProjectDuringWrap:true});f.calls[0].process.emitExit(0); + const distinct=join(f.root,'distinct');await mkdir(distinct);const local=f.create(distinct);f.calls[1].process.emitExit(0); + await assert.rejects(f.terminal.withCheckpointRestore(local.id,async()=>assert.fail()),code('isolated-environment')); +}); + +for(const isolated of [false,null])test(`a local worktree environment keeps restore support (${isolated===false?'explicitly local':'undeclared'})`,{timeout:5000},async t=>{ + const f=await environmentFixture(t,{isolated,kind:'worktree'});f.calls[0].process.emitExit(0);let restored=false; + await f.terminal.withCheckpointRestore(f.launched.id,async()=>{restored=true;});assert.equal(restored,true); +}); + +test('isolated launch evidence is saved with the card, survives a reload and stays host-only',{timeout:5000},async t=>{ + const f=await environmentFixture(t,{persist:true,relocate:true});f.calls[0].process.emitExit(0); + await f.terminal.shutdown(); + const stored=JSON.parse(await readFile(join(f.root,'terminal-sessions.json'),'utf8')); + assert.ok(stored.sessions[0].isolatedEnvironmentScopes.roots.includes(f.project)); + f.provider.kinds[0].keeps.isolated=false; + const calls=[],manager=new TerminalManager(()=>{},availableRegistry(),undefined,undefined,true,fakeSpawner(calls)); + t.after(()=>manager.disposeAll()); + manager.configureSessionPersistence(new TerminalSessionStore(f.root),'continue');await manager.restorePersistedSessions(); + assert.ok(manager.getMetadata(f.launched.id));assert.equal('isolatedEnvironmentScopes' in manager.getMetadata(f.launched.id),false,'evidence never reaches the renderer'); + const local=manager.create({provider:'codex',profile:'normal',cwd:f.project,position:{x:0,y:0}});calls.at(-1).process.emitExit(0); + await assert.rejects(manager.withCheckpointRestore(local.id,async()=>assert.fail()),code('isolated-environment')); + await manager.shutdown();const again=await new TerminalSessionStore(f.root).load(); + assert.deepEqual(again.find(row=>row.id===f.launched.id).isolatedEnvironmentScopes,stored.sessions[0].isolatedEnvironmentScopes); +}); + +for(const evidence of [undefined,null,{roots:['relative'],ambiguous:false},{roots:['/absolute'],ambiguous:'no'},{roots:Array.from({length:33},(_,i)=>'/'+i),ambiguous:false}])test(`legacy or malformed isolation evidence reads as unknown scope (${JSON.stringify(evidence)})`,{timeout:5000},async t=>{ + const f=await environmentFixture(t,{persist:true});f.calls[0].process.emitExit(0);await f.terminal.shutdown(); + const file=join(f.root,'terminal-sessions.json'),saved=JSON.parse(await readFile(file,'utf8')); + if(evidence===undefined)delete saved.sessions[0].isolatedEnvironmentScopes;else saved.sessions[0].isolatedEnvironmentScopes=evidence; + await writeFile(file,JSON.stringify(saved)); + const persisted=await new TerminalSessionStore(f.root).load();assert.deepEqual(persisted[0].isolatedEnvironmentScopes,{roots:[],ambiguous:true}); +}); + +test('a card with unknown isolation scope blocks only restores of its own folder or task, not unrelated ones',{timeout:5000},async t=>{ + const f=await environmentFixture(t,{persist:true});f.calls[0].process.emitExit(0);await f.terminal.shutdown(); + const file=join(f.root,'terminal-sessions.json'),saved=JSON.parse(await readFile(file,'utf8')); + saved.sessions[0].isolatedEnvironmentScopes={roots:[],ambiguous:true};await writeFile(file,JSON.stringify(saved)); + const calls=[],manager=new TerminalManager(()=>{},availableRegistry(),undefined,undefined,true,fakeSpawner(calls)),store=new TerminalSessionStore(f.root); + manager.configureEnvironments(f.registry);manager.configureSessionPersistence(store,'continue');await manager.restorePersistedSessions(); + for(const call of calls)call.process.emitExit(0); + const distinct=join(f.root,'distinct');await mkdir(distinct); + const unrelated=manager.create({provider:'codex',profile:'normal',cwd:distinct,position:{x:0,y:0}});calls.at(-1).process.emitExit(0); + let restored=false;await manager.withCheckpointRestore(unrelated.id,async()=>{restored=true;});assert.equal(restored,true,'an unrelated folder is not blocked'); + const same=manager.create({provider:'codex',profile:'normal',cwd:f.project,position:{x:0,y:0}});calls.at(-1).process.emitExit(0); + await assert.rejects(manager.withCheckpointRestore(same.id,async()=>assert.fail()),code('isolated-environment')); + // Its saved-session writes finish before the fixture removes the folder they go to. + manager.disposeAll();await store.flush().catch(()=>undefined); +}); + +test('plugin card actions on a folder wait until its restore ends',{timeout:5000},async t=>{ + const f=await fixture(t),{PluginCards}=await import('../src/main/services/PluginCards.ts'); + const card=f.create();f.calls[0].process.emitExit(0); + const summary={id:card.id,provider:'codex',role:'agent',title:'x',status:'done',exitCode:0,cwd:f.project,workingDirectory:f.project,startedAt:0}; + let called=0;const cards=new PluginCards({providers:()=>[{pluginId:'p',pluginName:'P',serviceId:'s',actions:[{id:'apply',title:'Apply'}]}],trustedPlugins:()=>new Set(['p']), + call:async()=>{called++;return{message:'ok'};},session:()=>summary,restoring:folder=>f.terminal.isRestoringFolder(folder),redact:x=>x,changed(){}}); + const release=deferred(),entered=deferred(); + const restoring=f.terminal.withCheckpointRestore(card.id,async()=>{entered.resolve();await release.promise;});await entered.promise; + await assert.rejects(cards.invoke('p','apply',card.id),/restoration is in progress/u);assert.equal(called,0); + release.resolve();await restoring;assert.equal((await cards.invoke('p','apply',card.id)).message,'ok'); +}); + +test('typed and replayed input share one write: it moves the rollback point generation, and a restore refuses it',async t=>{ + const f=await fixture(t),a=f.create(); + const session=f.terminal.sessions.get(a.id),process=session.process; + const generation=session.checkpointGeneration??0,submitted=f.terminal.submittedInputs(a.id); + const guard=f.terminal.providerSignalGuard(a.id); + // A writer that held input and replays it later goes through writeInput, like input() does. + assert.equal(f.terminal.writeInput(a.id,session,process,'next task\r'),true); + assert.equal(session.checkpointGeneration??0,generation+1); + assert.equal(f.terminal.submittedInputs(a.id),submitted+1); + assert.equal(guard(),false,'the replayed prompt makes the earlier turn\'s rollback point stale'); + process.emitExit(0); + const entered=deferred(),release=deferred(); + const restore=f.terminal.withCheckpointRestore(a.id,async()=>{entered.resolve();await release.promise;});await entered.promise; + // Even a replayer that still holds the card's process (as if it had not exited yet) types nothing while it restores. + session.metadata.exitCode=null;const writes=f.writes.length; + assert.equal(f.terminal.writeInput(a.id,session,process,'late\r'),false); + assert.equal(f.writes.length,writes); + session.metadata.exitCode=0; + release.resolve();await restore; +}); diff --git a/tests/checkpoint-safety.test.mjs b/tests/checkpoint-safety.test.mjs new file mode 100644 index 00000000..a1c6932a --- /dev/null +++ b/tests/checkpoint-safety.test.mjs @@ -0,0 +1,159 @@ +// GitCheckpoints, second review: a restore never runs without its safety point, filter drivers stay blank in a race, +// pack chains stay bounded, start-up cleanup keeps what it cannot prove dead, previews never hold up a capture, and +// case-only renames restore on case-insensitive file systems. +// Run with a fake HOME and GIT_CONFIG_GLOBAL=/dev/null (the fixture also isolates the Git config it uses itself). +import assert from 'node:assert/strict'; +import test from 'node:test'; +import {execFileSync} from 'node:child_process'; +import {existsSync} from 'node:fs'; +import {mkdtemp,mkdir,writeFile,readFile,readdir,rm,rename,realpath} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {GitCheckpoints} from '../src/main/services/GitCheckpoints.ts'; +import {TerminalManager} from '../src/main/services/TerminalManager.ts'; +import {TerminalSessionStore} from '../src/main/services/TerminalSessionStore.ts'; +import {availableRegistry,fakeSpawner} from './helpers/terminal.mjs'; + +const NULL=process.platform==='win32'?'NUL':'/dev/null'; +const env={...process.env,GIT_CONFIG_GLOBAL:NULL,GIT_CONFIG_NOSYSTEM:'1'}; +async function fixture(t,{files={file:'saved\n'}}={}){ + const root=await realpath(await mkdtemp(join(tmpdir(),'ctty-cp-'))),project=join(root,'project'); + // Background pack writes of every store made here finish before the folder goes (else rm meets ENOTEMPTY). + const made=[];t.after(async()=>{await Promise.all(made.map(checkpoints=>checkpoints.idle().catch(()=>undefined)));await rm(root,{recursive:true,force:true});}); + const git=(...args)=>execFileSync('git',['-C',project,...args],{env,encoding:'utf8'}).trimEnd(); + await mkdir(project);git('init','-q','-b','main');git('config','user.name','Fixture');git('config','user.email','fixture@example.invalid');git('config','core.autocrlf','false'); + for(const [name,content] of Object.entries(files)){await mkdir(join(project,name,'..'),{recursive:true});await writeFile(join(project,name),content);} + git('add','-A');git('commit','-qm','base'); + const storage=join(root,'private','checkpoints.json'); + const make=(limits=50)=>{const checkpoints=new GitCheckpoints(text=>text,limits,storage);made.push(checkpoints);return checkpoints;}; + return{root,project,git,storage,make}; +} +const code=expected=>error=>error.code===expected; +const deferred=()=>{let resolve;const promise=new Promise(done=>resolve=done);return{promise,resolve};}; +const packsOf=async root=>(await readdir(join(root,'private','checkpoint-objects'),{recursive:true}).catch(()=>[])).filter(name=>name.endsWith('.pack')); + +test('a restore whose safety point cannot be taken (an orphan branch without a commit) is refused and deletes nothing',{timeout:30000},async t=>{ + const f=await fixture(t);const checkpoints=f.make(); + await checkpoints.capture('worker',f.project);const [point]=await checkpoints.list('worker',f.project); + f.git('checkout','-q','--orphan','x');await writeFile(join(f.project,'work.txt'),'unsaved work\n');f.git('add','work.txt'); + await assert.rejects(checkpoints.restore('worker',f.project,point.id),code('no-safety-point')); + assert.equal(await readFile(join(f.project,'work.txt'),'utf8'),'unsaved work\n'); + assert.equal((await checkpoints.list('worker',f.project)).length,1,'no before-restore point was claimed'); +}); + +test('the person\'s global filter driver never runs, even when .git/info/attributes names it during a capture or a restore',{timeout:30000},async t=>{ + const f=await fixture(t);const marker=join(f.root,'driver-ran'); + const home=join(f.root,'home');await mkdir(home); + await writeFile(join(home,'.gitconfig'),`[filter "lfs"]\n\tclean = sh -c 'touch ${marker}; echo CIPHER'\n\tsmudge = sh -c 'touch ${marker}; echo CIPHER'\n\trequired = true\n`); + const saved=process.env.HOME;process.env.HOME=home;t.after(()=>{process.env.HOME=saved;}); + const checkpoints=f.make();await checkpoints.capture('worker',f.project);const [point]=await checkpoints.list('worker',f.project); + // The attribute appears after the first filter check (an agent writing it in a race): the snapshot reads raw files + // and the second check drops the point. + const check=checkpoints.requireNoFilters.bind(checkpoints);let calls=0; + checkpoints.requireNoFilters=async(...args)=>{if(calls++===0){await check(...args);await writeFile(join(f.project,'.git','info','attributes'),'* filter=lfs\n');return;}return check(...args);}; + await writeFile(join(f.project,'file'),'changed\n'); + await assert.rejects(checkpoints.capture('worker',f.project),code('filters')); + assert.equal(existsSync(marker),false,'the global driver did not run during the snapshot'); + assert.equal((await checkpoints.list('worker',f.project)).length,1,'no point for a snapshot taken under a filter'); + // A restore that races the same way writes raw content and runs no driver. + checkpoints.requireNoFilters=async()=>undefined; + await checkpoints.restore('worker',f.project,point.id); + assert.equal(await readFile(join(f.project,'file'),'utf8'),'saved\n');assert.equal(existsSync(marker),false,'the global driver did not run during the restore'); +}); + +test('pack chains restart with a complete pack after 16 points, so no pack keeps growing',{timeout:120000},async t=>{ + const f=await fixture(t,{files:Object.fromEntries(Array.from({length:20},(_,index)=>[`f${index}`,`base ${index}\n`.repeat(50)]))});const checkpoints=f.make(); + for(let index=0;index<20;index++){await writeFile(join(f.project,`f${index}`),`turn ${index}\n`.repeat(50));await checkpoints.capture('worker',f.project);} + await checkpoints.idle(); + const rows=JSON.parse(await readFile(f.storage,'utf8')).sort((a,b)=>Number(a.id.split('/').pop().split('-')[0])-Number(b.id.split('/').pop().split('-')[0])); + const lengths=rows.map(row=>row.packs.length); + assert.deepEqual(lengths,[...Array.from({length:16},(_,index)=>index+1),1,2,3,4],'the chain restarts after 16 packs'); + assert.equal((await packsOf(f.root)).length,new Set(rows.flatMap(row=>row.packs)).size); +}); + +test('start-up keeps points of a folder that cannot be found (an unmounted volume) and the packs a set-aside registry names',{timeout:60000},async t=>{ + const f=await fixture(t);const first=f.make(); + await writeFile(join(f.project,'file'),'one\n');await first.capture('worker',f.project);await first.idle(); + const away=join(f.root,'away');await rename(f.project,away); + const second=f.make();assert.deepEqual(await second.list('other',join(f.root,'missing')),[]);await second.idle(); + await rename(away,f.project); + const third=f.make();assert.equal((await third.list('worker',f.project)).length,1,'the point survived the folder being away'); + const packs=await packsOf(f.root);assert.equal(packs.length,1); + // A registry that no longer parses is set aside; the packs it names stay for recovery by hand. + await writeFile(f.storage,(await readFile(f.storage,'utf8'))+'garbage'); + const fourth=f.make();assert.deepEqual(await fourth.list('worker',f.project),[]);await fourth.idle(); + assert.deepEqual(await packsOf(f.root),packs,'the set-aside registry\'s packs are kept'); +}); + +test('a transient registry read error is retried next time instead of disabling points for the run',{timeout:30000},async t=>{ + const f=await fixture(t);await mkdir(f.storage,{recursive:true});// reading a folder fails with EISDIR + const checkpoints=f.make(); + await assert.rejects(checkpoints.list('worker',f.project)); + await rm(f.storage,{recursive:true}); + await checkpoints.capture('worker',f.project);assert.equal((await checkpoints.list('worker',f.project)).length,1); +}); + +test('only saved cards known not to come back lose their points',{timeout:30000},async t=>{ + const f=await fixture(t);const checkpoints=f.make(); + for(const id of ['kept','gone','unknown']){await writeFile(join(f.project,'file'),id);await checkpoints.capture(id,f.project);} + await checkpoints.forgetSessions([]);await checkpoints.forgetSessions(['gone']);await checkpoints.idle(); + assert.deepEqual((await Promise.all(['kept','gone','unknown'].map(id=>checkpoints.list(id,f.project)))).map(rows=>rows.length),[1,0,1]); + assert.equal(f.git('for-each-ref','refs/canvastty/gone/'),''); +}); + +test('restorePersistedSessions names only saved cards that did not come back, and none when the saved cards cannot be trusted',{timeout:30000},async t=>{ + // Each manager has its own store on the same file, and a store's write uses one temporary name per process: every + // store's queued writes finish before the next manager starts or the file is replaced by hand. + const directory=await mkdtemp(join(tmpdir(),'ctty-cards-'));const stores=[];const settled=()=>Promise.all(stores.map(store=>store.flush().catch(()=>undefined))); + t.after(async()=>{await settled();await rm(directory,{recursive:true,force:true});}); + const make=(mode='continue')=>{const manager=new TerminalManager(()=>undefined,availableRegistry(),undefined,undefined,true,fakeSpawner([]));const store=new TerminalSessionStore(directory);stores.push(store);manager.configureSessionPersistence(store,mode);return manager;}; + const first=make();assert.deepEqual(await first.restorePersistedSessions(),[]); + const kept=first.create({provider:'codex',profile:'normal',cwd:process.cwd(),position:{x:0,y:0}}); + const dropped=first.create({provider:'codex',profile:'normal',cwd:process.cwd(),position:{x:0,y:0}}); + first.setRestore(dropped.id,false);await first.shutdown(); + const saved=await readFile(join(directory,'terminal-sessions.json'),'utf8'); + const second=make();assert.deepEqual(await second.restorePersistedSessions(),[dropped.id]);assert.ok(second.list().some(row=>row.id===kept.id));second.disposeAll();await settled(); + await writeFile(join(directory,'terminal-sessions.json'),saved); + assert.deepEqual(await make('off').restorePersistedSessions(),[],'restore turned off removes nothing'); + await settled();const newer=JSON.parse(saved);newer.version=99;await writeFile(join(directory,'terminal-sessions.json'),JSON.stringify(newer)); + const third=make();assert.deepEqual(await third.restorePersistedSessions(),[],'a newer version\'s cards');third.disposeAll(); +}); + +test('a preview runs beside captures (never ahead of a turn\'s point), holds the packs it reads, and shows what the restore does',{timeout:30000},async t=>{ + const f=await fixture(t);const checkpoints=f.make(); + await checkpoints.capture('worker',f.project);const [point]=await checkpoints.list('worker',f.project); + await writeFile(join(f.project,'file'),'changed‮\n'); + const entered=deferred(),release=deferred();const complete=checkpoints.complete.bind(checkpoints); + checkpoints.complete=async(...args)=>{entered.resolve();await release.promise;return complete(...args);}; + const preview=checkpoints.preview('worker',f.project,point.id);await entered.promise; + assert.ok(checkpoints.heldPacks.size>0,'the point\'s packs are held while the preview reads them'); + checkpoints.complete=complete; + const controller=new AbortController(),timer=setTimeout(()=>controller.abort(new Error('barrier timed out')),5000);t.after(()=>clearTimeout(timer)); + await checkpoints.capture('worker',f.project,controller.signal);assert.equal(controller.signal.aborted,false,'the capture did not wait for the preview'); + release.resolve();const result=await preview; + assert.match(result.text,/^-changed$/mu,'the current line goes');assert.match(result.text,/^\+saved$/mu,'the saved line comes back'); + assert.match(result.text,/^diff --git a\/\S+ b\//mu,'headers read from the current files (a/) to the point (b/), not swapped');assert.doesNotMatch(result.text,/^diff --git b\//mu); + assert.doesNotMatch(result.text,/‮/u,'bidirectional controls are removed'); + await checkpoints.idle();assert.equal(checkpoints.heldPacks.size,0); +}); + +test('a file renamed only in case since the point restores on a case-insensitive file system',{timeout:30000},async t=>{ + const f=await fixture(t,{files:{'Foo.txt':'saved\n'}}); + // Git leaves core.ignorecase unset on a case-sensitive file system (ext4), and `git config` then exits with 1. + let ignoreCase='false';try{ignoreCase=f.git('config','--type=bool','core.ignorecase');}catch{} + if(ignoreCase!=='true'){t.skip('the file system is case-sensitive');return;} + const checkpoints=f.make();await checkpoints.capture('worker',f.project);const [point]=await checkpoints.list('worker',f.project); + f.git('mv','-f','Foo.txt','foo.txt');await writeFile(join(f.project,'foo.txt'),'renamed and changed\n');f.git('add','foo.txt'); + await checkpoints.restore('worker',f.project,point.id); + assert.equal(await readFile(join(f.project,'Foo.txt'),'utf8'),'saved\n');assert.equal(f.git('ls-files'),'Foo.txt');assert.equal(f.git('status','--porcelain'),''); + const [safety]=await checkpoints.list('worker',f.project);assert.equal(safety.kind,'before-restore'); +}); + +test('a repository without core.ignorecase (as Git leaves it on ext4) restores; the missing key counts as false',{timeout:30000},async t=>{ + const f=await fixture(t,{files:{'Foo.txt':'saved\n'}}); + try{f.git('config','--unset','core.ignorecase');}catch{} + const checkpoints=f.make();await checkpoints.capture('worker',f.project);const [point]=await checkpoints.list('worker',f.project); + await writeFile(join(f.project,'Foo.txt'),'changed\n'); + await checkpoints.restore('worker',f.project,point.id); + assert.equal(await readFile(join(f.project,'Foo.txt'),'utf8'),'saved\n'); +}); diff --git a/tests/checkpoint-submodules.test.mjs b/tests/checkpoint-submodules.test.mjs new file mode 100644 index 00000000..c5c1b5fc --- /dev/null +++ b/tests/checkpoint-submodules.test.mjs @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import {execFileSync} from 'node:child_process'; +import {mkdtemp,mkdir,writeFile,readFile,rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {GitCheckpoints} from '../src/main/services/GitCheckpoints.ts'; + +async function fixture(t){ + const root=await mkdtemp(join(tmpdir(),'ctty-checkpoint-submodule-')); + const env={...process.env,GIT_CONFIG_GLOBAL:process.platform==='win32'?'NUL':'/dev/null',GIT_CONFIG_NOSYSTEM:'1',GIT_OPTIONAL_LOCKS:'0'}; + const git=(cwd,...args)=>execFileSync('git',args,{cwd,env,encoding:'utf8',stdio:['ignore','pipe','pipe']}).trimEnd(); + async function repository(name){const cwd=join(root,name);await mkdir(cwd);git(cwd,'init','-q');git(cwd,'config','user.name','Fixture');git(cwd,'config','user.email','fixture@example.invalid');git(cwd,'config','core.autocrlf','false');git(cwd,'config','core.eol','lf');await writeFile(join(cwd,'base.txt'),'base\n');git(cwd,'add','.');git(cwd,'commit','-qm','base');return cwd;} + const nested=await repository('nested'),project=await repository('project'),checkpoints=new GitCheckpoints(x=>x); + t.after(async()=>{await checkpoints.idle();await rm(root,{recursive:true,force:true});}); + const add=async()=>{git(project,'-c','protocol.file.allow=always','submodule','add','-q',nested,'module');git(join(project,'module'),'config','core.autocrlf','false');await writeFile(join(project,'module','base.txt'),'dirty nested contents\n');await writeFile(join(project,'module','untracked.txt'),'untracked nested contents\n');}; + return{root,project,git,checkpoints,add}; +} + +for(const kind of ['current index','saved worktree','saved index only'])test(`restore refuses ${kind} gitlink without mutating superproject or nested dirty/untracked content`,{timeout:30000},async t=>{ + const {project,git,checkpoints,add}=await fixture(t);await checkpoints.capture('worker',project);const [plain]=await checkpoints.list('worker',project);await add();let target=plain; + if(kind!=='current index'){ + await checkpoints.capture('worker',project);target=(await checkpoints.list('worker',project)).find(row=>row.id!==plain.id); + if(kind==='saved index only'){ + // Host snapshots store independent worktree and index objects. Keep the real saved gitlink index, + // but use the plain saved worktree to exercise its independent validation. + checkpoints.trusted.get(target.id).tree=checkpoints.trusted.get(plain.id).tree; + } + git(project,'rm','--cached','-q','-f','module'); + } + await writeFile(join(project,'base.txt'),'superproject still dirty\n');await writeFile(join(project,'unrelated.txt'),'unrelated untracked\n'); + const beforeIndex=await readFile(join(project,'.git','index')),beforeStatus=git(project,'status','--porcelain=v1'),beforeRefs=await checkpoints.list('worker',project),nestedStatus=git(join(project,'module'),'status','--porcelain=v1'); + await assert.rejects(checkpoints.restore('worker',project,target.id),/submodule.*not captured/i); + assert.deepEqual(await readFile(join(project,'.git','index')),beforeIndex);assert.equal(git(project,'status','--porcelain=v1'),beforeStatus);assert.deepEqual(await checkpoints.list('worker',project),beforeRefs,'no safety capture was created'); + assert.equal(await readFile(join(project,'base.txt'),'utf8'),'superproject still dirty\n');assert.equal(await readFile(join(project,'unrelated.txt'),'utf8'),'unrelated untracked\n'); + assert.equal(await readFile(join(project,'module','base.txt'),'utf8'),'dirty nested contents\n');assert.equal(await readFile(join(project,'module','untracked.txt'),'utf8'),'untracked nested contents\n');assert.equal(git(join(project,'module'),'status','--porcelain=v1'),nestedStatus); +}); + +test('gitlink preflight parses NUL records, not mode-looking text inside newline/tab filenames',{skip:process.platform==='win32'?'newline and tab file names are POSIX-only':false,timeout:30000},async t=>{ + const {project,git,checkpoints}=await fixture(t),file=join(project,'name\n160000 pretend\tfile');await writeFile(file,'saved\n');git(project,'add','.'); + await checkpoints.capture('worker',project);const [point]=await checkpoints.list('worker',project); + await writeFile(file,'changed\n');await checkpoints.restore('worker',project,point.id);assert.equal(await readFile(file,'utf8'),'saved\n'); +}); + +test('unverifiable gitlink inventory fails before any capture or restore',{timeout:30000},async t=>{ + const {project,checkpoints}=await fixture(t);await checkpoints.capture('worker',project);const [point]=await checkpoints.list('worker',project); + const original=checkpoints.git.bind(checkpoints),commands=[]; + checkpoints.git=async(cwd,args,options)=>{commands.push(args[0]);if(args[0]==='ls-files')throw new Error('fixture inventory exceeded output bound');return original(cwd,args,options);}; + await assert.rejects(checkpoints.restore('worker',project,point.id),/exceeded output bound/); + assert.ok(!commands.includes('stash')&&!commands.includes('restore'));assert.equal((await checkpoints.list('worker',project)).length,1); +}); + +test('ordinary tracked filenames containing 160000 remain restorable',{timeout:30000},async t=>{ + const {project,git,checkpoints}=await fixture(t),file=join(project,'ordinary 160000 file.txt');await writeFile(file,'saved ordinary text\n');git(project,'add','.');await checkpoints.capture('worker',project);const [point]=await checkpoints.list('worker',project); + await writeFile(file,'changed ordinary text\n');await checkpoints.restore('worker',project,point.id);assert.equal(await readFile(file,'utf8'),'saved ordinary text\n'); +}); diff --git a/tests/runtime-checkpoint-barrier.test.mjs b/tests/runtime-checkpoint-barrier.test.mjs new file mode 100644 index 00000000..aa23a342 --- /dev/null +++ b/tests/runtime-checkpoint-barrier.test.mjs @@ -0,0 +1,234 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import {mkdtemp,rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {execFileSync} from 'node:child_process'; +import {writeFile} from 'node:fs/promises'; +import {createConnection} from 'node:net'; +import {request as httpRequest} from 'node:http'; +import {RuntimeGateway} from '../src/main/services/agent-runtime/RuntimeGateway.ts'; +import {TerminalManager} from '../src/main/services/TerminalManager.ts'; +import {createCheckpointTurnBarrier} from '../src/main/services/checkpointTurnBarrier.ts'; +import {GitCheckpoints} from '../src/main/services/GitCheckpoints.ts'; +import {availableRegistry,fakeSpawner} from './helpers/terminal.mjs'; +import {AGENT_RUNTIME_ENV,CLAUDE_HTTP_HOOK} from '../src/agent-runtime/runtime-protocol.mjs'; +import {IMPLEMENTATIONS,SKIP_NATIVE,baseEnvironment,runOnce} from './native-helper-harness.mjs'; +const deferred=()=>{let resolve;const promise=new Promise(done=>resolve=done);return{promise,resolve};}; +const tick=()=>new Promise(resolve=>setImmediate(resolve)); +const windowsHostPath=join(process.cwd(),'build/windows-agent-pipe-host/canvastty-windows-agent-pipe-host.exe'); +async function fixture(t,options={}){ + const root=await mkdtemp(join(tmpdir(),'ctty-barrier-')),signals=[]; + const gateway=new RuntimeGateway({runtimeDirectory:root,windowsHostPath,onSignal:(id,signal)=>signals.push({id,signal}),...options}); + await gateway.start();t.after(async()=>{await gateway.close();await rm(root,{recursive:true,force:true});});return{gateway,signals}; +} +function send(capability,event='pre_llm_call',turnId='one',extra={}){ + const {address,...identity}=capability; + const socket=createConnection(address),done=new Promise(resolve=>{socket.on('data',()=>{socket.destroy();resolve(true);});socket.on('error',()=>resolve(false));socket.on('close',()=>resolve(false));}); + socket.on('connect',()=>socket.write(JSON.stringify({v:1,type:'lifecycle',...identity,state:'working',event,turnId,...extra})+'\n')); + return {socket,done}; +} +for(const implementation of IMPLEMENTATIONS)test(`Grok ${implementation.name} hook waits for the checkpoint before ack, with no decision hooks`,{skip:implementation.name==='native'?SKIP_NATIVE:false,timeout:5000},async t=>{ + const entered=deferred(),release=deferred();t.after(()=>release.resolve());let saved=false; + const f=await fixture(t,{beforeLifecycle:async(_id,_signal,cancel)=>{entered.resolve();await release.promise;cancel.throwIfAborted();saved=true;}}); + const cap=f.gateway.registerSession('grok-auto','grok',false,undefined,false); + const env=baseEnvironment(Object.fromEntries(Object.entries(AGENT_RUNTIME_ENV).map(([key,name])=>[name,cap[key]]))); + let exited=false;const running=runOnce(implementation.hook('working','pre_llm_call'),{env,input:'{"turn_id":"one"}'}).then(result=>{exited=true;return result;}); + await entered.promise;await tick();assert.equal(exited,false);assert.equal(saved,false);assert.equal(f.signals.length,0); + release.resolve();assert.equal((await running).code,0);assert.equal(saved,true);await tick();assert.equal(f.signals.length,1); +}); + +test('Claude HTTP turn-start acknowledgement uses the same checkpoint barrier',{skip:process.platform==='win32'?'HTTP lifecycle hooks are POSIX-only':false,timeout:5000},async t=>{ + const entered=deferred(),release=deferred();t.after(()=>release.resolve());const f=await fixture(t,{httpHooks:true,beforeLifecycle:async()=>{entered.resolve();await release.promise;}}); + const cap=f.gateway.registerSession('claude','claude'),url=new URL('/claude/v1/working/UserPromptSubmit',f.gateway.httpHookBase);let answered=false; + const done=new Promise((resolve,reject)=>{const req=httpRequest(url,{method:'POST',headers:{'content-type':'application/json',[CLAUDE_HTTP_HOOK.sessionHeader]:cap.terminalSessionId,[CLAUDE_HTTP_HOOK.capabilityHeader]:cap.capabilityToken}},res=>{res.resume();res.on('end',()=>{answered=true;resolve(res.statusCode);});});req.on('error',reject);req.end('{"prompt_id":"one"}');}); + await entered.promise;await tick();assert.equal(answered,false);release.resolve();assert.equal(await done,200); +}); + +test('unauthenticated and stale starts never checkpoint; timeout and throw are bounded',{timeout:5000},async t=>{ + const calls=[],entered=deferred();let mode='ok';const f=await fixture(t,{lifecycleBarrierMs:25,beforeLifecycle:async(_id,signal,cancel)=>{ + calls.push(signal.turnId);if(mode==='throw')throw new Error('fixture checkpoint failed');if(mode==='hold'){entered.resolve(cancel);await new Promise(()=>{});} + }});const cap=f.gateway.registerSession('grok','grok'); + assert.equal(await send(cap,'pre_llm_call','one',{capabilityToken:'x'.repeat(43)}).done,false);assert.deepEqual(calls,[]); + await send(cap,'pre_llm_call','one').done;await send(cap,'pre_llm_call','two').done;await send(cap,'pre_llm_call','one').done;assert.deepEqual(calls,['one','two']); + mode='throw';assert.equal(await send(cap,'pre_llm_call','three').done,true); + mode='hold';const held=send(cap,'pre_llm_call','four');const cancel=await entered.promise;assert.equal(await held.done,true);assert.equal(cancel.aborted,true); + const revoked=f.gateway.registerSession('revoked','grok');const pending=send(revoked);await tick();f.gateway.revokeTerminalSession('revoked');await pending.done; +}); + +for(const action of ['disconnect','revoke','supersede'])test(`an in-flight lifecycle barrier observes ${action} before any late checkpoint can complete`,{timeout:5000},async t=>{ + const entered=deferred(),release=deferred();t.after(()=>release.resolve());let saved=false; + const f=await fixture(t,{beforeLifecycle:async(_id,signal,cancel)=>{if(signal.turnId!=='old')return;entered.resolve(cancel);await release.promise;cancel.throwIfAborted();saved=true;}}); + const cap=f.gateway.registerSession('grok','grok'),old=send(cap,'pre_llm_call','old'),cancel=await entered.promise; + if(action==='disconnect')old.socket.destroy(); + else if(action==='revoke')f.gateway.revokeTerminalSession('grok'); + else await send(cap,'pre_llm_call','new').done; + await old.done; + if(!cancel.aborted)await new Promise(resolve=>cancel.addEventListener('abort',resolve,{once:true})); + assert.equal(cancel.aborted,true);release.resolve();await tick();assert.equal(saved,false); + if(action!=='disconnect')assert.ok(!f.signals.some(row=>row.signal.turnId==='old')); +}); + + +// The production callbacks (main/index.ts wires these into RuntimeGateway and onPermissionRequest). +function barrierFixture(t,capture,profile='yolo',options={}){ + const terminals=new TerminalManager(()=>undefined,availableRegistry(),undefined,undefined,true,fakeSpawner([]));t.after(()=>terminals.disposeAll()); + const worker=terminals.create({provider:'codex',profile,cwd:process.cwd(),position:{x:0,y:0}});terminals.resize(worker.id,80,24); + const barrier=createCheckpointTurnBarrier({terminals:()=>terminals,checkpoints:{capture},redact:text=>text,warn(){},...options}); + return{terminals,worker,barrier}; +} +const working={state:'working',event:'pre_llm_call'}; + +const status=(terminals,id,state)=>terminals.applyProviderSignal(id,{kind:'lifecycle',state}); +test('one rollback point per agent turn: the hook turn id or, without one, a submit while the card is not working',async t=>{ + let captures=0;const capture=async()=>{captures++;}; + const manual=barrierFixture(t,capture,'normal'); + assert.equal(await manual.barrier.beforeLifecycle(manual.worker.id,working,new AbortController().signal),true);assert.equal(captures,0); + const {terminals,worker,barrier}=barrierFixture(t,capture); + await barrier.beforeLifecycle(worker.id,{...working,turnId:'one'},new AbortController().signal);status(terminals,worker.id,'working'); + await barrier.beforeLifecycle(worker.id,{state:'working',event:'PostToolUse',turnId:'one'},new AbortController().signal); + assert.equal(await barrier.beforePermission(worker.id,new AbortController().signal),null);assert.equal(captures,1,'a permission inside a known turn takes none'); + assert.equal(terminals.inputChecked(worker.id,'1\r'),true,'answering a prompt or menu inside the turn'); + await barrier.beforeLifecycle(worker.id,{state:'working',event:'PostToolUse'},new AbortController().signal);assert.equal(captures,1,'an Enter inside a running turn is not a new turn'); + await barrier.beforeLifecycle(worker.id,{...working,turnId:'two'},new AbortController().signal);assert.equal(captures,2,'a new hook turn id is a new turn'); + barrier.onSignal(worker.id,'needs_approval');await barrier.beforeLifecycle(worker.id,{...working,turnId:'two'},new AbortController().signal);assert.equal(captures,2,'an approval stays in the turn'); + barrier.onSignal(worker.id,'idle');status(terminals,worker.id,'idle'); + assert.equal(terminals.inputChecked(worker.id,'typing without submit'),true); + assert.equal(terminals.inputChecked(worker.id,'next task\r'),true); + await barrier.beforeLifecycle(worker.id,working,new AbortController().signal);assert.equal(captures,3,'a turn end and a submitted prompt start the next turn'); + await barrier.beforeLifecycle(worker.id,working,new AbortController().signal);assert.equal(captures,3); + barrier.forget(worker.id);terminals.dispose(worker.id);await barrier.beforeLifecycle(worker.id,working,new AbortController().signal);assert.equal(captures,3,'a closed card gets none'); +}); + +test('a permission decision takes a point only when no turn is known, bounded in time',{timeout:5000},async t=>{ + let captures=0,aborted=null; + const {worker,barrier}=barrierFixture(t,async(_id,_cwd,signal)=>{captures++;await new Promise(resolve=>signal.addEventListener('abort',resolve,{once:true}));aborted=signal.aborted;throw signal.reason;},'yolo',{barrierMs:30}); + const started=Date.now();assert.equal(await barrier.beforePermission(worker.id,new AbortController().signal),null); + assert.ok(Date.now()-started<1000);await tick();assert.equal(aborted,true,'the capture is cancelled at the bound');assert.equal(captures,1); + assert.equal(await barrier.beforePermission(worker.id,new AbortController().signal),null);assert.equal(captures,1,'not retried within the turn'); +}); + +for(const mode of ['aborted','failed'])test(`a ${mode} rollback point is not retried within its turn (it would snapshot the turn's own edits)`,async t=>{ + const entered=deferred(),release=deferred();let captures=0; + const {terminals,worker,barrier}=barrierFixture(t,async(_id,_cwd,signal)=>{captures++;if(captures===1){entered.resolve();await release.promise;if(mode==='aborted')signal.throwIfAborted();throw new Error('capture failed');}}); + const cancel=new AbortController();const first=barrier.beforeLifecycle(worker.id,working,cancel.signal);await entered.promise; + // A barrier cancelled with no other barrier of the turn waiting cancels the capture (after the current callback). + if(mode==='aborted'){cancel.abort();await first;await tick();} + const post=barrier.beforeLifecycle(worker.id,{state:'working',event:'PostToolUse'},new AbortController().signal); + const permission=barrier.beforePermission(worker.id,new AbortController().signal); + release.resolve();await Promise.all([first,post,permission]);assert.equal(captures,1); + await barrier.beforeLifecycle(worker.id,working,new AbortController().signal);assert.equal(captures,1,'a settled failure still counts for this turn'); + assert.equal(terminals.inputChecked(worker.id,'new task\r'),true); + await barrier.beforeLifecycle(worker.id,working,new AbortController().signal);assert.equal(captures,2); +}); + +test('a turn that changed while its rollback point was taken is stale: the start is dropped and the permission denied',async t=>{ + const entered=deferred(),release=deferred(); + const {terminals,worker,barrier}=barrierFixture(t,async()=>{entered.resolve();await release.promise;}); + const permission=barrier.beforePermission(worker.id,new AbortController().signal); + const start=barrier.beforeLifecycle(worker.id,working,new AbortController().signal); + await entered.promise;assert.equal(terminals.inputChecked(worker.id,'something else\r'),true);release.resolve(); + assert.equal(await start,false);assert.match(await permission,/turn changed/u); + assert.equal(barrier.lifecycleGuard(worker.id)(),true,'the new turn is current'); + assert.equal(barrier.lifecycleGuard('missing')(),false); +}); + +test('a permission decision waits for the current turn\'s point in flight, and takes one for a stale turn',{timeout:5000},async t=>{ + const entered=deferred(),release=deferred();let captures=0,settled=false; + const {terminals,worker,barrier}=barrierFixture(t,async()=>{captures++;if(captures===1){entered.resolve();await release.promise;settled=true;}},'yolo',{barrierMs:2000}); + const start=barrier.beforeLifecycle(worker.id,{...working,turnId:'one'},new AbortController().signal);await entered.promise; + const permission=barrier.beforePermission(worker.id,new AbortController().signal);await tick(); + assert.equal(settled,false);release.resolve();assert.equal(await permission,null);assert.equal(settled,true,'the decision waited for the point');assert.equal(captures,1);await start; + // The person submitted a new prompt while the card was idle: the known entry is stale. + status(terminals,worker.id,'idle');assert.equal(terminals.inputChecked(worker.id,'next\r'),true); + assert.equal(await barrier.beforePermission(worker.id,new AbortController().signal),null);assert.equal(captures,2,'a stale turn gets its point at the permission'); +}); + +test('a reported turn id never revives a turn the person moved past',async t=>{ + let captures=0;const {terminals,worker,barrier}=barrierFixture(t,async()=>{captures++;}); + await barrier.beforeLifecycle(worker.id,{...working,turnId:'one'},new AbortController().signal);assert.equal(captures,1); + status(terminals,worker.id,'idle');assert.equal(terminals.inputChecked(worker.id,'next\r'),true); + await barrier.beforeLifecycle(worker.id,{...working,turnId:'one'},new AbortController().signal);assert.equal(captures,2); +}); + +test('the runtime gateway holds a working hook for the production barrier and drops the stale start',{timeout:5000},async t=>{ + const entered=deferred(),release=deferred();let captures=0; + const {terminals,worker,barrier}=barrierFixture(t,async()=>{captures++;entered.resolve();await release.promise;}); + const f=await fixture(t,{lifecycleGuard:id=>barrier.lifecycleGuard(id),beforeLifecycle:(id,signal,cancel)=>barrier.beforeLifecycle(id,signal,cancel)}); + const cap=f.gateway.registerSession(worker.id,'codex'); + const held=send(cap,'pre_llm_call','old');await entered.promise; + assert.equal(terminals.inputChecked(worker.id,'new task\r'),true);release.resolve(); + assert.equal(await held.done,true,'the hook is answered (fail-open)');await tick(); + assert.ok(!f.signals.some(row=>row.signal.turnId==='old'),'a stale turn start never reaches the app');assert.equal(captures,1); +}); + +test('a CLI that stays working (no turn-end hook): a new prompt without a turn id takes a new point once input was submitted',async t=>{ + let captures=0;const {terminals,worker,barrier}=barrierFixture(t,async()=>{captures++;}); + const prompt={state:'working',event:'UserPromptSubmit'}; + await barrier.beforeLifecycle(worker.id,prompt,new AbortController().signal);status(terminals,worker.id,'working');assert.equal(captures,1); + await barrier.beforeLifecycle(worker.id,prompt,new AbortController().signal);assert.equal(captures,1,'no input since the point: the same turn'); + assert.equal(terminals.inputChecked(worker.id,'next task\r'),true,'submitted while the card still shows working'); + await barrier.beforeLifecycle(worker.id,{state:'working',event:'PostToolUse'},new AbortController().signal);assert.equal(captures,1,'a tool event is not a new prompt'); + await barrier.beforeLifecycle(worker.id,prompt,new AbortController().signal);assert.equal(captures,2,'the new prompt gets its own point'); + status(terminals,worker.id,'needs_approval');assert.equal(terminals.inputChecked(worker.id,'1\r'),true,'answering an approval');status(terminals,worker.id,'working'); + await barrier.beforeLifecycle(worker.id,prompt,new AbortController().signal);assert.equal(captures,2,'an approval answer is not a new prompt'); +}); + +test('a failing HTTP lifecycle barrier still answers the hook',{skip:process.platform==='win32'?'HTTP lifecycle hooks are POSIX-only':false,timeout:5000},async t=>{ + const f=await fixture(t,{httpHooks:true,beforeLifecycle:async()=>undefined}); + f.gateway.waitBeforeLifecycle=async()=>{throw new Error('fixture barrier failed');}; + const cap=f.gateway.registerSession('claude','claude'),url=new URL('/claude/v1/working/UserPromptSubmit',f.gateway.httpHookBase); + const status=await new Promise((resolve,reject)=>{const req=httpRequest(url,{method:'POST',headers:{'content-type':'application/json',[CLAUDE_HTTP_HOOK.sessionHeader]:cap.terminalSessionId,[CLAUDE_HTTP_HOOK.capabilityHeader]:cap.capabilityToken}},res=>{res.resume();res.on('end',()=>resolve(res.statusCode));});req.on('error',reject);req.end('{"prompt_id":"one"}');}); + assert.equal(status,200); +}); + +// Live (OpenCode, auto profile): a send_to_agent follow-up into an idle card reports its turn start, then +// session.status:busy again for the same turn about 150-370 ms later, independent of the first hook's answer. A capture +// takes 200-300 ms, so the second signal can arrive while it runs: it supersedes the first barrier (RuntimeGateway), and +// a capture tied to that barrier was aborted ("AbortError: This operation was aborted"), leaving the turn without a point. +test('a follow-up prompt keeps its rollback point when a second lifecycle signal of the same turn arrives (real Git project)',{skip:process.platform==='win32'?'the lifecycle socket fixture is POSIX-only':false,timeout:15000},async t=>{ + const root=await mkdtemp(join(tmpdir(),'ctty-followup-')),project=join(root,'project'); + const git=(...args)=>execFileSync('git',args,{cwd:root,encoding:'utf8',env:{...process.env,GIT_CONFIG_GLOBAL:'/dev/null',GIT_CONFIG_NOSYSTEM:'1'}}).trim(); + git('init','-q',project);git('-C',project,'config','user.name','Fixture');git('-C',project,'config','user.email','fixture@example.invalid'); + await writeFile(join(project,'file.txt'),'base');git('-C',project,'add','.');git('-C',project,'commit','-qm','base'); + const checkpoints=new GitCheckpoints(text=>text,10,join(root,'registry.json')); + const terminals=new TerminalManager(()=>undefined,availableRegistry(),undefined,undefined,true,fakeSpawner([])); + t.after(async()=>{terminals.disposeAll();await checkpoints.idle();await rm(root,{recursive:true,force:true});}); + const worker=terminals.create({provider:'codex',profile:'auto',cwd:project,position:{x:0,y:0}});terminals.resize(worker.id,80,24); + // The follow-up turn's capture starts only once its second signal reached the barrier (deterministic overlap). + let gate=null;const warnings=[]; + const barrier=createCheckpointTurnBarrier({terminals:()=>terminals,redact:text=>text,warn:(message,detail)=>warnings.push(`${message} ${detail}`), + checkpoints:{capture:async(id,cwd,signal)=>{if(gate)await gate.promise;return checkpoints.capture(id,cwd,signal);}}}); + const entries=[]; + const f=await fixture(t,{lifecycleGuard:id=>barrier.lifecycleGuard(id), + beforeLifecycle:(id,signal,cancel)=>{entries.push(signal.turnId);if(gate&&entries.filter(turn=>turn==='two').length===2)gate.resolve();return barrier.beforeLifecycle(id,signal,cancel);}, + onSignal:(id,signal)=>{barrier.onSignal(id,signal.state);terminals.applyProviderSignal(id,{kind:'lifecycle',state:signal.state});}}); + const cap=f.gateway.registerSession(worker.id,'codex'); + assert.equal(await send(cap,'session.status:busy','one').done,true);await tick(); + assert.equal((await checkpoints.list(worker.id,project)).length,1,'the first turn has its point'); + await writeFile(join(project,'file.txt'),'first turn edit'); + assert.equal(await send(cap,'session.status:idle','one',{state:'idle'}).done,true);await tick(); + // send_to_agent: the follow-up prompt reaches the idle card through the one delivery path. + assert.deepEqual(await terminals.deliverInput(worker.id,'follow-up task\r'),{delivered:true}); + gate=deferred(); + const first=send(cap,'session.status:busy','two');await new Promise(resolve=>first.socket.on('connect',resolve)); + while(!entries.includes('two'))await tick(); + const second=send(cap,'session.status:busy','two'); + assert.equal(await first.done,true);assert.equal(await second.done,true); + await tick();await checkpoints.idle(); + assert.deepEqual(warnings,[],'no rollback point warning'); + const points=await checkpoints.list(worker.id,project); + assert.equal(points.length,2,'the follow-up turn has its own rollback point'); + assert.equal(git('-C',project,'show',`${points[0].id}:file.txt`),'first turn edit','taken before the follow-up turn'); +}); + +test('a superseded barrier of the same turn leaves the capture running; a new turn or the turn end cancels it',async t=>{ + const signals=[];const {terminals,worker,barrier}=barrierFixture(t,async(_id,_cwd,signal)=>{signals.push(signal);await new Promise(resolve=>signal.addEventListener('abort',resolve,{once:true}));},'yolo',{barrierMs:5000}); + const first=new AbortController();void barrier.beforeLifecycle(worker.id,{...working,turnId:'one'},first.signal); + first.abort();void barrier.beforeLifecycle(worker.id,{...working,turnId:'one'},new AbortController().signal);await tick();await tick(); + assert.equal(signals.length,1);assert.equal(signals[0].aborted,false,'the same turn\'s next signal waits for the same capture'); + void barrier.beforeLifecycle(worker.id,{...working,turnId:'two'},new AbortController().signal); + assert.equal(signals[0].aborted,true,'a new turn cancels the previous turn\'s capture');assert.equal(signals.length,2); + barrier.onSignal(worker.id,'idle');assert.equal(signals[1].aborted,true,'the turn end cancels its capture'); + terminals.dispose(worker.id); +}); diff --git a/tests/session-restore-v2.test.mjs b/tests/session-restore-v2.test.mjs index cc926ebe..23fe90ef 100644 --- a/tests/session-restore-v2.test.mjs +++ b/tests/session-restore-v2.test.mjs @@ -100,7 +100,7 @@ test("v2 slots are validated: thread ids, 4 KB plugin options and environment re }); assert.deepEqual(sessions, [ record("kept", { lastState: "failed", exitCode: 2, threadId: CONVERSATION, - options: { "good.plugin": { a: 1 } }, environment }), + options: { "good.plugin": { a: 1 } }, environment, isolatedEnvironmentScopes: { roots: [], ambiguous: true } }), record("flag-id", { restore: false }), record("hermes-id", { provider: "hermes", threadId: HERMES_CONVERSATION }), record("bad-hermes-id", { provider: "hermes" }) diff --git a/tests/worktree-checkpoints.test.mjs b/tests/worktree-checkpoints.test.mjs new file mode 100644 index 00000000..ae7a2b33 --- /dev/null +++ b/tests/worktree-checkpoints.test.mjs @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import {mkdtemp,readFile,readdir,rm,writeFile,mkdir} from "node:fs/promises"; +import {tmpdir} from "node:os"; +import {join} from "node:path"; +import {execFile} from "node:child_process"; +import {promisify} from "node:util"; +import test from "node:test"; +import {GitCheckpoints} from "../src/main/services/GitCheckpoints.ts"; +import {createHash} from "node:crypto"; +const exec=promisify(execFile); +const temp=()=>mkdtemp(join(tmpdir(),"canvastty-checkpoint-test-")); + +test("git checkpoints leave staged and unstaged edits intact, preview and restore both; retention prunes old captures safely",async()=>{ + const dir=await temp();const git=(...args)=>exec("git",["-C",dir,...args]);try { + await git("init");await git("config","user.name","Test");await git("config","user.email","test@example.invalid");await git("config","core.autocrlf","false"); + await writeFile(join(dir,"file.txt"),"base\n");await git("add","file.txt");await git("commit","-m","base"); + await writeFile(join(dir,"file.txt"),"staged\n");await git("add","file.txt");await writeFile(join(dir,"file.txt"),"unstaged\n"); + const before=(await git("diff","--cached")).stdout;const checkpoints=new GitCheckpoints(text=>text); + await checkpoints.capture("session",dir);assert.equal((await git("diff","--cached")).stdout,before);assert.equal(await readFile(join(dir,"file.txt"),"utf8"),"unstaged\n"); + const saved=(await checkpoints.list("session",dir))[0];await writeFile(join(dir,"file.txt"),"later\n"); + await git("update-ref",saved.id,"HEAD"); + const forged="refs/canvastty/session/9999999999999-agent-forged"; + await git("update-ref",forged,"HEAD"); + assert.equal((await checkpoints.list("session",dir)).some(entry=>entry.id===forged),false); + await assert.rejects(checkpoints.preview("session",dir,forged),/does not belong/); + assert.match((await checkpoints.preview("session",dir,saved.id)).text,/later/); + await git("update-ref",saved.id,"HEAD"); + await checkpoints.restore("session",dir,saved.id);assert.equal(await readFile(join(dir,"file.txt"),"utf8"),"unstaged\n");assert.equal((await git("diff","--cached")).stdout,before); + await assert.rejects(checkpoints.preview("foreign",dir,saved.id),/does not belong/); + const nongit=join(dir,"plain");await mkdir(nongit);assert.equal(await checkpoints.available(nongit),false); + await checkpoints.capture("other",dir); + const other=(await checkpoints.list("other",dir))[0]; + const first=(await checkpoints.list("session",dir))[0]; + for(let index=0;index<51;index++)await checkpoints.capture("session",dir); + const retained=await checkpoints.list("session",dir); + assert.equal(retained.length,50);assert.equal(retained.some(row=>row.id===first.id),false); + await assert.rejects(git("rev-parse","--verify",first.id)); + assert.equal((await checkpoints.list("other",dir))[0].id,other.id); + assert.equal(await readFile(join(dir,"file.txt"),"utf8"),"unstaged\n"); + assert.equal((await git("diff","--cached")).stdout,before); + } finally {await rm(dir,{recursive:true,force:true});} +}); + +test("rollback points are listed with their kind, and a restore first saves the current work as a before-restore point", async (t) => { + const run = promisify(execFile); + const root = await mkdtemp(join(tmpdir(), "ctty-checkpoint-kind-")); + t.after(() => rm(root, { recursive: true, force: true })); + const env = { ...process.env, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1" }; + const git = (...args) => run("git", ["-C", root, "-c", "user.name=t", "-c", "user.email=t@example.invalid", ...args], { env }); + await git("init", "-q"); await writeFile(join(root, "file.txt"), "one\n"); await git("add", "file.txt"); await git("commit", "-qm", "base"); + const checkpoints = new GitCheckpoints((text) => text, 50, join(root, ".app", "checkpoints.json")); + await writeFile(join(root, "file.txt"), "two\n"); + await checkpoints.capture("session", root); + const [point] = await checkpoints.list("session", root); + assert.equal(point.kind, "before-turn"); + assert.equal(Object.hasOwn(point, "label"), false, "no main-process text reaches the dialog"); + await writeFile(join(root, "file.txt"), "three\n"); + assert.deepEqual(await checkpoints.restore("session", root, point.id), { ok: true }); + assert.equal(await readFile(join(root, "file.txt"), "utf8"), "two\n"); + const kinds = (await checkpoints.list("session", root)).map((entry) => entry.kind); + assert.deepEqual(kinds.sort(), ["before-restore", "before-turn"]); + await assert.rejects(checkpoints.preview("session", root, "refs/canvastty/session/1-00000000-0000-0000-0000-000000000000"), + (error) => error.code === "unknown-checkpoint"); +}); From ac912f9faf10dd6b85f5d84b5738c03cc76e2b6e Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:29:50 +0000 Subject: [PATCH 28/28] feat(ui): add a Rollback points dialog to agent cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An agent card's options menu gets "Rollback points…" when its folder can have rollback points (an auto or YOLO agent at the top of a Git project) or once some exist; the options button shows only when its menu has an item. It opens a native modal dialog on the shared card-dialog frame, rendered by App inside the themed tree: the card's points with localized time and kind, and a preview of the selected one. - The preview lists changed files and a bounded diff (2000 lines and 500 file names, then "Show more"), classified by position, with a legend: "+" lines come back, "-" lines are removed. Added and removed lines use the success and danger tokens, headers are muted. Bidirectional controls are removed. - Restore needs an inline confirmation; the confirmation and the outcome line reserve the same three lines, so the dialog keeps its height. The main process answers with codes that the dialog shows as localized text; the preview is read again after a restore. While it is open the native Browser is hidden and canvas shortcuts do not fire; focus returns to the card's options button. The dialog and its Russian and English texts are a separate chunk (only the menu label is in lib/i18n.ts), preloaded once the app is idle and rendered directly like the change review, so the first open is not held back. --- docs/UI_CONTRACT.md | 1 + docs/UI_CONTRACT.ru.md | 1 + docs/UI_CONTRACT.zh-CN.md | 1 + src/renderer/src/App.tsx | 11 +- .../features/terminal/CheckpointsDialog.tsx | 302 ++++++++++++++++++ .../src/features/terminal/TerminalCard.tsx | 27 +- .../features/terminal/cardDialogSurfaces.ts | 1 + .../src/features/terminal/cardOptions.ts | 14 + .../src/features/terminal/checkpointsText.ts | 71 ++++ .../features/workspace/WorkspaceCanvas.tsx | 11 +- src/renderer/src/lib/i18n.ts | 2 + src/renderer/src/styles/app.css | 28 ++ tests/card-dialog-surfaces.test.mjs | 3 + tests/card-options.test.mjs | 26 ++ tests/checkpoints-dialog.test.mjs | 96 ++++++ tests/review-dialogs-ui-contract.test.mjs | 17 +- 16 files changed, 603 insertions(+), 9 deletions(-) create mode 100644 src/renderer/src/features/terminal/CheckpointsDialog.tsx create mode 100644 src/renderer/src/features/terminal/cardOptions.ts create mode 100644 src/renderer/src/features/terminal/checkpointsText.ts create mode 100644 tests/card-options.test.mjs create mode 100644 tests/checkpoints-dialog.test.mjs diff --git a/docs/UI_CONTRACT.md b/docs/UI_CONTRACT.md index 4f119c87..abc3d284 100644 --- a/docs/UI_CONTRACT.md +++ b/docs/UI_CONTRACT.md @@ -74,6 +74,7 @@ This contract preserves the approved MVP concept and prevents feature ownership - The canvas edge-pans RTS-style while the pointer rests within `56px` of a viewport edge over empty canvas; speed ramps linearly up to `900px/s` at the edge itself. Edge panning is off by default and enabled in Settings. Motion pauses over interactive surfaces (terminal cards, Home, controls) and while drag-panning. - Dialog close actions stay inside their own header/control row with a consistent inset; they never overlap a field, outline, or panel boundary. - A plugin card action that answers with a change review opens it as a modal dialog; nothing on a default surface opens it. Its header shows the review title and the card's title or folder, with the close action. Several subtasks are a wrapping segmented row, never a native select. The file list is virtualized, has one all-files checkbox and highlights the open file; a conflicting file offers Current version / Agent version as a segmented choice above both texts. The footer holds one primary action (Accept selected) and one secondary action (Reject subtask); accepting all files of the subtask, the whole task result, and rejecting the whole task live in an overflow menu. Every accept or reject is sent only after an inline confirmation in the footer (destructive confirmations use the danger color); there is no native confirm. Escape and a backdrop click step back from the menu, then the confirmation, then close the dialog, except while an accept or reject runs. The native Browser is hidden and canvas shortcuts cannot fire while it is open, focus returns to the card's options button, and it uses theme tokens, official Lucide assets and root `--ui-scale`. +- An agent card's options menu has **Rollback points…** when its folder can have them (an auto or YOLO agent at the top of a Git project) or once some exist; plain terminals never do. It opens a modal dialog with the card's title or folder, a list of the card's rollback points (localized date and time, and whether it was saved before an agent turn or before a restore) and a preview of the selected one (the files changed since then and a bounded diff whose added and removed lines use the success and danger tokens, marked when shortened). With none it says when they are saved. The footer has one primary action, Restore, which asks for an inline confirmation (danger color) before anything changes; there is no native confirm. The confirmation says that tracked files and the index go back while commits and the branch stay. Refusals from the main process arrive as codes and are shown as localized text (for example: close every agent in this folder first) in a line the footer reserves, so the dialog never changes height when an outcome appears. Escape and a backdrop click leave the confirmation first, then close the dialog, never during a restore. The native Browser is hidden and canvas shortcuts cannot fire while it is open, focus returns to the card's options button, and it uses theme tokens, official Lucide assets and root `--ui-scale`. - The radial quick launcher is opt-in under Agents → Quick launcher and defaults off for fresh profiles and existing profiles without an explicit preference. With it off, right-click opens the standard context menu. Disabling it preserves the configured action list. - Dropping local files onto a live terminal card pastes their quoted paths without pressing Enter. Quoting targets POSIX shells on Linux/macOS and PowerShell on Windows; nested shells and the Windows cmd.exe fallback may require manual quoting. Exited sessions and active title edits reject drops. Control characters and unavailable native paths produce an explicit error rather than partial input. diff --git a/docs/UI_CONTRACT.ru.md b/docs/UI_CONTRACT.ru.md index f34ed096..7cd8675f 100644 --- a/docs/UI_CONTRACT.ru.md +++ b/docs/UI_CONTRACT.ru.md @@ -60,6 +60,7 @@ - Canvas двигается RTS-style, пока указатель находится в пределах `56px` от viewport edge над пустым canvas; скорость линейно возрастает до `900px/s` на самой границе. Edge panning выключен по умолчанию и включается в Settings. Motion останавливается над interactive surfaces и во время drag-pan. - Dialog close actions остаются внутри своей header/control row с одинаковым inset и не перекрывают field, outline или panel boundary. - Card action плагина, который отвечает обзором изменений, открывает его модальным диалогом; на поверхностях по умолчанию ничего его не открывает. В заголовке — название обзора и название или папка окна, рядом действие закрытия. Несколько подзадач показываются переносящимся segmented-рядом, никогда не native select. Список файлов виртуализирован, имеет один чекбокс «все файлы» и подсвечивает открытый файл; у конфликтного файла выбор «Текущая версия» / «Версия агента» — segmented-переключатель над обоими текстами. В footer одно основное действие («Принять выбранные») и одно второстепенное («Отклонить подзадачу»); принять все файлы подзадачи, принять результат всей задачи и отклонить всю задачу — в overflow-меню. Каждое принятие или отклонение отправляется только после встроенного подтверждения в footer (разрушительные подтверждения — цветом danger); native confirm не используется. Escape и клик по фону по очереди закрывают меню, подтверждение, затем диалог, кроме времени, пока выполняется принятие или отклонение. Пока диалог открыт, native Browser скрыт и canvas shortcuts не срабатывают; фокус возвращается на кнопку параметров окна; диалог использует theme tokens, официальные ассеты Lucide и root `--ui-scale`. +- В меню параметров окна агента есть **Точки отката…**, если в его папке они возможны (агент в режиме auto или YOLO в корне Git-проекта) или уже есть; у обычного терминала его нет. Оно открывает модальный диалог с названием или папкой окна, списком точек отката окна (локализованные дата и время и пометка «перед ходом агента» или «перед восстановлением») и предпросмотром выбранной (файлы, изменённые с тех пор, и ограниченный diff, где добавленные и удалённые строки окрашены токенами success и danger, с пометкой о сокращении). Если точек нет, диалог объясняет, когда они сохраняются. В footer одно основное действие — «Восстановить», которое сначала просит встроенное подтверждение (цветом danger); native confirm не используется. Подтверждение говорит, что вернутся отслеживаемые файлы и индекс, а коммиты и ветка не изменятся. Отказы главного процесса приходят кодами и показываются локализованным текстом (например: сначала завершите все агенты в этой папке) в строке, зарезервированной в footer, поэтому высота диалога при появлении результата не меняется. Escape и клик по фону сначала закрывают подтверждение, затем диалог, но не во время восстановления. Пока диалог открыт, native Browser скрыт и canvas shortcuts не срабатывают; фокус возвращается на кнопку параметров окна; диалог использует theme tokens, официальные ассеты Lucide и root `--ui-scale`. ## Приёмочные проверки diff --git a/docs/UI_CONTRACT.zh-CN.md b/docs/UI_CONTRACT.zh-CN.md index 8858a567..d96dff14 100644 --- a/docs/UI_CONTRACT.zh-CN.md +++ b/docs/UI_CONTRACT.zh-CN.md @@ -59,6 +59,7 @@ - 指针停在空白 canvas 的 viewport edge `56px` 范围内时,canvas 以 RTS 风格 edge-pan;速度线性增加,到边缘达到 `900px/s`。Edge panning 默认关闭,在 Settings 启用。指针位于 interactive surface 或正在 drag-pan 时暂停。 - Dialog close action 留在自己的 header/control row 内,使用一致 inset,不覆盖 field、outline 或 panel boundary。 - 插件 card action 若以变更审查作答,会以模态对话框打开;默认界面上没有任何入口打开它。标题栏显示审查标题和卡片标题或文件夹,以及关闭操作。多个子任务以可换行的 segmented 行显示,绝不使用原生 select。文件列表虚拟化,只有一个“全部文件”复选框,并高亮当前打开的文件;冲突文件在两侧文本上方以 segmented 选择“当前版本”/“代理版本”。footer 只有一个主操作(接受所选)和一个次操作(拒绝子任务);接受子任务全部文件、接受整个任务结果、拒绝整个任务放在 overflow 菜单中。每次接受或拒绝都只在 footer 中的内联确认之后发送(破坏性确认使用 danger 颜色),不使用原生 confirm。Escape 与点击背景依次关闭菜单、确认、对话框;接受或拒绝执行期间除外。对话框打开时 native Browser 隐藏、canvas shortcuts 不会触发,关闭后焦点回到卡片的选项按钮;对话框使用 theme tokens、官方 Lucide 资源和根 `--ui-scale`。 +- 当代理卡片的文件夹可以有回滚点(auto 或 YOLO 代理位于 Git 项目根目录)或已经存在回滚点时,其选项菜单中有 **回滚点…**;普通终端没有。它打开一个模态对话框,显示卡片标题或文件夹、该卡片的回滚点列表(本地化的日期时间,以及保存于代理回合之前还是恢复之前)和所选回滚点的预览(此后更改的文件和有上限的 diff,新增与删除的行使用 success 与 danger tokens,被截断时会标明)。没有回滚点时说明何时会保存。footer 只有一个主操作“恢复”,在任何更改之前要求内联确认(danger 颜色),不使用原生 confirm。确认文字说明:恢复的是已跟踪文件和索引,提交与分支保持不变。主进程的拒绝以代码返回,并在 footer 预留的一行中显示为本地化文本(例如:请先关闭此文件夹中的所有代理),因此结果出现时对话框高度不变。Escape 与点击背景先关闭确认,再关闭对话框,恢复进行中除外。对话框打开时 native Browser 隐藏、canvas shortcuts 不会触发,关闭后焦点回到卡片的选项按钮;对话框使用 theme tokens、官方 Lucide 资源和根 `--ui-scale`。 ## 验收检查 diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index b8647d51..c2103257 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -72,7 +72,7 @@ import { GitRiskNotice } from "./features/terminal/GitRiskNotice"; import { UsageHistoryPanel } from "./features/usage/UsageHistoryPanel"; import { WorkspaceCanvas } from "./features/workspace/WorkspaceCanvas"; import type { PluginReviewRequest } from "./features/terminal/pluginReviewUiState"; -import { pluginReviewHostSurface } from "./features/terminal/cardDialogSurfaces"; +import { checkpointsHostSurface, pluginReviewHostSurface } from "./features/terminal/cardDialogSurfaces"; import { preloadWhenIdle, usePreloadedSurface } from "./lib/preloadedSurface"; import { createCameraStore } from "./features/workspace/cameraStore"; import { isPixelSkinThemeId } from "./features/skins/skinCatalog"; @@ -371,11 +371,14 @@ export function App(): React.JSX.Element { const [overlayHost, setOverlayHost] = useState(null); // The change review a card action answered with (rendered here, inside the themed tree, as a modal dialog). const [pluginReview, setPluginReview] = useState(null); + // A card's rollback points dialog (its "Rollback points…" menu item). + const [checkpointsFor, setCheckpointsFor] = useState<{ sessionId: string; cardTitle: string } | null>(null); // Out of the startup bundle, preloaded once the app is idle, rendered without a Suspense wait (see preloadedSurface). const PluginReviewHost = usePreloadedSurface(pluginReviewHostSurface, pluginReview !== null); - useEffect(() => preloadWhenIdle([pluginReviewHostSurface]), []); + const CheckpointsHost = usePreloadedSurface(checkpointsHostSurface, checkpointsFor !== null); + useEffect(() => preloadWhenIdle([pluginReviewHostSurface, checkpointsHostSurface]), []); // A modal dialog owns the keyboard: no canvas shortcut fires underneath it and the native Browser is hidden. - const modalDialogOpen = shortcutReferenceOpen || pluginReview !== null; + const modalDialogOpen = shortcutReferenceOpen || pluginReview !== null || checkpointsFor !== null; const [homeEditDraft, setHomeEditDraft] = useState(null); const [activeSessionId, setActiveSessionId] = useState(null); const [browserSelected, setBrowserSelected] = useState(false); @@ -2010,6 +2013,7 @@ export function App(): React.JSX.Element { onOpenBrowser={openBrowserFromUi} onOpenTerminalUrl={(url) => void openTerminalUrl(url)} onPluginReview={setPluginReview} + onOpenCheckpoints={(sessionId, cardTitle) => setCheckpointsFor({ sessionId, cardTitle })} onRequestMedia={requestMedia} onRemoveMedia={removeMedia} onHomeLayoutChange={changeHomeLayout} @@ -2091,6 +2095,7 @@ export function App(): React.JSX.Element { {shortcutReferenceOpen && setShortcutReferenceOpen(false)} />} + {checkpointsFor && CheckpointsHost && setCheckpointsFor(null)} />} {pluginReview && PluginReviewHost && } = { + "not-git-root": "errorNotGitRoot", + "unknown-checkpoint": "errorUnknown", + submodules: "errorSubmodules", + filters: "errorFilters", + "worktree-conflict": "errorWorktreeConflict", + "no-safety-point": "errorNoSafetyPoint", + "restore-in-progress": "errorRestoreInProgress", + "agent-running": "errorAgentRunning", + "pending-launch": "errorPendingLaunch", + "isolated-environment": "errorIsolatedEnvironment", + "missing-session": "errorMissingSession", + failed: "errorFailed" +}; + +type Preview = { text: string; changedFiles: string[]; truncated: boolean }; + +/** Diff lines rendered at first, and added by each "Show more" (a preview can be 2 MiB: tens of thousands of lines). */ +const DIFF_LINES_STEP = 2000; +/** File names listed at most. */ +const MAX_FILES_SHOWN = 500; +// Bidirectional formatting controls can make a shown diff or file name read differently from what it is. +const BIDI_CONTROLS = /[\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/gu; + +/** A call that failed outright: the person sees the localized generic refusal, the details go to the log. */ +function failedCall(error: unknown): { ok: false; code: "failed" } { + console.error("Rollback points request failed", error); + return { ok: false, code: "failed" }; +} + +/** + * A card's rollback points (docs/agent-orchestration.md): rendered by App inside the themed tree as a native modal + * dialog. Restoring needs an inline confirmation; main-process refusals arrive as codes and are localized here. + */ +export function CheckpointsDialog({ sessionId, cardTitle, locale, onClose }: CheckpointsDialogProps): React.JSX.Element { + const dialogRef = useRef(null); + const mountedRef = useRef(true); + const previewRequestRef = useRef(0); + const [entries, setEntries] = useState(null); + const [selectedId, setSelectedId] = useState(null); + const [preview, setPreview] = useState(null); + const [previewLoading, setPreviewLoading] = useState(false); + const [confirming, setConfirming] = useState(false); + const [restoring, setRestoring] = useState(false); + const [error, setError] = useState(null); + const [restored, setRestored] = useState(false); + // Bumped after a restore: the files changed, so the selected point's preview is read again. + const [previewNonce, setPreviewNonce] = useState(0); + const [shownLines, setShownLines] = useState(DIFF_LINES_STEP); + const text = checkpointsText(locale); + const time = new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "medium" }); + + const load = async (): Promise => { + const result = await window.canvasTTY.terminal.checkpoints(sessionId).catch(failedCall); + if (!mountedRef.current) return; + if (!result.ok) { + setEntries([]); + setError(result.code); + return; + } + setEntries(result.checkpoints); + setSelectedId((current) => result.checkpoints.some((entry) => entry.id === current) ? current : result.checkpoints[0]?.id ?? null); + }; + + useEffect(() => { + mountedRef.current = true; + const element = dialogRef.current; + if (element && !element.open) element.showModal(); + void load(); + return () => { + mountedRef.current = false; + element?.close(); + }; + }, [sessionId]); + + useEffect(() => { + setPreview(null); + setConfirming(false); + if (!selectedId) return; + const request = ++previewRequestRef.current; + setPreviewLoading(true); + void window.canvasTTY.terminal.previewCheckpoint(sessionId, selectedId) + .catch(failedCall) + .then((result) => { + if (!mountedRef.current || request !== previewRequestRef.current) return; + setPreviewLoading(false); + setShownLines(DIFF_LINES_STEP); + if (result.ok) { + setPreview({ text: result.text.replace(BIDI_CONTROLS, ""), changedFiles: result.changedFiles.map((path) => path.replace(BIDI_CONTROLS, "")), + truncated: result.truncated }); + } + else setError(result.code); + }); + }, [sessionId, selectedId, previewNonce]); + + const restore = async (): Promise => { + if (!selectedId || restoring) return; + setRestoring(true); + setError(null); + setRestored(false); + const result = await window.canvasTTY.terminal.restoreCheckpoint(sessionId, selectedId) + .catch(failedCall); + if (!mountedRef.current) return; + setRestoring(false); + setConfirming(false); + if (result.ok) { + setRestored(true); + await load(); + if (mountedRef.current) setPreviewNonce((nonce) => nonce + 1); + } else setError(result.code); + }; + + // Escape and the backdrop step back from the confirmation first; nothing closes while files are restored. + const dismiss = (): void => { + if (restoring) return; + if (confirming) setConfirming(false); + else onClose(); + }; + + const selected = entries?.find((entry) => entry.id === selectedId) ?? null; + const diff = preview?.text ? diffLines(preview.text, shownLines) : null; + + return ( + { event.preventDefault(); dismiss(); }} + onMouseDown={(event) => { if (event.target === event.currentTarget) dismiss(); }}> +
+
+
+

{text.title}

+

{cardTitle}

+
+ +
+ + {entries === null && ( +

+ {text.loading} +

+ )} + {entries !== null && entries.length === 0 && !error && ( +
+ {text.empty} +

{text.emptyDetail}

+
+ )} + {entries !== null && entries.length > 0 && ( +
+
    + {entries.map((entry) => { + const active = entry.id === selectedId; + return ( +
  1. + +
  2. + ); + })} +
+
+ {previewLoading && ( +

+ {text.loading} +

+ )} + {preview && <> +

{text.changedFiles}

+ {preview.changedFiles.length === 0 + ?

{text.noChanges}

+ : ( +
    + {preview.changedFiles.slice(0, MAX_FILES_SHOWN).map((path) => ( +
  • {path}
  • + ))} + {preview.changedFiles.length > MAX_FILES_SHOWN && ( +
  • {text.moreFiles(preview.changedFiles.length - MAX_FILES_SHOWN)}
  • + )} +
+ )} + {diff && <> +

{text.diffLegend}

+
{diff.lines}
+ {diff.hidden > 0 && ( + + )} + } + {preview.truncated &&

{text.truncated}

} + } +
+
+ )} + + {confirming ? ( +
+

{text.confirm}

+
+ {restoring && } + + +
+
+ ) : ( +
+ {/* The outcome sits in the footer's reserved line, so the dialog never grows when it appears. */} +

+ {error ? text[ERROR_TEXT[error]] : restored ? text.restored : ""} +

+
+ +
+
+ )} +
+
+ ); +} + +export type DiffLineKind = "meta" | "hunk" | "add" | "del" | null; + +/** + * The kind of each line of a unified diff, by position: a file's header block (from "diff --git" to its first "@@") is + * meta, so "--- a/x" there is a header while "--x" inside a hunk is a removed line. + */ +export function diffLineKinds(lines: readonly string[]): DiffLineKind[] { + let header = false; + return lines.map((line) => { + if (line.startsWith("diff --git ")) { header = true; return "meta"; } + if (line.startsWith("@@")) { header = false; return "hunk"; } + if (header) return "meta"; + if (line.startsWith("+")) return "add"; + if (line.startsWith("-")) return "del"; + if (line.startsWith("\\")) return "meta"; + return null; + }); +} + +/** + * The first `limit` lines of a unified diff (from the current files to the point: what the restore does), the ones + * the restore brings back and removes in the theme's success and danger colors; `hidden` lines are not rendered yet. + */ +function diffLines(diff: string, limit: number): { lines: React.JSX.Element[]; hidden: number } { + const all = diff.split("\n"); + const lines = all.slice(0, limit); + const kinds = diffLineKinds(lines); + return { + hidden: all.length - lines.length, + lines: lines.map((line, index) => ( + + {line}{index < all.length - 1 ? "\n" : ""} + + )) + }; +} + +/** App's entry to the dialog (loaded with this module on demand); focus returns to the card's options button on close. */ +export function CheckpointsHost({ request, locale, onClose }: { + request: { sessionId: string; cardTitle: string }; + locale: LocaleId; + onClose(): void; +}): React.JSX.Element { + return ( + { + onClose(); + focusCardOptions(request.sessionId); + }} /> + ); +} diff --git a/src/renderer/src/features/terminal/TerminalCard.tsx b/src/renderer/src/features/terminal/TerminalCard.tsx index 1bc3eabc..96028679 100644 --- a/src/renderer/src/features/terminal/TerminalCard.tsx +++ b/src/renderer/src/features/terminal/TerminalCard.tsx @@ -28,6 +28,7 @@ import { UiIcon } from "../../components/UiIcon"; import { t } from "../../lib/i18n"; import { matchesShortcut } from "../../lib/shortcuts"; import { GitRiskNotice } from "./GitRiskNotice"; +import { cardHasOptions, checkpointsItemShown } from "./cardOptions"; import type { PluginReviewRequest } from "./pluginReviewUiState"; import { isCustomTerminalBorderSkinId, terminalBorderSkinFallback } from "../../lib/skinStyles"; import { sessionStatusLabel } from "../../lib/sessionStatus"; @@ -117,6 +118,8 @@ interface TerminalCardProps { onOpenUrl(url: string): void; /** A card action answered with a change review (App shows it in the review dialog). */ onPluginReview(request: PluginReviewRequest): void; + /** The agent card's "Rollback points…" menu item (App shows the dialog). */ + onOpenCheckpoints(sessionId: string, cardTitle: string): void; } interface DragState { @@ -189,6 +192,7 @@ function TerminalCardView({ onDispose, onOpenUrl, onPluginReview, + onOpenCheckpoints, restoreEnabled = false }: TerminalCardProps): React.JSX.Element { const borderSkin = terminalBorderSkinFallback(selectedBorderSkin); @@ -246,7 +250,21 @@ function TerminalCardView({ const pluginDecorations = usePluginCardDecorations(session); const [actionRunning, setActionRunning] = useState(false); const [actionToast, setActionToast] = useState(null); - const hasOptions = restoreEnabled || pluginDecorations.actions.length > 0; + const isAgentCard = session.provider !== "terminal"; + // Rollback points are taken for auto/YOLO agents at the top of a Git project: the item shows when the folder can + // have them or once some exist. Asked when the card mounts (so the options button never opens an empty menu) and + // again when the menu opens. + const [checkpointsShown, setCheckpointsShown] = useState(false); + const hasOptions = cardHasOptions({ restoreEnabled, checkpointsShown: isAgentCard && checkpointsShown, + pluginActions: pluginDecorations.actions.length }); + useEffect(() => { + if (!isAgentCard) return; + let current = true; + window.canvasTTY.terminal.checkpoints(session.id) + .then((result) => { if (current) setCheckpointsShown(checkpointsItemShown(result, session.profile)); }) + .catch(() => { if (current) setCheckpointsShown(false); }); + return () => { current = false; }; + }, [optionsOpen, isAgentCard, session.id, session.profile, session.cwd]); const runPluginAction = (action: PluginCardActionEntry): void => { setOptionsOpen(false); setActionRunning(true); @@ -1328,6 +1346,13 @@ function TerminalCardView({ {t(locale, "cardSkipRestore")} )} + {isAgentCard && checkpointsShown && ( + + )} {pluginDecorations.actions.map((action, index) => (