From 4f5b717d27a6c5798b955e023f68631686f38a0a Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:16:29 +0000 Subject: [PATCH 01/15] fix(isolation): treat Kimi Code's KIMI_CODE_HOME as Kimi's own home The Kimi provider runs Kimi Code CLI, which keeps its configuration and OAuth sign-in in KIMI_CODE_HOME (default ~/.kimi-code); the older kimi-cli uses KIMI_SHARE_DIR (default ~/.kimi). The isolation layer only knew KIMI_HOME, which neither CLI reads. So: - a Kimi launch whose home was moved with KIMI_CODE_HOME (an account home a launch plugin hands it, e.g. one sign-in of an account pool) did not get that folder as its own writable state, and - other CLIs inside the layer could read ~/.kimi-code, where Kimi Code's refresh token lives. Kimi's own folders are now KIMI_CODE_HOME (or ~/.kimi-code), KIMI_SHARE_DIR (or ~/.kimi), KIMI_HOME when set, and the usual named folders; all three variables move Kimi's home. Nothing else changes. --- docs/agent-orchestration.md | 2 +- src/main/services/isolation/isolationPaths.ts | 14 +++++++++---- tests/agent-isolation.test.mjs | 21 +++++++++++++++++++ 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/docs/agent-orchestration.md b/docs/agent-orchestration.md index d53c4e212..48fab8b3a 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`), 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`, Kimi's `config.toml` in `~/.kimi-code` or `~/.kimi`, 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); - 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/isolationPaths.ts b/src/main/services/isolation/isolationPaths.ts index 5cdcb9a5e..e2ef706d7 100644 --- a/src/main/services/isolation/isolationPaths.ts +++ b/src/main/services/isolation/isolationPaths.ts @@ -87,8 +87,13 @@ function providerFolders(provider: ProviderId, env: IsolationPathInput["env"], h return { folders: named("opencode"), files: [] }; case "hermes": return { folders: [env.HERMES_HOME && isAbsolute(env.HERMES_HOME) ? env.HERMES_HOME : join(home, ".hermes"), ...named("hermes")], files: [] }; - case "kimi": - return { folders: [env.KIMI_HOME && isAbsolute(env.KIMI_HOME) ? env.KIMI_HOME : join(home, ".kimi"), ...named("kimi")], files: [] }; + case "kimi": { + // Kimi Code CLI keeps its sign-in in KIMI_CODE_HOME (default ~/.kimi-code); the older kimi-cli in + // KIMI_SHARE_DIR (default ~/.kimi). KIMI_HOME is kept as it was read before. + const moved = (name: string): string | null => { const value = env[name]; return value && isAbsolute(value) ? value : null; }; + return { folders: [moved("KIMI_CODE_HOME") ?? join(home, ".kimi-code"), moved("KIMI_SHARE_DIR") ?? join(home, ".kimi"), + ...(moved("KIMI_HOME") ? [moved("KIMI_HOME")!] : []), ...named("kimi")], files: [] }; + } case "cursor": return { folders: [...named("cursor"), ...named("cursor-agent")], files: [] }; case "antigravity": @@ -106,7 +111,7 @@ const HOME_VARIABLES: Partial> = { claude: ["CLAUDE_CONFIG_DIR"], grok: ["GROK_HOME"], hermes: ["HERMES_HOME"], - kimi: ["KIMI_HOME"], + kimi: ["KIMI_CODE_HOME", "KIMI_SHARE_DIR", "KIMI_HOME"], opencode: ["OPENCODE_CONFIG_DIR"], qwen: ["QWEN_HOME"] }; @@ -267,7 +272,8 @@ export function isolationPaths(input: IsolationPathInput): IsolationPaths { // 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")] : []), - ...(input.provider === "codex" ? ownFolders.map((folder) => join(folder, "config.toml")) : []), + // Codex's and Kimi's config.toml hold their permission rules and hooks. + ...(input.provider === "codex" || input.provider === "kimi" ? ownFolders.map((folder) => join(folder, "config.toml")) : []), ...(input.provider === "claude" ? ownFolders.flatMap((folder) => [join(folder, "settings.json"), join(folder, "settings.local.json")]) : []), ...configSources.jsonFiles ]), diff --git a/tests/agent-isolation.test.mjs b/tests/agent-isolation.test.mjs index 89b2266aa..c1497ef2b 100644 --- a/tests/agent-isolation.test.mjs +++ b/tests/agent-isolation.test.mjs @@ -180,6 +180,27 @@ 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("the paths: Kimi Code's own home (KIMI_CODE_HOME, default ~/.kimi-code) is Kimi's, and no other CLI's", async (t) => { + const w = await world(t); + const pool = join(w.userData, "account-homes", "kimi-a"); + const env = { ...w.env, KIMI_CODE_HOME: pool }; + const kimi = isolationPaths({ provider: "kimi", cwd: w.project, sessionTemp: join(w.temp, "s"), env, userDataPath: w.userData, sessionId: "s1" }); + assert.ok(kimi.writable.includes(pool) && !kimi.unreadable.includes(pool), "a moved Kimi Code home stays Kimi's own"); + const codex = isolationPaths({ provider: "codex", cwd: w.project, sessionTemp: join(w.temp, "s"), env, userDataPath: w.userData, sessionId: "s1" }); + assert.ok(codex.unreadable.includes(pool), "another CLI cannot read a moved Kimi Code home"); + const plain = isolationPaths({ provider: "codex", cwd: w.project, sessionTemp: join(w.temp, "s"), env: w.env, userDataPath: w.userData, sessionId: "s1" }); + assert.ok(plain.unreadable.includes(join(w.home, ".kimi-code")), "Kimi Code's default sign-in folder is hidden from other CLIs"); + assert.ok(plain.unreadable.includes(join(w.home, ".kimi")), "and the older kimi-cli's"); + const share = isolationPaths({ provider: "kimi", cwd: w.project, sessionTemp: join(w.temp, "s"), env: { ...w.env, KIMI_SHARE_DIR: join(w.base, "kimi-share") }, userDataPath: w.userData, sessionId: "s1" }); + assert.ok(share.writable.includes(join(w.base, "kimi-share")), "KIMI_SHARE_DIR moves the older kimi-cli's home"); + // Kimi's config.toml holds its permission rules and hooks: its folders are writable, but that file stays the person's. + const own = isolationPaths({ provider: "kimi", cwd: w.project, sessionTemp: join(w.temp, "s"), env: w.env, userDataPath: w.userData, sessionId: "s1" }); + assert.ok(own.writable.includes(join(w.home, ".kimi-code"))); + assert.ok(own.protectedWrites.includes(join(w.home, ".kimi-code", "config.toml")), "Kimi Code's permission settings stay the person's"); + assert.ok(own.protectedWrites.includes(join(w.home, ".kimi", "config.toml")), "and kimi-cli's"); + assert.ok(kimi.protectedWrites.includes(join(pool, "config.toml")), "and a moved home's"); +}); + 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); From ed4f2b48cca8cecc06e9aeb556785ea87261825b Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:16:29 +0000 Subject: [PATCH 02/15] fix(runtime): give Grok and Kimi cards in a contributed home their status hooks and tools Grok Build reads hooks only from GROK_HOME/hooks and Kimi Code only from KIMI_CODE_HOME/config.toml; Kimi also reads its MCP servers (mcp.json) and permission rules there. CanvasTTY wrote its lifecycle hook overlays and Kimi's browser/agents configuration into the default homes only, so a card a launch contributor started in its own account home (an account pool) ran without working/idle/needs_approval status and without the canvastty_browser / canvastty_agents tools. When a launch contributor sets GROK_HOME / KIMI_CODE_HOME to an account home below its own data folder (/plugin-data/, from the host's record), the launch pipeline reports it as the launch's account home and the same overlays are written there instead, shared by the cards in that home and removed when the last one ends. A variable pointing anywhere else, including another plugin's data, leaves the launch as before. Inside plugin data every folder from plugin-data down and every file CanvasTTY writes must be real (no symbolic link), or the launch is refused. The overlays keep the existing lock, journal, backup and marker rules, so the person's own settings are restored byte for byte. The checks live in accountHomes.ts and are shared by both overlays. Claude Code, Codex, Qwen Code and OpenCode take their hooks from the launch's arguments or environment and already work in any home. --- src/main/index.ts | 5 +- src/main/services/LaunchPipeline.ts | 12 +- src/main/services/PluginManager.ts | 3 +- src/main/services/TerminalManager.ts | 7 + src/main/services/accountHomes.ts | 104 ++++ .../agent-browser/AgentBrowserBridge.ts | 8 +- .../services/agent-browser/ProviderLaunch.ts | 63 ++- .../agent-runtime/AgentRuntimeBridge.ts | 4 +- .../agent-runtime/ProviderRuntimeLaunch.ts | 77 ++- tests/agent-runtime-account-homes.test.mjs | 453 ++++++++++++++++++ 10 files changed, 705 insertions(+), 31 deletions(-) create mode 100644 src/main/services/accountHomes.ts create mode 100644 tests/agent-runtime-account-homes.test.mjs diff --git a/src/main/index.ts b/src/main/index.ts index b9832dcee..361f34775 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -518,7 +518,8 @@ async function initializeServices(ipc: IpcRegistrar): Promise { providerClis, runtimeDirectory, hermesHomeDirectory, - kimiHomeDirectory + kimiHomeDirectory, + accountHomesRoot: join(userDataPath, "plugin-data") }); // Off the startup path: the first Kimi launch then finds the probe answered instead of blocking on it. const bridge = agentBrowserBridge; @@ -568,6 +569,8 @@ async function initializeServices(ipc: IpcRegistrar): Promise { openCodePluginPath, hermesHomeDirectory, kimiHomeDirectory, + // Account homes a launch contributor starts Grok/Kimi with live in plugin data (ProviderRuntimeLaunch.ownAccountHome). + accountHomesRoot: join(userDataPath, "plugin-data"), recoverOnStart: true, coreHooksEnabled: settings.get().agentLifecycleHooksEnabled, permissionGate: helpers.permissionGate, diff --git a/src/main/services/LaunchPipeline.ts b/src/main/services/LaunchPipeline.ts index 439370a56..aa74f268f 100644 --- a/src/main/services/LaunchPipeline.ts +++ b/src/main/services/LaunchPipeline.ts @@ -12,6 +12,7 @@ import type { import { claudeCoreSettingsKey, coreOwnedLaunchArgument, parseInlineSettings } from "./terminalLaunch.ts"; import { MAX_PLUGIN_SLOT_BYTES } from "./TerminalSessionStore.ts"; import { LaunchRefusal } from "./launchRefusal.ts"; +import { contributedHookHome } from "./accountHomes.ts"; import { MAX_INSPECTED_CONFIG_BYTES, parseJsonc, readInspectedFile } from "./inspectedConfig.ts"; /** A trusted plugin service that declared launch options (PluginManager.launchContributors). */ @@ -22,6 +23,8 @@ export interface LaunchContributor { launch: PluginServiceLaunch; /** The plugin holds the `secrets` permission, so `secretEnv` may name its secrets. */ secrets: boolean; + /** `/plugin-data/`, the host's record (never the plugin's answer); account homes live below it. */ + dataDir?: string; } export interface LaunchPipelineDependencies { @@ -73,6 +76,11 @@ export type PreparedLaunch = envSources: Record; /** A contributor runs the agent on another model than its vendor's: "auto" becomes accept-edits. */ thirdPartyModel: boolean; + /** + * Grok/Kimi: the home (`GROK_HOME` / `KIMI_CODE_HOME`) the contributor that set it handed this launch inside its own + * data folder (contributedHookHome); CanvasTTY writes that CLI's temporary configuration there. + */ + accountHome?: string; cleanup(): Promise; } | { ok: false; reason: string }; @@ -267,6 +275,7 @@ export class LaunchPipeline { const args: string[] = []; const secrets: string[] = []; let thirdPartyModel = false; + let accountHome: string | undefined; for (const answer of answers) { if ("refuse" in answer) return refuse(answer.refuse); } @@ -313,6 +322,7 @@ export class LaunchPipeline { const conflict = claim(key); if (conflict) return refuse(conflict); env[key] = expand(value); + accountHome = contributedHookHome(context.provider, contributor.dataDir, key, env[key]) ?? accountHome; } for (const [key, secretKey] of Object.entries(contribution.secretEnv)) { const conflict = claim(key); @@ -327,7 +337,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, ...(accountHome ? { accountHome } : {}), cleanup }; } private async ask( diff --git a/src/main/services/PluginManager.ts b/src/main/services/PluginManager.ts index 0e86e7192..aa8306292 100644 --- a/src/main/services/PluginManager.ts +++ b/src/main/services/PluginManager.ts @@ -654,7 +654,8 @@ export class PluginManager { pluginName: manifest.name, serviceId: service.id, launch: structuredClone(service.launch), - secrets: manifest.permissions.includes("secrets") + secrets: manifest.permissions.includes("secrets"), + dataDir: join(this.dataRoot, plugin.manifest.id) }); } return contributors; diff --git a/src/main/services/TerminalManager.ts b/src/main/services/TerminalManager.ts index 7688dcc1e..04ad4bc8b 100644 --- a/src/main/services/TerminalManager.ts +++ b/src/main/services/TerminalManager.ts @@ -53,6 +53,7 @@ import { type ControlConnection } from "./agent-control/controlCapabilities.ts"; import { codexTrustArguments, mergeOpenCodeLaunchEnvironment } from "./agent-runtime/ProviderRuntimeLaunch.ts"; +import { accountHookHome } from "./accountHomes.ts"; import { openCodeProjectFolderEnvironment } from "./openCodeConfig.ts"; import { onDiskPath } from "./onDiskPath.ts"; import { RESULT_CAPTURE_PROVIDERS } from "./resultCapture.ts"; @@ -1730,10 +1731,15 @@ export class TerminalManager { args: contribution?.args ?? [], cwd } : undefined; + // Grok and Kimi read their hooks (Kimi also its MCP servers) from their home: the launch's own account home (inside + // the data folder of the plugin that set it) gets CanvasTTY's temporary configuration there. + const hookHome = accountHookHome(provider, contribution?.env); + const accountHome = hookHome !== undefined && hookHome === contribution?.accountHome ? hookHome : undefined; const agentRuntime = provider === "terminal" ? null : this.agentRuntime?.prepareLaunch({ terminalSessionId: id, provider, cwd, ...(captureResult ? { captureResult: true } : {}), + ...(accountHome ? { accountHome } : {}), ...(claudeHttp ? { claudeHttp } : {}), ...(answerCaptureGrantExpiresAt === undefined ? {} : { answerCaptureGrantExpiresAt }) }) ?? null; let pluginTools: string[] = []; @@ -1765,6 +1771,7 @@ export class TerminalManager { terminalSessionId: id, provider, cwd, + ...(accountHome ? { accountHome } : {}), ...(bridged ? { includeOrchestration: true, orchestrationTools: [...(role === "orchestrator" ? ORCHESTRATION_TOOL_NAMES : []), ...pluginTools] diff --git a/src/main/services/accountHomes.ts b/src/main/services/accountHomes.ts new file mode 100644 index 000000000..673639c0b --- /dev/null +++ b/src/main/services/accountHomes.ts @@ -0,0 +1,104 @@ +import { lstatSync, mkdirSync, realpathSync } from "node:fs"; +import { isAbsolute, join, relative, resolve, sep } from "node:path"; +import type { ProviderId } from "../../shared/contracts.ts"; + +// Account homes: a CLI home a launch contributor starts Grok or Kimi with (an account of an account pool). Grok Build +// and Kimi Code read their hooks, and Kimi its MCP servers and permission rules, only from that home, so CanvasTTY's +// temporary overlays go there for such a launch. These are the rules for when a home counts as the launch's own. + +/** The variable that moves the home whose configuration Grok Build / Kimi Code read. */ +const ACCOUNT_HOME_VARIABLES: Readonly>> = { grok: "GROK_HOME", kimi: "KIMI_CODE_HOME" }; + +/** A folder path without trailing separators (`/a/b/` names the same folder as `/a/b`); a root stays as it is. */ +export function withoutTrailingSeparator(path: string): string { + const trimmed = path.replace(/[\\/]+$/u, ""); + return trimmed === "" || /^[A-Za-z]:$/u.test(trimmed) ? path : trimmed; +} + +/** The path segments of `path` strictly 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 home a launch contribution started a Grok or Kimi launch with (its `GROK_HOME` / `KIMI_CODE_HOME`); undefined for + * other providers or when the contribution leaves the home alone. `KIMI_SHARE_DIR` (the older kimi-cli) is not it. + */ +export function accountHookHome(provider: ProviderId, contributedEnvironment: Readonly> | undefined): string | undefined { + const name = ACCOUNT_HOME_VARIABLES[provider]; + const value = name ? contributedEnvironment?.[name] : undefined; + return typeof value === "string" && value.length > 0 ? withoutTrailingSeparator(value) : undefined; +} + +/** + * The account home one contributor handed a Grok/Kimi launch: `value` of the provider's home variable `key` when it is an + * exact absolute path strictly inside that contributor's own data folder. `dataDir` is the host's record of the + * contributor (`/plugin-data/`), never the plugin's answer, so a plugin can only name a folder of + * its own data. + */ +export function contributedHookHome(provider: ProviderId, dataDir: string | undefined, key: string, value: string): string | undefined { + if (!dataDir || !isAbsolute(dataDir) || ACCOUNT_HOME_VARIABLES[provider] !== key) return undefined; + const folder = withoutTrailingSeparator(value); + if (!isAbsolute(folder) || resolve(folder) !== folder) return undefined; + return segmentsBelow(resolve(dataDir), folder) ? folder : undefined; +} + +/** + * The account home an overlay may be written into, or null for the default home (the launch is then exactly as before). + * Only an exact absolute folder at least `/` deep inside `root` (`/plugin-data`), other than the + * default home, counts. Inside the root every folder from the root down must be a real folder (a missing one is created, + * private) and none of `written` (names in the home CanvasTTY writes) a symbolic link, or this throws and the launch is + * refused: CanvasTTY never writes through a link into another place. + */ +export function ownedAccountHome(options: { + root: string | undefined; + candidate: string | undefined; + defaultHome: string; + written: readonly string[]; + label: string; +}): string | null { + const { root, candidate } = options; + if (!root || candidate === undefined) return null; + const home = withoutTrailingSeparator(candidate); + if (!isAbsolute(home) || resolve(home) !== home || home === options.defaultHome) return null; + const segments = segmentsBelow(resolve(root), home); + if (!segments || segments.length < 2) return null; + try { + let path = resolve(root); + let canonical = realpathSync.native(path); + for (const segment of ["", ...segments]) { + if (segment) { + path = join(path, segment); + canonical = join(canonical, segment); + } + // A new account's home (or the folders above it in the plugin's data) may not exist yet: the CLI would create it + // on its first run. One private folder at a time, never recursively, so no link is followed while creating it. + if (segment && !exists(path)) mkdirSync(path, { mode: 0o700 }); + 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 ${options.label} account home is missing or crosses a symbolic link; CanvasTTY did not write its configuration there.`); + } + for (const name of options.written) { + let link = false; + try { + link = lstatSync(join(home, name)).isSymbolicLink(); + } catch { + continue; + } + if (link) throw new Error(`The ${options.label} account home holds a symbolic link where CanvasTTY writes its configuration.`); + } + return home; +} + +function exists(path: string): boolean { + try { + lstatSync(path); + return true; + } catch { + return false; + } +} diff --git a/src/main/services/agent-browser/AgentBrowserBridge.ts b/src/main/services/agent-browser/AgentBrowserBridge.ts index 06fa4f53b..8dae4ac6e 100644 --- a/src/main/services/agent-browser/AgentBrowserBridge.ts +++ b/src/main/services/agent-browser/AgentBrowserBridge.ts @@ -14,6 +14,8 @@ export interface PrepareAgentBrowserLaunchInput { includeOrchestration?: boolean; /** The canvastty_agents tools this session may use (default: the core tools). */ orchestrationTools?: readonly string[]; + /** Kimi: the launch's own account home (`KIMI_CODE_HOME` inside the contributing plugin's data), for its config. */ + accountHome?: string; } export interface PreparedAgentBrowserPtyLaunch { @@ -71,7 +73,8 @@ export class AgentBrowserBridge implements AgentBrowserLaunchCoordinator { try { providerLaunch = this.providers.prepare(input.provider, capability.connectionId, { ...(input.includeOrchestration ? { orchestration: true } : {}), - ...(input.orchestrationTools ? { orchestrationTools: input.orchestrationTools } : {}) + ...(input.orchestrationTools ? { orchestrationTools: input.orchestrationTools } : {}), + ...(input.accountHome ? { accountHome: input.accountHome } : {}) }); } catch (error) { this.gateway.revokeTerminalSession(input.terminalSessionId); @@ -93,7 +96,8 @@ export class AgentBrowserBridge implements AgentBrowserLaunchCoordinator { const providerLaunch = this.providers.prepare(input.provider, connectionId, { orchestration: true, browser: false, - ...(input.orchestrationTools ? { orchestrationTools: input.orchestrationTools } : {}) + ...(input.orchestrationTools ? { orchestrationTools: input.orchestrationTools } : {}), + ...(input.accountHome ? { accountHome: input.accountHome } : {}) }); return this.launchWith(providerLaunch, "", connectionId, () => undefined, {}); } diff --git a/src/main/services/agent-browser/ProviderLaunch.ts b/src/main/services/agent-browser/ProviderLaunch.ts index f59cbe12c..ac7ee3daf 100644 --- a/src/main/services/agent-browser/ProviderLaunch.ts +++ b/src/main/services/agent-browser/ProviderLaunch.ts @@ -22,6 +22,7 @@ import { resolveHermesHomeDirectory } from "../hermesConfig.ts"; import { openCodeBrowserEnvironment } from "../openCodeConfig.ts"; +import { ownedAccountHome } from "../accountHomes.ts"; import { acquireConfigurationLock, atomicWrite, @@ -74,6 +75,11 @@ export interface ProviderLaunchOptions { /** The same probe off the main thread, for warmKimiProbe; defaults to the sync probe's answer when only that is given. */ probeKimiPerRunConfigAsync?: (cli: AvailableProviderCli) => Promise; environment?: Readonly>; + /** + * `/plugin-data`: where an account home (the `KIMI_CODE_HOME` a launch contributor started Kimi with) may be + * for Kimi's temporary configuration to be written into it (accountHomes.ts). Without it no account home gets it. + */ + accountHomesRoot?: string; } interface KimiLockHooks extends ConfigurationLockHooks { @@ -95,8 +101,8 @@ export class ProviderLaunchAdapters { private kimiWarmed: { executable: string; generation: number; result: boolean } | null = null; private kimiWarming: Promise | null = null; private kimiGeneration = 0; - private kimiConfiguration: KimiTemporaryConfiguration | null = null; - private kimiConfigurationUsers = 0; + /** The temporary Kimi configuration per Kimi home (the default home and account homes) and its launches. */ + private readonly kimiConfigurations = new Map(); private hermesConfiguration: HermesTemporaryConfiguration | null = null; private hermesConfigurationUsers = 0; @@ -108,6 +114,9 @@ export class ProviderLaunchAdapters { this.kimiHomeDirectory = validateKimiHomeDirectory( options.kimiHomeDirectory ?? join(homedir(), ".kimi-code") ); + if (options.accountHomesRoot !== undefined && !isAbsolute(options.accountHomesRoot)) { + throw new Error("Account homes root must be absolute."); + } this.environment = options.environment ?? process.env; // The probe runs Kimi with this adapter's environment (a fake HOME, an account's), never the app's own. this.probe = options.probeKimiPerRunConfig ?? ((cli) => probeKimiPerRunMcpConfig(cli, undefined, this.environment)); @@ -148,9 +157,11 @@ export class ProviderLaunchAdapters { /** * `orchestrationTools`: the canvastty_agents tools this session may use (default: the core tools). `browser: false` - * (browser access off) leaves canvastty_browser out, so only canvastty_agents is attached. + * (browser access off) leaves canvastty_browser out, so only canvastty_agents is attached. `accountHome` (Kimi only): + * the `KIMI_CODE_HOME` a launch contributor started this launch with; Kimi's temporary configuration goes there when + * it is an account home (accountHomes.ts), since that is the only home this Kimi reads. */ - prepare(provider: AgentProvider, connectionId: string, options?: { orchestration?: boolean; orchestrationTools?: readonly string[]; browser?: boolean }): PreparedProviderLaunch { + prepare(provider: AgentProvider, connectionId: string, options?: { orchestration?: boolean; orchestrationTools?: readonly string[]; browser?: boolean; accountHome?: string }): PreparedProviderLaunch { const providerCli = this.providerClis.get(provider); if (providerCli.state === "unavailable") throw new Error(providerCli.diagnostic); const orchestrationHelper = orchestrationHelperFor(this.options, options); @@ -192,7 +203,14 @@ export class ProviderLaunchAdapters { releaseConfiguration: this.acquireHermesConfiguration(browserHelper, orchestrationHelper) }; } - return this.prepareKimi(connectionId, browserHelper, orchestrationHelper); + const home = ownedAccountHome({ + root: this.options.accountHomesRoot, + candidate: options?.accountHome, + defaultHome: this.kimiHomeDirectory, + written: KIMI_WRITTEN, + label: "Kimi" + }) ?? this.kimiHomeDirectory; + return this.prepareKimi(connectionId, browserHelper, orchestrationHelper, home); } recoverKimiConfiguration(): void { @@ -234,7 +252,7 @@ export class ProviderLaunchAdapters { }; } - private prepareKimi(connectionId: string, browserHelper: StdioHelperLaunch | null, orchestrationHelper?: StdioHelperLaunch): PreparedProviderLaunch { + private prepareKimi(connectionId: string, browserHelper: StdioHelperLaunch | null, orchestrationHelper: StdioHelperLaunch | undefined, home: string): PreparedProviderLaunch { const kimiCli = this.providerClis.get("kimi"); if (kimiCli.state === "unavailable") throw new Error(kimiCli.diagnostic); if (this.kimiProbedExecutable !== kimiCli.executable) { @@ -252,6 +270,7 @@ export class ProviderLaunchAdapters { // The per-run document carries orchestration per launch, so the shared // fallback configuration only tracks it when mcp.json is actually mutated. const releaseShared = this.acquireKimiConfiguration( + home, !supportsPerRun, browserHelper, supportsPerRun ? undefined : orchestrationHelper @@ -286,6 +305,7 @@ export class ProviderLaunchAdapters { } private acquireKimiConfiguration( + home: string, includeMcpEntry: boolean, browserHelper: StdioHelperLaunch | null, orchestrationHelper?: StdioHelperLaunch @@ -293,32 +313,34 @@ export class ProviderLaunchAdapters { const requiresOrchestration = includeMcpEntry && orchestrationHelper !== undefined; // The shared mcp.json carries the browser entry only while browser access is on. const includeBrowser = includeMcpEntry && browserHelper !== null; - if (!this.kimiConfiguration) { - this.kimiConfiguration = KimiTemporaryConfiguration.begin({ - homeDirectory: this.kimiHomeDirectory, + let shared = this.kimiConfigurations.get(home); + if (!shared) { + shared = { configuration: KimiTemporaryConfiguration.begin({ + homeDirectory: home, helper: includeBrowser ? browserHelper : null, includeMcpEntry, ...(requiresOrchestration && orchestrationHelper ? { orchestrationHelper } : {}) - }); - } else if (this.kimiConfiguration.includeMcpEntry !== includeMcpEntry) { + }), users: 0 }; + this.kimiConfigurations.set(home, shared); + } else if (shared.configuration.includeMcpEntry !== includeMcpEntry) { throw new Error("Kimi MCP capability changed while temporary configuration is active."); - } else if (includeMcpEntry && this.kimiConfiguration.hasBrowserEntry !== includeBrowser) { + } else if (includeMcpEntry && shared.configuration.hasBrowserEntry !== includeBrowser) { throw new Error("Kimi browser access cannot change while a temporary Kimi configuration is active."); - } else if (requiresOrchestration && !this.kimiConfiguration.hasOrchestrationEntry) { + } else if (requiresOrchestration && !shared.configuration.hasOrchestrationEntry) { // Mirrors the Hermes guard: never silently drop the canvastty_agents // entry an orchestrator needs because a plain launch owns the config. throw new Error("Kimi MCP orchestration cannot be enabled while a temporary Kimi configuration is active."); } - this.kimiConfigurationUsers += 1; + const owned = shared; + owned.users += 1; let released = false; return () => { if (released) return; released = true; - this.kimiConfigurationUsers -= 1; - if (this.kimiConfigurationUsers !== 0) return; - const configuration = this.kimiConfiguration; - this.kimiConfiguration = null; - configuration?.cleanup(); + owned.users -= 1; + if (owned.users !== 0) return; + if (this.kimiConfigurations.get(home) === owned) this.kimiConfigurations.delete(home); + owned.configuration.cleanup(); }; } } @@ -867,6 +889,9 @@ function parseJournal(raw: string, paths: ReturnType): Recover return value as unknown as RecoveryJournal; } +/** What KimiTemporaryConfiguration writes in a Kimi home (kimiPaths); none may be a symbolic link in an account home. */ +const KIMI_WRITTEN = ["mcp.json", "config.toml", ".canvastty-browser.lock", ".canvastty-browser-recovery.json", ".canvastty-browser-backups"]; + function kimiPaths(homeDirectory: string) { return { mcp: join(homeDirectory, "mcp.json"), diff --git a/src/main/services/agent-runtime/AgentRuntimeBridge.ts b/src/main/services/agent-runtime/AgentRuntimeBridge.ts index 18b6ef2d2..161ac208f 100644 --- a/src/main/services/agent-runtime/AgentRuntimeBridge.ts +++ b/src/main/services/agent-runtime/AgentRuntimeBridge.ts @@ -23,6 +23,8 @@ export interface PrepareAgentRuntimeLaunchInput { decisions?: boolean; /** Claude Code: what decides whether its lifecycle hooks may go over HTTP (see ClaudeHttpHooks.ts). */ claudeHttp?: ClaudeHttpLaunchFacts; + /** Grok/Kimi: the CLI home a launch contributor started the launch with (`accountHookHome`), for its status hooks. */ + accountHome?: string; } export interface PreparedAgentRuntimePtyLaunch { @@ -92,7 +94,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, input.accountHome); } 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 a945496fe..29ceaed4b 100644 --- a/src/main/services/agent-runtime/ProviderRuntimeLaunch.ts +++ b/src/main/services/agent-runtime/ProviderRuntimeLaunch.ts @@ -11,6 +11,7 @@ import { dirname, isAbsolute, join, win32 } from "node:path"; import { pathToFileURL } from "node:url"; import { lazyRequire } from "../../lazyRequire.ts"; import { isOwnLifecycleHookText } from "../agentHelpers.ts"; +import { ownedAccountHome } from "../accountHomes.ts"; import { atomicWrite, ensurePrivateDirectory, @@ -93,6 +94,11 @@ export interface ProviderRuntimeLaunchOptions { pluginHooks?: RuntimePluginHookSource; /** The decision hook (permission-gate.mjs); without it decision hooks are never installed. */ permissionGate?: RuntimeHookHelperLaunch; + /** + * `/plugin-data`: the only place an account home (a CLI home a launch contributor started the launch with) + * may be for the Grok/Kimi lifecycle overlays to be written into it. Without it no account home gets them. + */ + accountHomesRoot?: string; } export interface PreparedProviderRuntimeLaunch { @@ -101,6 +107,10 @@ export interface PreparedProviderRuntimeLaunch { releaseConfiguration(): void; } +const KIMI_JOURNAL = ".canvastty-runtime-kimi.json"; +const RUNTIME_BACKUPS = ".canvastty-runtime-backups"; +const GROK_HOOKS_FILE = "canvastty-runtime-hooks.json"; + const HOOK_PROVIDERS: ReadonlySet = new Set(["claude", "codex", "qwen", "opencode", "kimi", "hermes", "grok", "omp", "pi"]); export class ProviderRuntimeLaunchAdapters { @@ -120,6 +130,8 @@ export class ProviderRuntimeLaunchAdapters { private grokOverlay: GrokRuntimeHooks | null = null; private grokOverlaySignature: string | null = null; private grokUsers = 0; + /** Grok/Kimi overlays in account homes, by provider and home; each is removed when its last launch ends. */ + private readonly accountOverlays = new Map(); constructor(options: ProviderRuntimeLaunchOptions) { validateHelper(options.helper); @@ -129,6 +141,9 @@ export class ProviderRuntimeLaunchAdapters { if (!isAbsolute(options.openCodePluginPath)) { throw new Error("OpenCode lifecycle plugin path must be absolute."); } + if (options.accountHomesRoot !== undefined && !isAbsolute(options.accountHomesRoot)) { + throw new Error("Account homes root must be absolute."); + } if (options.permissionGate) { validateHelper(options.permissionGate); // Either Electron running one absolute script, or the native helper's permission-gate subcommand. @@ -175,6 +190,10 @@ 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. + * `accountHome` (Grok and Kimi only, see accountHomes.ts): the CLI home a launch contributor started this launch + * with. When it is an account home inside `accountHomesRoot`, the Grok/Kimi overlay goes there instead of into the + * default home, since that is the only home the CLI reads. Claude Code, Codex, Qwen Code and OpenCode take their hooks + * from the launch's arguments or environment, so they work in any home already. */ prepare( provider: AgentProvider, @@ -182,7 +201,8 @@ export class ProviderRuntimeLaunchAdapters { coreHooksEnabled = true, decisions = false, decisionBudgetMs?: number, - claudeHttpHookBase?: string + claudeHttpHookBase?: string, + accountHome?: string ): PreparedProviderRuntimeLaunch { const pluginRegistrations = this.options.pluginHooks?.list(provider) ?? []; const gate = decisions && this.decisionsSupported(provider); @@ -244,6 +264,10 @@ export class ProviderRuntimeLaunchAdapters { ? ["--extension", join(dirname(this.options.openCodePluginPath), "omp-extension.mjs")] : [], environment); } + if (provider === "kimi" || provider === "grok") { + const ownHome = this.ownAccountHome(provider, accountHome); + if (ownHome) return prepared([], environment, this.acquireAccountHome(provider, ownHome, coreHooksEnabled, pluginCommands)); + } if (provider === "kimi") { return prepared([], environment, this.acquireKimi(coreHooksEnabled, pluginCommands)); } @@ -355,6 +379,47 @@ export class ProviderRuntimeLaunchAdapters { }); } + /** The account home this Grok/Kimi launch's overlay goes into (accountHomes.ts), or null for the default home. */ + private ownAccountHome(provider: "kimi" | "grok", candidate: string | undefined): string | null { + return ownedAccountHome({ + root: this.options.accountHomesRoot, + candidate, + defaultHome: provider === "kimi" ? this.kimiHomeDirectory : this.grokHomeDirectory, + written: provider === "kimi" ? ["config.toml", KIMI_JOURNAL, RUNTIME_BACKUPS] : ["hooks", join("hooks", GROK_HOOKS_FILE)], + label: provider === "kimi" ? "Kimi" : "Grok" + }); + } + + private acquireAccountHome( + provider: "kimi" | "grok", + home: string, + coreHooksEnabled: boolean, + pluginCommands: readonly ProviderHookCommand[] + ): () => void { + const key = `${provider}\0${home}`; + const signature = overlaySignature(coreHooksEnabled, pluginCommands); + let entry = this.accountOverlays.get(key); + if (!entry || entry.signature !== signature) { + if (entry) { + this.accountOverlays.delete(key); + entry.overlay.cleanup(); + } + const overlay = provider === "kimi" + ? KimiRuntimeHooks.begin(home, this.options.helper, this.platform, coreHooksEnabled, pluginCommands) + : GrokRuntimeHooks.begin(home, this.options.helper, this.platform, coreHooksEnabled, pluginCommands); + entry = { overlay, signature, users: 0 }; + this.accountOverlays.set(key, entry); + } + const owned = entry; + owned.users += 1; + return once(() => { + owned.users -= 1; + if (owned.users !== 0 || this.accountOverlays.get(key) !== owned) return; + this.accountOverlays.delete(key); + owned.overlay.cleanup(); + }); + } + /** Whether this provider can take the decision hook: Claude Code, Codex, Qwen Code (PreToolUse) and OpenCode. */ decisionsSupported(provider: AgentProvider): boolean { return Boolean(this.options.permissionGate) && (provider === "opencode" || Object.hasOwn(DECISION_TOOL_MATCHERS, provider)); @@ -794,7 +859,7 @@ class KimiRuntimeHooks { ): KimiRuntimeHooks { ensurePrivateDirectory(home); const path = join(home, "config.toml"); - const journalPath = join(home, ".canvastty-runtime-kimi.json"); + const journalPath = join(home, KIMI_JOURNAL); this.recover(home); const original = readOptional(path); const block = kimiHookBlock(helper, platform, coreHooksEnabled, pluginCommands); @@ -809,7 +874,7 @@ class KimiRuntimeHooks { } static recover(home: string): void { - const journalPath = join(home, ".canvastty-runtime-kimi.json"); + const journalPath = join(home, KIMI_JOURNAL); const journal = readJournal(journalPath); if (!journal) return; const path = join(home, "config.toml"); @@ -895,7 +960,7 @@ class GrokRuntimeHooks { ): GrokRuntimeHooks { const hooksDirectory = join(home, "hooks"); ensurePrivateDirectory(hooksDirectory); - const path = join(hooksDirectory, "canvastty-runtime-hooks.json"); + const path = join(hooksDirectory, GROK_HOOKS_FILE); this.recover(home); if (existsSync(path)) throw new Error("Grok CanvasTTY lifecycle hook path is already occupied."); const expected = `${JSON.stringify({ @@ -909,7 +974,7 @@ class GrokRuntimeHooks { } static recover(home: string): void { - const path = join(home, "hooks", "canvastty-runtime-hooks.json"); + const path = join(home, "hooks", GROK_HOOKS_FILE); const current = readOptional(path); if (current === null) return; if ( @@ -948,7 +1013,7 @@ function createTextJournal( mutated: string, extra: Record ): TextOverlayJournal { - const backupDirectory = join(home, ".canvastty-runtime-backups"); + const backupDirectory = join(home, RUNTIME_BACKUPS); ensurePrivateDirectory(backupDirectory); const backupPath = join(backupDirectory, `${provider}-${randomUUID()}.bak`); if (original !== null) atomicWrite(backupPath, original, modeOf(join(home, provider === "kimi" ? "config.toml" : "config.yaml"))); diff --git a/tests/agent-runtime-account-homes.test.mjs b/tests/agent-runtime-account-homes.test.mjs new file mode 100644 index 000000000..62d2bf6d8 --- /dev/null +++ b/tests/agent-runtime-account-homes.test.mjs @@ -0,0 +1,453 @@ +import assert from "node:assert/strict"; +import { lstat, mkdir, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile, chmod } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { AgentRuntimeBridge } from "../src/main/services/agent-runtime/AgentRuntimeBridge.ts"; +import { LaunchPipeline } from "../src/main/services/LaunchPipeline.ts"; +import { ProviderRuntimeLaunchAdapters } from "../src/main/services/agent-runtime/ProviderRuntimeLaunch.ts"; +import { ProviderLaunchAdapters } from "../src/main/services/agent-browser/ProviderLaunch.ts"; +import { AgentBrowserBridge } from "../src/main/services/agent-browser/AgentBrowserBridge.ts"; +import { accountHookHome, contributedHookHome } from "../src/main/services/accountHomes.ts"; +import { TerminalManager } from "../src/main/services/TerminalManager.ts"; + +const helper = Object.freeze({ + command: "/opt/CanvasTTY Agent/electron", + args: ["/opt/CanvasTTY Agent/agent-runtime/hook-helper.mjs"], + env: { ELECTRON_RUN_AS_NODE: "1" } +}); + +const GROK_FILE = ["hooks", "canvastty-runtime-hooks.json"]; +const KIMI_MARKER = "# >>> CanvasTTY lifecycle hooks >>>"; +const PERSON_KIMI = "# the person's own settings\ndefault_model = \"kimi-k2\"\n\n[[hooks]]\nevent = \"Stop\"\ncommand = \"notify-send done\"\n"; + +/** userData with plugin-data//homes/, the default CLI homes, and adapters bound to them. */ +async function fixture(t, { root: withRoot = true } = {}) { + const base = await mkdtemp(join(tmpdir(), "canvastty-account-homes-")); + t.after(() => rm(base, { recursive: true, force: true })); + const pluginData = join(base, "userData", "plugin-data"); + const accountHome = (plugin, account) => join(pluginData, plugin, "homes", account); + const defaultGrok = join(base, "home", ".grok"); + const defaultKimi = join(base, "home", ".kimi-code"); + await Promise.all([ + mkdir(accountHome("accounts", "a"), { recursive: true }), + mkdir(accountHome("accounts", "b"), { recursive: true }), + mkdir(defaultGrok, { recursive: true }), + mkdir(defaultKimi, { recursive: true }) + ]); + const adapters = () => new ProviderRuntimeLaunchAdapters({ + helper, + runtimeDirectory: join(base, "runtime"), + openCodePluginPath: join(base, "opencode-plugin.mjs"), + grokHomeDirectory: defaultGrok, + kimiHomeDirectory: defaultKimi, + hermesHomeDirectory: join(base, "home", ".hermes"), + ...(withRoot ? { accountHomesRoot: pluginData } : {}), + platform: "linux" + }); + return { base, pluginData, accountHome, defaultGrok, defaultKimi, adapters }; +} + +const exists = (path) => lstat(path).then(() => true, () => false); +const markers = (text) => text.split(KIMI_MARKER).length - 1; + +test("accountHookHome: GROK_HOME for Grok, KIMI_CODE_HOME for Kimi, nothing for the other CLIs", () => { + assert.equal(accountHookHome("grok", { GROK_HOME: "/data/plugin-data/a/homes/x/" }), "/data/plugin-data/a/homes/x"); + assert.equal(accountHookHome("kimi", { KIMI_CODE_HOME: "/data/plugin-data/a/homes/k" }), "/data/plugin-data/a/homes/k"); + // The older kimi-cli's home holds no Kimi Code hooks. + assert.equal(accountHookHome("kimi", { KIMI_SHARE_DIR: "/data/plugin-data/a/homes/k" }), undefined); + assert.equal(accountHookHome("grok", { KIMI_CODE_HOME: "/x" }), undefined); + assert.equal(accountHookHome("claude", { CLAUDE_CONFIG_DIR: "/x", GROK_HOME: "/y" }), undefined); + assert.equal(accountHookHome("codex", { CODEX_HOME: "/x" }), undefined); + assert.equal(accountHookHome("grok", undefined), undefined); + assert.equal(accountHookHome("grok", { GROK_HOME: "" }), undefined); +}); + +test("contributedHookHome: only the contributor's own data folder, only the hook home variable", () => { + const dataDir = "/data/plugin-data/accounts"; + assert.equal(contributedHookHome("grok", dataDir, "GROK_HOME", `${dataDir}/homes/a/`), `${dataDir}/homes/a`); + assert.equal(contributedHookHome("kimi", dataDir, "KIMI_CODE_HOME", `${dataDir}/homes/k`), `${dataDir}/homes/k`); + assert.equal(contributedHookHome("grok", dataDir, "GROK_HOME", "/data/plugin-data/other/homes/a"), undefined); + assert.equal(contributedHookHome("grok", dataDir, "GROK_HOME", dataDir), undefined); + assert.equal(contributedHookHome("grok", dataDir, "GROK_HOME", `${dataDir}/../other/homes/a`), undefined); + assert.equal(contributedHookHome("grok", dataDir, "GROK_HOME", "homes/a"), undefined); + assert.equal(contributedHookHome("grok", dataDir, "KIMI_CODE_HOME", `${dataDir}/homes/a`), undefined); + assert.equal(contributedHookHome("kimi", dataDir, "KIMI_SHARE_DIR", `${dataDir}/homes/a`), undefined); + assert.equal(contributedHookHome("claude", dataDir, "CLAUDE_CONFIG_DIR", `${dataDir}/homes/a`), undefined); + assert.equal(contributedHookHome("grok", undefined, "GROK_HOME", `${dataDir}/homes/a`), undefined); +}); + +test("Grok: an account home gets the status hooks, the default home is left alone, and the release removes them", async (t) => { + const { accountHome, defaultGrok, adapters } = await fixture(t); + const home = accountHome("accounts", "a"); + await mkdir(join(home, "hooks"), { recursive: true }); + await writeFile(join(home, "hooks", "person.json"), "{\"hooks\":{}}\n"); + await writeFile(join(home, "config.toml"), "model = \"grok-4\"\n"); + const launch = adapters().prepare("grok", "card-grok", true, false, undefined, undefined, home); + const written = JSON.parse(await readFile(join(home, ...GROK_FILE), "utf8")); + assert.match(written.hooks.UserPromptSubmit[0].hooks[0].command, /working.*UserPromptSubmit/u); + assert.match(written.hooks.Notification[0].hooks[0].command, /needs_approval.*Notification/u); + assert.equal(written.hooks.Notification[0].matcher, "permission_prompt"); + assert.equal((await stat(join(home, ...GROK_FILE))).mode & 0o777, 0o600); + assert.equal(await exists(join(defaultGrok, ...GROK_FILE)), false, "the default ~/.grok is not touched"); + launch.releaseConfiguration(); + assert.equal(await exists(join(home, ...GROK_FILE)), false); + assert.equal(await readFile(join(home, "hooks", "person.json"), "utf8"), "{\"hooks\":{}}\n"); + assert.equal(await readFile(join(home, "config.toml"), "utf8"), "model = \"grok-4\"\n"); +}); + +test("Kimi: an account home's config.toml gets one owned block; the person's settings and file mode are kept and restored", async (t) => { + const { accountHome, defaultKimi, adapters } = await fixture(t); + const home = accountHome("accounts", "a"); + const config = join(home, "config.toml"); + await writeFile(config, PERSON_KIMI); + await chmod(config, 0o640); + await writeFile(join(defaultKimi, "config.toml"), "default_model = \"default\"\n"); + const launch = adapters().prepare("kimi", "card-kimi", true, false, undefined, undefined, home); + const during = await readFile(config, "utf8"); + assert.ok(during.startsWith(PERSON_KIMI), "the person's settings stay first and unchanged"); + assert.equal(markers(during), 1); + assert.match(during, /event = "TurnStarted"\ncommand = ".*working.*TurnStarted/u); + assert.equal((await stat(config)).mode & 0o777, 0o640); + assert.equal(await readFile(join(defaultKimi, "config.toml"), "utf8"), "default_model = \"default\"\n"); + assert.equal(await exists(join(defaultKimi, ".canvastty-runtime-kimi.json")), false); + launch.releaseConfiguration(); + assert.equal(await readFile(config, "utf8"), PERSON_KIMI); + assert.equal(await exists(join(home, ".canvastty-runtime-kimi.json")), false); + assert.deepEqual(await readdir(join(home, ".canvastty-runtime-backups")).catch(() => []), [], "no backup is left"); +}); + +test("idempotent: two cards in one account home share one overlay; another home gets its own; a crashed overlay is refreshed", async (t) => { + const { accountHome, adapters } = await fixture(t); + const home = accountHome("accounts", "a"); + const other = accountHome("accounts", "b"); + const config = join(home, "config.toml"); + await writeFile(config, PERSON_KIMI); + const runtime = adapters(); + const first = runtime.prepare("kimi", "card-1", true, false, undefined, undefined, home); + const second = runtime.prepare("kimi", "card-2", true, false, undefined, undefined, `${home}/`); + const third = runtime.prepare("kimi", "card-3", true, false, undefined, undefined, other); + assert.equal(markers(await readFile(config, "utf8")), 1); + assert.equal(markers(await readFile(join(other, "config.toml"), "utf8")), 1); + first.releaseConfiguration(); + first.releaseConfiguration(); + assert.equal(markers(await readFile(config, "utf8")), 1, "still in use by card-2"); + second.releaseConfiguration(); + assert.equal(await readFile(config, "utf8"), PERSON_KIMI); + third.releaseConfiguration(); + assert.equal(await exists(join(other, "config.toml")), false, "a file CanvasTTY created is removed again"); + + // An app that ended without releasing (a crash) leaves its overlay; the next launch there replaces it, not adds one. + runtime.prepare("kimi", "card-crashed", true, false, undefined, undefined, home); + runtime.prepare("grok", "card-crashed-grok", true, false, undefined, undefined, home); + const restarted = adapters(); + const again = restarted.prepare("kimi", "card-again", true, false, undefined, undefined, home); + const grokAgain = restarted.prepare("grok", "card-again-grok", true, false, undefined, undefined, home); + assert.equal(markers(await readFile(config, "utf8")), 1); + assert.ok((await readFile(config, "utf8")).startsWith(PERSON_KIMI)); + again.releaseConfiguration(); + grokAgain.releaseConfiguration(); + assert.equal(await readFile(config, "utf8"), PERSON_KIMI); + assert.equal(await exists(join(home, ...GROK_FILE)), false); +}); + +test("symbolic links: a linked account home, a linked folder above it, or a linked config file is refused and nothing is written", async (t) => { + const { base, pluginData, accountHome, defaultGrok, defaultKimi, adapters } = await fixture(t); + const outside = join(base, "outside"); + await mkdir(join(outside, "hooks"), { recursive: true }); + await writeFile(join(outside, "config.toml"), "outside = true\n"); + const runtime = adapters(); + + const linkedHome = accountHome("accounts", "linked"); + await symlink(outside, linkedHome); + for (const provider of ["grok", "kimi"]) { + assert.throws(() => runtime.prepare(provider, `card-${provider}`, true, false, undefined, undefined, linkedHome), + /missing or crosses a symbolic link/u); + } + + await symlink(join(pluginData, "accounts"), join(pluginData, "alias")); + assert.throws(() => runtime.prepare("grok", "card-alias", true, false, undefined, undefined, join(pluginData, "alias", "homes", "a")), + /missing or crosses a symbolic link/u); + + const kimiHome = accountHome("accounts", "a"); + await symlink(join(outside, "config.toml"), join(kimiHome, "config.toml")); + assert.throws(() => runtime.prepare("kimi", "card-config", true, false, undefined, undefined, kimiHome), + /symbolic link where CanvasTTY writes/u); + + const grokHome = accountHome("accounts", "b"); + await symlink(join(outside, "hooks"), join(grokHome, "hooks")); + assert.throws(() => runtime.prepare("grok", "card-hooks", true, false, undefined, undefined, grokHome), + /symbolic link where CanvasTTY writes/u); + + assert.deepEqual((await readdir(outside)).sort(), ["config.toml", "hooks"]); + assert.equal(await readFile(join(outside, "config.toml"), "utf8"), "outside = true\n"); + assert.deepEqual(await readdir(join(outside, "hooks")), []); + assert.deepEqual(await readdir(defaultGrok), []); + assert.deepEqual(await readdir(defaultKimi), []); + assert.equal(await exists(join(kimiHome, ".canvastty-runtime-kimi.json")), false); +}); + +test("a new account's home that does not exist yet is created (private, no link followed) and gets the overlays", async (t) => { + const { accountHome, defaultGrok, defaultKimi, adapters } = await fixture(t); + const runtime = adapters(); + for (const provider of ["grok", "kimi"]) { + // A first sign-in: the contributor names the account's home before the CLI ever created it. + const home = accountHome("accounts", `new-${provider}`); + assert.equal(await exists(home), false); + const launch = runtime.prepare(provider, `card-${provider}`, true, false, undefined, undefined, home); + assert.equal((await stat(home)).mode & 0o777, 0o700, `${provider}: created private`); + if (provider === "grok") assert.equal(await exists(join(home, ...GROK_FILE)), true); + else assert.match(await readFile(join(home, "config.toml"), "utf8"), /CanvasTTY lifecycle hooks/u); + launch.releaseConfiguration(); + } + assert.deepEqual(await readdir(defaultGrok), []); + assert.deepEqual(await readdir(defaultKimi), []); +}); + +test("homes the launch does not own are never written: outside plugin data, a whole plugin's folder, relative, or no root", async (t) => { + const { base, pluginData, defaultGrok, defaultKimi, adapters } = await fixture(t); + const elsewhere = join(base, "elsewhere"); + await mkdir(elsewhere, { recursive: true }); + const runtime = adapters(); + for (const candidate of [elsewhere, join(pluginData, "accounts"), pluginData, "relative/home", `${pluginData}/accounts/../accounts/homes/a`]) { + const launch = runtime.prepare("grok", "card", true, false, undefined, undefined, candidate); + const kimi = runtime.prepare("kimi", "card-k", true, false, undefined, undefined, candidate); + assert.equal(await exists(join(defaultGrok, ...GROK_FILE)), true, `${candidate}: the default home, as before`); + assert.match(await readFile(join(defaultKimi, "config.toml"), "utf8"), /CanvasTTY lifecycle hooks/u); + launch.releaseConfiguration(); + kimi.releaseConfiguration(); + } + assert.deepEqual(await readdir(elsewhere), []); + assert.deepEqual(await readdir(join(pluginData, "accounts", "homes", "a")), []); + + const noRoot = await fixture(t, { root: false }); + const home = noRoot.accountHome("accounts", "a"); + const launch = noRoot.adapters().prepare("grok", "card", true, false, undefined, undefined, home); + assert.deepEqual(await readdir(home), []); + assert.equal(await exists(join(noRoot.defaultGrok, ...GROK_FILE)), true); + launch.releaseConfiguration(); +}); + +test("default homes are unchanged without an account home, and hooks off writes nowhere", async (t) => { + const { accountHome, defaultGrok, defaultKimi, adapters } = await fixture(t); + const runtime = adapters(); + const grok = runtime.prepare("grok", "card-grok"); + const kimi = runtime.prepare("kimi", "card-kimi"); + assert.equal(await exists(join(defaultGrok, ...GROK_FILE)), true); + assert.equal(markers(await readFile(join(defaultKimi, "config.toml"), "utf8")), 1); + grok.releaseConfiguration(); + kimi.releaseConfiguration(); + assert.equal(await exists(join(defaultGrok, ...GROK_FILE)), false); + assert.equal(await exists(join(defaultKimi, "config.toml")), false); + + const home = accountHome("accounts", "a"); + const off = runtime.prepare("grok", "card-off", false, false, undefined, undefined, home); + assert.deepEqual(await readdir(home), []); + off.releaseConfiguration(); + // Claude Code and Codex take their hooks as arguments; their homes are never written either way. + for (const provider of ["claude", "codex"]) { + const launch = runtime.prepare(provider, `card-${provider}`, true, false, undefined, undefined, home); + assert.ok(launch.args.length > 0); + launch.releaseConfiguration(); + } + assert.deepEqual(await readdir(home), []); +}); + +const PERSON_MCP = `${JSON.stringify({ mcpServers: { person: { transport: "stdio", command: "/usr/bin/true" } } }, null, 2)}\n`; +const BROWSER_RULE = "# CanvasTTY temporary browser permission begin"; + +const browserHelper = Object.freeze({ command: "/opt/CanvasTTY Agent/electron", args: ["/opt/CanvasTTY Agent/agent-browser/helper.mjs"], env: { ELECTRON_RUN_AS_NODE: "1" } }); +const orchestrationHelper = Object.freeze({ command: "/opt/CanvasTTY Agent/electron", args: ["/opt/CanvasTTY Agent/agent-browser/orchestration.mjs"], env: { ELECTRON_RUN_AS_NODE: "1" } }); + +const registry = Object.freeze({ + get: (provider) => ({ state: "available", provider, executable: `/resolved/${provider}`, launcher: "native", environment: { PATH: "/usr/bin" }, checked: [] }), + snapshot: () => ({}) +}); + +function browserAdapters(fix, perRun, withRoot = true) { + return new ProviderLaunchAdapters({ + helper: browserHelper, + orchestrationHelper, + providerClis: registry, + runtimeDirectory: join(fix.base, "browser-runtime"), + hermesHomeDirectory: join(fix.base, "home", ".hermes"), + kimiHomeDirectory: fix.defaultKimi, + probeKimiPerRunConfig: () => perRun, + ...(withRoot ? { accountHomesRoot: fix.pluginData } : {}) + }); +} + +test("Kimi tools (per-run MCP file): the account home gets the permission rule, the default home nothing; restored after", async (t) => { + const fix = await fixture(t); + const home = fix.accountHome("accounts", "a"); + await writeFile(join(home, "config.toml"), PERSON_KIMI); + await writeFile(join(fix.defaultKimi, "config.toml"), "default_model = \"default\"\n"); + const adapters = browserAdapters(fix, true); + const launch = adapters.prepare("kimi", "connection-1", { orchestration: true, accountHome: home }); + const mcpFile = launch.args[launch.args.indexOf("--mcp-config-file") + 1]; + const servers = JSON.parse(await readFile(mcpFile, "utf8")).mcpServers; + assert.deepEqual(Object.keys(servers).sort(), ["canvastty_agents", "canvastty_browser"]); + const during = await readFile(join(home, "config.toml"), "utf8"); + assert.ok(during.startsWith(PERSON_KIMI)); + assert.equal(during.split(BROWSER_RULE).length - 1, 1); + assert.equal(await readFile(join(fix.defaultKimi, "config.toml"), "utf8"), "default_model = \"default\"\n"); + // A second card in the same home shares the rule; another home gets its own. + const second = adapters.prepare("kimi", "connection-2", { orchestration: true, accountHome: `${home}/` }); + assert.equal((await readFile(join(home, "config.toml"), "utf8")).split(BROWSER_RULE).length - 1, 1); + launch.releaseConfiguration(); + assert.equal((await readFile(join(home, "config.toml"), "utf8")).split(BROWSER_RULE).length - 1, 1); + second.releaseConfiguration(); + assert.equal(await readFile(join(home, "config.toml"), "utf8"), PERSON_KIMI); + assert.deepEqual((await readdir(home)).sort(), ["config.toml"]); +}); + +test("Kimi tools (shared mcp.json): the account home's mcp.json gets both servers next to the person's; restored after", async (t) => { + const fix = await fixture(t); + const home = fix.accountHome("accounts", "a"); + await writeFile(join(home, "mcp.json"), PERSON_MCP); + const launch = browserAdapters(fix, false).prepare("kimi", "connection-1", { orchestration: true, accountHome: home }); + assert.equal(launch.args.includes("--mcp-config-file"), false); + const servers = JSON.parse(await readFile(join(home, "mcp.json"), "utf8")).mcpServers; + assert.deepEqual(Object.keys(servers).sort(), ["canvastty_agents", "canvastty_browser", "person"]); + assert.match(await readFile(join(home, "config.toml"), "utf8"), /CanvasTTY temporary browser permission begin/u); + assert.deepEqual(await readdir(fix.defaultKimi), [], "the default home is not touched"); + launch.releaseConfiguration(); + assert.equal(await readFile(join(home, "mcp.json"), "utf8"), PERSON_MCP); + assert.deepEqual((await readdir(home)).sort(), ["mcp.json"]); +}); + +test("Kimi tools: symbolic links are refused, and a home the launch does not own falls back to the default home", async (t) => { + const fix = await fixture(t); + const outside = join(fix.base, "outside"); + await mkdir(outside, { recursive: true }); + await writeFile(join(outside, "mcp.json"), PERSON_MCP); + const home = fix.accountHome("accounts", "a"); + await symlink(join(outside, "mcp.json"), join(home, "mcp.json")); + const adapters = browserAdapters(fix, false); + assert.throws(() => adapters.prepare("kimi", "c", { accountHome: home }), /symbolic link where CanvasTTY writes/u); + const linked = fix.accountHome("accounts", "linked"); + await symlink(outside, linked); + assert.throws(() => adapters.prepare("kimi", "c", { accountHome: linked }), /missing or crosses a symbolic link/u); + assert.equal(await readFile(join(outside, "mcp.json"), "utf8"), PERSON_MCP); + assert.deepEqual(await readdir(outside), ["mcp.json"]); + + const elsewhere = join(fix.base, "elsewhere"); + await mkdir(elsewhere, { recursive: true }); + for (const [candidate, runtime] of [[elsewhere, adapters], [fix.accountHome("accounts", "b"), browserAdapters(fix, false, false)]]) { + const launch = runtime.prepare("kimi", "c", { accountHome: candidate }); + assert.ok(JSON.parse(await readFile(join(fix.defaultKimi, "mcp.json"), "utf8")).mcpServers.canvastty_browser); + launch.releaseConfiguration(); + assert.deepEqual(await readdir(candidate), []); + } +}); + +/** A card a launch contributor (data folder `plugin-data/`) starts with `variable` = `home`, through TerminalManager. */ +async function contributedCard(t, fix, { provider, variable, home, owner = "accounts" }) { + const pipeline = new LaunchPipeline({ + contributors: () => [{ pluginId: owner, pluginName: "Accounts", serviceId: "launcher", + launch: { appliesTo: [provider], fields: [{ key: "on", label: "On", kind: "boolean", default: true }] }, secrets: false, + dataDir: join(fix.pluginData, owner) }], + call: async () => ({ env: { [variable]: home } }), + secret: async () => null, + runsRoot: join(fix.base, "runs"), + timeoutMs: 1_000 + }); + const gateway = { + registerSession: (terminalSessionId, kind) => ({ address: join(fix.base, "rt.sock"), terminalSessionId, provider: kind, capabilityToken: "t" }), + revokeTerminalSession() {}, + currentStatus: () => null + }; + const bridge = new AgentRuntimeBridge(gateway, { + helper, + runtimeDirectory: join(fix.base, "runtime"), + openCodePluginPath: join(fix.base, "opencode-plugin.mjs"), + grokHomeDirectory: fix.defaultGrok, + kimiHomeDirectory: fix.defaultKimi, + hermesHomeDirectory: join(fix.base, "home", ".hermes"), + accountHomesRoot: fix.pluginData, + platform: "linux" + }); + const browserGateway = { + isEnabled: true, + registerAgent: (input) => ({ agentId: "agent", connectionId: `connection-${input.terminalSessionId}`, terminalSessionId: input.terminalSessionId, + provider: input.provider, capabilityToken: "c", address: join(fix.base, "browser.sock"), authenticated: new Promise(() => {}) }), + revokeTerminalSession() {}, + holdPendingForTerminal() {} + }; + const browser = new AgentBrowserBridge(browserGateway, { + helper: browserHelper, + providerClis: registry, + runtimeDirectory: join(fix.base, "browser-runtime"), + hermesHomeDirectory: join(fix.base, "home", ".hermes"), + kimiHomeDirectory: fix.defaultKimi, + probeKimiPerRunConfig: () => false, + accountHomesRoot: fix.pluginData + }); + const calls = []; + const spawner = (command, args, options) => { + const exits = []; + calls.push({ command, args, options, exit: (code) => exits.forEach((listener) => listener({ exitCode: code })) }); + return { + pid: 41_000 + calls.length, process: command, write() {}, resize() {}, kill() {}, pause() {}, resume() {}, + onData() { return { dispose() {} }; }, + onExit(listener) { exits.push(listener); return { dispose() {} }; } + }; + }; + const manager = new TerminalManager(() => undefined, registry, browser, bridge, true, spawner); + t.after(() => manager.shutdown()); + manager.configureLaunchPipeline(pipeline); + const created = manager.create({ provider, profile: "normal", cwd: process.cwd(), position: { x: 0, y: 0 }, launchOptions: { [owner]: {} } }); + // Grok starts once the card reports its measured grid. + manager.resize(created.id, 100, 30); + const deadline = Date.now() + 5_000; + while (calls.length === 0 && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 10)); + assert.equal(calls.length, 1, JSON.stringify(manager.list().map((card) => [card.status, card.failureDetails]))); + assert.equal(calls[0].options.env[variable], home); + return { exit: () => calls[0].exit(0) }; +} + +async function until(predicate) { + const deadline = Date.now() + 5_000; + while (!(await predicate()) && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 10)); + assert.ok(await predicate()); +} + +test("a Grok card a contributor starts with GROK_HOME in its own account home gets the hooks there, through TerminalManager", async (t) => { + const fix = await fixture(t); + const home = fix.accountHome("accounts", "a"); + const card = await contributedCard(t, fix, { provider: "grok", variable: "GROK_HOME", home }); + assert.equal(await exists(join(home, ...GROK_FILE)), true); + assert.equal(await exists(join(fix.defaultGrok, ...GROK_FILE)), false); + card.exit(); + await until(async () => !(await exists(join(home, ...GROK_FILE)))); +}); + +test("a Kimi card in its own account home gets status hooks and its browser/agents tools there, through TerminalManager", async (t) => { + const fix = await fixture(t); + const home = fix.accountHome("accounts", "a"); + await writeFile(join(home, "config.toml"), PERSON_KIMI); + await writeFile(join(home, "mcp.json"), PERSON_MCP); + const card = await contributedCard(t, fix, { provider: "kimi", variable: "KIMI_CODE_HOME", home }); + const config = await readFile(join(home, "config.toml"), "utf8"); + assert.ok(config.startsWith(PERSON_KIMI)); + assert.equal(markers(config), 1); + assert.equal(config.split(BROWSER_RULE).length - 1, 1); + assert.ok(JSON.parse(await readFile(join(home, "mcp.json"), "utf8")).mcpServers.canvastty_browser); + assert.deepEqual(await readdir(fix.defaultKimi), []); + card.exit(); + await until(async () => (await readFile(join(home, "config.toml"), "utf8")) === PERSON_KIMI); + await until(async () => (await readFile(join(home, "mcp.json"), "utf8")) === PERSON_MCP); +}); + +test("a home in another plugin's data is not the launch's account home: nothing is written there, the default home is used", async (t) => { + const fix = await fixture(t); + const home = fix.accountHome("accounts", "a"); + for (const [provider, variable] of [["grok", "GROK_HOME"], ["kimi", "KIMI_CODE_HOME"]]) { + const card = await contributedCard(t, fix, { provider, variable, home, owner: "other" }); + assert.deepEqual(await readdir(home), [], provider); + if (provider === "grok") assert.equal(await exists(join(fix.defaultGrok, ...GROK_FILE)), true); + else assert.match(await readFile(join(fix.defaultKimi, "config.toml"), "utf8"), /CanvasTTY lifecycle hooks/u); + card.exit(); + } +}); From 47b5535fa1f9bb033b8f58e133f68051afaf8d41 Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:28:18 +0000 Subject: [PATCH 03/15] fix(agents): keep ambient admin keys from agents and credentials from delegated cards Agent cards inherited CanvasTTY's whole environment, so every agent, every subagent and every plugin-started card, isolated or not, received provider API keys, tokens and organization-level admin keys (OPENAI_ADMIN_KEY, ANTHROPIC_ADMIN_KEY) exported where the app was started. Now an agent card never inherits admin credentials (*_ADMIN_*KEY, *_ADMIN_*TOKEN) or CanvasTTY's own (CANVASTTY_GITHUB_TOKEN and any CANVASTTY_*TOKEN/KEY/SECRET/PASSWORD); the person's own top-level agent cards otherwise keep their environment, so CLIs signed in by API key keep working. Subagents and every card a plugin starts, a plain shell card included since the plugin drives it, inherit only an allowlist (path, home, user, shell, locale, terminal, temporary and XDG folders, Windows system folders, proxy and certificate variables, the CLIs' home folders); what they need beyond that comes explicitly through a launch contribution's secretEnv. The launch builders (agent runtime hooks, browser and orchestration bridge, OpenCode inline config) now read the card's filtered environment instead of the app's, so they no longer hand the person's OPENCODE_CONFIG_CONTENT to a delegated agent. A plain shell card the person starts keeps everything but CanvasTTY's own credentials. CANVASTTY_AGENT_PASS_ENV in the app's environment lists variables to keep anyway. --- docs/agent-orchestration.md | 1 + docs/installing-and-security.md | 2 + docs/installing-and-security.ru.md | 2 + docs/installing-and-security.zh-CN.md | 2 + src/main/services/TerminalManager.ts | 15 +- .../agent-browser/AgentBrowserBridge.ts | 11 +- .../services/agent-browser/ProviderLaunch.ts | 11 +- .../agent-runtime/AgentRuntimeBridge.ts | 4 +- .../agent-runtime/ProviderRuntimeLaunch.ts | 30 +- src/main/services/ambientProviderKeys.ts | 87 ++++++ tests/agent-ambient-keys.test.mjs | 280 ++++++++++++++++++ tests/launch-contributors.test.mjs | 19 ++ 12 files changed, 441 insertions(+), 23 deletions(-) create mode 100644 src/main/services/ambientProviderKeys.ts create mode 100644 tests/agent-ambient-keys.test.mjs diff --git a/docs/agent-orchestration.md b/docs/agent-orchestration.md index 48fab8b3a..985d0656e 100644 --- a/docs/agent-orchestration.md +++ b/docs/agent-orchestration.md @@ -48,6 +48,7 @@ The core applies these to every way an agent can start another one: `spawn_agent | 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). | | 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. | +| Provider keys | A subagent and every card a plugin starts (a plain terminal card included) inherit only an allowlist of the person's environment (path, home, locale, terminal, temporary/XDG folders, proxy and certificate variables, the CLIs' home folders): no provider API key (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, …), token (`GH_TOKEN`, `CLAUDE_CODE_OAUTH_TOKEN`, …), cloud credential, admin key, CanvasTTY credential (`CANVASTTY_GITHUB_TOKEN`) or OpenCode inline config, also not through the configuration CanvasTTY adds for the launch. A key it needs comes from a launch contribution's `secretEnv`, or the person lists the variable in `CANVASTTY_AGENT_PASS_ENV`. Orchestrators cannot start plain terminal cards. See [installing-and-security.md](installing-and-security.md#agent-protection-layers). | | No settings | The `canvastty_agents` tools and an orchestrator's control connection cannot change settings, base protection, profiles, plugin trust or isolation. | ## Agent isolation diff --git a/docs/installing-and-security.md b/docs/installing-and-security.md index c81480251..35562e338 100644 --- a/docs/installing-and-security.md +++ b/docs/installing-and-security.md @@ -93,6 +93,8 @@ Agents work in their own mode; CanvasTTY's layers sit outside it as a quiet safe 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`. +**Provider keys in the environment.** A plain terminal card you start keeps your whole environment, except CanvasTTY's own credentials (`CANVASTTY_GITHUB_TOKEN`, any `CANVASTTY_*TOKEN`/`*KEY`/`*SECRET`/`*PASSWORD`), which only the app uses. An agent card never inherits those nor organization-level admin credentials (`OPENAI_ADMIN_KEY`, `ANTHROPIC_ADMIN_KEY`, any `*_ADMIN_*KEY` or `*_ADMIN_*TOKEN`), whoever starts it. An agent you start yourself otherwise keeps your environment, ordinary provider keys such as `OPENAI_API_KEY` or `ANTHROPIC_API_KEY` included, in every profile, so a CLI signed in by API key keeps working (an exported `ANTHROPIC_API_KEY` still takes precedence over Claude Code's subscription sign-in). Subagents and every card a plugin starts (a plain terminal card included, since the plugin drives it) inherit only what a process needs: `PATH`, `HOME`, user, shell, locale and terminal variables, temporary and XDG folders, the Windows system folders, proxy variables (`HTTP(S)_PROXY`, `ALL_PROXY`, `NO_PROXY`), certificate settings (`SSL_CERT_FILE`, `SSL_CERT_DIR`, `NODE_EXTRA_CA_CERTS`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`), `EDITOR`/`VISUAL`/`PAGER` and the CLIs' own home folders (`CODEX_HOME`, `CLAUDE_CONFIG_DIR`, …). No provider key, token, cloud credential, `SSH_AUTH_SOCK` or OpenCode inline config (`OPENCODE_CONFIG_CONTENT`, which can hold keys) reaches them, also not through the configuration CanvasTTY adds for the launch; a key they need comes explicitly, through a launch contribution's `secretEnv`. To pass a variable anyway, list it in `CANVASTTY_AGENT_PASS_ENV` (comma-separated, e.g. `CANVASTTY_AGENT_PASS_ENV=OPENAI_ADMIN_KEY`) in the environment CanvasTTY starts with. This is about inherited variables only: without the isolation layer an agent runs as you and can still read a key from your shell profile file, and a plain terminal card runs your login shell, which reads that file. + 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. **Git audit.** When an isolated agent session ends, is closed or is restored after a quit, CanvasTTY checks the repositories under its folder whose git folder changed. If git would now run something outside isolation (a `core.hooksPath`, a filter or diff driver, `fsmonitor`, a hook file, `info/attributes`), a notice lists exactly what changed; **Neutralize** removes those keys and disables those files, **Keep as is** leaves them. It does not undo other file changes inside the project. diff --git a/docs/installing-and-security.ru.md b/docs/installing-and-security.ru.md index 0532080dd..c18a7cf80 100644 --- a/docs/installing-and-security.ru.md +++ b/docs/installing-and-security.ru.md @@ -89,6 +89,8 @@ gh variable set SPARKLE_PUBLIC_ED_KEY --repo howdeploy/CanvasTTY \ **Аудит git.** Когда изолированная сессия агента заканчивается, закрывается или восстанавливается после выхода, CanvasTTY проверяет репозитории в её папке, у которых изменилась папка git. Если git теперь запустит что-то вне изоляции (`core.hooksPath`, фильтр или diff-драйвер, `fsmonitor`, файл хука, `info/attributes`), появляется уведомление со списком изменений; **Обезвредить** удаляет эти ключи и отключает файлы, **Оставить как есть** ничего не трогает. Другие изменения файлов в проекте аудит не откатывает. +**Ключи провайдеров в окружении.** Обычный терминал, запущенный вами, получает всё ваше окружение, кроме собственных учётных данных CanvasTTY (`CANVASTTY_GITHUB_TOKEN`, любые `CANVASTTY_*TOKEN`/`*KEY`/`*SECRET`/`*PASSWORD`), которые нужны только приложению. Карточка агента никогда не наследует ни их, ни админ-ключи и админ-токены организации (`OPENAI_ADMIN_KEY`, `ANTHROPIC_ADMIN_KEY`, любые `*_ADMIN_*KEY` и `*_ADMIN_*TOKEN`). В остальном агент, запущенный вами, получает ваше окружение, включая обычные ключи вроде `OPENAI_API_KEY` и `ANTHROPIC_API_KEY`, в любом режиме, так что CLI со входом по API-ключу работает как раньше. Субагенты и любые карточки, запущенные плагинами (включая обычный терминал, ведь им управляет плагин), наследуют только то, что нужно процессу: `PATH`, `HOME`, переменные пользователя, оболочки, локали и терминала, временные и XDG-папки, системные папки Windows, прокси (`HTTP(S)_PROXY`, `ALL_PROXY`, `NO_PROXY`), настройки сертификатов (`SSL_CERT_FILE`, `SSL_CERT_DIR`, `NODE_EXTRA_CA_CERTS`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`), `EDITOR`/`VISUAL`/`PAGER` и домашние папки CLI (`CODEX_HOME`, `CLAUDE_CONFIG_DIR`, …). Ни ключей провайдеров, ни токенов, ни облачных учётных данных, ни `SSH_AUTH_SOCK`, ни встроенной конфигурации OpenCode (`OPENCODE_CONFIG_CONTENT`, в ней бывают ключи) они не получают, в том числе через конфигурацию, которую CanvasTTY добавляет к запуску; нужный ключ передаётся явно, через `secretEnv` вклада в запуск. Чтобы всё же передать переменную, перечислите её в `CANVASTTY_AGENT_PASS_ENV` (через запятую) в окружении, с которым запускается CanvasTTY. Речь только о наследуемых переменных: без слоя изоляции агент работает от вашего имени и может прочитать ключ из файла профиля оболочки, а обычный терминал запускает вашу login-оболочку, которая этот файл читает. + ### Linux: если bubblewrap не запускается Ubuntu 24.04 и новее (и другие дистрибутивы с `kernel.apparmor_restrict_unprivileged_userns=1`) разрешают создавать непривилегированные пространства имён пользователя только программам с профилем AppArmor, а bubblewrap без них не работает. CanvasTTY проверяет это один раз (после неудачи — снова через минуту) и, если `bwrap` установлен, но не запускается, ведёт себя как на компьютере без слоя изоляции: субагенты и агенты от плагинов работают вручную, а карточка объясняет почему. Чтобы разрешить, дайте bubblewrap собственный профиль (рекомендуется, касается только `bwrap`): diff --git a/docs/installing-and-security.zh-CN.md b/docs/installing-and-security.zh-CN.md index 605deda0b..b9d2d080a 100644 --- a/docs/installing-and-security.zh-CN.md +++ b/docs/installing-and-security.zh-CN.md @@ -89,6 +89,8 @@ gh variable set SPARKLE_PUBLIC_ED_KEY --repo howdeploy/CanvasTTY \ **Git 审计。** 隔离的智能体会话结束、被关闭或在退出后恢复时,CanvasTTY 会检查其文件夹下 git 目录发生变化的仓库。如果 git 现在会在隔离外运行某些东西(`core.hooksPath`、filter 或 diff 驱动、`fsmonitor`、hook 文件、`info/attributes`),会出现一条列出具体变化的通知;**Neutralize** 删除这些键并停用这些文件,**Keep as is** 保持不变。它不会撤销项目内的其他文件改动。 +**环境中的服务商密钥。** 你自己启动的普通终端卡片保留你的全部环境,但不包括 CanvasTTY 自己的凭据(`CANVASTTY_GITHUB_TOKEN`,以及任何 `CANVASTTY_*TOKEN`/`*KEY`/`*SECRET`/`*PASSWORD`),它们只供应用本身使用。智能体卡片永远不会继承这些凭据,也不会继承组织级管理员凭据(`OPENAI_ADMIN_KEY`、`ANTHROPIC_ADMIN_KEY`,以及任何 `*_ADMIN_*KEY` 或 `*_ADMIN_*TOKEN`)。除此之外,你自己启动的智能体在任何模式下都保留你的环境,包括 `OPENAI_API_KEY`、`ANTHROPIC_API_KEY` 等普通密钥,因此用 API 密钥登录的 CLI 照常工作。子智能体以及插件启动的任何卡片(包括普通终端卡片,因为由插件操控)只继承进程所需的变量:`PATH`、`HOME`、用户、shell、语言区域和终端变量、临时目录和 XDG 目录、Windows 系统目录、代理变量(`HTTP(S)_PROXY`、`ALL_PROXY`、`NO_PROXY`)、证书设置(`SSL_CERT_FILE`、`SSL_CERT_DIR`、`NODE_EXTRA_CA_CERTS`、`REQUESTS_CA_BUNDLE`、`CURL_CA_BUNDLE`)、`EDITOR`/`VISUAL`/`PAGER` 以及各 CLI 自己的主目录(`CODEX_HOME`、`CLAUDE_CONFIG_DIR` 等)。它们拿不到任何服务商密钥、令牌、云凭据、`SSH_AUTH_SOCK` 或 OpenCode 内联配置(`OPENCODE_CONFIG_CONTENT`,其中可能含有密钥),通过 CanvasTTY 为启动添加的配置也不会;需要的密钥通过启动贡献的 `secretEnv` 显式传入。如仍要传递某个变量,请在 CanvasTTY 启动时的环境中把它列入 `CANVASTTY_AGENT_PASS_ENV`(逗号分隔)。这里只涉及继承的变量:没有隔离层时,智能体以你的身份运行,仍可从 shell 配置文件中读取密钥;普通终端卡片运行你的登录 shell,也会读取该文件。 + ### Linux:bubblewrap 无法启动时 Ubuntu 24.04 及更新版本(以及其他设置了 `kernel.apparmor_restrict_unprivileged_userns=1` 的发行版)只允许带 AppArmor 配置文件的程序创建非特权用户命名空间,而 bubblewrap 需要它。CanvasTTY 会检查一次(失败后一分钟再查),如果 `bwrap` 已安装却无法启动,就按没有隔离层的电脑处理:子智能体和插件启动的智能体以手动模式运行,卡片会说明原因。要允许它,可以为 bubblewrap 单独添加配置文件(推荐,只影响 `bwrap`): diff --git a/src/main/services/TerminalManager.ts b/src/main/services/TerminalManager.ts index 04ad4bc8b..6be087aed 100644 --- a/src/main/services/TerminalManager.ts +++ b/src/main/services/TerminalManager.ts @@ -73,6 +73,7 @@ import { LaunchRefusal } from "./launchRefusal.ts"; import { configuredMode } from "./configuredMode.ts"; import { envKey, RESERVED_ENV, type LaunchPipeline, type PreparedLaunch } from "./LaunchPipeline.ts"; import type { EnvironmentRegistry } from "./EnvironmentRegistry.ts"; +import { agentEnvironment, shellEnvironment } from "./ambientProviderKeys.ts"; import { persistedTerminalSession, type PersistedEnvironmentRef, @@ -1722,12 +1723,20 @@ export class TerminalManager { ): PlannedSpawn | { failure: LaunchFailure } { const providerCli = provider === "terminal" ? undefined : this.providerClis.get(provider); if (providerCli?.state === "unavailable") return { failure: providerCli }; + // The person's environment: a plain shell the person starts keeps all of it but CanvasTTY's own credentials; their + // own agent cards also lose admin keys; a delegated card (a subagent, or any card a plugin started, plain shell + // included, since its owner drives it) gets only an allowlist (ambientProviderKeys.ts). Every launch builder below + // reads this, never process.env; what a launch sets explicitly is added on top. + const delegated = this.isDelegated(id, role); + const baseEnvironment = provider === "terminal" && !delegated + ? shellEnvironment(terminalEnvironment()) + : agentEnvironment(terminalEnvironment(), { delegated }); // What decides whether Claude's lifecycle hooks may go over HTTP (ClaudeHttpHooks.ts): where and how it runs. const claudeHttp = provider === "claude" && providerCli?.state === "available" ? { executable: providerCli.executable, profile, environmentWrapped, - env: { ...terminalEnvironment(), ...providerCli.environment, ...(contribution?.env ?? {}) }, + env: { ...baseEnvironment, ...providerCli.environment, ...(contribution?.env ?? {}) }, args: contribution?.args ?? [], cwd } : undefined; @@ -1737,7 +1746,7 @@ export class TerminalManager { const accountHome = hookHome !== undefined && hookHome === contribution?.accountHome ? hookHome : undefined; const agentRuntime = provider === "terminal" ? null - : this.agentRuntime?.prepareLaunch({ terminalSessionId: id, provider, cwd, + : this.agentRuntime?.prepareLaunch({ terminalSessionId: id, provider, cwd, environment: baseEnvironment, ...(captureResult ? { captureResult: true } : {}), ...(accountHome ? { accountHome } : {}), ...(claudeHttp ? { claudeHttp } : {}), @@ -1772,12 +1781,12 @@ export class TerminalManager { provider, cwd, ...(accountHome ? { accountHome } : {}), + environment: baseEnvironment, ...(bridged ? { includeOrchestration: true, orchestrationTools: [...(role === "orchestrator" ? ORCHESTRATION_TOOL_NAMES : []), ...pluginTools] } : {}) }) ?? null; - const baseEnvironment = terminalEnvironment(); const browserEnvironment = agentBrowser?.environment ?? {}; const runtimeEnvironment = agentRuntime?.environment ?? {}; const orchestrationEnvironment = agentOrchestration?.environment ?? {}; diff --git a/src/main/services/agent-browser/AgentBrowserBridge.ts b/src/main/services/agent-browser/AgentBrowserBridge.ts index 8dae4ac6e..216663f85 100644 --- a/src/main/services/agent-browser/AgentBrowserBridge.ts +++ b/src/main/services/agent-browser/AgentBrowserBridge.ts @@ -16,6 +16,8 @@ export interface PrepareAgentBrowserLaunchInput { orchestrationTools?: readonly string[]; /** Kimi: the launch's own account home (`KIMI_CODE_HOME` inside the contributing plugin's data), for its config. */ accountHome?: string; + /** The card's own (already filtered) environment, which the launch builds from instead of the app's. */ + environment?: Readonly>; } export interface PreparedAgentBrowserPtyLaunch { @@ -68,13 +70,15 @@ export class AgentBrowserBridge implements AgentBrowserLaunchCoordinator { // Browser access off: no canvastty_browser and no browser capability, but a session that gets canvastty_agents // (orchestrators, plugin tools) still gets it; that server has its own capability and gateway. if (!this.gateway.isEnabled) return input.includeOrchestration ? this.prepareOrchestrationOnly(input) : null; - const capability = this.gateway.registerAgent(input); + const { environment, ...agent } = input; + const capability = this.gateway.registerAgent(agent); let providerLaunch; try { providerLaunch = this.providers.prepare(input.provider, capability.connectionId, { ...(input.includeOrchestration ? { orchestration: true } : {}), ...(input.orchestrationTools ? { orchestrationTools: input.orchestrationTools } : {}), - ...(input.accountHome ? { accountHome: input.accountHome } : {}) + ...(input.accountHome ? { accountHome: input.accountHome } : {}), + ...(environment ? { environment } : {}) }); } catch (error) { this.gateway.revokeTerminalSession(input.terminalSessionId); @@ -97,7 +101,8 @@ export class AgentBrowserBridge implements AgentBrowserLaunchCoordinator { orchestration: true, browser: false, ...(input.orchestrationTools ? { orchestrationTools: input.orchestrationTools } : {}), - ...(input.accountHome ? { accountHome: input.accountHome } : {}) + ...(input.accountHome ? { accountHome: input.accountHome } : {}), + ...(input.environment ? { environment: input.environment } : {}) }); return this.launchWith(providerLaunch, "", connectionId, () => undefined, {}); } diff --git a/src/main/services/agent-browser/ProviderLaunch.ts b/src/main/services/agent-browser/ProviderLaunch.ts index ac7ee3daf..b5e2fdcf1 100644 --- a/src/main/services/agent-browser/ProviderLaunch.ts +++ b/src/main/services/agent-browser/ProviderLaunch.ts @@ -161,7 +161,14 @@ export class ProviderLaunchAdapters { * the `KIMI_CODE_HOME` a launch contributor started this launch with; Kimi's temporary configuration goes there when * it is an account home (accountHomes.ts), since that is the only home this Kimi reads. */ - prepare(provider: AgentProvider, connectionId: string, options?: { orchestration?: boolean; orchestrationTools?: readonly string[]; browser?: boolean; accountHome?: string }): PreparedProviderLaunch { + prepare(provider: AgentProvider, connectionId: string, options?: { + orchestration?: boolean; + orchestrationTools?: readonly string[]; + browser?: boolean; + accountHome?: string; + /** The card's own (already filtered) environment; the launch reads the person's OpenCode inline config from it. */ + environment?: Readonly>; + }): PreparedProviderLaunch { const providerCli = this.providerClis.get(provider); if (providerCli.state === "unavailable") throw new Error(providerCli.diagnostic); const orchestrationHelper = orchestrationHelperFor(this.options, options); @@ -192,7 +199,7 @@ export class ProviderLaunchAdapters { if (provider === "opencode") { return { args: [], - environment: openCodeBrowserEnvironment(browserHelper, this.environment, orchestrationHelper), + environment: openCodeBrowserEnvironment(browserHelper, options?.environment ?? this.environment, orchestrationHelper), releaseConfiguration() {} }; } diff --git a/src/main/services/agent-runtime/AgentRuntimeBridge.ts b/src/main/services/agent-runtime/AgentRuntimeBridge.ts index 161ac208f..72aa1e65f 100644 --- a/src/main/services/agent-runtime/AgentRuntimeBridge.ts +++ b/src/main/services/agent-runtime/AgentRuntimeBridge.ts @@ -25,6 +25,8 @@ export interface PrepareAgentRuntimeLaunchInput { claudeHttp?: ClaudeHttpLaunchFacts; /** Grok/Kimi: the CLI home a launch contributor started the launch with (`accountHookHome`), for its status hooks. */ accountHome?: string; + /** The card's own (already filtered) environment, which the launch builds from instead of the app's. */ + environment?: Readonly>; } export interface PreparedAgentRuntimePtyLaunch { @@ -94,7 +96,7 @@ export class AgentRuntimeBridge implements AgentRuntimeLaunchCoordinator { let prepared; try { prepared = this.providers.prepare(input.provider, input.terminalSessionId, this.coreHooksEnabled, decisions, budgetMs, - httpHookBase ?? undefined, input.accountHome); + httpHookBase ?? undefined, input.accountHome, input.environment); } 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 29ceaed4b..e14ccd973 100644 --- a/src/main/services/agent-runtime/ProviderRuntimeLaunch.ts +++ b/src/main/services/agent-runtime/ProviderRuntimeLaunch.ts @@ -202,7 +202,9 @@ export class ProviderRuntimeLaunchAdapters { decisions = false, decisionBudgetMs?: number, claudeHttpHookBase?: string, - accountHome?: string + accountHome?: string, + /** The card's own (already filtered) environment; the launch reads the person's OpenCode inline config from it. */ + environment: Readonly> = this.environment ): PreparedProviderRuntimeLaunch { const pluginRegistrations = this.options.pluginHooks?.list(provider) ?? []; const gate = decisions && this.decisionsSupported(provider); @@ -215,7 +217,7 @@ export class ProviderRuntimeLaunchAdapters { // MiniMax, Devin and Antigravity must never reach Grok's shared hook overlay. const hasHooks = HOOK_PROVIDERS.has(provider) && (coreHooksEnabled || pluginCommands.length > 0 || (provider === "opencode" && (pluginRegistrations.length > 0 || openCodeDecisions))); - const environment = hasHooks + const hookEnvironment = hasHooks ? { ...(pluginRegistrations.length > 0 ? { [PLUGIN_HOOK_TERMINAL_SESSION_ENV]: terminalSessionId @@ -224,13 +226,13 @@ export class ProviderRuntimeLaunchAdapters { : {}; if (!hasHooks) { this.clearSharedOverlay(provider); - return prepared([], environment); + return prepared([], hookEnvironment); } if (provider === "claude") { - return prepared(claudeHookArgs(this.options.helper, this.platform, coreHooksEnabled, pluginCommands, claudeHttpHookBase), environment); + return prepared(claudeHookArgs(this.options.helper, this.platform, coreHooksEnabled, pluginCommands, claudeHttpHookBase), hookEnvironment); } if (provider === "codex") { - return prepared(codexHookArgs(this.options.helper, this.platform, coreHooksEnabled, pluginCommands), environment); + return prepared(codexHookArgs(this.options.helper, this.platform, coreHooksEnabled, pluginCommands), hookEnvironment); } if (provider === "qwen") { const path = createQwenHookSettings({ @@ -242,7 +244,7 @@ export class ProviderRuntimeLaunchAdapters { coreHooksEnabled, pluginCommands }); - return prepared([], { ...environment, [QWEN_SYSTEM_SETTINGS]: path }, () => unlinkIfExists(path)); + return prepared([], { ...hookEnvironment, [QWEN_SYSTEM_SETTINGS]: path }, () => unlinkIfExists(path)); } if (provider === "opencode") { const pluginEnvironment = this.openCodePluginEnvironment( @@ -250,11 +252,11 @@ export class ProviderRuntimeLaunchAdapters { coreHooksEnabled ); return prepared([], { - ...environment, + ...hookEnvironment, ...pluginEnvironment, ...(openCodeDecisions ? { [OPENCODE_DECISIONS_ENV]: "1", ...budgetEnvironment(decisionBudgetMs) } : {}), [OPENCODE_CONFIG_CONTENT]: openCodeLifecycleConfig( - this.environment[OPENCODE_CONFIG_CONTENT], + environment[OPENCODE_CONFIG_CONTENT], this.options.openCodePluginPath ) }); @@ -262,20 +264,20 @@ export class ProviderRuntimeLaunchAdapters { if (provider === "omp" || provider === "pi") { return prepared(coreHooksEnabled ? ["--extension", join(dirname(this.options.openCodePluginPath), "omp-extension.mjs")] - : [], environment); + : [], hookEnvironment); } if (provider === "kimi" || provider === "grok") { const ownHome = this.ownAccountHome(provider, accountHome); - if (ownHome) return prepared([], environment, this.acquireAccountHome(provider, ownHome, coreHooksEnabled, pluginCommands)); + if (ownHome) return prepared([], hookEnvironment, this.acquireAccountHome(provider, ownHome, coreHooksEnabled, pluginCommands)); } if (provider === "kimi") { - return prepared([], environment, this.acquireKimi(coreHooksEnabled, pluginCommands)); + return prepared([], hookEnvironment, this.acquireKimi(coreHooksEnabled, pluginCommands)); } if (provider === "hermes") { - return prepared([], environment, this.acquireHermes(coreHooksEnabled, pluginCommands)); + return prepared([], hookEnvironment, this.acquireHermes(coreHooksEnabled, pluginCommands)); } - if (provider === "grok") return prepared([], environment, this.acquireGrok(coreHooksEnabled, pluginCommands)); - return prepared([], environment); + if (provider === "grok") return prepared([], hookEnvironment, this.acquireGrok(coreHooksEnabled, pluginCommands)); + return prepared([], hookEnvironment); } recoverConfigurations(): void { diff --git a/src/main/services/ambientProviderKeys.ts b/src/main/services/ambientProviderKeys.ts new file mode 100644 index 000000000..3657702f6 --- /dev/null +++ b/src/main/services/ambientProviderKeys.ts @@ -0,0 +1,87 @@ +/** + * The person's opt-in: a comma- or space-separated list of variable names that agent cards keep from CanvasTTY's + * own environment although this module would leave them out (e.g. `OPENAI_ADMIN_KEY`). Read from the app's + * environment only, so an agent cannot widen it for itself. + */ +export const AGENT_PASS_ENV = "CANVASTTY_AGENT_PASS_ENV"; + +/** + * Organization-level credentials (OPENAI_ADMIN_KEY, ANTHROPIC_ADMIN_KEY, XAI_ADMIN_API_KEY, GITLAB_ADMIN_TOKEN, …): + * projects, members, usage, billing. No agent CLI signs in with them. + */ +const ADMIN_CREDENTIAL = /(?:^|_)ADMIN_[A-Z0-9_]*(?:KEY|TOKEN)$/u; + +/** + * CanvasTTY's own credentials in its environment (CANVASTTY_GITHUB_TOKEN, which the plugin catalog uses, and any + * CANVASTTY_*TOKEN / *KEY / *SECRET / *PASSWORD): the app's, read by the main process, never needed by a card. + * "KEY" counts only as a whole word part (CANVASTTY_CODEX_KEYBOARD is not one). + */ +const APP_CREDENTIAL = /^CANVASTTY_(?:[A-Z0-9]+_)*[A-Z0-9]*(?:TOKEN|KEY|SECRET|PASSWORD|PASSWD)(?:_|$)/u; + +/** + * What a delegated card inherits from the person's environment: what a process needs to run, find its tools, reach + * the network and find its CLI's own state, and nothing that signs in anywhere. Proxy variables are kept on purpose + * (without them nothing reaches the network behind a corporate proxy), although a proxy URL may carry its own + * user:password. SSH_AUTH_SOCK, GH_TOKEN, AWS_*, cloud and provider credentials are left out; the person lists one in + * AGENT_PASS_ENV, or a launch contribution passes it explicitly (secretEnv). + */ +const DELEGATED_NAMES = new Set([ + // Process basics + "PATH", "HOME", "USER", "LOGNAME", "SHELL", "PWD", "SHLVL", "TZ", "LANG", "LANGUAGE", + "TERM", "COLORTERM", "TERM_PROGRAM", "TERM_PROGRAM_VERSION", "COLORFGBG", "NO_COLOR", "FORCE_COLOR", + "TMPDIR", "TEMP", "TMP", "EDITOR", "VISUAL", "PAGER", "__CF_USER_TEXT_ENCODING", + // Linux desktop session (addresses only) + "DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "DBUS_SESSION_BUS_ADDRESS", + // Windows + "SYSTEMROOT", "SYSTEMDRIVE", "WINDIR", "COMSPEC", "PATHEXT", "APPDATA", "LOCALAPPDATA", "USERPROFILE", "USERNAME", + "HOMEDRIVE", "HOMEPATH", "PROGRAMDATA", "PROGRAMFILES", "PROGRAMFILES(X86)", "PROGRAMW6432", "COMMONPROGRAMFILES", + "COMMONPROGRAMFILES(X86)", "PUBLIC", "ALLUSERSPROFILE", "OS", "PROCESSOR_ARCHITECTURE", "NUMBER_OF_PROCESSORS", + // Network: proxies and the person's own certificate authorities + "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", + "SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS", "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE", + // Where each CLI keeps its own state (a moved home is where the CLI's sign-in lives, not a credential) + "CLAUDE_CONFIG_DIR", "CLAUDE_SECURESTORAGE_CONFIG_DIR", "CODEX_HOME", "GROK_HOME", "KIMI_CODE_HOME", "KIMI_SHARE_DIR", + "KIMI_HOME", "QWEN_HOME", "QWEN_RUNTIME_DIR", "HERMES_HOME", "PI_CODING_AGENT_DIR", "PI_CODING_AGENT_SESSION_DIR", + "PI_CONFIG_DIR", "OPENCODE_CONFIG", "OPENCODE_CONFIG_DIR", "MINIMAX_DATA_DIR", "MAVIS_DATA_DIR", "CURSOR_CONFIG_DIR" +]); +/** + * Locale (LC_ALL, LC_CTYPE, …) and XDG folders. No CANVASTTY_* variable is inherited: what a card needs of CanvasTTY's + * own (its runtime, browser and orchestration addresses and capabilities, the Codex keyboard) the launch sets itself, + * and the rest (CANVASTTY_HELPERS, CANVASTTY_USER_DATA_DIR, CANVASTTY_GITHUB_TOKEN, …) is read by the main process. + */ +const DELEGATED_PREFIX = /^(?:LC_|XDG_)/u; + +export interface AgentEnvironmentInput { + /** Not started by the person: a subagent, or any card (agent or plain shell) a plugin started. */ + delegated: boolean; +} + +/** + * The environment a card inherits from CanvasTTY's own (terminalEnvironment), before what the core and launch + * contributions set for the launch. A plain shell card the person starts goes through shellEnvironment instead. + * Every launch builder (agent runtime, browser bridge, OpenCode inline config) reads this result, not process.env. + * - The person's own top-level agent cards keep everything but admin and CanvasTTY credentials: a CLI signed in by + * API key keeps working, in any profile. + * - A delegated card gets only DELEGATED_NAMES / DELEGATED_PREFIX (an allowlist, so no provider, cloud or git + * credential, whatever its name); what it needs beyond that it gets explicitly, through a launch contribution's + * secretEnv. + * Names in AGENT_PASS_ENV are kept. Names compare case-insensitively (Windows). + */ +/** A plain shell card the person starts: their whole environment, without CanvasTTY's own credentials. */ +export function shellEnvironment(source: Readonly>): Record { + return Object.fromEntries(Object.entries(source).filter(([key]) => !APP_CREDENTIAL.test(key.toUpperCase()))); +} + +export function agentEnvironment( + source: Readonly>, + input: AgentEnvironmentInput +): Record { + const passKey = Object.keys(source).find((key) => key.toUpperCase() === AGENT_PASS_ENV); + const pass = new Set((passKey ? source[passKey] : "").split(/[\s,]+/u).filter(Boolean).map((name) => name.toUpperCase())); + return Object.fromEntries(Object.entries(source).filter(([key]) => { + const name = key.toUpperCase(); + if (pass.has(name)) return true; + if (ADMIN_CREDENTIAL.test(name) || APP_CREDENTIAL.test(name)) return false; + return !input.delegated || DELEGATED_NAMES.has(name) || DELEGATED_PREFIX.test(name); + })); +} diff --git a/tests/agent-ambient-keys.test.mjs b/tests/agent-ambient-keys.test.mjs new file mode 100644 index 000000000..81ba49641 --- /dev/null +++ b/tests/agent-ambient-keys.test.mjs @@ -0,0 +1,280 @@ +import assert from "node:assert/strict"; +import { mkdtemp, realpath, 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 { AgentBrowserBridge } from "../src/main/services/agent-browser/AgentBrowserBridge.ts"; +import { AgentRuntimeBridge } from "../src/main/services/agent-runtime/AgentRuntimeBridge.ts"; +import { isControlProvider } from "../src/main/services/agent-control/controlCapabilities.ts"; +import { PluginSessions } from "../src/main/services/PluginSessions.ts"; +import { AgentIsolation } from "../src/main/services/isolation/AgentIsolation.ts"; +import { AGENT_PASS_ENV, agentEnvironment, shellEnvironment } from "../src/main/services/ambientProviderKeys.ts"; +import { TerminalManager } from "../src/main/services/TerminalManager.ts"; +import { availableRegistry, fakeSpawner } from "./helpers/terminal.mjs"; + +const at = { x: 0, y: 0 }; +// Fake values only: the test sets them on its own process, never reads the person's environment. +const FAKE = { + OPENAI_API_KEY: "fake-oa", + OPENAI_ADMIN_KEY: "fake-oa-adm", + ANTHROPIC_API_KEY: "fake-an", + ANTHROPIC_ADMIN_KEY: "fake-an-adm", + OPENROUTER_API_KEY: "fake-or", + GEMINI_API_KEY: "fake-gemini", + GITLAB_ADMIN_TOKEN: "fake-gl-adm", + ANTHROPIC_AUTH_TOKEN: "fake-at", + CLAUDE_CODE_OAUTH_TOKEN: "fake-oauth", + AWS_BEARER_TOKEN_BEDROCK: "fake-bedrock", + AWS_ACCESS_KEY_ID: "fake-akid", + AWS_SECRET_ACCESS_KEY: "fake-sak", + AWS_SESSION_TOKEN: "fake-st", + GITHUB_TOKEN: "fake-gh", + GH_TOKEN: "fake-gh2", + SSH_AUTH_SOCK: "/fake/agent", + XAI_API_KEY: "fake-xai", + HF_TOKEN: "fake-hf", + NPM_TOKEN: "fake-npm", + HTTPS_PROXY: "http://proxy:3128", + no_proxy: "localhost", + NODE_EXTRA_CA_CERTS: "/fake/ca.pem", + LC_ALL: "C.UTF-8", + EDITOR: "vi", + CANVASTTY_GITHUB_TOKEN: "fake-ctgh" +}; +const ADMIN = ["OPENAI_ADMIN_KEY", "ANTHROPIC_ADMIN_KEY", "GITLAB_ADMIN_TOKEN"]; +const ORDINARY = ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY"]; +/** Credentials that are not provider API keys by name; a delegated card gets none of them (allowlist). */ +const OTHER_CREDENTIALS = ["ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", "AWS_BEARER_TOKEN_BEDROCK", "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", "GITHUB_TOKEN", "GH_TOKEN", "SSH_AUTH_SOCK", "XAI_API_KEY", "HF_TOKEN", "NPM_TOKEN"]; +/** What a delegated card still needs to run and reach the network. */ +const KEPT = ["PATH", "HTTPS_PROXY", "no_proxy", "NODE_EXTRA_CA_CERTS", "LC_ALL", "EDITOR"]; +const SECRETS = [...ADMIN, ...ORDINARY, ...OTHER_CREDENTIALS]; +const SECRET_VALUES = [...SECRETS, "CANVASTTY_GITHUB_TOKEN"].map((name) => FAKE[name]); + +/** Sets fake keys (and optionally the opt-in) on this process for the test, restoring what was there. */ +function withFakeKeys(t, extra = {}) { + const names = [...Object.keys(FAKE), AGENT_PASS_ENV]; + const saved = Object.fromEntries(names.map((name) => [name, process.env[name]])); + t.after(() => { + for (const [name, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + }); + delete process.env[AGENT_PASS_ENV]; + Object.assign(process.env, FAKE, extra); +} + +async function project(t) { + const base = await realpath(await mkdtemp(join(tmpdir(), "ctty-keys-"))); + t.after(() => rm(base, { recursive: true, force: true })); + return base; +} + +function present(env, names) { + return names.filter((name) => env[name] !== undefined); +} + +function manager(t, calls, isolation) { + const terminals = new TerminalManager(() => undefined, availableRegistry(), undefined, undefined, true, fakeSpawner(calls)); + t.after(() => terminals.shutdown()); + if (isolation) terminals.configureIsolation(isolation); + return terminals; +} + +/** A layer that always applies and records the environment it wraps. */ +function recordingLayer(userData, wrapped) { + return { + containment: () => true, + decide: (input) => new AgentIsolation({ userDataPath: userData, enabled: () => true, platform: "darwin", exists: () => true }).decide(input), + wrap: (launch) => { + wrapped.push(launch.env); + return { command: "/usr/bin/sandbox-exec", args: ["-f", "/p.sb", launch.command, ...launch.args], env: { ...launch.env, TMPDIR: "/s/" }, cleanup: () => undefined }; + } + }; +} + +test("(a) a plain shell card the person starts keeps their environment, provider and admin keys included", async (t) => { + withFakeKeys(t); + const cwd = await project(t); + const calls = []; + manager(t, calls).create({ provider: "terminal", profile: "normal", cwd, position: at }); + const env = calls.at(-1).options.env; + assert.deepEqual(present(env, SECRETS), SECRETS); + assert.equal(env.CANVASTTY_GITHUB_TOKEN, undefined, "CanvasTTY's own token is the app's, not the shell's"); +}); + +test("a plain shell card a plugin starts is delegated: its owner drives it, so it gets only the allowlist", async (t) => { + withFakeKeys(t); + const cwd = await project(t); + const calls = []; + const terminals = manager(t, calls); + const sessions = new PluginSessions({ terminals, notify: () => true }); + await sessions.handle("p.shell", "svc", "sessions.create", { provider: "terminal", cwd }, ["sessions:launch"]); + assert.equal(calls.length, 1); + const env = calls.at(-1).options.env; + assert.deepEqual(present(env, SECRETS), [], "no admin key, provider key or other credential"); + assert.deepEqual(present(env, KEPT), KEPT, "what it needs to run and reach the network stays"); + // Directly through the manager too (whatever the caller), keyed on the owning plugin. + terminals.create({ provider: "terminal", profile: "normal", cwd, position: at }, { origin: "plugin", ownerPluginId: "p.shell" }); + assert.deepEqual(present(calls.at(-1).options.env, SECRETS), []); +}); + +test("an orchestrator cannot start a plain shell card, neither through spawn_agent nor its control connection", async (t) => { + const cwd = await project(t); + const calls = []; + const terminals = manager(t, calls); + const control = new AgentControlService(terminals, { containment: () => terminals.containment() }); + const orchestrator = terminals.create({ provider: "codex", profile: "normal", cwd, position: at, role: "orchestrator" }); + const before = calls.length; + assert.throws(() => control.spawn({ parentSessionId: orchestrator.id, provider: "terminal", cwd }), /Unknown agent provider/u); + assert.equal(calls.length, before); + assert.equal(isControlProvider("terminal"), false); +}); + +test("(b) an agent card the person starts keeps ordinary provider keys but never admin keys", async (t) => { + withFakeKeys(t); + const cwd = await project(t); + const calls = []; + const terminals = manager(t, calls); + for (const provider of ["claude", "codex", "opencode", "qwen"]) { + terminals.create({ provider, profile: "normal", cwd, position: at }); + const env = calls.at(-1).options.env; + assert.deepEqual(present(env, ADMIN), [], `${provider}: no admin key`); + assert.equal(env.CANVASTTY_GITHUB_TOKEN, undefined, `${provider}: no CanvasTTY credential`); + assert.deepEqual(present(env, ORDINARY), ORDINARY, `${provider}: its sign-in by API key keeps working`); + assert.deepEqual(present(env, OTHER_CREDENTIALS), OTHER_CREDENTIALS, `${provider}: otherwise unchanged for the person's own card`); + assert.equal(env.OPENAI_API_KEY, FAKE.OPENAI_API_KEY); + } +}); + +test("(c) an agent card under isolation: no admin keys reach the layer or the process; the person's own card keeps ordinary keys", async (t) => { + withFakeKeys(t); + const cwd = await project(t); + const calls = []; + const wrapped = []; + const terminals = manager(t, calls, recordingLayer(join(cwd, "u"), wrapped)); + const card = terminals.create({ provider: "claude", profile: "auto", cwd, position: at }); + assert.deepEqual(card.isolation, { state: "on", layer: "seatbelt" }); + assert.equal(calls.at(-1).command, "/usr/bin/sandbox-exec"); + for (const env of [wrapped.at(-1), calls.at(-1).options.env]) { + assert.deepEqual(present(env, ADMIN), []); + assert.deepEqual(present(env, ORDINARY), ORDINARY); + } +}); + +test("(d) a subagent gets no ambient provider key, admin key or other credential, isolated or not", async (t) => { + withFakeKeys(t); + const cwd = await project(t); + for (const layer of ["isolated", "no layer"]) { + const calls = []; + const wrapped = []; + const isolation = layer === "isolated" + ? recordingLayer(join(cwd, "u"), wrapped) + : new AgentIsolation({ userDataPath: join(cwd, "u"), enabled: () => true, platform: "win32" }); + const terminals = manager(t, calls, isolation); + const control = new AgentControlService(terminals, { containment: () => terminals.containment() }); + const orchestrator = terminals.create({ provider: "codex", profile: "auto", cwd, position: at, role: "orchestrator" }); + assert.deepEqual(present(calls.at(-1).options.env, ORDINARY), ORDINARY, `${layer}: the person's orchestrator keeps them`); + const child = await control.spawn({ parentSessionId: orchestrator.id, provider: "claude", cwd }); + assert.equal(child.role, "subagent"); + const env = calls.at(-1).options.env; + assert.deepEqual(present(env, SECRETS), [], `${layer}: the subagent`); + if (layer === "isolated") assert.deepEqual(present(wrapped.at(-1), SECRETS), []); + assert.deepEqual(present(env, KEPT), KEPT, "what it needs to run and reach the network stays"); + } +}); + +test("the person's explicit opt-in passes named keys to every agent card, subagents included", async (t) => { + withFakeKeys(t, { [AGENT_PASS_ENV]: "OPENAI_ADMIN_KEY, ANTHROPIC_API_KEY" }); + const cwd = await project(t); + const calls = []; + const terminals = manager(t, calls, new AgentIsolation({ userDataPath: join(cwd, "u"), enabled: () => true, platform: "win32" })); + const control = new AgentControlService(terminals, { containment: () => terminals.containment() }); + const top = terminals.create({ provider: "codex", profile: "normal", cwd, position: at, role: "orchestrator" }); + assert.deepEqual(present(calls.at(-1).options.env, ADMIN), ["OPENAI_ADMIN_KEY"]); + await control.spawn({ parentSessionId: top.id, provider: "claude", cwd }); + assert.deepEqual(present(calls.at(-1).options.env, SECRETS), ["OPENAI_ADMIN_KEY", "ANTHROPIC_API_KEY"]); +}); + +test("agentEnvironment: names compare case-insensitively (Windows); admin credentials of any provider; delegated is an allowlist", () => { + const base = { Path: "C:\\bin", openai_api_key: "x", Openai_Admin_Key: "y", GITHUB_TOKEN: "g", AZURE_OPENAI_API_KEY: "z", + XAI_ADMIN_API_KEY: "w", ADMIN_TOKEN: "a", SOME_ADMIN_ACCESS_TOKEN: "b", HOME: "/h", SystemRoot: "C:\\Windows", + https_proxy: "http://p", Lc_Ctype: "UTF-8", XDG_CONFIG_HOME: "/h/.config", CODEX_HOME: "/h/.codex", MY_SERVICE_PASSWORD: "p" }; + assert.deepEqual(agentEnvironment(base, { delegated: false }), { Path: "C:\\bin", openai_api_key: "x", GITHUB_TOKEN: "g", + AZURE_OPENAI_API_KEY: "z", HOME: "/h", SystemRoot: "C:\\Windows", https_proxy: "http://p", Lc_Ctype: "UTF-8", + XDG_CONFIG_HOME: "/h/.config", CODEX_HOME: "/h/.codex", MY_SERVICE_PASSWORD: "p" }); + const delegated = { Path: "C:\\bin", HOME: "/h", SystemRoot: "C:\\Windows", https_proxy: "http://p", Lc_Ctype: "UTF-8", + XDG_CONFIG_HOME: "/h/.config", CODEX_HOME: "/h/.codex" }; + assert.deepEqual(agentEnvironment(base, { delegated: true }), delegated); + assert.deepEqual(agentEnvironment({ ...base, [AGENT_PASS_ENV]: "openai_admin_key GITHUB_TOKEN" }, { delegated: true }), + { ...delegated, Openai_Admin_Key: "y", GITHUB_TOKEN: "g" }); + // CanvasTTY's own credentials reach no agent card, the person's own included; its other variables are not inherited + // by a delegated card (the launch sets what a card needs). + const app = { CANVASTTY_GITHUB_TOKEN: "t", CANVASTTY_API_KEY: "k", CANVASTTY_CODEX_KEYBOARD: "{}", CANVASTTY_HELPERS: "node" }; + assert.deepEqual(agentEnvironment(app, { delegated: false }), { CANVASTTY_CODEX_KEYBOARD: "{}", CANVASTTY_HELPERS: "node" }); + assert.deepEqual(agentEnvironment(app, { delegated: true }), {}); + assert.deepEqual(shellEnvironment({ ...app, GITHUB_TOKEN: "g" }), { CANVASTTY_CODEX_KEYBOARD: "{}", CANVASTTY_HELPERS: "node", GITHUB_TOKEN: "g" }); +}); + +/** The real launch builders (agent runtime hooks, browser/orchestration MCP bridge), both built over process.env. */ +function builders(root) { + const helper = { command: "/app/CanvasTTY", args: ["/app/helper.mjs"], env: { ELECTRON_RUN_AS_NODE: "1" } }; + const browserGateway = { + isEnabled: true, + setEnabled() {}, + registerAgent: (input) => ({ agentId: "a", connectionId: "c", terminalSessionId: input.terminalSessionId, provider: input.provider, + capabilityToken: "b".repeat(40), address: "/tmp/b.sock" }), + revokeTerminalSession() {}, + holdPendingForTerminal() {} + }; + const runtimeGateway = { + registerSession: (id, provider) => ({ address: "/tmp/r.sock", terminalSessionId: id, provider, capabilityToken: "r".repeat(40) }), + revokeTerminalSession() {}, + currentStatus: () => null + }; + const browser = new AgentBrowserBridge(browserGateway, { helper, orchestrationHelper: helper, providerClis: availableRegistry(), + runtimeDirectory: join(root, "rt-b"), hermesHomeDirectory: join(root, "hermes"), kimiHomeDirectory: join(root, "kimi"), + probeKimiPerRunConfig: () => true }); + const runtime = new AgentRuntimeBridge(runtimeGateway, { helper, runtimeDirectory: join(root, "rt-r"), + openCodePluginPath: "/app/opencode-plugin.mjs", kimiHomeDirectory: join(root, "kimi"), hermesHomeDirectory: join(root, "hermes"), + grokHomeDirectory: join(root, "grok"), coreHooksEnabled: true }); + return { browser, runtime }; +} + +test("no launch builder re-injects what the filter removed: the env handed to the PTY of a delegated card holds no ambient credential", async (t) => { + // The person's OpenCode inline config carries a provider key; the builders read it from the app's environment. + withFakeKeys(t, { OPENCODE_CONFIG_CONTENT: JSON.stringify({ provider: { x: { options: { apiKey: "fake-oc" } } } }) }); + t.after(() => { delete process.env.OPENCODE_CONFIG_CONTENT; }); + const cwd = await project(t); + const calls = []; + const { browser, runtime } = builders(cwd); + const terminals = new TerminalManager(() => undefined, availableRegistry(), browser, runtime, true, fakeSpawner(calls)); + t.after(() => terminals.shutdown()); + const control = new AgentControlService(terminals, { containment: () => terminals.containment() }); + const orchestrator = terminals.create({ provider: "opencode", profile: "normal", cwd, position: at, role: "orchestrator" }); + const own = calls.at(-1).options.env; + assert.match(own.OPENCODE_CONFIG_CONTENT, /fake-oc/u, "the person's own card keeps their OpenCode config"); + assert.match(own.OPENCODE_CONFIG_CONTENT, /opencode-plugin\.mjs/u); + assert.equal(own.CANVASTTY_GITHUB_TOKEN, undefined); + assert.deepEqual(present(own, ADMIN), []); + for (const provider of ["opencode", "claude", "codex", "qwen"]) { + await control.spawn({ parentSessionId: orchestrator.id, provider, cwd }); + const env = calls.at(-1).options.env; + const text = JSON.stringify(env); + for (const value of [...SECRET_VALUES, "fake-oc"]) assert.ok(!text.includes(value), `${provider} subagent: ${value} is not in its env`); + assert.deepEqual(present(env, [...SECRETS, "CANVASTTY_GITHUB_TOKEN"]), [], provider); + if (provider === "opencode") { + const config = JSON.parse(env.OPENCODE_CONFIG_CONTENT); + assert.ok(config.plugin.some((entry) => entry.endsWith("/opencode-plugin.mjs")), "its lifecycle plugin is still there"); + assert.ok(config.mcp.canvastty_browser, "its browser server is still there"); + assert.equal(config.provider, undefined, "the person's inline config is not handed to a delegated agent"); + } + } + // A plugin-started plain shell, through the same manager. + const sessions = new PluginSessions({ terminals, notify: () => true }); + await sessions.handle("p.shell", "svc", "sessions.create", { provider: "terminal", cwd }, ["sessions:launch"]); + const shell = JSON.stringify(calls.at(-1).options.env); + for (const value of [...SECRET_VALUES, "fake-oc"]) assert.ok(!shell.includes(value), `plugin shell: ${value}`); +}); diff --git a/tests/launch-contributors.test.mjs b/tests/launch-contributors.test.mjs index df2491a1e..426cf4135 100644 --- a/tests/launch-contributors.test.mjs +++ b/tests/launch-contributors.test.mjs @@ -430,3 +430,22 @@ test("end to end: the example service prepares a launch over JSON-RPC", async (t const empty = await pipeline.prepare(context({ greeting: "" })); assert.match(empty.reason, /Launch Env: Value is empty/u); }); + +test("a plugin-started agent drops ambient provider keys, yet the key its launch contribution names explicitly arrives", async (t) => { + // Fake values only, set on this test process. + const saved = { OPENAI_API_KEY: process.env.OPENAI_API_KEY, OPENAI_ADMIN_KEY: process.env.OPENAI_ADMIN_KEY, ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY }; + t.after(() => { for (const [key, value] of Object.entries(saved)) { if (value === undefined) delete process.env[key]; else process.env[key] = value; } }); + Object.assign(process.env, { OPENAI_API_KEY: "fake-oa", OPENAI_ADMIN_KEY: "fake-oa-adm", ANTHROPIC_API_KEY: "fake-an" }); + const { pipeline } = await pipelineFixture(t, { + contributors: [contributor("p.keys", { secrets: true })], + answers: { "p.keys": { secretEnv: { OPENAI_API_KEY: "api-key" } } }, + secrets: { "p.keys/api-key": SECRET } + }); + const { manager, calls } = await managerFixture(t, pipeline); + manager.create({ provider: "codex", profile: "normal", cwd, position: at, launchOptions: { "p.keys": {} } }, { ownerPluginId: "p.keys" }); + await waitFor(() => calls.length === 1); + const env = calls[0].options.env; + assert.equal(env.OPENAI_API_KEY, SECRET, "the contribution's own key"); + assert.equal(env.ANTHROPIC_API_KEY, undefined, "no ambient provider key for a delegated agent"); + assert.equal(env.OPENAI_ADMIN_KEY, undefined, "never an admin key"); +}); From fc434b3f525d14ec93e87d39ecb64ff86719905d Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:16:29 +0000 Subject: [PATCH 04/15] fix(opencode): send OpenCode's file tools to decisions in Claude Code's shapes OpenCode's decision bridge sent write, edit and multiedit all as toolName "edit" with {file_path, content}. A decision service could not tell a whole-file write from a partial edit, and an edit's replaced text was dropped, so it could not see what the edit removes. The bridge now uses Claude Code's names and shapes, which hooks and decision services already read: - write: Write {file_path, content} - edit: Edit {file_path, old_string, new_string, replace_all} - multiedit: MultiEdit {file_path, edits: [{old_string, new_string, replace_all}]} - bash and apply_patch are unchanged. Base protection reads file_path for Write, Edit and MultiEdit as before. A test goes through DecisionHooks: the service gets kind "edit", the path and both texts, and a MultiEdit outside the project is still denied. The docs (en/ru/zh-CN, plugin-api.d.ts) list the shapes OpenCode's tools arrive in. --- docs/plugin-api.d.ts | 6 +++- docs/plugins.md | 2 +- docs/plugins.ru.md | 2 +- docs/plugins.zh-CN.md | 2 +- src/agent-runtime/opencode-decisions.mjs | 45 ++++++++++++++++-------- tests/decision-hooks.test.mjs | 26 +++++++++++--- 6 files changed, 61 insertions(+), 22 deletions(-) diff --git a/docs/plugin-api.d.ts b/docs/plugin-api.d.ts index 8ab1b19a3..65c14bd51 100644 --- a/docs/plugin-api.d.ts +++ b/docs/plugin-api.d.ts @@ -287,7 +287,11 @@ export interface CanvasTTYDecisionRequest { /** The agent's current folder, when its CLI reports it. */ agentCwd: string | null; tool: { name: string; kind: "shell" | "edit" | "other"; command: string | null; paths: string[] }; - /** The tool input as the agent sent it; null when it was over 40 KB (then `truncated`). */ + /** + * The tool input as the agent sent it; null when it was over 40 KB (then `truncated`). OpenCode's tools come in Claude + * Code's names and shapes: bash {command, workdir}, Write {file_path, content}, Edit {file_path, old_string, new_string, + * replace_all}, MultiEdit {file_path, edits: [{old_string, new_string, replace_all}]}, apply_patch {patch}. + */ input: unknown; truncated: boolean; /** How long the host waits for this answer (the service's `timeoutMs`). */ diff --git a/docs/plugins.md b/docs/plugins.md index 0047ad17b..1ce010fb3 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -286,7 +286,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, 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`). OpenCode's tools arrive under Claude Code's names and input shapes: `bash` `{command, workdir}`, `Write` `{file_path, content}` (the whole new file), `Edit` `{file_path, old_string, new_string, replace_all}`, `MultiEdit` `{file_path, edits: [{old_string, new_string, replace_all}]}` and `apply_patch` `{patch}`. `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 { diff --git a/docs/plugins.ru.md b/docs/plugins.ru.md index 5d2cfe9d1..ac4965b95 100644 --- a/docs/plugins.ru.md +++ b/docs/plugins.ru.md @@ -264,7 +264,7 @@ CanvasTTY владеет окном, PTY, сохранённой записью }] ``` -`pre-tool` — каждый вызов shell-инструмента и записи файла до выполнения, в любом режиме разрешений, включая YOLO: у Claude Code, Codex и Qwen Code через их хук `PreToolUse`, у OpenCode через плагин CanvasTTY для OpenCode (`tool.execute.before`). `appliesTo` ограничивает агентов; без него — все четыре. Хост отправляет `canvastty.decide` (только хост) и ждёт не больше `timeoutMs` (от 1000 до 60000; без него 3000). Вызов агента ждёт столько же, поэтому просите больше, только когда ответу это нужно (например, локальная модель читает команду); CanvasTTY настраивает хук каждого окна под самый долгий бюджет сервисов, которые к нему относятся на момент запуска, а сервис, которому доверились позже, получает не больше, чем позволяет окно. Бюджет приходит в запросе как `budgetMs`: +`pre-tool` — каждый вызов shell-инструмента и записи файла до выполнения, в любом режиме разрешений, включая YOLO: у Claude Code, Codex и Qwen Code через их хук `PreToolUse`, у OpenCode через плагин CanvasTTY для OpenCode (`tool.execute.before`). Инструменты OpenCode приходят с именами и формой ввода Claude Code: `bash` `{command, workdir}`, `Write` `{file_path, content}` (весь новый файл), `Edit` `{file_path, old_string, new_string, replace_all}`, `MultiEdit` `{file_path, edits: [{old_string, new_string, replace_all}]}` и `apply_patch` `{patch}`. `appliesTo` ограничивает агентов; без него — все четыре. Хост отправляет `canvastty.decide` (только хост) и ждёт не больше `timeoutMs` (от 1000 до 60000; без него 3000). Вызов агента ждёт столько же, поэтому просите больше, только когда ответу это нужно (например, локальная модель читает команду); CanvasTTY настраивает хук каждого окна под самый долгий бюджет сервисов, которые к нему относятся на момент запуска, а сервис, которому доверились позже, получает не больше, чем позволяет окно. Бюджет приходит в запросе как `budgetMs`: ```ts interface DecisionRequest { diff --git a/docs/plugins.zh-CN.md b/docs/plugins.zh-CN.md index c91ff2c60..0f2e185cf 100644 --- a/docs/plugins.zh-CN.md +++ b/docs/plugins.zh-CN.md @@ -262,7 +262,7 @@ CanvasTTY 负责卡片、PTY、保存的记录和恢复顺序;服务回答五 }] ``` -`pre-tool` 指每一次 shell 和写文件的工具调用,在运行之前,并且适用于所有权限模式(包括 YOLO):Claude Code、Codex 和 Qwen Code 通过它们的 `PreToolUse` hook,OpenCode 通过 CanvasTTY 的 OpenCode 插件(`tool.execute.before`)。`appliesTo` 限定 agent;省略时为全部四个。主机发送 `canvastty.decide`(仅主机可发),最多等待 `timeoutMs`(1000 到 60000;省略时 3000)。agent 的调用会等待同样长的时间,所以只有在回答确实需要时才申请更长时间(例如让本地模型阅读命令);CanvasTTY 在卡片启动时按适用服务中最长的预算设置该卡片的 hook,之后才被信任的服务所得时间不超过卡片允许的范围。请求以 `budgetMs` 携带该预算: +`pre-tool` 指每一次 shell 和写文件的工具调用,在运行之前,并且适用于所有权限模式(包括 YOLO):Claude Code、Codex 和 Qwen Code 通过它们的 `PreToolUse` hook,OpenCode 通过 CanvasTTY 的 OpenCode 插件(`tool.execute.before`)。OpenCode 的工具以 Claude Code 的名称和输入形状到达:`bash` `{command, workdir}`、`Write` `{file_path, content}`(整个新文件)、`Edit` `{file_path, old_string, new_string, replace_all}`、`MultiEdit` `{file_path, edits: [{old_string, new_string, replace_all}]}` 和 `apply_patch` `{patch}`。`appliesTo` 限定 agent;省略时为全部四个。主机发送 `canvastty.decide`(仅主机可发),最多等待 `timeoutMs`(1000 到 60000;省略时 3000)。agent 的调用会等待同样长的时间,所以只有在回答确实需要时才申请更长时间(例如让本地模型阅读命令);CanvasTTY 在卡片启动时按适用服务中最长的预算设置该卡片的 hook,之后才被信任的服务所得时间不超过卡片允许的范围。请求以 `budgetMs` 携带该预算: ```ts interface DecisionRequest { diff --git a/src/agent-runtime/opencode-decisions.mjs b/src/agent-runtime/opencode-decisions.mjs index d9e9c4ca3..f3ec617e6 100644 --- a/src/agent-runtime/opencode-decisions.mjs +++ b/src/agent-runtime/opencode-decisions.mjs @@ -62,9 +62,13 @@ 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}. + * An OpenCode tool call in the shape the decision hook reads (Claude Code's hook tool names and input shapes), 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, replaceAll}, multiedit {filePath, edits: [{filePath, + * oldString, newString, replaceAll}]}, apply_patch {patchText}. They go out as bash {command, workdir}, Write {file_path, + * content} (the whole new file), Edit {file_path, old_string, new_string, replace_all} (one replacement in the file), + * MultiEdit {file_path, edits: [{old_string, new_string, replace_all}]} and apply_patch {patch}, so a decision service + * can tell a whole-file write from a partial edit and sees the text an edit replaces. */ export function guardedCall(tool, args) { if (!tool || !args || typeof args !== "object") return null; @@ -74,13 +78,15 @@ export function guardedCall(tool, args) { return { toolName: "bash", toolInput: { command: args.command, ...(workdir ? { workdir } : {}) } }; } if (tool === "write" || tool === "edit" || tool === "multiedit") { - const filePath = typeof args.filePath === "string" && args.filePath.length > 0 ? args.filePath - : typeof args.file_path === "string" && args.file_path.length > 0 ? args.file_path : null; + const filePath = pathOf(args); if (!filePath) return null; - const content = typeof args.content === "string" ? args.content - : typeof args.newString === "string" ? args.newString - : multieditText(args.edits); - return { toolName: "edit", toolInput: { file_path: filePath, ...(content !== null ? { content } : {}) } }; + if (tool === "write") { + return { toolName: "Write", toolInput: { file_path: filePath, ...(typeof args.content === "string" ? { content: args.content } : {}) } }; + } + if (tool === "edit") return { toolName: "Edit", toolInput: { file_path: filePath, ...replacement(args) } }; + const edits = Array.isArray(args.edits) ? args.edits.filter((edit) => edit && typeof edit === "object").map(replacement) + .filter((edit) => Object.keys(edit).length > 0) : []; + return { toolName: "MultiEdit", toolInput: { file_path: filePath, edits } }; } if (tool === "apply_patch" || tool === "patch") { const patch = typeof args.patchText === "string" ? args.patchText : typeof args.patch === "string" ? args.patch : null; @@ -89,11 +95,22 @@ export function guardedCall(tool, args) { return null; } -/** Every multiedit edit's new text, one per line (what the batch writes), or null. */ -function multieditText(edits) { - if (!Array.isArray(edits)) return null; - const texts = edits.flatMap((edit) => edit && typeof edit === "object" && typeof edit.newString === "string" ? [edit.newString] : []); - return texts.length > 0 ? texts.join("\n") : null; +function pathOf(args) { + return typeof args.filePath === "string" && args.filePath.length > 0 ? args.filePath + : typeof args.file_path === "string" && args.file_path.length > 0 ? args.file_path : null; +} + +/** One OpenCode replacement {oldString, newString, replaceAll} as Claude's Edit input (fields that are not text are left out). */ +function replacement(edit) { + const text = (camel, snake) => typeof edit[camel] === "string" ? edit[camel] : typeof edit[snake] === "string" ? edit[snake] : null; + const oldString = text("oldString", "old_string"); + const newString = text("newString", "new_string"); + const replaceAll = typeof edit.replaceAll === "boolean" ? edit.replaceAll : typeof edit.replace_all === "boolean" ? edit.replace_all : null; + return { + ...(oldString !== null ? { old_string: oldString } : {}), + ...(newString !== null ? { new_string: newString } : {}), + ...(replaceAll !== null ? { replace_all: replaceAll } : {}) + }; } /** POST /permission/{requestID}/reply with `once`. False on any failure (OpenCode's prompt stays for the person). */ diff --git a/tests/decision-hooks.test.mjs b/tests/decision-hooks.test.mjs index b7a4a2308..2afb7fda2 100644 --- a/tests/decision-hooks.test.mjs +++ b/tests/decision-hooks.test.mjs @@ -345,7 +345,7 @@ test("OpenCode: the guard throws a deny, remembers an allow and answers OpenCode const sent = []; const decisions = createOpenCodeDecisions({ client, env, send: async (_address, message) => { sent.push(message); return next; } }); await assert.rejects(decisions.guard({ tool: "write", callID: "c1" }, { args: { filePath: "/etc/hosts", content: "x" } }), /Not outside the project/u); - assert.equal(sent[0].toolName, "edit"); + assert.equal(sent[0].toolName, "Write", "a whole-file write keeps its own name"); next = { behavior: "allow", message: "" }; await decisions.guard({ tool: "bash", callID: "c2" }, { args: { command: "ls" } }); assert.equal(await decisions.permissionAsked({ id: "r1", tool: { callID: "c2" } }), true); @@ -358,9 +358,27 @@ test("OpenCode: the guard throws a deny, remembers an allow and answers OpenCode assert.equal(sent.length, 3, "tools that neither run commands nor write files are not sent"); 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" } }); - // 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 /" } }); + // OpenCode's file tools go out in Claude Code's shapes: Write is the whole new file; Edit and MultiEdit carry the + // text they replace as well as the new text (OpenCode run 15: edit {filePath, oldString, newString, replaceAll}). + assert.deepEqual(guardedCall("write", { filePath: "/p/new.mjs", content: "export const a = 1;\n" }), { toolName: "Write", toolInput: { file_path: "/p/new.mjs", content: "export const a = 1;\n" } }); + assert.deepEqual(guardedCall("edit", { filePath: "/p/a.ts", oldString: "const a = 1;", newString: "const a = 2;", replaceAll: false }), + { toolName: "Edit", toolInput: { file_path: "/p/a.ts", old_string: "const a = 1;", new_string: "const a = 2;", replace_all: false } }); + assert.deepEqual(guardedCall("edit", { filePath: "/p/a.ts", oldString: "x", newString: "" }), + { toolName: "Edit", toolInput: { file_path: "/p/a.ts", old_string: "x", new_string: "" } }, "a deletion keeps its empty new text; replaceAll absent stays absent"); + assert.deepEqual(guardedCall("multiedit", { filePath: "/p/a.ts", edits: [{ filePath: "/p/a.ts", oldString: "a", newString: "TOKEN=1" }, + { oldString: "b", newString: "rm -rf /", replaceAll: true }, { newString: 5 }, null] }), + { toolName: "MultiEdit", toolInput: { file_path: "/p/a.ts", edits: [{ old_string: "a", new_string: "TOKEN=1" }, + { old_string: "b", new_string: "rm -rf /", replace_all: true }] } }); + assert.equal(guardedCall("edit", { oldString: "a", newString: "b" }), null, "no path, nothing to check"); + // Through the host: base protection still finds the path, and the service gets the edit's kind, path and both texts. + const calls = []; + const host = hooks({ services: [service("p")], answers: { p: null }, calls }); + const edit = guardedCall("edit", { filePath: join(project, "a.ts"), oldString: "a", newString: "b" }); + assert.equal((await host.decide("s1", request(edit.toolName, edit.toolInput), live())).behavior, "none"); + assert.deepEqual(calls[0].params.tool, { name: "Edit", kind: "edit", command: null, paths: [join(project, "a.ts")] }); + assert.deepEqual(calls[0].params.input, { file_path: join(project, "a.ts"), old_string: "a", new_string: "b" }); + const outside = guardedCall("multiedit", { filePath: "/etc/hosts", edits: [{ oldString: "a", newString: "b" }] }); + assert.equal((await host.decide("s1", request(outside.toolName, outside.toolInput), live())).behavior, "deny", "MultiEdit outside the project"); }); test("manifest: decide needs decision:provide, lists pre-tool, and at most one service per plugin decides", () => { From 60714a789d1cc578b418d10944f7ccd37e75a75f Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:16:29 +0000 Subject: [PATCH 05/15] fix(opencode): bring a subagent's shell ask to the person, never to its orchestrator An OpenCode subagent on a launch contributor's model keeps bash on "ask" (a launch contributor's other model keeps bash asking, like accept-edits elsewhere). When no decision service decides, OpenCode's own prompt waits in the subagent's card for the person. That part already works: the card turns needs_approval, Needs attention lists it, and wait_for_agent returns at once with the prompt in output. Two things stood in the way: - send_to_agent still typed into that card. Any prompt text ends with Enter, which answers OpenCode's prompt, so the orchestrator (on the same third-party model) could approve the command for the person. It is now refused while the subagent is needs_approval, with a reason that tells the orchestrator to ask the person and wait. - wait_for_agent returned the same pending prompt again on every call, so an orchestrator could only poll or give up. It now returns a prompt once; a later wait on the same prompt (the card has not changed since) waits for the person's answer and then for the subagent's turn, so the orchestrator carries on in the same turn once the person has answered. A new prompt is reported at once again. Unchanged: bash stays "ask" for a third-party model in every card; a decision service's deny still fails the call before OpenCode asks and its allow (from a plugin the person let allow) still answers OpenCode's prompt once; no verdict is never turned into an allow; person cards and OpenCode on its own model are untouched; no new UI. The test runs CanvasTTY's real OpenCode plugin, runtime gateway, decision hooks, TerminalManager and orchestration tools: allow answers the prompt, deny fails the call, no verdict reaches Needs attention and the orchestrator, send_to_agent writes nothing, and the person's answer resumes the subagent and the orchestrator's wait. --- agent/orchestrator/SKILL.md | 2 +- docs/agent-orchestration.md | 10 +- native/canvastty-helper/catalog.json | 4 +- src/agent-browser/orchestration-catalog.mjs | 4 +- src/main/services/AgentControlService.ts | 20 +- src/main/services/openCodeConfig.ts | 3 +- tests/opencode-subagent-ask.test.mjs | 192 ++++++++++++++++++++ 7 files changed, 225 insertions(+), 10 deletions(-) create mode 100644 tests/opencode-subagent-ask.test.mjs diff --git a/agent/orchestrator/SKILL.md b/agent/orchestrator/SKILL.md index 1b68bdb22..337f22e36 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` 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. -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 600; call it again after `timeout`) instead of polling. `needs_approval` means the person must answer a prompt in that card (`output` shows it, for example a shell command): never answer it yourself, and `send_to_agent` is refused meanwhile. Tell the person what it asks, then call `wait_for_agent` again: it waits for their answer. 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 985d0656e..97c443648 100644 --- a/docs/agent-orchestration.md +++ b/docs/agent-orchestration.md @@ -10,9 +10,9 @@ 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`. 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 ≤ 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` (once per prompt: a later wait on the same prompt waits for the person's answer instead of returning it again), `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. `send_to_agent` is refused while the subagent is `needs_approval`: what it typed (or its Enter) would answer the person's prompt. | ### Subagent profiles @@ -28,7 +28,11 @@ OpenCode has no auto flag, so its auto profile is a per-run `OPENCODE_CONFIG_CON - `external_directory` is untouched: anything outside the project folder asks as before (each tool checks it first). - The person's own deny and ask rules for these tools (global and project `opencode.json(c)`, `OPENCODE_CONFIG`, the inline config) are appended after the auto rules, so they still win: auto never allows what the person denied or wanted to be asked about. -A launch contributor that puts OpenCode on another model keeps `bash` asking, like accept-edits for the other CLIs. +A launch contributor that puts OpenCode on another model keeps `bash` asking, like accept-edits for the other CLIs. In a subagent's card that ask goes to the person as every subagent's prompt does: + +- CanvasTTY's guard sees the command first. A decision service's deny fails the call with its reason before OpenCode asks; an allow from a plugin the person let allow answers OpenCode's prompt once. Neither leaves a prompt waiting. +- With no verdict, OpenCode's own prompt stays. The card turns `needs_approval`, which puts it in Needs attention (and the notification, when it is on). Its row opens the card, where the person answers the prompt OpenCode shows for that exact call. +- The orchestrator's `wait_for_agent` returns `needs_approval` with the prompt in `output`. It cannot answer it: `send_to_agent` is refused while the subagent waits, and a second `wait_for_agent` waits for the person's answer, then for the subagent's turn to end. The control CLI's `create --profile auto|normal|acceptEdits|plan|yolo` is the equivalent for its workers (from the person's own terminal it defaults to YOLO, which needs the person's acknowledgement for that CLI). diff --git a/native/canvastty-helper/catalog.json b/native/canvastty-helper/catalog.json index d3ec39a27..c70ab2c90 100644 --- a/native/canvastty-helper/catalog.json +++ b/native/canvastty-helper/catalog.json @@ -1045,7 +1045,7 @@ }, { "name": "send_to_agent", - "description": "Write a prompt into one of this session's subagents. Plain terminal sessions are not agents.", + "description": "Write a prompt into one of this session's subagents. Plain terminal sessions are not agents. Refused while the subagent is needs_approval: that prompt is the person's to answer.", "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 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, such as a shell command it wants to run, shown in output; never answer it yourself: tell the person what it asks, then call wait_for_agent again, which waits for their answer instead of returning the same prompt again), \"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.", "inputSchema": { "type": "object", "properties": { diff --git a/src/agent-browser/orchestration-catalog.mjs b/src/agent-browser/orchestration-catalog.mjs index ad7b53d22..409f1a70f 100644 --- a/src/agent-browser/orchestration-catalog.mjs +++ b/src/agent-browser/orchestration-catalog.mjs @@ -73,7 +73,7 @@ export const ORCHESTRATION_TOOL_DEFINITIONS = Object.freeze([ ), tool( "send_to_agent", - "Write a prompt into one of this session's subagents. Plain terminal sessions are not agents.", + "Write a prompt into one of this session's subagents. Plain terminal sessions are not agents. Refused while the subagent is needs_approval: that prompt is the person's to answer.", { sessionId, prompt, submit: boolean() }, ["sessionId", "prompt"] ), @@ -85,7 +85,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, such as a shell command it wants to run, shown in output; never answer it yourself: tell the person what it asks, then call wait_for_agent again, which waits for their answer instead of returning the same prompt again), "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.`, { sessionId, timeoutSeconds: integer({ minimum: 1, maximum: MAX_AGENT_WAIT_SECONDS }) }, ["sessionId"] ), diff --git a/src/main/services/AgentControlService.ts b/src/main/services/AgentControlService.ts index 34a5fda11..ead8c8c01 100644 --- a/src/main/services/AgentControlService.ts +++ b/src/main/services/AgentControlService.ts @@ -133,6 +133,11 @@ export class PromptNotDeliveredError extends Error { export class AgentControlService { private readonly terminals: TerminalManager; private readonly options: AgentControlOptions; + /** + * The card revision at which wait_for_agent last returned needs_approval for a subagent. While the card is still at + * that same prompt, the next wait waits for the person's answer instead of returning it again. + */ + private readonly reportedApprovals = new Map(); constructor(terminals: TerminalManager, options: AgentControlOptions = {}) { this.terminals = terminals; @@ -216,6 +221,9 @@ export class AgentControlService { 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."); + // The card shows a prompt only the person may answer (OpenCode's own shell prompt, for example): text typed into + // it now, or the Enter after it, would answer that prompt on the person's behalf. + if (session.status === "needs_approval") throw new DelegationRefusal(APPROVAL_REFUSAL); const input = session.provider === "codex" ? codexPromptInput(text, submit) : submit ? `${text}\r` : text; return this.deliver(sessionId, input, "text", signal); } @@ -346,6 +354,8 @@ export class AgentControlService { const started = Date.now(); const answer = (session: SessionMetadata | null, reason: AgentWaitReason): AgentWaitResult => { const waitedMs = Date.now() - started; + if (reason === "needs_approval") this.reportedApprovals.set(sessionId, session?.revision); + else if (session?.status !== "needs_approval") this.reportedApprovals.delete(sessionId); if (!session) return { sessionId, reason, exitCode: null, waitedMs, output: "" }; let observation: AgentObservation | null = null; try { observation = this.observe(sessionId); } catch { observation = null; } @@ -363,7 +373,10 @@ export class AgentControlService { if (current !== offset) { offset = current; changedAt = now; } const quietFor = now - changedAt; if (session.exitCode !== null) return answer(session, session.exitCode === 0 ? "done" : "failed"); - if (session.status === "needs_approval") return answer(session, "needs_approval"); + // A prompt this wait already returned (the card has not changed since) is the person's to answer: wait for it. + const reported = this.reportedApprovals.has(sessionId) && this.reportedApprovals.get(sessionId) === session.revision; + if (session.status === "needs_approval" && !reported) return answer(session, "needs_approval"); + if (session.status !== "needs_approval") this.reportedApprovals.delete(sessionId); // After a prompt, an idle that no turn followed (the CLI's startup idle, or one reported before the turn began) // is not the answer: only an idle after a turn that started since that prompt is. A CLI that never reports its // turns still ends as "quiet" once its screen stops changing. @@ -435,6 +448,11 @@ export class AgentControlService { } } +/** send_to_agent while the subagent's card waits for the person (needs_approval). */ +export const APPROVAL_REFUSAL = "This subagent is waiting for the person to answer a prompt in its card (needs_approval); " + + "only the person may answer it, so nothing was sent. Tell the person what it asks (wait_for_agent's output shows it), " + + "then call wait_for_agent again: it waits for their answer."; + /** A delegation rule refused the request; the text says why, for the orchestrator to adapt instead of retrying. */ export class DelegationRefusal extends LaunchRefusal { constructor(message: string) { diff --git a/src/main/services/openCodeConfig.ts b/src/main/services/openCodeConfig.ts index d9e33e91a..b1f911dbf 100644 --- a/src/main/services/openCodeConfig.ts +++ b/src/main/services/openCodeConfig.ts @@ -102,7 +102,8 @@ export function openCodeYoloEnvironment( * Auto's rules: read, glob, grep, list allowed, except `.env` files (they ask, like OpenCode's default); edit (edit, * write, apply_patch) allowed; bash allowed only when `shellGuarded` (CanvasTTY's base protection is on and its guard * runs in this OpenCode), otherwise it asks. external_directory is not touched: a path outside the project still asks. - * `thirdPartyModel` (a launch contributor put OpenCode on another model) keeps bash asking, like accept-edits. + * `thirdPartyModel` (a launch contributor put OpenCode on another model) keeps bash asking, like accept-edits, in a + * subagent's card too: that ask reaches the person through needs_approval, and the orchestrator cannot answer it. */ export function openCodeAutoEnvironment( environment: Readonly>, diff --git a/tests/opencode-subagent-ask.test.mjs b/tests/opencode-subagent-ask.test.mjs new file mode 100644 index 000000000..21b9b20e5 --- /dev/null +++ b/tests/opencode-subagent-ask.test.mjs @@ -0,0 +1,192 @@ +/** + * An OpenCode subagent on a launch contributor's model keeps `bash` on "ask" (the rule of 566cb4f7: a third-party + * model's own judgement is not a safety boundary). Its ask must reach someone who may answer it: + * - a decision service's deny fails the call before OpenCode asks; an allow answers OpenCode's prompt once; + * - with no verdict, OpenCode's own prompt stays and the card turns needs_approval: Needs attention lists it, the + * orchestrator's wait_for_agent returns it with the prompt, the orchestrator cannot answer it, and the person's + * answer resumes the subagent and the orchestrator's next wait. + * The real OpenCode plugin module, runtime gateway, decision hooks, TerminalManager and orchestration tools run here; + * no OpenCode, no model. HOME is whatever the test runner's fake HOME is. + */ +import assert from "node:assert/strict"; +import { mkdtemp, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { AGENT_RUNTIME_ENV, OPENCODE_DECISIONS_ENV } from "../src/agent-runtime/runtime-protocol.mjs"; +import { AgentControlService, APPROVAL_REFUSAL } from "../src/main/services/AgentControlService.ts"; +import { DecisionHooks } from "../src/main/services/DecisionHooks.ts"; +import { LaunchPipeline } from "../src/main/services/LaunchPipeline.ts"; +import { TerminalManager } from "../src/main/services/TerminalManager.ts"; +import { RuntimeGateway } from "../src/main/services/agent-runtime/RuntimeGateway.ts"; +import { ScopedOrchestrationHandler } from "../src/main/services/agent-browser/OrchestrationTools.ts"; +import { attentionSessions } from "../src/renderer/src/features/home/attentionQueue.ts"; +import { availableRegistry, fakeSpawner } from "./helpers/terminal.mjs"; + +const POSIX = { skip: process.platform === "win32" ? "Unix sockets." : false }; +const until = async (check, what, ms = 4_000) => { + const deadline = Date.now() + ms; + while (!check()) { + if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`); + await new Promise((resolve) => setTimeout(resolve, 5)); + } +}; + +function runtime(decisions) { + return { + prepareLaunch: () => ({ args: [], environment: decisions ? { CANVASTTY_RUNTIME_DECISIONS: "1" } : {}, decisions, cleanup() {} }), + currentStatus: () => null + }; +} + +/** A launch contributor that puts OpenCode on its own model, as the Assistant's Auto router does. */ +async function modelContributor(t) { + const runsRoot = await mkdtemp(join(tmpdir(), "ctty-oc-ask-runs-")); + t.after(() => rm(runsRoot, { recursive: true, force: true })); + return new LaunchPipeline({ + contributors: () => [{ pluginId: "p.router", pluginName: "Router", serviceId: "svc", secrets: false, + launch: { fields: [{ key: "on", label: "On", kind: "boolean", default: true }], delegable: true } }], + call: async () => ({ thirdPartyModel: true }), + secret: async () => null, + runsRoot, + timeoutMs: 500 + }); +} + +async function cards(t, { thirdPartyModel }) { + const root = await realpath(await mkdtemp(join(tmpdir(), "ctty-oc-ask-"))); + t.after(() => rm(root, { recursive: true, force: true })); + const calls = []; + const writes = []; + const terminals = new TerminalManager(() => undefined, availableRegistry(), undefined, runtime(true), true, + fakeSpawner(calls, { onWrite: (data, options) => writes.push({ data, options }) })); + terminals.configureBaseProtection(() => true); + t.after(() => terminals.disposeAll()); + if (thirdPartyModel) terminals.configureLaunchPipeline(await modelContributor(t)); + const options = thirdPartyModel ? { launchOptions: { "p.router": { on: true } } } : {}; + const launch = async (request) => { + const before = calls.length; + const created = terminals.create({ provider: "opencode", cwd: root, profile: "auto", position: { x: 0, y: 0 }, ...options, ...request }); + await until(() => calls.length > before, "the launch"); + const inline = calls.at(-1).options.env.OPENCODE_CONFIG_CONTENT; + return { id: created.id, env: calls.at(-1).options.env, bash: JSON.parse(inline).agent.build.permission.bash, + metadata: () => terminals.getMetadata(created.id) }; + }; + const person = await launch({}); + const orchestrator = await launch({ role: "orchestrator" }); + const subagent = await launch({ role: "subagent", parentSessionId: orchestrator.id }); + return { root, terminals, writes, person, orchestrator, subagent }; +} + +test("a subagent on a launch contributor's model keeps bash asking; person cards and an own-model OpenCode are unchanged", async (t) => { + const routed = await cards(t, { thirdPartyModel: true }); + for (const card of [routed.person, routed.orchestrator, routed.subagent]) { + assert.equal(card.bash, "ask", "a third-party model's shell commands ask, in every card"); + assert.equal(card.metadata().autoDowngraded, true, "and the card says auto runs as edits only"); + } + assert.equal(routed.subagent.metadata().role, "subagent"); + const own = await cards(t, { thirdPartyModel: false }); + for (const card of [own.person, own.orchestrator, own.subagent]) { + assert.equal(card.bash, "allow", "OpenCode on its own model under base protection: the guard is the gate, as before"); + assert.equal(card.metadata().autoDowngraded, undefined); + } +}); + +test("the subagent's shell ask: decisions answer OpenCode's prompt, otherwise it reaches the person, not the orchestrator", POSIX, async (t) => { + const { terminals, writes, orchestrator, subagent, root } = await cards(t, { thirdPartyModel: true }); + // What the decision service answers for each command; null is no opinion. + const verdicts = { "npm test": { verdict: "allow", reason: "tests" }, "rm -rf build": { verdict: "deny", reason: "not that" }, ls: null }; + const decide = new DecisionHooks({ + baseProtection: () => true, + services: () => [{ pluginId: "p.review", pluginName: "Review", serviceId: "decide", mayAllow: true }], + call: async (_plugin, _service, _method, params) => verdicts[params.tool.command] ?? null, + session: (id) => id === subagent.id ? { provider: "opencode", role: "subagent", cwd: root, configDirs: [], profile: "auto" } : null + }); + const runtimeDirectory = await mkdtemp(join(tmpdir(), "ctty-oc-ask-rt-")); + const gateway = new RuntimeGateway({ + runtimeDirectory, + onPermissionRequest: (id, request, signal) => decide.decide(id, request, signal), + // As src/main/index.ts applies it. + onSignal: (id, signal) => terminals.applyProviderSignal(id, { kind: "lifecycle", state: signal.state, event: signal.event, + ...(signal.turnId ? { requestId: signal.turnId } : {}), ...(signal.threadId ? { threadId: signal.threadId } : {}) }) + }); + await gateway.start(); + t.after(async () => { await gateway.close(); await rm(runtimeDirectory, { recursive: true, force: true }); }); + const capability = gateway.registerSession(subagent.id, "opencode", false, undefined, true); + + // The subagent's OpenCode loads CanvasTTY's plugin with the environment its launch gives it. + assert.equal(subagent.env[OPENCODE_DECISIONS_ENV], "1", "the launch installs the guard"); + Object.assign(process.env, { + [OPENCODE_DECISIONS_ENV]: "1", + [AGENT_RUNTIME_ENV.address]: capability.address, + [AGENT_RUNTIME_ENV.terminalSessionId]: subagent.id, + [AGENT_RUNTIME_ENV.provider]: "opencode", + [AGENT_RUNTIME_ENV.capabilityToken]: capability.capabilityToken + }); + const { CanvasTTYLifecycle } = await import("../src/agent-runtime/opencode-plugin.mjs"); + const replies = []; + const plugin = await CanvasTTYLifecycle({ client: { permission: { reply: async (body) => { replies.push(body); return { response: { ok: true } }; } } } }); + const root_ = "ses_root01"; + const event = (type, properties) => plugin.event({ event: { type, properties } }); + await event("session.created", { info: { id: root_ } }); + await event("session.status", { sessionID: root_, status: { type: "busy" } }); + await until(() => subagent.metadata().status === "working", "the subagent working"); + const bash = async (callID, command) => { + await plugin["tool.execute.before"]({ tool: "bash", sessionID: root_, callID }, { args: { command } }); + // The guard let the call go on: OpenCode now checks bash, which asks. + await event("permission.asked", { id: `per_${callID}`, sessionID: root_, permission: "bash", patterns: [command], tool: { messageID: "msg_1", callID } }); + }; + + // A decision service's allow answers OpenCode's prompt once; nobody else is asked. + await bash("c1", "npm test"); + assert.deepEqual(replies, [{ requestID: "per_c1", reply: "once" }]); + assert.equal(subagent.metadata().status, "working", "no needs_approval for an answered prompt"); + // Its deny fails the call with the reason before OpenCode asks anything. + await assert.rejects(plugin["tool.execute.before"]({ tool: "bash", sessionID: root_, callID: "c2" }, { args: { command: "rm -rf build" } }), + /Review.*not that/u); + assert.equal(replies.length, 1); + + // No verdict: OpenCode's own prompt stays, for the person. CanvasTTY never answers it. + await bash("c3", "ls"); + assert.equal(replies.length, 1, "no verdict is never turned into an allow"); + await until(() => subagent.metadata().status === "needs_approval", "needs_approval"); + assert.deepEqual(attentionSessions(terminals.list()).map((session) => session.id), [subagent.id], "Needs attention lists the subagent"); + + // The orchestrator learns at once, with the prompt; it cannot answer it. + const control = new AgentControlService(terminals, { waitTiming: { checkMs: 5, settleMs: 20, quietMs: 60_000 } }); + const handler = new ScopedOrchestrationHandler(control, null, { cli: () => "available", limits: () => ({ maxDepth: 2, maxSubagents: 8 }) }); + const call = (tool, args) => handler.execute(orchestrator.id, { id: "r", tool, arguments: args }); + const first = await call("wait_for_agent", { sessionId: subagent.id, timeoutSeconds: 5 }); + assert.equal(first.reason, "needs_approval"); + assert.ok(first.waitedMs < 1_000); + const typed = writes.length; + for (const prompt of ["y", "allow"]) { + await assert.rejects(call("send_to_agent", { sessionId: subagent.id, prompt }), (error) => { + assert.equal(error.bridgeError?.code ?? error.code, "INVALID_REQUEST"); + assert.equal(error.message, APPROVAL_REFUSAL); + return true; + }); + } + assert.equal(writes.length, typed, "nothing reached the subagent's terminal"); + + // The next wait waits for the person instead of returning the same prompt again. + const again = call("wait_for_agent", { sessionId: subagent.id, timeoutSeconds: 5 }); + let settled = false; + void again.then(() => { settled = true; }, () => { settled = true; }); + await new Promise((resolve) => setTimeout(resolve, 150)); + assert.equal(settled, false, "still waiting while the prompt waits for the person"); + // The person answers OpenCode's prompt in the card; OpenCode replies, runs `ls` and ends the turn. + await event("permission.replied", { sessionID: root_, requestID: "per_c3", reply: "once" }); + await until(() => subagent.metadata().status === "working", "the subagent working again"); + assert.deepEqual(attentionSessions(terminals.list()), [], "the answered card leaves Needs attention"); + await event("session.status", { sessionID: root_, status: { type: "idle" } }); + const done = await again; + assert.equal(done.reason, "idle"); + assert.equal(replies.length, 1, "the person answered, not CanvasTTY"); + + // A new prompt after that is reported at once again. + await event("session.status", { sessionID: root_, status: { type: "busy" } }); + await bash("c4", "ls"); + await until(() => subagent.metadata().status === "needs_approval", "the second prompt"); + assert.equal((await call("wait_for_agent", { sessionId: subagent.id, timeoutSeconds: 5 })).reason, "needs_approval"); +}); From 78ff159aede06bea91557e02a84af2e713327e1f Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:16:29 +0000 Subject: [PATCH 06/15] fix(orchestration): an orchestrator's control connection never answers a subagent's approval prompt Asks are answered by the person, or by a decision service the person allowed, never by the orchestrator. The canvastty_agents tools already refuse send_to_agent while a subagent is needs_approval. The control endpoint did not: on an Orchestrator session's own connection, choose answered Codex's approval prompt ("1. Yes, proceed") and dismiss answered it with Escape ("No, and tell Codex what to do differently"). On an orchestrator's connection, choose, dismiss and send are now refused with NEEDS_PERSON (not retryable, nothing is written) while the worker is needs_approval, the status each CLI's own approval signal sets. For Codex's choose and dismiss the menu is also checked: Codex can draw its approval prompt before its PermissionRequest hook's report arrives, and choose acts on that exact screen. codexApprovalPrompt matches Codex's command, edits, permissions, network and MCP approval prompts, not hooks review, folder trust, model or update menus. Unchanged: menus that are not approvals stay the orchestrator's; status, screen and result still read; interrupt still stops its own turn (the asked call does not run); the person's own app-wide connection, the card's input and decision services are untouched. Tests run the real TerminalManager, AgentControlService, gateway and control CLI request path with an orchestrator's session grant: approval prompts refused before and after the hook report, non-approval menus chosen and dismissed, the person's connection still choosing, and send_to_agent refused at every CLI's approval signal and delivered once the person answered. --- agent/orchestrator/SKILL.md | 4 +- docs/agent-orchestration.md | 4 +- scripts/canvastty-control.mjs | 6 +- .../agent-control/AgentControlGateway.ts | 26 ++- .../agent-control/controlCapabilities.ts | 5 +- tests/orchestrator-approval-answers.test.mjs | 217 ++++++++++++++++++ 6 files changed, 254 insertions(+), 8 deletions(-) create mode 100644 tests/orchestrator-approval-answers.test.mjs diff --git a/agent/orchestrator/SKILL.md b/agent/orchestrator/SKILL.md index 337f22e36..4deda8bba 100644 --- a/agent/orchestrator/SKILL.md +++ b/agent/orchestrator/SKILL.md @@ -32,10 +32,10 @@ Use the bundled `canvastty-control.mjs` CLI with a running CanvasTTY instance wh Run `providers` first to see which providers are installed and signed in. If the person names a model, add `--model ` (OpenCode: `provider/model`); `--effort ` works for Codex, Claude and Grok. - `--provider` accepts codex, claude, qwen, kimi, opencode, hermes, grok, omp and pi; each worker uses that provider's own normal/YOLO launch flags. The create response includes `capabilities`: only Codex workers have `result: true` (captured final answer) and `menus: true` (startup/approval menus that `choose`/`dismiss` can act on). For other providers, `result` ends as `no_result` and menus must be resolved by the user; treat their `screen` text as the only evidence. From the person's own terminal CLI creation defaults to YOLO, using the provider's full-access, no-approval launch flag; inside an Orchestrator session it defaults to that session's profile. It does not change global provider settings. `--profile normal` is available when the user requests their ordinary configured profile; do not silently replace their selected permissions. + `--provider` accepts codex, claude, qwen, kimi, opencode, hermes, grok, omp and pi; each worker uses that provider's own normal/YOLO launch flags. The create response includes `capabilities`: only Codex workers have `result: true` (captured final answer) and `menus: true` (startup menus that `choose`/`dismiss` can act on). For other providers, `result` ends as `no_result` and menus must be resolved by the user; treat their `screen` text as the only evidence. From the person's own terminal CLI creation defaults to YOLO, using the provider's full-access, no-approval launch flag; inside an Orchestrator session it defaults to that session's profile. It does not change global provider settings. `--profile normal` is available when the user requests their ordinary configured profile; do not silently replace their selected permissions. 4. Save the returned session ID and request ID. Verify provider, profile, cwd and launch status. A returned ID or an idle status alone does not prove a working agent. 5. Inspect `screen ` until the worker is at an empty composer (verified automatically for Codex; for other providers judge from the screen text). Trust, authentication, permission menus and startup failures are not task prompts. Do not submit a task to dismiss them. -6. Resolve only reviewed, authorized menu actions: `choose --choice --revision `. For an idle menu offering Escape, `dismiss --revision ` closes it. A stale revision requires another inspection. In particular, inspect new or changed lifecycle hooks before trusting them; YOLO does not imply approval of unknown hooks. Verify the next screen after each choice. +6. An approval or permission prompt (`status` is `needs_approval`, or Codex asks to run a command, apply edits or grant access) is the person's to answer, never yours: inside an Orchestrator session `choose`, `dismiss` and `send` are refused with `NEEDS_PERSON` meanwhile. Tell the person what it asks, then check `status` again. Resolve only reviewed, authorized startup menu actions: `choose --choice --revision `. For an idle menu offering Escape, `dismiss --revision ` closes it. A stale revision requires another inspection. In particular, inspect new or changed lifecycle hooks before trusting them; YOLO does not imply approval of unknown hooks. Verify the next screen after each choice. ## Run and verify diff --git a/docs/agent-orchestration.md b/docs/agent-orchestration.md index 97c443648..38e07d3ce 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`. 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` (once per prompt: a later wait on the same prompt waits for the person's answer instead of returning it again), `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` is refused while the subagent is `needs_approval`: what it typed (or its Enter) would answer the person's prompt. | +| `send_to_agent`, `cancel_agent`, `list_agents` | Follow-up prompts, disposing a subagent, listing this session's subagents. `send_to_agent` is refused while the subagent is `needs_approval` (any CLI's own approval or permission prompt: Claude Code, Codex, Grok, Kimi, Qwen, Hermes, OpenCode's permission and question prompts): what it typed (or its Enter) would answer the person's prompt. | ### Subagent profiles @@ -132,7 +132,7 @@ Automated Codex delivery waits for the current launch's empty composer, includin multiline draft checks, and sends task text with explicit paste boundaries before Enter. It does not treat startup output or a previous launch's screen as readiness. -For a visible numbered menu, `screen` returns `interaction.options`, the selected option and a screen revision. After inspecting the choices and confirming the action is within the task's authorization, use `choose SESSION_ID --choice N --revision HASH`. To close an idle menu that explicitly offers Escape, use `dismiss SESSION_ID --revision HASH`. Both commands reject a changed screen, act only on the owned PTY and return delivery receipts; inspect the next screen to confirm the outcome. They do not automatically approve anything. +For a visible numbered menu, `screen` returns `interaction.options`, the selected option and a screen revision. After inspecting the choices and confirming the action is within the task's authorization, use `choose SESSION_ID --choice N --revision HASH`. To close an idle menu that explicitly offers Escape, use `dismiss SESSION_ID --revision HASH`. Both commands reject a changed screen, act only on the owned PTY and return delivery receipts; inspect the next screen to confirm the outcome. They do not automatically approve anything. On an orchestrator's own connection they never answer an approval or permission prompt: while the worker is `needs_approval`, or the menu is Codex's own approval prompt (run a command, apply edits, grant permissions or network access, an MCP server's request), `choose`, `dismiss` and `send` are refused with `NEEDS_PERSON` and nothing is written. The person answers it in the card (or a decision service they allowed does, before the CLI asks); the orchestrator tells them what it asks and checks `status` again. Other menus (hooks review, model and update notices) stay the orchestrator's to resolve. The person's own connection (the app-wide descriptor) is unchanged. `interrupt` still stops the orchestrator's own submitted turn: stopping is not an answer, and the asked call does not run. Codex may show **Hooks need review** on first launch or after the bundled lifecycle helpers change. Review the listed commands before trusting them through the observed menu. This is a separate startup step from selecting YOLO and is never dismissed by submitting a task. Controlled launches disable Codex's terminal animations for stable composer detection; ordinary sessions keep their configuration. diff --git a/scripts/canvastty-control.mjs b/scripts/canvastty-control.mjs index 78e3762e9..50050ae92 100644 --- a/scripts/canvastty-control.mjs +++ b/scripts/canvastty-control.mjs @@ -155,8 +155,10 @@ create --cwd [--provider ] [--title ] [--yolo | --profile --model: the CLI's own --model for this worker (OpenCode: provider/model); if the person names a model, pass it. --effort: codex, claude and grok only. providers shows each provider's model format and effort levels. - Only Codex workers report a captured result and expose startup/approval menus - to choose/dismiss; the create response lists each worker's capabilities. + Only Codex workers report a captured result and expose startup menus to + choose/dismiss; the create response lists each worker's capabilities. From an + Orchestrator session, choose, dismiss and send are refused (NEEDS_PERSON) while + a worker waits at an approval or permission prompt: only the person answers it. list status screen diff --git a/src/main/services/agent-control/AgentControlGateway.ts b/src/main/services/agent-control/AgentControlGateway.ts index 0d3d140d9..3a47c9df8 100644 --- a/src/main/services/agent-control/AgentControlGateway.ts +++ b/src/main/services/agent-control/AgentControlGateway.ts @@ -612,7 +612,16 @@ export class AgentControlGateway { this.redact(terminalScreenText(owned.terminal, true)), codexPlaceholderIsDim(owned.terminal), codexContinuationIsEmpty(owned.terminal)); if (request.method === "screen") return { sessionId: id, text: screen, revision: hash(screen), outputOffset: owned.outputOffset, interaction: capabilities.menus ? codexChoices(screen) : null, composerReady }; - if ((request.method === "choose" || request.method === "dismiss") && !capabilities.menus) { + // A prompt only the person may answer: the card waits for them (needs_approval, from the CLI's own permission + // hook), or Codex shows its approval prompt (before that hook's report arrived; for text, only while no composer is + // ready, so earlier output on screen does not refuse a task). An orchestrator's connection never answers it, with a + // choice, Escape or text. The person's own connection and the card itself still can. + const menuAction = request.method === "choose" || request.method === "dismiss"; + const approvalOnScreen = capabilities.menus && (menuAction || !composerReady) && codexApprovalPrompt(screen); + if (agent && (menuAction || request.method === "send") && (fresh.status === "needs_approval" || approvalOnScreen)) { + throw new ControlError("NEEDS_PERSON", PERSON_ANSWERS_REFUSAL); + } + if (menuAction && !capabilities.menus) { throw new ControlError("NOT_SUPPORTED", `Menus are parsed for Codex only; resolve ${fresh.provider} prompts from the desktop and treat screen as the only evidence.`); } // Its launch plugins are still preparing it: nothing can be written yet, and nothing is queued. @@ -777,6 +786,21 @@ export class AgentControlGateway { } } +/** What an orchestrator's connection gets for choose, dismiss or send while its worker waits for the person. */ +export const PERSON_ANSWERS_REFUSAL = "This worker is waiting for the person to answer an approval or permission prompt in its card " + + "(needs_approval); only the person may answer it, so nothing was written. Do not retry: tell the person what it asks " + + "(screen shows it), then check status again. Once they have answered it is no longer needs_approval."; + +/** + * Codex's own approval prompts (running a command, applying edits, granting permissions or network access, an MCP + * server's request): the person's to answer, whatever the card's status says yet. Its other menus (hooks review, + * model and update notices) are not approvals. + */ +export function codexApprovalPrompt(screen: string): boolean { + return /Would you like to (?:run the following command|make the following edits|grant these permissions)|\bneeds your approval\b|Do you want to approve\b/i.test(screen) + || /^\s*(?:›\s*)?\d+\.\s+(?:Yes, proceed\b|Yes, and don['’]t ask again\b|No, and tell Codex what to do differently\b)/m.test(screen); +} + function codexChoices(screen: string): { revision: string; selected: number; options: Array<{ number: number; label: string }> } | null { const matches = screen.split("\n").map((line) => line.match(/^\s*(›\s*)?(\d+)\.\s+(.+)$/)).filter((m) => m !== null); if (matches.length < 2 || matches.length > 12 || matches.filter((m) => m[1]).length !== 1) return null; diff --git a/src/main/services/agent-control/controlCapabilities.ts b/src/main/services/agent-control/controlCapabilities.ts index e1b872f5a..2d4d37dc5 100644 --- a/src/main/services/agent-control/controlCapabilities.ts +++ b/src/main/services/agent-control/controlCapabilities.ts @@ -17,7 +17,10 @@ export const CONTROL_PROVIDERS: readonly AgentProviderId[] = AGENT_PROVIDERS; export interface ControlCapabilities { /** The worker reports a captured final answer through `result`. */ result: boolean; - /** `screen` parses startup/approval menus that `choose`/`dismiss` can act on. */ + /** + * `screen` parses numbered menus that `choose`/`dismiss` can act on. An approval or permission prompt is the person's: + * an orchestrator's own connection is refused it (NEEDS_PERSON). + */ menus: boolean; } diff --git a/tests/orchestrator-approval-answers.test.mjs b/tests/orchestrator-approval-answers.test.mjs new file mode 100644 index 000000000..11e546b80 --- /dev/null +++ b/tests/orchestrator-approval-answers.test.mjs @@ -0,0 +1,217 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { mkdir, mkdtemp, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import test from "node:test"; +import { controlRequest } from "../scripts/canvastty-control.mjs"; +import { AgentControlService, APPROVAL_REFUSAL, DelegationRefusal } from "../src/main/services/AgentControlService.ts"; +import { AgentControlGateway, PERSON_ANSWERS_REFUSAL, codexApprovalPrompt } from "../src/main/services/agent-control/AgentControlGateway.ts"; +import { TerminalManager } from "../src/main/services/TerminalManager.ts"; +import { availableRegistry, fakeSpawner } from "./helpers/terminal.mjs"; + +// An orchestrator never answers a subagent's approval or permission prompt: not with send_to_agent (MCP), and not with +// choose, dismiss or send on its own control connection. Those prompts are the person's (or a decision service's the +// person allowed, which answers before the CLI asks). Non-approval menus stay the orchestrator's, the person's own +// connection and the card's own input are unchanged. + +const localSocket = { skip: process.platform === "win32" ? "Unix socket tests" : false }; +const at = { x: 0, y: 0 }; +const COMPOSER = "\x1b[2J\x1b[H>_ OpenAI Codex\r\nmodel: test\r\n\r\n› \x1b[2mAsk Codex to do anything\x1b[22m"; +const APPROVAL = "\x1b[2J\x1b[HWould you like to run the following command?\r\n\r\n $ rm -rf build\r\n\r\n" + + "› 1. Yes, proceed (y)\r\n 2. Yes, and don't ask again for this command (p)\r\n" + + " 3. No, and tell Codex what to do differently (esc)\r\n\r\nPress enter to confirm or esc to cancel"; +const EDITS = "\x1b[2J\x1b[HWould you like to make the following edits?\r\n\r\n src/a.ts (+1 -0)\r\n\r\n" + + "› 1. Yes, proceed (y)\r\n 2. Yes, and don't ask again for these files (a)\r\n" + + " 3. No, and tell Codex what to do differently (esc)\r\n\r\nPress enter to confirm or esc to cancel"; +const HOOKS = "\x1b[2J\x1b[HHooks need review\r\n› 1. Review hooks\r\n 2. Trust all and continue\r\n 3. Continue without hooks\r\n" + + "Press enter to confirm or esc to cancel"; +const MODEL = "\x1b[2J\x1b[HApproaching rate limits\r\nSwitch to a smaller model for lower credit usage?\r\n" + + "› 1. Switch model\r\n 2. Keep current model\r\n 3. Keep current model (never show again)\r\nPress enter to confirm or esc to go back"; + +async function fixture(t) { + const root = await realpath(await mkdtemp(join(tmpdir(), "ctty-no-approve-"))); + const userData = await realpath(await mkdtemp(join("/tmp", "ctty-no-approve-grant-"))); + t.after(() => Promise.all([rm(root, { recursive: true, force: true }), rm(userData, { recursive: true, force: true })])); + const project = join(root, "project"); + const inner = join(project, "src"); + await mkdir(inner, { recursive: true }); + const calls = []; + const writes = new Map(); + let gateway; + const terminals = new TerminalManager((channel, payload) => gateway?.observe(channel, payload), availableRegistry(), undefined, undefined, true, + fakeSpawner(calls, { onWrite: (data, options) => { + const call = calls.find((entry) => entry.options === options); + if (call) writes.set(call, [...(writes.get(call) ?? []), data]); + } })); + const control = new AgentControlService(terminals, { waitTiming: { checkMs: 10, settleMs: 20, quietMs: 200 } }); + gateway = new AgentControlGateway({ userDataPath: userData, terminals, lifecycleEnabled: () => true, + spawnSubagent: (request) => control.spawn(request) }); + const appConnection = await gateway.start(); + terminals.setControlConnection({ connectionPath: appConnection, cliPath: "/cli.mjs", grant: (id) => gateway.grantSession(id) }); + t.after(async () => { await gateway.close(); await terminals.shutdown(); }); + const orchestrator = terminals.create({ provider: "codex", profile: "auto", cwd: project, position: at, role: "orchestrator" }); + const connectionPath = calls.at(-1).options.env.CANVASTTY_CONTROL_CONNECTION; + assert.ok(connectionPath && connectionPath !== appConnection, "the orchestrator has its own connection"); + const agentClient = join(dirname(connectionPath), "controller.json"); + const agent = (method, params = {}, requestId = randomUUID()) => + controlRequest({ connectionPath, clientPath: agentClient, method, params, requestId, timeoutMs: 10_000 }); + const person = (method, params = {}, requestId = randomUUID()) => + controlRequest({ connectionPath: appConnection, clientPath: join(userData, "person-client.json"), method, params, requestId, timeoutMs: 10_000 }); + // The card a create just launched (each launch spawns one PTY). + const callOf = (id) => { + const call = calls.at(-1); + assert.equal(terminals.getMetadata(id)?.exitCode, null); + return call; + }; + const written = (call) => writes.get(call) ?? []; + return { root, project, inner, terminals, control, gateway, orchestrator, calls, agent, person, callOf, written }; +} + +/** Prints a screen into the card and waits until its controller's view shows it. */ +async function show(request, id, call, data, expected) { + call.process.emitData(data); + for (let i = 0; i < 60; i++) { + const screen = await request("screen", { sessionId: id }); + if (screen.text.includes(expected)) return screen; + await delay(5); + } + assert.fail(`the screen did not show ${expected}`); +} + +const needsPerson = (error) => error.code === "NEEDS_PERSON" && error.message === PERSON_ANSWERS_REFUSAL; + +test("Codex's approval prompts are told apart from its other menus", () => { + for (const screen of [APPROVAL, EDITS, "Would you like to grant these permissions?\n› 1. Yes\n 2. No", + "Do you want to approve network access to \"example.com\"?\n› 1. Yes, just this once\n 2. No", + "docs needs your approval.\n› 1. Yes, provide the requested info\n 2. No, but continue without it", + "› 1. Yes, proceed (y)\n 2. No (esc)"]) { + assert.equal(codexApprovalPrompt(screen), true, screen); + } + for (const screen of [HOOKS, MODEL, COMPOSER, "Do you trust the contents of this directory?\n› 1. Yes, continue\n 2. No, quit", + "Update available!\n› 1. Update now\n 2. Skip\n 3. Skip until next version"]) { + assert.equal(codexApprovalPrompt(screen), false, screen); + } +}); + +test("an orchestrator's control connection cannot choose, dismiss or type into a subagent's approval prompt; the person still answers it", localSocket, async (t) => { + const f = await fixture(t); + const created = await f.agent("create", { provider: "codex", cwd: f.inner }); + const id = created.session.id; + assert.equal(f.terminals.getMetadata(id).parentSessionId, f.orchestrator.id); + assert.equal(created.capabilities.menus, true); + const call = f.callOf(id); + + // A task the orchestrator legitimately sends, at an empty composer. + f.terminals.applyProviderSignal(id, { kind: "lifecycle", state: "idle" }); + await show(f.agent, id, call, COMPOSER, "Ask Codex"); + await f.agent("send", { sessionId: id, text: "clean the build" }); + assert.equal(f.written(call).length, 1); + f.terminals.applyProviderSignal(id, { kind: "lifecycle", state: "working" }); + + // Codex shows its approval prompt before its PermissionRequest hook's report arrived: the screen alone refuses. + const early = await show(f.agent, id, call, APPROVAL, "Yes, proceed"); + assert.equal(early.interaction.options.length, 3, "the menu is still shown to the orchestrator"); + await assert.rejects(f.agent("choose", { sessionId: id, choice: 1, revision: early.interaction.revision }), needsPerson); + await assert.rejects(f.agent("dismiss", { sessionId: id, revision: early.revision }), needsPerson); + await assert.rejects(f.agent("send", { sessionId: id, text: "y" }), needsPerson, "text (and its Enter) would answer it too"); + // The hook's report: the card is needs_approval. + f.terminals.applyProviderSignal(id, { kind: "lifecycle", state: "needs_approval" }); + const screen = await f.agent("screen", { sessionId: id }); + for (const choice of [1, 2, 3]) { + await assert.rejects(f.agent("choose", { sessionId: id, choice, revision: screen.interaction.revision }), needsPerson, `choice ${choice}`); + } + await assert.rejects(f.agent("dismiss", { sessionId: id, revision: screen.revision }), needsPerson, "Escape is an answer too"); + await assert.rejects(f.agent("send", { sessionId: id, text: "y" }), needsPerson); + // Not retryable: the same request id replays the refusal instead of writing. + const again = randomUUID(); + await assert.rejects(f.agent("choose", { sessionId: id, choice: 1, revision: screen.interaction.revision }, again), needsPerson); + await assert.rejects(f.agent("choose", { sessionId: id, choice: 1, revision: screen.interaction.revision }, again), needsPerson); + assert.equal(f.written(call).length, 1, "nothing reached the prompt"); + // Reading stays open, and stopping its own turn is not an answer: the asked command does not run. + assert.equal((await f.agent("status", { sessionId: id })).session.status, "needs_approval"); + await f.agent("interrupt", { sessionId: id }); + assert.deepEqual(f.written(call).slice(1), ["\x03"]); + + // The person answers in the card (its own input path), whatever the orchestrator was refused. + f.terminals.input(id, "y"); + assert.deepEqual(f.written(call).slice(2), ["y"]); + f.terminals.applyProviderSignal(id, { kind: "lifecycle", state: "working" }); + f.terminals.applyProviderSignal(id, { kind: "lifecycle", state: "idle", event: "Stop" }); + // Edits ask the same way. + const edits = await show(f.agent, id, call, EDITS, "make the following edits"); + await assert.rejects(f.agent("choose", { sessionId: id, choice: 1, revision: edits.interaction.revision }), needsPerson); + assert.equal(f.written(call).length, 3); +}); + +test("an orchestrator still resolves menus that are not approvals, and the person's own connection still chooses approvals", localSocket, async (t) => { + const f = await fixture(t); + const { session } = await f.agent("create", { provider: "codex", cwd: f.inner }); + const call = f.callOf(session.id); + f.terminals.applyProviderSignal(session.id, { kind: "lifecycle", state: "idle" }); + const hooks = await show(f.agent, session.id, call, HOOKS, "Hooks need review"); + await f.agent("choose", { sessionId: session.id, choice: 3, revision: hooks.interaction.revision }); + const model = await show(f.agent, session.id, call, MODEL, "Approaching rate limits"); + await f.agent("choose", { sessionId: session.id, choice: 2, revision: model.interaction.revision }); + const notice = await show(f.agent, session.id, call, "\x1b[2J\x1b[HHooks\r\n6 hooks reviewed\r\nPress esc to close", "Press esc"); + await f.agent("dismiss", { sessionId: session.id, revision: notice.revision }); + assert.deepEqual(f.written(call), ["\x1b[B\x1b[B\r", "\x1b[B\r", "\x1b"]); + // A worker with no approval pending takes the orchestrator's task as before. + await show(f.agent, session.id, call, COMPOSER, "Ask Codex"); + await f.agent("send", { sessionId: session.id, text: "next task" }); + assert.equal(f.written(call).length, 4); + + // The person's own automation (the app-wide connection) is the person: its worker's approval prompt is its to choose. + const own = await f.person("create", { provider: "codex", profile: "normal", cwd: f.inner, title: "Person's worker" }); + const ownCall = f.callOf(own.session.id); + f.terminals.applyProviderSignal(own.session.id, { kind: "lifecycle", state: "needs_approval" }); + const approval = await show(f.person, own.session.id, ownCall, APPROVAL, "Yes, proceed"); + await f.person("choose", { sessionId: own.session.id, choice: 3, revision: approval.interaction.revision }); + assert.deepEqual(f.written(ownCall), ["\x1b[B\x1b[B\r"]); + // And the orchestrator cannot reach the person's worker at all. + await assert.rejects(f.agent("choose", { sessionId: own.session.id, choice: 1, revision: approval.interaction.revision }), + (error) => error.code === "SESSION_NOT_FOUND"); +}); + +test("send_to_agent is refused at every CLI's approval or permission prompt and allowed once the person answered", async (t) => { + const root = await realpath(await mkdtemp(join(tmpdir(), "ctty-no-approve-send-"))); + t.after(() => rm(root, { recursive: true, force: true })); + const calls = []; + const writes = new Map(); + const terminals = new TerminalManager(() => undefined, availableRegistry(), undefined, undefined, true, fakeSpawner(calls, { + onWrite: (data, options) => writes.set(options, [...(writes.get(options) ?? []), data]) + })); + t.after(() => terminals.shutdown()); + const control = new AgentControlService(terminals); + const orchestrator = terminals.create({ provider: "claude", profile: "normal", cwd: root, position: at, role: "orchestrator" }); + calls.forEach((entry) => { entry.claimed = true; }); + // Each maps its CLI's own approval signal to needs_approval: Claude Code, Codex, Qwen and Kimi PermissionRequest, + // Grok's permission_prompt notification, Hermes pre_approval_request, OpenCode permission.asked and question.asked. + for (const provider of ["claude", "codex", "grok", "kimi", "qwen", "hermes", "opencode"]) { + const child = terminals.create({ provider, profile: "normal", cwd: root, position: at, role: "subagent", parentSessionId: orchestrator.id }); + if (provider === "grok") terminals.resize(child.id, 80, 24); // Grok starts with the renderer-measured grid. + let call; + for (let i = 0; i < 100 && !call; i++) { + call = calls.find((entry) => entry.options.cwd === root && JSON.stringify([entry.command, entry.args]).includes(`/resolved/${provider}`) && !entry.claimed); + if (!call) await delay(5); + } + assert.ok(call, `${provider}: launched`); + call.claimed = true; + terminals.applyProviderSignal(child.id, { kind: "lifecycle", state: "needs_approval" }); + for (const prompt of ["y", "1", "allow", "yes, and don't ask again"]) { + assert.throws(() => control.send(child.id, prompt), (error) => error instanceof DelegationRefusal && error.message === APPROVAL_REFUSAL, `${provider}: ${prompt}`); + assert.throws(() => control.send(child.id, prompt, false), DelegationRefusal, `${provider}: ${prompt} without Enter`); + } + assert.deepEqual(writes.get(call.options) ?? [], [], `${provider}: nothing reached its prompt`); + // The person answers in the card; the subagent works and finishes its turn. + terminals.input(child.id, "1"); + terminals.applyProviderSignal(child.id, { kind: "lifecycle", state: "working" }); + terminals.applyProviderSignal(child.id, { kind: "lifecycle", state: "idle", event: "Stop" }); + if (provider === "codex") call.process.emitData(COMPOSER); + await control.send(child.id, "next task"); + const sent = writes.get(call.options); + assert.equal(sent?.[0], "1", `${provider}: the person's answer`); + assert.equal(sent.length, 2, `${provider}: the orchestrator's next task once the person answered`); + } +}); From dc63889b947b1d36df8e7b40bce0d73fa3780779 Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:16:29 +0000 Subject: [PATCH 07/15] fix(ui): scale the card menu, hints panel and plugin settings with the UI scale Some UI chrome kept fixed pixel sizes, so Settings -> Appearance -> UI scale (0.85-1.25) did not reach it: - The terminal card's options menu (Don't restore this window, plugin card actions) had a 12px font and pixel paddings while the card header around it scaled. - The canvas shortcut-hints panel looked the same at 0.85 and 1.25. - In Settings -> Plugins the plugin description scaled with the settings panel, but the permission list, the contribution rows ("App on canvas"), the plugin title, author, version, buttons, module and native-hook lists kept fixed 9-17px sizes, so at 1.25 one card mixed large and small text. The menu and the hints panel now take their font size from calc(Npx * var(--ui-scale, 1)) and size their paddings, gaps, radii and minimum widths in em; the hints icon is sized in em too. The plugin settings font sizes use the same calc. At scale 1.0 every size is the same as before. UI_CONTRACT names the menu and the hints panel among the chrome that follows --ui-scale. The title bar is left as is: its height is shared with the native window chrome (macOS traffic-light position, the startup page) and with every viewport-bound overlay through --titlebar-height. --- docs/UI_CONTRACT.md | 2 +- .../features/workspace/WorkspaceCanvas.tsx | 2 +- src/renderer/src/styles/app.css | 54 ++++++++--------- tests/ui-scale-chrome.test.mjs | 58 +++++++++++++++++++ 4 files changed, 87 insertions(+), 29 deletions(-) create mode 100644 tests/ui-scale-chrome.test.mjs diff --git a/docs/UI_CONTRACT.md b/docs/UI_CONTRACT.md index 7acaf490d..609d8cb14 100644 --- a/docs/UI_CONTRACT.md +++ b/docs/UI_CONTRACT.md @@ -65,7 +65,7 @@ This contract preserves the approved MVP concept and prevents feature ownership - An authenticated connection or heartbeat alone never shows agent presence. A branded badge appears only after that agent actually issues a browser command, and its cursor appears only after a real pointer position exists. Claude uses `#D97757`, Codex `#10A37F`, Qwen `#6D44E8`, Kimi `#7C5CFC`, OpenCode `#5A5858`, Hermes `#D6A700`, and an unknown provider `#7A8291`. Browser Settings separately controls indicator visibility without revoking browser access; the agent-access kill switch revokes the connection itself. - With window snapping enabled, drag and resize use a hidden `10px` grid and a `10px` magnetic threshold for neighboring edges, centers, and a consistent `20px` gap. - One trusted context dispatcher owns right-clicks on the canvas. Truly empty canvas offers region creation, a sticky note, the Settings-configured launcher submenu, Browser, and Settings. Holding RMB on empty canvas also opens the configurable radial quick launcher: moving toward an action and releasing activates it, while a stationary right-click continues to open the standard empty-canvas menu. Region and note right-clicks remain context-specific. A region offers rename, color, a note at the pressed point, and region-only deletion. A note frame offers edit, bring-to-front, and deletion. Terminal, Browser, plugin, textarea/input, and contenteditable surfaces keep their native context menus; middle-button panning is unchanged. `Cmd/Ctrl+K` opens a searchable palette over live sessions and the same global creation/launch actions. Regions persist independently of terminal restore, render behind HOME and windows, resize from every edge/corner, and move every terminal, plugin, Browser, or note whose complete bounds were inside the region at drag start. Attached contents follow the region continuously during the gesture and persist once at pointer release; deleting a region never deletes its contents. Sticky notes persist bounded text and geometry, snap like other windows, and resize from every edge/corner. General Settings exposes independent **Save colored regions after exit** and **Save notes after exit** controls. Both default on to preserve existing profiles. Turning either off removes only that collection from disk persistence without deleting its live canvas objects; turning it back on saves the current collection, while leaving it off starts the next launch with that collection empty. -- Context menus, region editors, the radial launcher, and the command palette use official Lucide/provider assets and shared application color tokens. Their dimensions follow root `--ui-scale`. Appearance owns the `0.85–1.25` scale in `0.05` steps and the sage/lilac/night system palette; the scale changes UI chrome rather than canvas zoom or terminal font size. Agents owns the independent context-menu and radial-launcher subsets and their safe reset defaults. +- Context menus, region editors, the radial launcher, and the command palette use official Lucide/provider assets and shared application color tokens. Their dimensions follow root `--ui-scale`, as do the terminal card options menu and the canvas shortcut-hints panel. Appearance owns the `0.85–1.25` scale in `0.05` steps and the sage/lilac/night system palette; the scale changes UI chrome rather than canvas zoom or terminal font size. Agents owns the independent context-menu and radial-launcher subsets and their safe reset defaults. - Settings uses the approved two-pane modal grammar: a persistent icon-labelled sidebar for all seven sections, a separate current-section header, and a scrollable content pane. Settings must not fall back to the legacy light slide-over with one horizontal segmented tab strip. Simple settings use compact copy/control rows; all Settings chrome reads the active system tokens, official Lucide assets, and root `--ui-scale`. - General Settings places the macOS/Windows/Linux/Custom keyboard preset after language, followed by an expandable Keyboard shortcuts section for canvas, terminal, Codex editor and canvas navigation bindings. Recording a shortcut selects Custom; choosing a named preset replaces bindings. Optional commands can be disabled, while submit, newline and whole-draft selection remain bound. Canvas and terminal bindings update immediately; native Codex editor bindings apply to new or restarted cards. Focused terminal and text inputs retain ownership of canvas keyboard shortcuts on every platform. - Empty-canvas drag and middle-button drag pan the canvas. On fresh profiles ordinary scroll over empty canvas pans on both axes; pinch and `Cmd/Ctrl + scroll` zoom around the pointer. **Use scroll wheel to zoom** restores historical ordinary-wheel zoom and is enabled by migration for existing profiles. Only a focused input widget interrupts canvas wheel/pinch, except the HOME session list keeps ordinary local scrolling without a focus precondition; unfocused or non-focusable widgets otherwise leave those events to the canvas. **Wheel/pinch capture over widgets** has Off, On, and Key modes: Off preserves local ownership, On always transfers only wheel/pinch to the canvas, and Key transfers only wheel/pinch while its binding is held. Key and full-navigation bindings accept modifier chords or Mouse3/Mouse4/Mouse5. A focused live Browser page therefore scrolls normally in Off or released-Key mode; unfocused Browser content, On, an active Key binding, the full override, pinch, and `Cmd/Ctrl + scroll` are canvas-owned. The owner selected for a Browser wheel sequence is held until 250 ms of wheel inactivity. Browser summaries and placeholders are always canvas-owned. Fresh profiles use Key with `Command` on macOS or `Ctrl` elsewhere. A separate configurable **Canvas navigation override** (default `Option` on macOS and `Alt` elsewhere) transfers wheel/pinch and drag while held. While full override is held all canvas surfaces show `grab`; an owned drag shows `grabbing` until pointer up/cancel even after the binding is released. diff --git a/src/renderer/src/features/workspace/WorkspaceCanvas.tsx b/src/renderer/src/features/workspace/WorkspaceCanvas.tsx index 6fef8d6a7..965f91a8c 100644 --- a/src/renderer/src/features/workspace/WorkspaceCanvas.tsx +++ b/src/renderer/src/features/workspace/WorkspaceCanvas.tsx @@ -1687,7 +1687,7 @@ export function WorkspaceCanvas(props: WorkspaceCanvasProps): React.JSX.Element )} diff --git a/src/renderer/src/styles/app.css b/src/renderer/src/styles/app.css index f4786c22b..b8713372a 100644 --- a/src/renderer/src/styles/app.css +++ b/src/renderer/src/styles/app.css @@ -324,11 +324,11 @@ button { border: 0; } .canvas-controls { height: 44px; display: flex; align-items: center; padding: 4px; border-radius: 12px; color: white; background: rgba(40,41,52,.94); box-shadow: var(--shadow-sm); pointer-events: auto; } .canvas-controls button { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 8px; color: white; background: transparent; cursor: pointer; transition: background .15s ease; } .canvas-controls button:hover { background: rgba(255,255,255,.13); } -.shortcut-hints { min-width: 224px; padding: 9px 11px; display: grid; gap: 7px; border-radius: 12px; color: white; background: rgba(40,41,52,.9); box-shadow: var(--shadow-sm); pointer-events: none; } -.shortcut-hints > div { display: grid; grid-template-columns: minmax(52px, auto) 1fr; align-items: center; gap: 10px; } -.shortcut-hints kbd { min-width: 52px; padding: 5px 7px; border: 1px solid rgba(255,255,255,.14); border-radius: 7px; color: white; background: rgba(255,255,255,.1); font: 800 11px var(--font-mono); text-align: center; } -.shortcut-hints span { color: rgba(255,255,255,.66); font-size: 11px; font-weight: 750; } -.shortcut-hints__more { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 7px 0 2px; border-top: 1px solid rgba(255,255,255,.12); outline: 0; color: white; background: transparent; cursor: pointer; pointer-events: auto; font-size: 11px; font-weight: 750; } +.shortcut-hints { min-width: 20.36em; padding: .82em 1em; display: grid; gap: .64em; border-radius: 1.09em; color: white; background: rgba(40,41,52,.9); box-shadow: var(--shadow-sm); pointer-events: none; font-size: calc(11px * var(--ui-scale, 1)); } +.shortcut-hints > div { display: grid; grid-template-columns: minmax(4.73em, auto) 1fr; align-items: center; gap: .91em; } +.shortcut-hints kbd { min-width: 4.73em; padding: .45em .64em; border: 1px solid rgba(255,255,255,.14); border-radius: .64em; color: white; background: rgba(255,255,255,.1); font: 800 1em var(--font-mono); text-align: center; } +.shortcut-hints span { color: rgba(255,255,255,.66); font-size: 1em; font-weight: 750; } +.shortcut-hints__more { display: flex; align-items: center; justify-content: space-between; gap: 1.09em; padding: .64em 0 .18em; border-top: 1px solid rgba(255,255,255,.12); outline: 0; color: white; background: transparent; cursor: pointer; pointer-events: auto; font-size: 1em; font-weight: 750; } .shortcut-hints__more:hover { color: var(--secondary); } .shortcut-hints__more:focus-visible { outline: 2px solid var(--secondary); outline-offset: 3px; border-radius: 3px; } .shortcut-reference-dialog { width: min(1200px, calc(100vw - 40px)); max-width: none; max-height: none; padding: 0; overflow: visible; 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: clamp(10px, calc(1.5vh * var(--ui-scale)), calc(13px * var(--ui-scale))); } @@ -1061,34 +1061,34 @@ button { border: 0; } .home-grid-size strong { font: 850 12px var(--font-mono); } .plugin-settings { display: grid; grid-template-columns: minmax(0, 1fr); gap: 2px; } .plugin-install-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 7px; } -.plugin-install-row input { min-width: 0; height: 45px; padding: 0 13px; border: 1px solid rgba(45,45,56,.14); outline: 0; border-radius: 10px; color: var(--text-dark); background: rgba(255,255,255,.58); font: 700 12px var(--font-mono); user-select: text; } +.plugin-install-row input { min-width: 0; height: 45px; padding: 0 13px; border: 1px solid rgba(45,45,56,.14); outline: 0; border-radius: 10px; color: var(--text-dark); background: rgba(255,255,255,.58); font: 700 calc(12px * var(--ui-scale, 1)) var(--font-mono); user-select: text; } .plugin-install-row input:focus { border-color: var(--surface); box-shadow: 0 0 0 3px color-mix(in srgb, var(--surface) 15%, transparent); } -.plugin-install-row button, .plugin-preview__actions button, .installed-plugin__header button, .plugin-contribution button, .home-widget-list__row button { min-height: 36px; padding: 7px 11px; border-radius: 9px; color: var(--text-dark); background: rgba(255,255,255,.56); cursor: pointer; font-size: 11px; font-weight: 850; } +.plugin-install-row button, .plugin-preview__actions button, .installed-plugin__header button, .plugin-contribution button, .home-widget-list__row button { min-height: 36px; padding: 7px 11px; border-radius: 9px; color: var(--text-dark); background: rgba(255,255,255,.56); cursor: pointer; font-size: calc(11px * var(--ui-scale, 1)); font-weight: 850; } .plugin-install-row button:disabled, .plugin-preview__actions button:disabled, .installed-plugin__header button:disabled, .plugin-contribution button:disabled, .home-widget-list__row button:disabled { cursor: not-allowed; opacity: .5; } .plugin-preview { margin-top: 9px; padding: 14px; border-radius: 13px; color: white; background: var(--surface); } .plugin-preview header { display: flex; align-items: center; justify-content: flex-start; gap: 12px; } .plugin-preview header > span { min-width: 0; display: flex; flex-direction: column; gap: 3px; } -.plugin-preview header strong { font-size: 17px; } -.plugin-preview header span { color: var(--text-muted-on-dark); font: 750 11px var(--font-mono); } -.plugin-preview p, .installed-plugin > p { margin: 8px 0 11px; font-size: 12px; line-height: 1.45; opacity: .78; } +.plugin-preview header strong { font-size: calc(17px * var(--ui-scale, 1)); } +.plugin-preview header span { color: var(--text-muted-on-dark); font: 750 calc(11px * var(--ui-scale, 1)) var(--font-mono); } +.plugin-preview p, .installed-plugin > p { margin: 8px 0 11px; font-size: calc(12px * var(--ui-scale, 1)); line-height: 1.45; opacity: .78; } .plugin-preview__actions { margin-top: 13px; display: flex; justify-content: flex-end; gap: 7px; } .plugin-preview__actions .plugin-primary-action { color: var(--text-dark); background: var(--primary); } -.plugin-permissions { display: grid; gap: 6px; font-size: 11px; line-height: 1.35; } +.plugin-permissions { display: grid; gap: 6px; font-size: calc(11px * var(--ui-scale, 1)); line-height: 1.35; } .plugin-permissions > strong { opacity: .72; } .plugin-permissions > span, .plugin-permissions li { opacity: .68; } .plugin-permissions ul { margin: 0; padding-left: 18px; display: grid; gap: 3px; } -.plugin-hook-warning { margin-top: 9px; padding: 10px 11px; display: flex; flex-direction: column; gap: 4px; border: 1px solid rgba(239,208,132,.35); border-radius: 10px; color: #ffe7a9; background: rgba(96,62,34,.48); font-size: 10px; line-height: 1.4; } -.plugin-hook-warning strong { font-size: 10px; } +.plugin-hook-warning { margin-top: 9px; padding: 10px 11px; display: flex; flex-direction: column; gap: 4px; border: 1px solid rgba(239,208,132,.35); border-radius: 10px; color: #ffe7a9; background: rgba(96,62,34,.48); font-size: calc(10px * var(--ui-scale, 1)); line-height: 1.4; } +.plugin-hook-warning strong { font-size: calc(10px * var(--ui-scale, 1)); } .plugin-hook-warning span { opacity: .82; } .plugin-hook-warning ul { margin: 4px 0 0; padding: 0; display: grid; gap: 4px; list-style: none; } .plugin-hook-warning li { min-width: 0; display: flex; align-items: baseline; justify-content: space-between; gap: 8px; } -.plugin-hook-warning code { overflow-wrap: anywhere; font: 750 9px var(--font-mono); opacity: .82; text-align: right; } +.plugin-hook-warning code { overflow-wrap: anywhere; font: 750 calc(9px * var(--ui-scale, 1)) var(--font-mono); opacity: .82; text-align: right; } .plugin-modules { margin: 10px 0 0; padding: 9px 10px; display: grid; gap: 7px; border: 1px solid rgba(255,255,255,.12); border-radius: 10px; } -.plugin-modules legend { padding: 0 5px; font-size: 11px; font-weight: 850; opacity: .72; } +.plugin-modules legend { padding: 0 5px; font-size: calc(11px * var(--ui-scale, 1)); font-weight: 850; opacity: .72; } .plugin-modules label { display: flex; align-items: flex-start; gap: 8px; cursor: pointer; } .plugin-modules label > span { min-width: 0; display: grid; flex: 1; gap: 2px; } -.plugin-modules label strong { font-size: 12px; } -.plugin-modules label small { font-size: 10px; line-height: 1.3; opacity: .68; } +.plugin-modules label strong { font-size: calc(12px * var(--ui-scale, 1)); } +.plugin-modules label small { font-size: calc(10px * var(--ui-scale, 1)); line-height: 1.3; opacity: .68; } .plugin-settings__error { margin: 9px 0 0; color: #8f3939; font-size: 12px; font-weight: 800; line-height: 1.4; } .plugin-settings__empty { margin: 0; padding: 17px; border-radius: 12px; background: rgba(255,255,255,.34); font-size: 12px; text-align: center; opacity: .65; } .plugin-icon { width: 34px; height: 34px; flex: 0 0 auto; border-radius: 8px; object-fit: cover; background: rgba(255,255,255,.12); } @@ -1192,13 +1192,13 @@ button { border: 0; } .installed-plugin__details .plugin-modules { margin: 8px 0; } .installed-plugin__header > span.installed-plugin__title { min-width: 0; display: flex; flex-direction: row; align-items: center; gap: 10px; } .installed-plugin__header > span.installed-plugin__title > span { min-width: 0; display: flex; flex-direction: column; gap: 3px; } -.installed-plugin__header > span strong { overflow: hidden; font-size: 16px; text-overflow: ellipsis; white-space: nowrap; } -.installed-plugin__header > span small { color: var(--text-muted-on-dark); font: 700 10px var(--font-mono); display: flex; align-items: baseline; gap: 5px; } -.installed-plugin__version-arrow { color: var(--primary); font-size: 11px; } +.installed-plugin__header > span strong { overflow: hidden; font-size: calc(16px * var(--ui-scale, 1)); text-overflow: ellipsis; white-space: nowrap; } +.installed-plugin__header > span small { color: var(--text-muted-on-dark); font: 700 calc(10px * var(--ui-scale, 1)) var(--font-mono); display: flex; align-items: baseline; gap: 5px; } +.installed-plugin__version-arrow { color: var(--primary); font-size: calc(11px * var(--ui-scale, 1)); } .installed-plugin__version-new { color: var(--primary); } -.installed-plugin__host { display: block; margin-top: 2px; font: 700 10px var(--font-mono); } +.installed-plugin__host { display: block; margin-top: 2px; font: 700 calc(10px * var(--ui-scale, 1)) var(--font-mono); } .installed-plugin__author-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 8px; } -.installed-plugin__author { color: var(--text-muted-on-dark); font-size: 14px; font-weight: 750; opacity: .85; } +.installed-plugin__author { color: var(--text-muted-on-dark); font-size: calc(14px * var(--ui-scale, 1)); font-weight: 750; opacity: .85; } .installed-plugin__repo-actions { display: inline-flex; gap: 6px; } .installed-plugin__header .installed-plugin__host, .plugin-showcase-tile__host { font-weight: 700; } .installed-plugin__header > div { display: flex; gap: 6px; } @@ -1209,8 +1209,8 @@ button { border: 0; } .plugin-contribution-list { margin-top: 10px; display: grid; gap: 6px; } .plugin-contribution { min-height: 51px; padding: 7px 7px 7px 11px; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 10px; border-radius: 10px; background: var(--surface-soft); } .plugin-contribution > span { min-width: 0; display: flex; flex-direction: column; gap: 3px; } -.plugin-contribution > span strong { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } -.plugin-contribution > span small { color: var(--text-muted-on-dark); font-size: 10px; } +.plugin-contribution > span strong { overflow: hidden; font-size: calc(12px * var(--ui-scale, 1)); text-overflow: ellipsis; white-space: nowrap; } +.plugin-contribution > span small { color: var(--text-muted-on-dark); font-size: calc(10px * var(--ui-scale, 1)); } /* Settings v3: iteration 7 two-pane structure with iteration 9 token roles. */ .settings-panel .setting-group:not(.setting-group--field) { padding: 1.1em 0; border-bottom: .08em solid color-mix(in srgb, var(--text) 9%, transparent); } @@ -1368,9 +1368,9 @@ button { border: 0; } .terminal-card__search input:focus { border-color: var(--primary); } .terminal-card__search button { width: 26px; height: 26px; display: grid; place-items: center; border-radius: 6px; color: rgba(255,255,255,.78); background: rgba(255,255,255,.07); cursor: pointer; } .terminal-card__search button:hover { color: white; background: rgba(255,255,255,.16); } -.terminal-card__menu { position: absolute; z-index: 18; top: calc(var(--card-header-height) + 6px); right: 10px; padding: 6px 8px; border: 1px solid rgba(255,255,255,.14); border-radius: 9px; color: white; background: rgba(28,30,40,.96); box-shadow: var(--shadow-md); font-size: 12px; font-weight: 700; } -.terminal-card__menu label { display: flex; align-items: center; gap: 8px; padding: 4px 2px; cursor: pointer; } -.terminal-card__menu-action { display: block; width: 100%; padding: 5px 4px; border-radius: 6px; color: inherit; background: transparent; font: inherit; text-align: left; cursor: pointer; } +.terminal-card__menu { position: absolute; z-index: 18; top: calc(var(--card-header-height) + .5em); right: .84em; padding: .5em .67em; border: 1px solid rgba(255,255,255,.14); border-radius: .75em; color: white; background: rgba(28,30,40,.96); box-shadow: var(--shadow-md); font-size: calc(12px * var(--ui-scale, 1)); font-weight: 700; } +.terminal-card__menu label { display: flex; align-items: center; gap: .67em; padding: .33em .17em; cursor: pointer; } +.terminal-card__menu-action { display: block; width: 100%; padding: .42em .33em; border-radius: .5em; color: inherit; background: transparent; font: inherit; text-align: left; cursor: pointer; } .terminal-card__menu-action:hover, .terminal-card__menu-action:focus-visible { background: rgba(255,255,255,.1); } .terminal-card__plugin-badge { flex: 0 0 auto; min-width: 0; max-width: 14em; overflow: hidden; padding: .15em .55em; border-radius: .5em; background: rgba(255,255,255,.14); font: 700 .7em var(--font-mono); text-overflow: ellipsis; white-space: nowrap; } .terminal-card__plugin-badge--info { background: color-mix(in srgb, #5b9cf5 38%, transparent); } diff --git a/tests/ui-scale-chrome.test.mjs b/tests/ui-scale-chrome.test.mjs new file mode 100644 index 000000000..28230b88c --- /dev/null +++ b/tests/ui-scale-chrome.test.mjs @@ -0,0 +1,58 @@ +// UI chrome follows the root --ui-scale (docs/UI_CONTRACT.md): the card options menu, the canvas +// shortcut-hints panel and the Settings → Plugins cards must not keep fixed pixel text sizes. +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const css = await readFile(new URL("../src/renderer/src/styles/app.css", import.meta.url), "utf8"); +const canvas = await readFile(new URL("../src/renderer/src/features/workspace/WorkspaceCanvas.tsx", import.meta.url), "utf8"); +const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +const rule = (selector) => css.match(new RegExp(`^${escape(selector)} \\{[^}]*\\}`, "mu"))?.[0] ?? ""; +const declaration = (block, property) => block.match(new RegExp(`(?:^|[;{ ])${property}: ([^;]+);`, "u"))?.[1]; +const scaledSource = String.raw`calc\(\d+(?:\.\d+)?px \* var\(--ui-scale, 1\)\)`; + +test("the card options menu scales its font with --ui-scale and sizes everything else from it", () => { + const menu = rule(".terminal-card__menu"); + assert.match(menu, /font-size: calc\(12px \* var\(--ui-scale, 1\)\)/u); + for (const selector of [".terminal-card__menu", ".terminal-card__menu label", ".terminal-card__menu-action"]) { + const block = rule(selector); + assert.ok(block, selector); + for (const property of ["padding", "gap", "border-radius", "right"]) { + const value = declaration(block, property); + if (value) assert.doesNotMatch(value, /\dpx/u, `${selector} ${property} follows the menu's font size`); + } + } +}); + +test("the shortcut-hints panel scales its font with --ui-scale and sizes everything else from it", () => { + assert.match(rule(".shortcut-hints"), /font-size: calc\(11px \* var\(--ui-scale, 1\)\)/u); + for (const selector of [".shortcut-hints", ".shortcut-hints > div", ".shortcut-hints kbd", ".shortcut-hints span", ".shortcut-hints__more"]) { + const block = rule(selector); + assert.ok(block, selector); + for (const property of ["min-width", "padding", "gap", "border-radius", "grid-template-columns", "font", "font-size"]) { + const value = declaration(block, property); + if (value) assert.doesNotMatch(value.replace(new RegExp(scaledSource, "gu"), ""), /\dpx/u, `${selector} ${property} follows the panel's font size`); + } + } + assert.match(canvas, /className="shortcut-hints__more"[\s\S]{0,300}?/u, + "the Keyboard shortcuts icon scales with the panel text"); +}); + +test("Settings → Plugins text in the installed, preview, permission and contribution cards follows --ui-scale", () => { + const prefixes = [".plugin-install-row", ".plugin-preview", ".plugin-permissions", ".plugin-hook-warning", ".plugin-modules", ".installed-plugin", ".plugin-contribution"]; + const blocks = css.split("\n").filter((line) => prefixes.some((prefix) => line.startsWith(prefix)) && line.includes("{")); + assert.ok(blocks.length > 20, "plugin settings rules were found"); + let checked = 0; + for (const block of blocks) { + for (const property of ["font", "font-size"]) { + const value = declaration(block, property); + if (!value) continue; + checked += 1; + assert.doesNotMatch(value.replace(new RegExp(scaledSource, "gu"), ""), /\dpx/u, `${block.slice(0, block.indexOf("{")).trim()} ${property} follows --ui-scale`); + } + } + assert.ok(checked > 15, "plugin settings font sizes were checked"); + for (const selector of [".plugin-permissions", ".plugin-contribution > span strong", ".plugin-contribution > span small"]) { + assert.match(declaration(rule(selector), "font-size") ?? "", new RegExp(scaledSource, "u"), selector); + } +}); From 1518cd5238241d5f488b04ab8eb41469237806d7 Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:16:29 +0000 Subject: [PATCH 08/15] fix(i18n): finish the Russian canvas hints and format their key caps like the reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Russian canvas hints and Settings still showed English: the wheel/pinch hint, the hard-coded "Shift + drag" key cap in the hints panel and the shortcut reference, the canvas navigation override description ("wheel, pinch и drag ... Drag колёсиком") and the Settings > Canvas wheel capture strings and Off/On/Key labels. - Translate them, reusing the existing on/off keys and adding shortcutShiftDrag and canvasWheelCaptureKey; the hints use one word for the drag gesture ("перетаскивание"). The group-selection label no longer repeats its key cap (ru/en). A test checks that these ru strings hold no Latin words besides key names. - The wheel/pinch and navigation override rows in Settings > Keyboard shortcuts wrap instead of cutting their longer Russian labels off. - The hints panel showed raw bindings ("Alt+ArrowUp") while the shortcut reference showed "Alt+↑". Both use one formatter now. It maps key by key, so on macOS "Meta+Comma" reads "Command+," instead of the reference's ",nd+Comma" (the Comma key was replaced inside "Command"). The canvas wheel and navigation settings' text (only Settings shows it) moves from the startup string table to canvasNavigationText.ts, next to Settings, so it loads with the Settings chunk; renderer-bundle-splitting checks it stays out of the startup bundle. --- .../src/components/ShortcutReference.tsx | 8 +- .../src/features/settings/SettingsPanel.tsx | 11 +- .../features/settings/canvasNavigationText.ts | 28 +++++ .../features/workspace/WorkspaceCanvas.tsx | 12 +- src/renderer/src/lib/i18n.ts | 18 +-- src/renderer/src/lib/shortcuts.ts | 9 ++ src/renderer/src/styles/app.css | 2 + tests/renderer-bundle-splitting.test.mjs | 30 ++++- tests/ru-shortcut-hints-i18n.test.mjs | 119 ++++++++++++++++++ 9 files changed, 207 insertions(+), 30 deletions(-) create mode 100644 src/renderer/src/features/settings/canvasNavigationText.ts create mode 100644 tests/ru-shortcut-hints-i18n.test.mjs diff --git a/src/renderer/src/components/ShortcutReference.tsx b/src/renderer/src/components/ShortcutReference.tsx index 2f736c8af..8752d086a 100644 --- a/src/renderer/src/components/ShortcutReference.tsx +++ b/src/renderer/src/components/ShortcutReference.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef } from "react"; import type { AppSettings } from "../../../shared/contracts"; -import { displayCanvasNavigationBinding } from "../lib/shortcuts"; +import { displayCanvasNavigationBinding, displayShortcutBinding } from "../lib/shortcuts"; import { UiIcon } from "./UiIcon"; import { t, type TranslationKey } from "../lib/i18n"; @@ -11,9 +11,7 @@ export function ShortcutReference({ settings, onClose }: { const dialog = useRef(null); const { locale, shortcuts: bindings } = settings; const mac = window.canvasTTY.window.isMacOS; - const display = (binding: string) => binding.replace("Meta", mac ? "Command" : "Super") - .replace("Comma", ",").replace(/ArrowUp/g, "↑").replace(/ArrowDown/g, "↓") - .replace(/ArrowLeft/g, "←").replace(/ArrowRight/g, "→") || t(locale, "disabled"); + const display = (binding: string) => displayShortcutBinding(binding, mac) || t(locale, "disabled"); const groups: { title: TranslationKey; shortcuts: [string[], TranslationKey][] }[] = [ { title: "keyboardCanvas", shortcuts: [ [[bindings.home], "homeShortcut"], @@ -22,7 +20,7 @@ export function ShortcutReference({ settings, onClose }: { [[bindings.commandPalette], "commandPalette"], [[bindings.openSettings], "settings"], [[bindings.toggleDetail], "keyboardToggleDetail"], - [["Shift + drag"], "marqueeSelectionHint"] + [[t(locale, "shortcutShiftDrag")], "marqueeSelectionHint"] ] }, { title: "keyboardNavigation", shortcuts: [ [[bindings.focusUp], "keyboardFocusUp"], diff --git a/src/renderer/src/features/settings/SettingsPanel.tsx b/src/renderer/src/features/settings/SettingsPanel.tsx index db4619189..005568372 100644 --- a/src/renderer/src/features/settings/SettingsPanel.tsx +++ b/src/renderer/src/features/settings/SettingsPanel.tsx @@ -84,6 +84,7 @@ import { } from "../../lib/providers"; import { shortcutFromKeyboardEvent, shortcutFromPointerEvent } from "../../lib/shortcuts"; import { t, type TranslationKey } from "../../lib/i18n"; +import { canvasNavigationText } from "./canvasNavigationText"; import { formatBytes } from "../materials/materialCardModel"; import { createTerminalBorderSkinPreviewStyleController, @@ -626,20 +627,20 @@ export function SettingsPanel({ ))} ))} - + changeCanvasWheelCaptureMode(value as CanvasWheelCaptureMode)} /> {settings.canvasWheelCaptureMode === "key" && ( { setCapturing(null); setShortcutError(null); }} onChange={(canvasWheelOverride) => onChange({ keyboardPreset: "custom", canvasWheelOverride })} /> )} - {canvasOverrideBindingsMatch &&

{t(locale, "canvasOverrideBindingsMatch")}

} + {canvasOverrideBindingsMatch &&

{canvasNavigationText(locale, "canvasOverrideBindingsMatch")}

}
- + >; + +export type CanvasNavigationTextKey = keyof typeof CANVAS_NAVIGATION_TEXT.en; + +export function canvasNavigationText(locale: LocaleId, key: CanvasNavigationTextKey): string { + return CANVAS_NAVIGATION_TEXT[locale === "ru" ? "ru" : "en"][key]; +} diff --git a/src/renderer/src/features/workspace/WorkspaceCanvas.tsx b/src/renderer/src/features/workspace/WorkspaceCanvas.tsx index 965f91a8c..f445f5ee6 100644 --- a/src/renderer/src/features/workspace/WorkspaceCanvas.tsx +++ b/src/renderer/src/features/workspace/WorkspaceCanvas.tsx @@ -30,7 +30,7 @@ import type { } from "../../../../shared/contracts"; import { UiIcon } from "../../components/UiIcon"; import { t } from "../../lib/i18n"; -import { displayCanvasNavigationBinding, isRenameInputTarget, isShortcutCaptureTarget, matchesPhysicalOrLayoutKey, matchesShortcut, shouldKeepNativeKeyboardInput } from "../../lib/shortcuts"; +import { displayCanvasNavigationBinding, displayShortcutBinding, isRenameInputTarget, isShortcutCaptureTarget, matchesPhysicalOrLayoutKey, matchesShortcut, shouldKeepNativeKeyboardInput } from "../../lib/shortcuts"; import { BrowserCard } from "../browser/BrowserCard"; import type { FileBrowserCardProps, FileSessionOption } from "../files/FileBrowserCard"; import { fileCardRuntimeFor, type FileCardRuntimeState } from "../files/fileCardRuntime"; @@ -1671,11 +1671,11 @@ export function WorkspaceCanvas(props: WorkspaceCanvasProps): React.JSX.Element )} {settings.showShortcutHints && settings.shortcutHintsPlacement === placement && (