diff --git a/agent/orchestrator/SKILL.md b/agent/orchestrator/SKILL.md index 1b68bdb22..7e371352d 100644 --- a/agent/orchestrator/SKILL.md +++ b/agent/orchestrator/SKILL.md @@ -10,8 +10,8 @@ description: Create and coordinate native agent sessions (Codex, Claude, Qwen, K A session started from CanvasTTY's launcher with the **Orchestrator** role has the `canvastty_agents` MCP tools. Use them in this order: 1. `list_providers`: which agents CanvasTTY can launch here. Each entry's `id` is the exact `spawn_agent.provider` value; prefer entries that are `available` with `signIn: "ok"` (`unknown` only means CanvasTTY has not checked yet). When plugins offer launch options (for example an accounts plugin's `list_routes` tool), the answer names that tool. -2. `spawn_agent` once per part of the task: a known `provider` id, an absolute `cwd` and a self-contained prompt. An unknown provider is refused with a pointer back to `list_providers`. **If the person names a model, pass it as `model`** in the format `list_providers` gives for that provider (`model.format`; OpenCode takes `provider/model`, for example `zai-coding-plan/glm-5.3-flash`, and `model.known` lists what `opencode models` reported); without `model` the CLI uses its own default. `effort` sets the reasoning effort where the CLI has one (`efforts`). Without `profile` the subagent gets your own launch profile, or the next lower one its CLI has (never more than yours, never YOLO); `list_providers` shows each provider's `profiles`. `cwd` must be this project's folder or a folder inside it. A refusal says why (a folder outside the project, a profile above yours, a limit the person set): change the request, do not retry it. -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. +2. `spawn_agent` once per part of the task: a known `provider` id, an absolute `cwd` and a self-contained prompt. An unknown provider is refused with a pointer back to `list_providers`. **If the person names a model, pass it as `model`** in the format `list_providers` gives for that provider (`model.format`; OpenCode takes `provider/model`, for example `zai-coding-plan/glm-5.3-flash`, and `model.known` lists what `opencode models` reported); without `model` a subagent of your own CLI keeps your plugin launch options (for example your model account), and any other CLI uses its own default. A plugin you do not name in `launchOptions` keeps your own values; passing `model` next to inherited options that already set the model is refused, and a subagent of another CLI is refused while your model comes from a plugin's options (spawn one of your own CLI instead). The answer's `servedBy` names the CLI, the model asked for, and whether each plugin's options were `inherited` or `explicit`. `effort` sets the reasoning effort where the CLI has one (`efforts`). Without `profile` the subagent gets your own launch profile, or the next lower one its CLI has (never more than yours, never YOLO); `list_providers` shows each provider's `profiles`. `cwd` must be this project's folder or a folder inside it. A refusal says why (a folder outside the project, a profile above yours, a limit the person set): change the request, do not retry it. +3. `wait_for_agent` for each subagent (`timeoutSeconds` up to 100; a call returns within 50 seconds unless you are Claude Code; `timeout` means it is still running, so read `output` as progress and call it again) instead of polling. `needs_approval` means the person must answer a prompt in that card (`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`. @@ -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/ARCHITECTURE.md b/docs/ARCHITECTURE.md index aa004a203..218c35143 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -70,7 +70,7 @@ Electron main process - `src/main/services/BrowserService.ts` is the only owner of the built-in browser's `WebContentsView` tabs and shared persistent partition. Remote pages have no preload or Node access, keep context isolation and sandbox enabled, and cannot request hardware, location, notification, clipboard-read, certificate-bypass, or external-protocol capabilities. HTTP(S) popups are adopted as internal tabs; other schemes are rejected. - `src/main/services/browser/` contains the browser kernel. `BrowserStore` atomically persists only tab order, active tab, and safe restore URLs. `BrowserPolicyService` centralizes URL, permission, download, and upload rules; validated uploads are copied through an already-open no-follow file descriptor into private staging before Chromium sees them. `BrowserAutomationService` attaches Electron's internal debugger to the existing live tab without a remote-debugging port. `BrowserCommandDispatcher` adds revisions, revision-bound refs, mutation request deduplication, per-tab FIFO mutation lanes, bounded concurrency, typed errors, and redacted fail-closed audit for agent mutations. - `src/main/services/agent-browser/` exposes the kernel only through an authenticated user-local Unix socket (`0600`) or Windows named pipe. The Windows pipe is created by the bundled native host with a protected DACL containing only the exact current-user SID and rejects remote clients. Each agent PTY receives a one-use bootstrap capability through its child environment. A successful authentication rotates it to a session-scoped reconnect capability held only in helper memory; duplicate bootstrap authentication is accepted only while the same `connectionId` is already live, and every capability is revoked when the PTY ends. The bundled stdio MCP helper is the only protocol adapter; no TCP listener, cookie/storage endpoint, arbitrary evaluation tool, or raw CDP surface exists. -- `src/main/services/agent-runtime/` is a separate lifecycle boundary and is not controlled by the Browser access switch. When CanvasTTY status hooks are enabled, every agent PTY receives a distinct capability for a protected user-local socket/pipe. Provider command hooks and the OpenCode event plugin may report only the fixed status enum, bounded event name, and optional opaque turn/prompt ID; prompt text, responses, tool input, and arbitrary telemetry are rejected by the exact gateway schema. On macOS and Linux the helpers are the native `canvastty-helper` (`native/canvastty-helper`, Go, standard library only; subcommands `mcp-browser`, `mcp-orchestration`, `permission-gate`, `hook`), which speaks exactly the wire protocols of the `.mjs` helpers (`tests/native-helper-*-parity.test.mjs` run both) without an Electron-as-Node process per agent and per tool call; `src/main/services/agentHelpers.ts` falls back to the `.mjs` helpers when the binary is absent, on Windows, or with `CANVASTTY_HELPERS=node`. Electron helper commands carry `ELECTRON_RUN_AS_NODE=1` inside the exact hook command only; the provider PTY never inherits that process-mode flag, so a provider cannot accidentally launch a second CanvasTTY GUI instance. The user can revoke this capability from Agents settings, immediately returning live agent status to `unavailable`; re-enabling requires a new/restarted PTY. Explicitly trusted plugin hooks use a separate process runner which re-checks the private PluginManager registry on every invocation and strips CanvasTTY internal capabilities before passing the provider payload to third-party code. Provider-native review remains an independent gate; CanvasTTY does not bypass Codex hook trust globally. +- `src/main/services/agent-runtime/` is a separate lifecycle boundary and is not controlled by the Browser access switch. When CanvasTTY status hooks are enabled, every agent PTY receives a distinct capability for a protected user-local socket/pipe. Provider command hooks and the OpenCode event plugin may report only the fixed status enum, bounded event name, and optional opaque turn/prompt ID; prompt text, responses, tool input, and arbitrary telemetry are rejected by the exact gateway schema. On macOS and Linux the helpers are the native `canvastty-helper` (`native/canvastty-helper`, Go, standard library only; subcommands `mcp-browser`, `mcp-orchestration`, `permission-gate`, `hook`), which speaks exactly the wire protocols of the `.mjs` helpers (`tests/native-helper-*-parity.test.mjs` run both) without an Electron-as-Node process per agent and per tool call; `src/main/services/agentHelpers.ts` falls back to the `.mjs` helpers when the binary is absent, on Windows, or with `CANVASTTY_HELPERS=node`. Electron helper commands carry `ELECTRON_RUN_AS_NODE=1` inside the exact hook command only; the provider PTY never inherits that process-mode flag, so a provider cannot accidentally launch a second CanvasTTY GUI instance. The user can revoke this capability from Agents settings, immediately returning live agent status to `unavailable`; re-enabling requires a new/restarted PTY. Explicitly trusted plugin hooks use a separate process runner which re-checks the private PluginManager registry on every invocation and strips CanvasTTY internal capabilities before passing the provider payload to third-party code. Provider-native review remains an independent gate; CanvasTTY does not bypass Codex hook trust globally. A Codex or OpenCode subagent's final answer (Codex `Stop` hook, the OpenCode plugin at `session.idle`) travels on the same hooks, so it exists only while status hooks are on for that launch: whether a launch has them is decided when it is planned (a deferred plugin launch included), and a launch planned with them off has no lease and no lifecycle hooks at all. `wait_for_agent` and `get_agent_result` then return `answerUnavailable: { reason: "status_hooks_off", message }` and the orchestrator reads the output instead. An answer is kept only for its own turn (`src/main/services/agent-runtime/applyRuntimeSignal.ts`, the glue main runs for each signal): OpenCode names each turn, a submitted input other than an approval answer or an empty Enter in an idle card starts a new one, and a signal of the current turn that predates that input still ends the wait but brings no answer. - Lifecycle adapters use launch-only settings for Claude, Codex, Qwen, and OpenCode. Kimi, Hermes, and Grok, whose hook discovery is home-config based, receive ownership-checked temporary entries shared across live CanvasTTY sessions. Kimi and Hermes keep recovery journals and exact backups; Grok uses a dedicated owned hook file. Cleanup restores exact original bytes when no concurrent edit occurred and otherwise removes only CanvasTTY-owned entries. - `TerminalManager` injects the MCP helper per launch without leaving permanent provider configuration. Claude Code, Codex, and Qwen Code receive CLI arguments; Qwen gets one inline `--mcp-config` entry that overrides only the CanvasTTY server name and leaves unrelated user servers available. OpenCode receives a merged launch-only `OPENCODE_CONFIG_CONTENT` entry plus one scoped browser-tool permission; Kimi uses its per-run MCP configuration when supported. Older Kimi versions receive a compare-and-swap temporary CanvasTTY entry and one exact permission rule with an atomic recovery journal. Hermes receives a temporary `mcp_servers.canvastty_browser` entry in `HERMES_HOME/config.yaml` (defaulting to `~/.hermes/config.yaml` on POSIX or `%LOCALAPPDATA%\hermes\config.yaml` on Windows); sensitive capability values stay as child-environment placeholders. Temporary Kimi and Hermes configuration remains until the final owning PTY session ends, then exact original bytes are restored when safe. A journal repairs an interrupted Hermes launch at the next CanvasTTY startup, while compare-and-swap checks preserve concurrent user edits. Unrelated MCP entries, credentials, and file/shell permissions are preserved. Qwen, OpenCode, and Hermes YOLO remain launch-only and do not change persistent permission settings. - `src/main/services/providerCliRegistry.ts` is the single owner of provider CLI discovery. During main-process startup it creates a shared snapshot for every provider in `PROVIDER_CLI_DEFINITIONS` — each definition declares the executable command names it may install as (which may differ from the provider ID, e.g. a provider shipping as `mcode`) and optional known home-relative or Windows LOCALAPPDATA-relative directories — by checking smoke-only overrides, the inherited `PATH`, platform defaults, and those known directories in that order. Available entries retain an absolute executable, launcher kind, and supplemented child `PATH`; POSIX entries must be executable files and Windows entries must be supported native or batch launchers. `TerminalManager`, `LimitsService`, agent-browser probes, and provider smoke tests consume that same snapshot and never repeat command lookup. Missing entries produce a failed session with copyable checked-path diagnostics before PTY or temporary browser configuration creation, while the limit adapter reports `cli-not-found`; its HOME row is hidden until explicitly selected after CLI detection. Launchers keep a provider without a local CLI visible when an account bound to a remote computer or a saved container profile provides another route. CanvasTTY never reads shell startup scripts. Agents settings can recheck candidate paths, atomically replace the registry snapshot, reconcile saved launcher and limit selections, and refresh CLI-bound adapters without restarting the app. Existing sessions keep running; a newly found CLI remains disabled until selected. diff --git a/docs/ARCHITECTURE.ru.md b/docs/ARCHITECTURE.ru.md index ae66a88d0..d7e0054a4 100644 --- a/docs/ARCHITECTURE.ru.md +++ b/docs/ARCHITECTURE.ru.md @@ -48,7 +48,7 @@ Electron main process - `src/main/services/PluginSecretsService.ts` сериализует запись секретов каждого плагина, шифрует весь ограниченный payload через Electron `safeStorage`, отклоняет plaintext-only backend и удаляет зашифрованный файл при uninstall. `ProviderSecretsService.ts` применяет ту же архитектуру к API-ключам провайдеров для CLI с BYOK: значения остаются в main-процессе, а renderer-контракт раскрывает только флаги `configured` и операции set/clear. Записи настроек `ApiProfile` именуют model-бэкенды (протокол, HTTPS base URL, ссылка на секрет) для тех же BYOK-рантаймов; это не agent providers, а normalizer настроек отбрасывает невалидные профили вместо «ремонта». - `src/main/services/PluginMediaService.ts` сохраняет разрешения только после нативного выбора папки, скрывает абсолютные пути, пропускает symlinks и отдаёт аудио с HTTP Range. Чтение плейлистов остаётся внутри разрешённых библиотек; ограниченная атомарная запись разрешена только в `Playlists/`. - `src/main/services/BrowserService.ts` владеет вкладками встроенного браузера в `WebContentsView`. Удалённые страницы используют отдельный persistent partition с выключенным Node, включёнными context isolation/sandbox и отклонением website permissions по умолчанию. Это core service, а не возможность runtime-плагина. -- `src/main/services/agent-runtime/` — отдельная всегда включённая lifecycle-граница, не зависящая от переключателя Browser access. Каждый agent PTY получает собственный capability для защищённого user-local socket/pipe. Provider command hooks и OpenCode event plugin могут передать только фиксированный status enum, ограниченное имя события и необязательный opaque turn/prompt ID; точная schema Gateway отклоняет prompt text, ответы, tool input и произвольную telemetry. При завершении PTY capability и временные файлы отзываются. +- `src/main/services/agent-runtime/` — отдельная всегда включённая lifecycle-граница, не зависящая от переключателя Browser access. Каждый agent PTY получает собственный capability для защищённого user-local socket/pipe. Provider command hooks и OpenCode event plugin могут передать только фиксированный status enum, ограниченное имя события и необязательный opaque turn/prompt ID; точная schema Gateway отклоняет prompt text, ответы, tool input и произвольную telemetry. При завершении PTY capability и временные файлы отзываются. Финальный ответ субагента Codex или OpenCode (хук Codex `Stop`, плагин OpenCode на `session.idle`) идёт через те же хуки, поэтому он есть только пока status hooks включены для этого запуска: это решается при планировании запуска (включая отложенный запуск с плагинами), и запуск, спланированный с выключенными хуками, не получает ни lease, ни lifecycle-хуков. Тогда `wait_for_agent` и `get_agent_result` возвращают `answerUnavailable: { reason: "status_hooks_off", message }`, и оркестратор читает вывод. Ответ хранится только для своего хода (`applyRuntimeSignal.ts`): OpenCode именует каждый ход, отправленный ввод (кроме ответа на запрос подтверждения и пустого Enter в idle-карточке) начинает новый, а сигнал текущего хода, начатого до этого ввода, завершает ожидание, но без ответа. - Claude, Codex, Qwen и OpenCode получают lifecycle hooks только на текущий запуск. Для Kimi, Hermes и Grok, которые ищут hooks в home-конфигурации, используются ownership-checked временные записи с совместным владением живых сессий. Kimi и Hermes используют recovery journals и точные backups, Grok — отдельный owned hook file; cleanup восстанавливает исходные байты или удаляет только записи CanvasTTY при конкурентных изменениях. - `TerminalManager` подмешивает MCP helper, не оставляя постоянных изменений в provider-конфигах. Claude Code, Codex и Qwen Code получают CLI arguments; Qwen получает одну inline-запись `--mcp-config`, которая переопределяет только имя сервера CanvasTTY и не скрывает сторонние user servers. OpenCode — объединённый launch-only `OPENCODE_CONFIG_CONTENT` с одной scoped browser-tool permission, Kimi — per-run MCP config или временную запись с compare-and-swap и recovery journal для старых версий. Hermes получает временную запись `mcp_servers.canvastty_browser` в `HERMES_HOME/config.yaml` (по умолчанию `~/.hermes/config.yaml` в POSIX или `%LOCALAPPDATA%\hermes\config.yaml` в Windows); чувствительные capability-значения остаются ссылками на окружение дочернего процесса. Временная конфигурация Kimi и Hermes живёт до завершения последней владеющей PTY-сессии, после чего исходные байты точно восстанавливаются, если файл не менялся параллельно. Journal восстанавливает Hermes после прерванного запуска при следующем старте CanvasTTY, а compare-and-swap сохраняет одновременные пользовательские изменения. Сторонние MCP-записи, credentials и file/shell permissions не затрагиваются. Qwen, OpenCode и Hermes YOLO остаются launch-only и не меняют постоянные permission-настройки. - `src/main/services/providerCliRegistry.ts` — единственный владелец обнаружения provider CLI. При запуске main-процесса он создаёт общий snapshot для каждого провайдера из `PROVIDER_CLI_DEFINITIONS` — каждое определение объявляет имена executable-команд, под которыми провайдер может устанавливаться (они могут отличаться от ID провайдера, например провайдер с командой `mcode`), и опциональные известные каталоги (относительно home или Windows LOCALAPPDATA), — последовательно проверяя smoke-only overrides, унаследованный `PATH`, системные каталоги платформы и эти известные каталоги. Доступная запись хранит абсолютный executable, тип launcher-а и дополненный дочерний `PATH`; POSIX-кандидат обязан быть исполняемым файлом, а Windows-кандидат — поддерживаемым native или batch launcher-ом. `TerminalManager`, `LimitsService`, agent-browser probes и provider smoke используют один и тот же snapshot и не повторяют поиск команды. Недоступный CLI создаёт failed-сессию с копируемой диагностикой проверенных путей до создания PTY или временной browser-конфигурации, а адаптер лимитов сообщает `cli-not-found`; строка HOME скрыта до ручного выбора после обнаружения CLI. Провайдер без локального CLI остаётся в панелях запуска, если для него есть аккаунт на удалённом компьютере или сохранённый контейнерный профиль. CanvasTTY не читает shell startup scripts. Настройки агентов позволяют повторно проверить пути, атомарно заменить snapshot registry, согласовать сохранённые списки запуска и лимитов и обновить зависящие от CLI адаптеры без перезапуска. Работающие сессии продолжаются; найденный позже CLI остаётся выключенным до ручного выбора. diff --git a/docs/ARCHITECTURE.zh-CN.md b/docs/ARCHITECTURE.zh-CN.md index 34dcc04e0..1f7d0b66e 100644 --- a/docs/ARCHITECTURE.zh-CN.md +++ b/docs/ARCHITECTURE.zh-CN.md @@ -49,7 +49,7 @@ Electron main process - `src/main/services/PluginSecretsService.ts` 串行化每个插件的机密写入,通过 Electron `safeStorage` 加密完整的有界 payload,拒绝 plaintext-only backend,并在卸载时删除加密文件。`ProviderSecretsService.ts` 将同一架构应用于面向 BYOK CLI 的服务商 API key:值只留在 main 进程,renderer 契约只暴露每个 key 的 `configured` 标志与 set/clear 操作。`ApiProfile` 设置项为同一批 BYOK 运行时命名 model 后端(协议、HTTPS base URL、secret 引用);它们不是 agent provider,且 settings normalizer 会丢弃而非修复无效 profile。 - `src/main/services/PluginMediaService.ts` 仅在原生目录选择后保存授权,隐藏绝对路径,跳过 symlink,并以 HTTP Range 提供音频。Playlist 读取限制在授权媒体库内;写入受大小限制,并且只能原子写入 `Playlists/`。 - `src/main/services/BrowserService.ts` 管理内置浏览器的 `WebContentsView` tab。远程页面使用独立 persistent partition,禁用 Node,启用 context isolation/sandbox,并默认拒绝网站权限。这是 core service,不是 runtime 插件能力。 -- `src/main/services/agent-runtime/` 是独立且始终启用的 lifecycle 边界,不受 Browser access 开关控制。每个 agent PTY 都为受保护的 user-local socket/pipe 获得独立 capability。Provider command hook 与 OpenCode event plugin 只能提交固定 status enum、受限 event 名称和可选 opaque turn/prompt ID;Gateway 的精确 schema 会拒绝 prompt text、回复、tool input 与任意 telemetry。PTY 退出时 capability 与临时文件都会被撤销。 +- `src/main/services/agent-runtime/` 是独立且始终启用的 lifecycle 边界,不受 Browser access 开关控制。每个 agent PTY 都为受保护的 user-local socket/pipe 获得独立 capability。Provider command hook 与 OpenCode event plugin 只能提交固定 status enum、受限 event 名称和可选 opaque turn/prompt ID;Gateway 的精确 schema 会拒绝 prompt text、回复、tool input 与任意 telemetry。PTY 退出时 capability 与临时文件都会被撤销。 Codex 或 OpenCode 子 agent 的最终回答(Codex `Stop` hook、OpenCode 插件在 `session.idle` 时)经由同一组 hook 传递,因此只有该次启动开启了 status hooks 时才存在:是否开启在规划启动时决定(包括延迟的插件启动),在关闭状态下规划的启动既没有 lease 也没有任何 lifecycle hook。此时 `wait_for_agent` 与 `get_agent_result` 返回 `answerUnavailable: { reason: "status_hooks_off", message }`,编排者改为读取输出。回答只归属于它自己的 turn(`applyRuntimeSignal.ts`):OpenCode 为每个 turn 命名;除审批回答和空闲卡片中的空 Enter 外,提交的输入会开始新 turn;在该输入之前开始的当前 turn 的信号仍会结束等待,但不带回答。 - Claude、Codex、Qwen 与 OpenCode 使用仅本次启动有效的 lifecycle hook。Kimi、Hermes 与 Grok 只能从 home 配置发现 hook,因此使用由实时 CanvasTTY 会话共享、带 ownership 检查的临时条目。Kimi 与 Hermes 使用 recovery journal 和精确 backup;Grok 使用独立 owned hook 文件。Cleanup 在无并发编辑时逐字恢复原文件,否则只移除 CanvasTTY 自己的条目。 - `TerminalManager` 注入 MCP helper 时不会留下永久的服务商配置变更。Claude Code、Codex 与 Qwen Code 使用 CLI 参数;Qwen 使用一个 inline `--mcp-config`,只覆盖 CanvasTTY 服务名,不隐藏无关用户服务。OpenCode 使用合并后的、仅本次启动有效的 `OPENCODE_CONFIG_CONTENT` 和一条 scoped browser-tool 权限;Kimi 使用 per-run MCP 配置,旧版本则使用带 compare-and-swap 与 recovery journal 的临时配置。Hermes 会在 `HERMES_HOME/config.yaml` 中获得临时 `mcp_servers.canvastty_browser` 配置项(POSIX 默认路径为 `~/.hermes/config.yaml`,Windows 默认路径为 `%LOCALAPPDATA%\hermes\config.yaml`),敏感 capability 值仍以子进程环境变量占位符保存。Kimi 与 Hermes 的临时配置会保留到最后一个所属 PTY 会话结束;若文件未被并发修改,则精确恢复原始字节。若 Hermes 启动意外中断,journal 会在 CanvasTTY 下次启动时修复配置,compare-and-swap 则保留用户的并发修改。其他 MCP 配置项、凭据和文件/shell 权限不会受影响。Qwen、OpenCode 与 Hermes 的 YOLO 都不修改持久权限设置。 - `src/main/services/providerCliRegistry.ts` 是服务商 CLI 发现的唯一职责边界。main 进程启动时,它按 smoke-only override、继承的 `PATH`、平台默认目录、已知用户/服务商目录的顺序,为 `PROVIDER_CLI_DEFINITIONS` 中的每个服务商创建一个共享快照——每个定义声明该服务商可能安装的 executable 命令名(可以与服务商 ID 不同,例如命令为 `mcode` 的服务商),以及可选的已知目录(相对 home 或 Windows LOCALAPPDATA)。可用条目保存绝对 executable、launcher 类型以及补充后的子进程 `PATH`;POSIX 候选必须是可执行文件,Windows 候选必须是受支持的 native 或 batch launcher。`TerminalManager`、`LimitsService`、agent-browser probe 与 provider smoke 共用该快照,不再各自查找命令。CLI 不可用时,系统会在创建 PTY 或临时 browser 配置之前生成 failed session,并提供可复制的已检查路径诊断;限额适配器报告 `cli-not-found`;HOME 行保持隐藏,直到检测到 CLI 后由用户手动选择。若某服务商绑定了远程计算机上的账户或已保存的容器配置,即使本地没有 CLI,启动器仍会显示它。CanvasTTY 不读取 shell startup script。Agents 设置可重新检查候选路径、原子替换 registry 快照、调整已保存的启动器和限额选择,并在无需重启的情况下刷新依赖 CLI 的适配器。运行中的 session 保持不变;新检测到的 CLI 需手动启用。 diff --git a/docs/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/docs/agent-orchestration.md b/docs/agent-orchestration.md index d53c4e212..ab0719f33 100644 --- a/docs/agent-orchestration.md +++ b/docs/agent-orchestration.md @@ -9,10 +9,10 @@ A session launched from the desktop with the **Orchestrator** role gets the `can | Tool | What it does | | --- | --- | | `list_providers` | The agent providers CanvasTTY can launch as subagents: `id` (the exact `spawn_agent.provider` value), `name`, `installed`/`available` (from the provider CLI registry resolved at startup or on a recheck), `signIn` (`ok`, `signed_out`, `expired` or `unknown`, from the last usage-limits read; it never starts a read and never reads credentials), `subagent` and `orchestrator` support, `model` (whether the CLI takes one, its format, and for OpenCode the models a cached `opencode models` listing reported: run in the background with a 5 s timeout, refreshed after 10 minutes, never awaited) and `efforts`, and plugin launch options (`launchOptions`, plus `launchOptionTools` such as an accounts plugin's `list_routes`). Fast: cached state only, no network calls. | -| `spawn_agent` | Launches a subagent (profile: see below). `provider` must be a `list_providers` id (the schema lists them); an unknown one is refused with `INVALID_REQUEST` naming `list_providers`. Optional `model` goes to the CLI's own `--model` for that run (OpenCode `provider/model`; Codex, Claude, Qwen, Kimi alias, Grok, OMP, Pi, Cursor; Hermes, MiniMax, Devin and Antigravity take none) and optional `effort` to its reasoning effort (Codex `-c model_reasoning_effort`, Claude `--effort`, Grok `--reasoning-effort`). Both are checked per provider, kept on restart and restore, and never written to the CLI's configuration. If the person names a model, the orchestrator passes it as `model`. An OpenCode model its own `opencode models` list does not contain is refused before launch with up to five closest listed ids (OpenCode would otherwise stop with only "Unexpected server error"); when that list cannot be read, the model is passed as given. A subagent that exits anyway reports the last lines of its screen as `exitLines` in `wait_for_agent`, `observe_agent` and `get_agent_result`. | -| `wait_for_agent` | `{ sessionId, timeoutSeconds ≤ 600 }` (default 55). Returns when the subagent is `idle` (after a prompt: only once a turn that started after the latest delivered prompt has ended, so the CLI's startup idle does not count), `needs_approval`, `done`/`failed` (exited), `quiet` (reports no status and its screen stopped changing), `closed`, or on `timeout`, with `status`, `exitCode`, `waitedMs` and the masked terminal tail (masked before it is cut). Only this orchestrator's own subagents; it stops at once when the call is canceled or the orchestrator disconnects. | -| `get_agent_result`, `observe_agent` | `get_agent_result`: `answer`, the final reply of the subagent's last turn as the agent itself reported it (Codex through its Stop hook, OpenCode through CanvasTTY's plugin reading the session's last assistant message at `session.idle`; at most 4,096 characters with the end kept, masked, in memory only and cleared when the next turn starts), plus `status`, the exit state and the masked terminal tail. Other providers have no `answer`: their tail is raw screen output. `observe_agent`: the current status and tail. | -| `send_to_agent`, `cancel_agent`, `list_agents` | Follow-up prompts, disposing a subagent, listing this session's subagents. | +| `spawn_agent` | Launches a subagent (profile: see below). `provider` must be a `list_providers` id (the schema lists them); an unknown one is refused with `INVALID_REQUEST` naming `list_providers`. Optional `model` goes to the CLI's own `--model` for that run (OpenCode `provider/model`; Codex, Claude, Qwen, Kimi alias, Grok, OMP, Pi, Cursor; Hermes, MiniMax, Devin and Antigravity take none) and optional `effort` to its reasoning effort (Codex `-c model_reasoning_effort`, Claude `--effort`, Grok `--reasoning-effort`). Both are checked per provider, kept on restart and restore, and never written to the CLI's configuration. If the person names a model, the orchestrator passes it as `model`. Plugin launch options the call does not name are inherited from the orchestrator for a subagent of the same CLI (see Plugin launch options below); `model` next to inherited options that set the model is refused, and so is a subagent of another CLI while the orchestrator's model comes from a plugin's options. The answer's `servedBy` is `{ provider, model, launchOptions: { : "inherited" | "explicit" } }`. An OpenCode model its own `opencode models` list does not contain is refused before launch with up to five closest listed ids (OpenCode would otherwise stop with only "Unexpected server error"); when that list cannot be read, the model is passed as given. A subagent that exits anyway reports the last lines of its screen as `exitLines` in `wait_for_agent`, `observe_agent` and `get_agent_result`. | +| `wait_for_agent` | `{ sessionId, timeoutSeconds ≤ 100 }` (default 50). For an orchestrator other than Claude Code a call returns after at most 50 seconds, because its MCP client (Codex, OpenCode and others) ends a tool call after about 60 seconds; `timeout` then means the subagent is still running, with its progress in `output` and a `message` to call again. Returns when the subagent is `idle` (after a prompt: only once a turn that started after the latest delivered prompt has ended, so the CLI's startup idle does not count), `needs_approval` (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`, the end of the subagent's screen as masked plain text (as `get_agent_result`) and, for Codex and OpenCode subagents, `answer` (see `get_agent_result`). A turn that the person interrupted by submitting their own input in the subagent's card still ends the wait, without an `answer`: that answer is no longer for the orchestrator's prompt. With CanvasTTY status hooks off (Settings → Agents) a subagent has no hooks at all, so no `answer` can be captured: the result carries `answerUnavailable: { reason: "status_hooks_off", message }` instead and the wait ends `quiet`; read `output` (or `observe_agent`). Turning status hooks on applies to subagents started or restarted afterwards. Only this orchestrator's own subagents; it stops at once when the call is canceled or the orchestrator disconnects. | +| `get_agent_result`, `observe_agent` | `get_agent_result`: `answer`, the final reply of the subagent's last turn as the agent itself reported it (Codex through its Stop hook, OpenCode through CanvasTTY's plugin reading the session's last assistant message at `session.idle`; at most 4,096 characters with the end kept, masked, in memory only, kept only for the turn it ended and cleared when the next input is submitted or the next turn starts; an Enter with nothing typed in an idle card keeps it; an answer reported as the subagent exits still counts; `answerUnavailable` instead when status hooks are off), plus `status`, the exit state and the end of the subagent's screen as masked plain text (a headless terminal renders the redraws of a full-screen TUI such as OpenCode, so no control sequences remain; `wait_for_agent` returns the same). Other providers have no `answer`: read that text. `observe_agent`: the current status and the end of the screen as masked plain text, rendered like `wait_for_agent`'s `output` (at most `maxChars`). | +| `send_to_agent`, `cancel_agent`, `list_agents` | Follow-up prompts, disposing a subagent, listing this session's subagents. Prompts to one subagent arrive in the order they were sent. OpenCode drops keys typed while it starts, so a prompt for it (also `spawn_agent`'s first one) is typed only once its input field is up: the terminal control sequence that shows its text cursor on its full screen, or a lifecycle hook only 2 s after its first one (OpenCode's plugin can report shortly before the field takes keys). This wait is used on macOS and Linux; on Windows, whose pseudo console may not pass those sequences, OpenCode gets input as soon as its PTY exists, as before. `spawn_agent` (its checks, the launch and this wait together) and `send_to_agent` wait at most 45 s, under the MCP client's tool deadline; then the call fails with the reason and the session id, and nothing is typed. A send cancelled while it waits returns at once. `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 @@ -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). @@ -43,21 +47,23 @@ The core applies these to every way an agent can start another one: `spawn_agent | Rule | What it guarantees | | --- | --- | | Profile ceiling | A subagent's profile is at most its orchestrator's, never YOLO. A saved subagent whose record says more is restored with its orchestrator's ceiling. | -| Project folder | A subagent's `cwd` must be the folder the person chose for the agent it descends from, or a folder inside it, compared as real paths in both Unicode spellings (NFC and NFD name the same folder on macOS). `/`, the home folder or another project is refused: "A subagent works only inside this project's folder …". A relative `cwd` is taken from the orchestrator's folder. | +| Project folder | A subagent's `cwd` must be the folder the person chose for the agent it descends from, or a folder inside it, compared as real paths in both Unicode spellings (NFC and NFD name the same folder on macOS). `/`, the home folder or another project is refused: "A subagent works only inside this project's folder …". CanvasTTY's own private data is refused even when the project contains it (a project at the home folder): its tokens, account homes and plugins' data ("A subagent cannot work in … it is CanvasTTY's private data"); a folder inside one plugin's data only when the project itself is a folder inside that same plugin's data (an environment's worktree). A relative `cwd` is taken from the orchestrator's folder. | | Limits | At most `orchestrationMaxDepth` levels of subagents below the agent the person started (2 by default) and `orchestrationMaxSubagents` live subagents in one orchestration, all levels together (8 by default). Only the person sets them, in Settings → Agents. Exited subagents do not count. | -| Plugin launch options | `launchOptions` from an orchestrator reach only plugins that declared `launch.delegable: true`; everything a plugin contributes still passes the core-owned flag and configuration checks (no permission, sandbox, hook or bypass flags, no OpenCode/Kimi configuration that sets approvals). | +| Plugin launch options | `launchOptions` from an orchestrator reach only plugins that declared `launch.delegable: true` (a plugin the spawn does not name keeps the orchestrator's own values for a subagent of the same CLI; repeating them needs no declaration; a plugin turned off since is left out, or refused when it set the model; changed plugin fields refuse with that reason); everything a plugin contributes still passes the core-owned flag and configuration checks (no permission, sandbox, hook or bypass flags, no OpenCode/Kimi configuration that sets approvals). | | YOLO | Enforced in the main process: only for a CLI the person acknowledged YOLO for in the launcher, never for a subagent, never from an orchestrator's control connection; a plugin with `sessions:launch` needs the same acknowledgement. | | Isolation | Every subagent and every agent a plugin starts runs inside agent isolation (below) when it is on; where there is no layer, a subagent runs in normal (it asks) and its card says why. | +| 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 Settings → Agents → Agent isolation (on by default) wraps an agent's whole process tree (the CLI, its commands, its MCP servers and hooks) in an operating-system layer. It applies to every subagent, every agent a plugin starts, and every agent in a profile other than normal (manual), whoever launched it. Inside it: -- files can be written only in the project folder (both spellings), the launch's own temporary folder (`TMPDIR`), the CLI's own state, configuration and cache folders, and the npm/bun caches; the project's git hooks and an existing repository's git config, and the CLI's own permission settings files (`~/.codex/config.toml`, `~/.claude/settings.json`, OpenCode's config) are not writable; -- SSH, cloud and package-registry credentials, other CLIs' folders (their credentials) and CanvasTTY's own tokens, secret stores, account homes and launch files are unreadable, except what this launch was handed (its own control connection, its account home); +- files can be written only in the project folder (both spellings), the launch's own temporary folder (`TMPDIR`; for Claude also `CLAUDE_CODE_TMPDIR`, so its Bash tool and editor files stay out of the `/tmp/claude-` folder all Claude sessions share), the CLI's own state, configuration and cache folders, and the npm/bun caches; the project's git hooks and an existing repository's git config, and the CLI's own permission settings files (`~/.codex/config.toml`, `~/.claude/settings.json`, 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, launch files and plugins' private data are unreadable, except what this launch was handed (its own control connection, its account home, the one CLI home its launch plugin selected); - no other process can be signalled, no app opened through Launch Services, no Apple events sent (osascript driving Terminal), no preferences written through cfprefsd, no launchd job submitted, and Unix sockets reach only DNS, syslog, the launch's own folder and CanvasTTY's token-checked gateways (not Docker, tmux or an SSH agent); - network and the keychain's services work as before: the CLI reaches its provider and reads its own sign-in. +- when the project contains CanvasTTY's own data folder (a project at the home folder), that folder stays read-only as a whole (its settings, its plugins); only the CLI's own folders inside it (a selected account home) stay writable. Its parent folders inside the project (such as `~/.config`) cannot be renamed or replaced; entries can still be created in them. A project CanvasTTY keeps inside its data folder is writable as usual. The agent gets `CANVASTTY_ISOLATION` explaining the rule, so "Operation not permitted" outside it is understood rather than worked around. macOS uses `sandbox-exec` with a profile generated per launch; Linux uses bubblewrap (`bwrap`) when it is installed (read-only root, the same writable folders, empty tmpfs over what may not be read, its own PID namespace, the user runtime folder hidden). Windows has no layer yet. Where there is no layer (Windows, Linux without bubblewrap) a subagent runs in normal (it asks) and a CLI without an auto mode of its own gets no auto; the card shows the reason. Turning isolation off is the person's opt-in: subagents then keep their profile without the layer. The layer fails closed: when it cannot be set up the launch is refused with the reason, never started without it. @@ -67,6 +73,8 @@ Claude Code keeps its sign-in in the macOS login keychain and rewrites that file After an isolated session ends, is closed or is restored after a quit, CanvasTTY audits the repositories under its folder whose git folder changed: config keys and files git would execute outside the layer (`core.hooksPath`, filter and diff drivers, `fsmonitor`, `!` aliases, hooks, `info/attributes`) are listed in a notice, and the person can neutralize them or keep them. See [Installing and security](installing-and-security.md#agent-protection-layers). +Inside the layer, the files git reads its config and paths from are not writable in any repository of the project, existing or not: `.git/commondir`, `.git/config.worktree` and the linked worktrees' records in `.git/worktrees/` (so `git worktree add` fails there); objects, refs, the index, logs and `HEAD` stay writable, so add, commit, checkout, branch and stash work. A repository's `.git`, `.git/hooks` and `.git/info` folders cannot be renamed or removed inside the layer, so these rules cannot be sidestepped by moving the folder away and back. On Linux these are mounted read-only for the project's own existing repository; a `commondir` that does not exist yet cannot be mounted (an empty one would stop git), and repositories created during the session are left to the audit above. + ## Plugin environments A plugin environment declares in its manifest what it keeps (`keeps`): `launch` (the launch's arguments and environment reach the agent unchanged, so CanvasTTY's hooks and the profile's per-run settings work there), `isolated` (it does not run on this computer's files: a container or a remote host) and `confines`. Without `launch`, any profile but normal is refused and the card says that base protection and CanvasTTY's hooks do not reach the agent there. An `isolated` environment is not wrapped again (its own boundary applies; the card says so); a local one (a worktree) runs inside the layer with the worktree as its project. @@ -127,7 +135,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/docs/installing-and-security.md b/docs/installing-and-security.md index c81480251..c58bedf69 100644 --- a/docs/installing-and-security.md +++ b/docs/installing-and-security.md @@ -87,11 +87,13 @@ Agents work in their own mode; CanvasTTY's layers sit outside it as a quiet safe | Layer | Guarantees | Does not guarantee | | --- | --- | --- | | The agent's own permission system | What its CLI promises in the chosen mode (Claude Code, Codex, OpenCode, Grok…). | Anything the CLI's own classifier or reviewer lets through. | -| Base protection (hooks) | Denies elevation, pipes into a shell, disk commands, fork bombs, writes and deletes outside the project and access to CanvasTTY's private data, before the tool call runs, for agents whose CLI has hooks (Claude Code, Codex, Qwen Code, OpenCode). A decision plugin's "ask" is shown by Claude Code; for a CLI that cannot ask it is a deny with the reason. | It reads commands; code that hides what it does, or a CLI without hooks, is not covered. It is a guard, not a sandbox. | +| Base protection (hooks) | Denies elevation, pipes into a shell, disk commands, fork bombs, writes and deletes outside the project and access to CanvasTTY's private data, before the tool call runs, for agents whose CLI has hooks (Claude Code, Codex, Qwen Code, OpenCode). A decision plugin's "ask" is shown by Claude Code; for a CLI that cannot ask it is a deny with the reason. | Best effort: it reads tool calls, so code that hides what it does, a path built at run time, a script that walks folders itself, a recursive search started above CanvasTTY's data (a project at the home folder), or a CLI without hooks is not covered. It is a guard, not a sandbox: a project that contains CanvasTTY's data needs agent isolation to keep that data private. | | Delegation rules | A subagent never gets more than its orchestrator (never YOLO), works only in its orchestrator's project folder, within the depth and count limits the person set; orchestrators get a control connection of their own; no agent-facing tool changes settings, protection, profiles, trust or isolation; plugin launch options from an agent only where the plugin declared them delegable. | What a plugin the person trusted does with its own permissions. | | Agent isolation (OS layer) | On macOS (sandbox-exec) and Linux (bubblewrap), for subagents, plugin-started agents and every agent not in Manual: the whole process tree writes only in the project, its own temporary folder and its CLI's own folders; keys, other CLIs' credentials and CanvasTTY's tokens are unreadable; no other process, app, Apple event, preference write, launchd job or foreign Unix socket (Docker, tmux, SSH agent) is reachable. Fails closed. | Network is not restricted. Linux cannot filter Unix sockets, only hide the user runtime folder. The CLI's own sandbox cannot run inside the layer on macOS, so it is switched off there (approvals unchanged) and commands can use the network. A Claude launch may rewrite the login keychain file to save a refreshed sign-in, so it could also damage that file (other items stay behind their own access lists). Windows has no layer yet: subagents run in Manual there unless the person turns isolation off. | -Isolation keeps approval configuration read-only, including OpenCode's project and custom config files and its `agent`/`agents` directories. CLI state and caches remain writable. A moved CLI home is refused if its real path overlaps host credentials or private CanvasTTY data; a selected account folder below `account-homes` remains available. Credential exclusions use the host environment captured before launch contributions, even if the launch changes `HOME`. +Isolation keeps approval configuration read-only, including OpenCode's project and custom config files and its `agent`/`agents` directories. CLI state and caches remain writable. A moved CLI home is refused if its real path overlaps host credentials or private CanvasTTY data; a selected account folder below `account-homes` remains available. Plugins' private data (`plugin-data`) is unreadable as a whole; only the CLI home a chosen launch plugin started this agent with, an exact real folder inside that plugin's own data, is reopened, and a project inside plugin data is reopened as that one folder only where CanvasTTY placed the launch: a folder an environment prepared inside its own plugin's data, or the folder the person chose for the card (and its subagents' folders below it). Any other project inside plugin data, such as a subagent folder an agent asked for, is refused. On Linux, CanvasTTY's gateway socket folders are bound read-only, and only the sockets CanvasTTY's own gateways set up for the launch are: a `CANVASTTY_*_ADDRESS` from the person's environment or a plugin binds nothing, and a gateway socket folder that holds a hidden or writable folder is refused. Credential exclusions use the host environment captured before launch contributions, even if the launch changes `HOME`. + +**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. diff --git a/docs/installing-and-security.ru.md b/docs/installing-and-security.ru.md index 0532080dd..5deb9fe1c 100644 --- a/docs/installing-and-security.ru.md +++ b/docs/installing-and-security.ru.md @@ -83,12 +83,14 @@ gh variable set SPARKLE_PUBLIC_ED_KEY --repo howdeploy/CanvasTTY \ Агент работает в своём режиме; слои CanvasTTY стоят снаружи как тихая страховка: не добавляют вопросов при обычной работе в проекте и останавливают опасное с короткой причиной. - **Режимы запуска**: Авто (по умолчанию), Вручную, Правки, План, Обход — только там, где их поддерживает CLI. Обход выбирает человек для каждого CLI, он проверяется в main-процессе, никогда не даётся субагенту и всё равно остаётся внутри слоёв ниже. -- **Базовая защита** (хуки) запрещает повышение прав, `curl | sh`, дисковые команды и запись вне проекта у агентов с хуками; это проверка команд, а не песочница. +- **Базовая защита** (хуки) запрещает повышение прав, `curl | sh`, дисковые команды и запись вне проекта у агентов с хуками, а также доступ к закрытым данным CanvasTTY; это проверка команд по возможности, а не песочница: проект, содержащий данные CanvasTTY (например, домашняя папка), защищает только изоляция. - **Правила делегирования**: субагент получает не больше оркестратора (никогда YOLO), работает только в папке проекта, в пределах глубины и числа, заданных человеком; агенты не могут менять настройки, защиту, профили и изоляцию. -- **Изоляция агентов** (macOS — sandbox-exec, Linux — bubblewrap): запись только в проект, свою временную папку и папки своего CLI; ключи и токены недоступны; другие процессы, приложения и демоны недоступны; при сбое запуск отклоняется. Сеть не ограничивается; на Windows слоя пока нет, и субагенты там работают вручную. Подробности — в [agent-orchestration.md](agent-orchestration.md). +- **Изоляция агентов** (macOS — sandbox-exec, Linux — bubblewrap): запись только в проект, свою временную папку и папки своего CLI; ключи и токены недоступны; другие процессы, приложения и демоны недоступны; при сбое запуск отклоняется. Приватные данные плагинов (`plugin-data`) недоступны целиком; снова открываются только папка CLI, с которой выбранный плагин запуска стартовал агента (точная реальная папка внутри данных этого плагина), и проект внутри данных плагинов — как одна эта папка и только там, где запуск разместил CanvasTTY: папка, подготовленная окружением внутри данных своего плагина, или папка, выбранная человеком для карточки (и папки её субагентов ниже). Любой другой проект внутри данных плагинов, например папка субагента, о которой попросил агент, отклоняется. На Linux папки сокетов шлюзов CanvasTTY подключаются только для чтения, и только сокеты, которые собственные шлюзы CanvasTTY подготовили для этого запуска: `CANVASTTY_*_ADDRESS` из окружения человека или от плагина ничего не подключает, а папка сокетов шлюза со скрытой или записываемой папкой отклоняется. Сеть не ограничивается; на Windows слоя пока нет, и субагенты там работают вручную. Подробности — в [agent-orchestration.md](agent-orchestration.md). **Аудит git.** Когда изолированная сессия агента заканчивается, закрывается или восстанавливается после выхода, CanvasTTY проверяет репозитории в её папке, у которых изменилась папка git. Если git теперь запустит что-то вне изоляции (`core.hooksPath`, фильтр или diff-драйвер, `fsmonitor`, файл хука, `info/attributes`), появляется уведомление со списком изменений; **Обезвредить** удаляет эти ключи и отключает файлы, **Оставить как есть** ничего не трогает. Другие изменения файлов в проекте аудит не откатывает. +**Ключи провайдеров в окружении.** Обычный терминал, запущенный вами, получает всё ваше окружение, кроме собственных учётных данных 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..54756ae72 100644 --- a/docs/installing-and-security.zh-CN.md +++ b/docs/installing-and-security.zh-CN.md @@ -83,12 +83,14 @@ gh variable set SPARKLE_PUBLIC_ED_KEY --repo howdeploy/CanvasTTY \ 智能体按自己的模式工作;CanvasTTY 的各层位于其外部,作为安静的安全网:项目内的常规工作不会增加提问,只拦截危险操作并给出简短原因。 - **启动模式**:自动(默认)、手动、接受编辑、计划、绕过,仅在 CLI 支持时提供。绕过由用户按 CLI 确认,在主进程中检查,绝不交给子智能体,并且仍处于下列各层之内。 -- **基础保护**(hooks)为有 hooks 的智能体拒绝提权、`curl | sh`、磁盘命令以及项目外的写入;它检查命令,不是沙箱。 +- **基础保护**(hooks)为有 hooks 的智能体拒绝提权、`curl | sh`、磁盘命令以及项目外的写入,并拒绝访问 CanvasTTY 的私有数据;它是尽力而为的命令检查,不是沙箱:包含 CanvasTTY 数据的项目(例如主目录)只有在隔离下才受保护。 - **委派规则**:子智能体的权限不超过其编排者(绝不为 YOLO),只在项目文件夹内工作,并受用户设定的深度和数量限制;智能体无法更改设置、防护、配置档或隔离。 -- **智能体隔离**(macOS 使用 sandbox-exec,Linux 使用 bubblewrap):只能写入项目、自身临时目录和其 CLI 的目录;密钥和令牌不可读;其他进程、应用和守护进程不可达;无法建立时拒绝启动。网络不受限制;Windows 暂无此层,子智能体在那里以手动模式运行。详见 [agent-orchestration.md](agent-orchestration.md)。 +- **智能体隔离**(macOS 使用 sandbox-exec,Linux 使用 bubblewrap):只能写入项目、自身临时目录和其 CLI 的目录;密钥和令牌不可读;其他进程、应用和守护进程不可达;无法建立时拒绝启动。插件的私有数据(`plugin-data`)整体不可读;只重新开放两类路径:所选启动插件启动该智能体时使用的 CLI 目录(该插件自身数据内的一个确切真实文件夹),以及插件数据内的项目——仅作为这一个文件夹,且仅限 CanvasTTY 安排启动的位置:环境在其插件自身数据内准备的文件夹,或用户为卡片选择的文件夹(及其下方子智能体的文件夹)。插件数据内的任何其他项目(例如智能体要求的子智能体文件夹)都会被拒绝。在 Linux 上,CanvasTTY 网关的 socket 文件夹以只读方式挂载,且只挂载 CanvasTTY 自身网关为本次启动准备的 socket:来自用户环境或插件的 `CANVASTTY_*_ADDRESS` 不会挂载任何东西,包含隐藏或可写文件夹的网关 socket 文件夹会被拒绝。网络不受限制;Windows 暂无此层,子智能体在那里以手动模式运行。详见 [agent-orchestration.md](agent-orchestration.md)。 **Git 审计。** 隔离的智能体会话结束、被关闭或在退出后恢复时,CanvasTTY 会检查其文件夹下 git 目录发生变化的仓库。如果 git 现在会在隔离外运行某些东西(`core.hooksPath`、filter 或 diff 驱动、`fsmonitor`、hook 文件、`info/attributes`),会出现一条列出具体变化的通知;**Neutralize** 删除这些键并停用这些文件,**Keep as is** 保持不变。它不会撤销项目内的其他文件改动。 +**环境中的服务商密钥。** 你自己启动的普通终端卡片保留你的全部环境,但不包括 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/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..bd1f96f8e 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -196,7 +196,7 @@ Up to 8 fields; `kind` is `boolean`, `select` (1–16 options) or `text` (at mos A `select` with `"optionsFrom": "service"` also lists choices the service offers, such as its own accounts. When the launcher opens, CanvasTTY asks the service `canvastty.launch.options` `{ provider, fields: [keys] }` and waits at most 3 s; the answer `{ "": [{ value, label }] }` adds up to 64 choices per field after the declared ones (which stay required and are all the launcher shows when the service does not answer). Because such a list can change after a card was saved, its value is accepted as any text up to 200 characters without control characters, and `canvastty.launch.prepare` must check it and refuse a value it no longer knows. -Orchestrators pass the same values to `spawn_agent` as `launchOptions` (`{ "": { "": value } }`), checked exactly like the launcher's, but only to a plugin whose `launch` declares `"delegable": true` (its options are safe for an agent to choose; without it only the person chooses them, and `list_providers` does not list them); a plugin tool can hand them out (for example the account it picked). Whatever a contribution adds is checked the same way for every launch: no permission, sandbox, hook or bypass flags, and no configuration the CLI reads that sets approvals (an OpenCode `OPENCODE_CONFIG` file or `OPENCODE_CONFIG_DIR`, `OPENCODE_PERMISSION`, Kimi's `--config`/`--config-file`); a file CanvasTTY cannot read and check is refused. While a child's launch waits for its plugins (launch options, a launch policy, an environment), `spawn_agent` answers only after its `prompt` reached the started agent, and `send_to_agent` waits the same way. A refused, failed or cancelled launch fails the call with the reason and the session id (the card stays); the text is dropped, never kept for a later restart. The control CLI answers `NOT_READY` for such a card. +Orchestrators pass the same values to `spawn_agent` as `launchOptions` (`{ "": { "": value } }`), checked exactly like the launcher's, but only to a plugin whose `launch` declares `"delegable": true` (its options are safe for an agent to choose; without it only the person chooses them, and `list_providers` does not list them); a plugin tool can hand them out (for example the account it picked). A plugin the spawn does not name keeps the orchestrator's own values for a subagent of the same CLI (the person chose them; this needs no `delegable`), so a subagent never falls back silently to its CLI's default model; a subagent of another CLI is refused while a plugin's contribution sets the orchestrator's model (its CLI model argument or `thirdPartyModel`) unless the spawn names that plugin, and `spawn_agent` answers with `servedBy` (CLI, requested model, and whether each plugin's options were inherited or explicit). An inherited plugin that was turned off since the orchestrator started is left out, unless it set the model (then the spawn is refused); one whose fields changed refuses the spawn with that reason. For a subagent's launch, a contribution that chooses the model (`--model`/`--model=`, `-m` for OpenCode, Codex, Qwen, Kimi and Grok, and for Codex also `--profile`/`-p` and `-c`/`--config` setting `model`, `model_provider` or `profile`) refuses a launch that also asks for a model, and two contributions that both set the model are refused, instead of starting the CLI with two. A person's own launches and restores start as before. Whatever a contribution adds is checked the same way for every launch: no permission, sandbox, hook or bypass flags, and no configuration the CLI reads that sets approvals (an OpenCode `OPENCODE_CONFIG` file or `OPENCODE_CONFIG_DIR`, `OPENCODE_PERMISSION`, Kimi's `--config`/`--config-file`); a file CanvasTTY cannot read and check is refused. While a child's launch waits for its plugins (launch options, a launch policy, an environment), `spawn_agent` answers only after its `prompt` reached the started agent, and `send_to_agent` waits the same way. A refused, failed or cancelled launch fails the call with the reason and the session id (the card stays); the text is dropped, never kept for a later restart. The control CLI answers `NOT_READY` for such a card. Before the agent starts, the host sends the service a `canvastty.launch.prepare` request, which surfaces cannot send: @@ -222,6 +222,7 @@ Rules the host enforces, none of which is ever skipped: - Claude Code applies only its last `--settings`, so a plugin's inline `--settings` JSON is merged into CanvasTTY's own (objects such as `env` key by key, hook lists appended); one that sets `permissions`, `hooks`, `disableAllHooks`, `sandbox`, `defaultMode` or `apiKeyHelper` is refused. Both `--settings ` and `--settings=` are checked; a settings file is accepted only as one of the contribution's own launch files (`{launchFiles}/…`), which CanvasTTY reads, checks the same way and passes inline; any other file path is refused. `--bare`, `--safe-mode`, `--allowedTools`, `--permission-prompt-tool` and `--permission-prompts` are CanvasTTY's too. - No answer within 5 s, an error, an invalid answer, a missing secret, or a plugin that is disabled, removed or no longer trusted refuses the launch with the reason on the card. The agent is never started without a contribution the person chose. A restored card whose plugin is unavailable comes back stopped with that reason and keeps its record until the plugin returns or the card is closed. - A plain terminal takes no launch options. +- An isolated agent cannot read any plugin's data folder (`/plugin-data`). If an `env` value sets the agent CLI's own home variable to an exact absolute folder inside the plugin's own data folder (not the data folder itself; a trailing `/` is ignored), that one folder is reopened for this agent. Only these variables count, each for its own CLI: `CODEX_HOME` (Codex), `CLAUDE_CONFIG_DIR` (Claude Code), `GROK_HOME` (Grok), `HERMES_HOME` (Hermes), `KIMI_CODE_HOME`, `KIMI_SHARE_DIR` and `KIMI_HOME` (Kimi), `OPENCODE_CONFIG_DIR` (OpenCode) and `QWEN_HOME` (Qwen Code); never `HOME` or `XDG_*`. It must be a real folder, with no symbolic link on the way. Nothing else in plugin data is reopened, and a plugin cannot name other folders to reopen. **Launch profiles.** `profile` is `normal` (the default), `yolo`, or `auto`. `auto` exists only for agents whose CLI has a native auto mode, checked against each CLI's `--help`: Codex `--approve-for-me` (its own reviewer, in its `workspace-write` sandbox), Claude Code `--permission-mode auto` with its sandbox (`{"sandbox":{"enabled":true,"autoAllowBashIfSandboxed":false}}` merged into the one `--settings`), and Grok `--permission-mode auto` (no Grok sandbox is relied on). Base protection and decision services keep answering in front of it (Codex, Claude Code). When any contributor answers `thirdPartyModel: true`, `auto` becomes the CLI's accept-edits mode in the same sandbox (Codex `--sandbox workspace-write --ask-for-approval on-request`, Claude Code and Grok `--permission-mode acceptEdits`): the native reviewer would be that same model, and a weak model's own classifier is not a safety boundary. @@ -286,7 +287,7 @@ Before a local agent's shell command or file write runs, CanvasTTY can ask a plu }] ``` -`pre-tool` is every shell and file-writing tool call, before it runs and in every permission mode, YOLO included: Claude Code, Codex and Qwen Code through their `PreToolUse` hook, OpenCode through CanvasTTY's OpenCode plugin (`tool.execute.before`). `appliesTo` limits the agents; all four when omitted. The host sends `canvastty.decide` (host-only) and waits at most `timeoutMs` (1000 to 60000; 3000 when omitted). The agent's call waits as long, so ask for more only when the answer needs it (for example a local model reading the command); CanvasTTY sizes each card's hook for the longest budget of the services that apply when the card starts, and a service trusted later gets no more than its card allows. The request carries the budget as `budgetMs`: +`pre-tool` is every shell and file-writing tool call (read-only file tools reach only base protection, never a service, and only while base protection was on when the agent started), before it runs and in every permission mode, YOLO included: Claude Code, Codex and Qwen Code through their `PreToolUse` hook, OpenCode through CanvasTTY's OpenCode plugin (`tool.execute.before`). 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 { @@ -316,7 +317,7 @@ The full example is [`examples/plugins/deny-rm`](../examples/plugins/deny-rm): i ### Agent tools (`tools:agents`) -A service may offer up to 16 `tools` to agents. They appear in the `canvastty_agents` MCP server as `__` (dots in the plugin id become `_`, so `com.example.tools` + `lookup` is `com_example_tools__lookup`; Anthropic and OpenAI allow only letters, digits, `_` and `-` in tool names, at most 64 characters, and a longer name keeps the start of the id plus a short hash), next to CanvasTTY's own orchestration tools, and count as CanvasTTY's own tools: base protection does not check them (it checks shells and file writes only). +A service may offer up to 16 `tools` to agents. They appear in the `canvastty_agents` MCP server as `__` (dots in the plugin id become `_`, so `com.example.tools` + `lookup` is `com_example_tools__lookup`; Anthropic and OpenAI allow only letters, digits, `_` and `-` in tool names, at most 64 characters, and a longer name keeps the start of the id plus a short hash), next to CanvasTTY's own orchestration tools, and count as CanvasTTY's own tools: base protection does not check them (it checks shells and file writes, and read-only file tools for its private-data rule only). ```json "permissions": ["tools:agents"], @@ -406,7 +407,9 @@ A service may run another browser engine for agents' background tabs, for exampl Two safety parts are built in and need no plugin: -- **Base protection** (Settings → Agents, on by default; the person can turn it off) denies, through the same hook, sudo and other elevation, piping downloaded or generated text into a shell, download-and-run, disk and format commands, fork bombs, and writing or deleting outside the working folder: the home folder, other projects and `/tmp` included, and deleting the working folder itself. An agent's own plan and memory folders (`~/.claude/plans`, `~/.claude/projects//memory`, and the same inside the run's `CLAUDE_CONFIG_DIR`) are not "outside". It also denies any use of CanvasTTY's own private data (from the app's userData folder: the agent-control token and descriptor, the gateways' connection records and sockets, the secret stores, account homes; and the control/runtime socket folders under the temporary folder), by any program, interpreter one-liners and socket clients included; the reason points the model at an **Orchestrator** launch and the `canvastty_agents` tools. The bundled control CLI may name its descriptor. It only ever denies; each reason tells the model what to do instead (a write to `/tmp` suggests a scratch folder inside the project). +- **Base protection** (Settings → Agents, on by default; the person can turn it off) denies, through the same hook, sudo and other elevation, piping downloaded or generated text into a shell, download-and-run, disk and format commands, fork bombs, and writing or deleting outside the working folder: the home folder, other projects and `/tmp` included, and deleting the working folder itself. An agent's own plan and memory folders (`~/.claude/plans`, `~/.claude/projects//memory`, and the same inside the run's `CLAUDE_CONFIG_DIR`) are not "outside". It also denies use of CanvasTTY's own private data (from the app's userData folder: the agent-control token and descriptor, the gateways' connection records and sockets, the secret stores, account homes, plugins' data (`plugin-data`) except the account home this card's launch plugin selected for it; and the control/runtime socket folders under the temporary folder), even when the working folder contains it (a project at the home folder): commands that name it, including interpreter one-liners and socket clients it recognises, file writes, and read-only file tools (Claude Code's Read, NotebookRead, Grep, Glob and LS; Qwen Code's and OpenCode's equivalents, checked for nothing else and only while base protection was on when the agent started); the reason points the model at an **Orchestrator** launch and the `canvastty_agents` tools. The bundled control CLI may name its descriptor. It only ever denies; each reason tells the model what to do instead (a write to `/tmp` suggests a scratch folder inside the project). + + Base protection is best effort: it reads each tool call before it runs, so it does not catch a path the call builds at run time (a command substitution, a variable it cannot resolve, a script that walks folders itself such as Python's `os.walk`), and it does not refuse a recursive search started above the app's data folder (`grep -r token ~`, a Grep of the home folder). A call that names more paths, longer paths or more nested shells than it checks is refused rather than checked in part, and so is a shell, file-edit or read call whose check fails with an error. A project set to the home folder, or to any folder that contains CanvasTTY's data, is therefore not a safe configuration on its own. Run such agents with agent isolation (sandbox-exec on macOS, bubblewrap on Linux): it hides the private data from every program in the agent's process tree, walks included. - **Secret redaction**: every text CanvasTTY hands from one agent to another (`observe_agent`, `wait_for_agent`, `get_agent_result`, the control CLI's `screen`, `result` and failure details) is masked: provider keys CanvasTTY holds, launch `secretEnv` values, values a service registered with `redaction.register`, also when the terminal wrapped them over lines, plus common key shapes (`sk-…`, GitHub, Slack, AWS, Google, JWT, `Bearer …`, `"apiKey": "…"`, PEM private keys, long random runs). Plugin tool answers, `screen` in session events, card badges and card action messages are masked the same way. host.onStorageChange(listener) notifies every live contribution of the same plugin — canvases, HOME widgets, and separate windows — of writes made through host.storage.set, avoiding polling when a plugin coordinates several surfaces. diff --git a/docs/plugins.ru.md b/docs/plugins.ru.md index 5d2cfe9d1..aaf279e16 100644 --- a/docs/plugins.ru.md +++ b/docs/plugins.ru.md @@ -203,6 +203,7 @@ host.service.onEvent(({ serviceId, event, data }) => { /* … */ }); - Claude Code применяет только последний `--settings`, поэтому встроенный JSON `--settings` плагина сливается с собственным JSON CanvasTTY (объекты вроде `env` по ключам, списки хуков дописываются); если он задаёт `permissions`, `hooks`, `disableAllHooks`, `sandbox`, `defaultMode` или `apiKeyHelper`, запуск отклоняется. Проверяются обе формы: `--settings ` и `--settings=`; файл настроек принимается только как один из собственных файлов запуска вклада (`{launchFiles}/…`) — CanvasTTY читает его, проверяет так же и передаёт встроенным JSON; любой другой путь к файлу отклоняется. `--bare`, `--safe-mode`, `--allowedTools`, `--permission-prompt-tool` и `--permission-prompts` тоже передаёт только CanvasTTY. - Нет ответа за 5 с, ошибка, неверный ответ, отсутствующий секрет или выключенный, удалённый либо переставший быть доверенным плагин отклоняют запуск, и причина видна в окне. Агент никогда не запускается без выбранного вклада. Восстановленное окно с недоступным плагином возвращается остановленным с этой причиной и сохраняет запись, пока плагин не вернётся или окно не закроют. - Обычный терминал параметров запуска не принимает. +- Изолированный агент не может читать данные плагинов (`/plugin-data`). Если значение `env` задаёт собственную переменную домашней папки CLI агента точным абсолютным путём к папке внутри папки данных этого плагина (не к самой папке данных; завершающий `/` не учитывается), для этого агента открывается только эта папка. Учитываются только эти переменные, каждая для своего CLI: `CODEX_HOME` (Codex), `CLAUDE_CONFIG_DIR` (Claude Code), `GROK_HOME` (Grok), `HERMES_HOME` (Hermes), `KIMI_CODE_HOME`, `KIMI_SHARE_DIR` и `KIMI_HOME` (Kimi), `OPENCODE_CONFIG_DIR` (OpenCode) и `QWEN_HOME` (Qwen Code); никогда `HOME` или `XDG_*`. Это должна быть настоящая папка без символических ссылок на пути. Больше ничего из данных плагинов не открывается, и плагин не может назвать другие папки для открытия. **Профили запуска.** `profile` — это `normal` (по умолчанию), `yolo` или `auto`. `auto` есть только у агентов, чей CLI имеет собственный авторежим, проверенный по `--help` каждого CLI: Codex `--approve-for-me` (его собственная проверка в песочнице `workspace-write`), Claude Code `--permission-mode auto` с его песочницей (`{"sandbox":{"enabled":true,"autoAllowBashIfSandboxed":false}}` сливается в единственный `--settings`) и Grok `--permission-mode auto` (на песочницу Grok не полагаемся). Базовая защита и сервисы решений отвечают перед ним (Codex, Claude Code). Если хотя бы один вклад ответил `thirdPartyModel: true`, `auto` превращается в режим «только правки» того же CLI в той же песочнице (Codex `--sandbox workspace-write --ask-for-approval on-request`, Claude Code и Grok `--permission-mode acceptEdits`): собственной проверкой была бы та же модель, а классификатор слабой модели — не граница безопасности. @@ -264,7 +265,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 { @@ -385,6 +386,8 @@ interface PluginSessionEvent { Две части безопасности встроены и не требуют плагина: - **Базовая защита** (Настройки → Агенты, включена по умолчанию; человек может её выключить) через тот же хук запрещает sudo и другое повышение прав, передачу скачанного или сгенерированного текста в shell, скачивание с запуском, команды для дисков и форматирования, форк-бомбы, а также запись и удаление вне рабочей папки — включая домашнюю папку, другие проекты и `/tmp` — и удаление самой рабочей папки. Собственные папки планов и памяти агента (`~/.claude/plans`, `~/.claude/projects//memory` и то же внутри `CLAUDE_CONFIG_DIR` запуска) не считаются «вне». Она только запрещает; каждая причина говорит модели, что сделать вместо этого (для записи в `/tmp` — завести временную папку внутри проекта). + + Она также запрещает использование собственных закрытых данных CanvasTTY (токен и описание agent-control, записи подключений и сокеты шлюзов, хранилища секретов, домашние папки аккаунтов, данные плагинов (`plugin-data`), кроме домашней папки аккаунта, которую плагин запуска этой карточки выбрал для неё, папки сокетов во временной папке), даже если рабочая папка их содержит: команды, которые называют эти пути, запись файлов и инструменты только для чтения (Read, NotebookRead, Grep, Glob и LS у Claude Code; аналоги у Qwen Code и OpenCode, только если базовая защита была включена при запуске агента). Эта проверка — по возможности: она читает вызов инструмента до запуска, поэтому не замечает путь, собранный во время выполнения (подстановка команды, неизвестная переменная, скрипт, который сам обходит папки, например `os.walk` в Python), и не запрещает рекурсивный поиск, начатый выше папки данных приложения (`grep -r token ~`, Grep по домашней папке). Вызов, в котором больше путей, более длинные пути или больше вложенных shell, чем она проверяет, запрещается, а не проверяется частично; так же запрещается вызов shell, правки или чтения файлов, проверка которого завершилась ошибкой. Поэтому проект в домашней папке или в любой папке, содержащей данные CanvasTTY, сам по себе небезопасен. Запускайте такие агенты в изоляции (sandbox-exec на macOS, bubblewrap на Linux): она скрывает закрытые данные от всех программ процесса агента, включая обход папок. - **Скрытие секретов**: весь текст, который CanvasTTY передаёт от одного агента другому (`observe_agent`, `wait_for_agent`, `get_agent_result`, `screen`, `result` и детали ошибок в control CLI), маскируется: ключи провайдеров, которые хранит CanvasTTY, значения `secretEnv` запуска, значения, зарегистрированные сервисом через `redaction.register`, в том числе перенесённые терминалом на несколько строк, а также типичные формы ключей (`sk-…`, GitHub, Slack, AWS, Google, JWT, `Bearer …`, `"apiKey": "…"`, приватные ключи PEM, длинные случайные строки). Так же маскируются ответы инструментов плагинов, `screen` в событиях сессий, метки на окнах и сообщения действий. host.onStorageChange(listener) сообщает всем открытым поверхностям того же плагина — canvas cards, HOME widgets и отдельным окнам — об изменениях через host.storage.set, поэтому нескольким поверхностям не требуется постоянный polling. diff --git a/docs/plugins.zh-CN.md b/docs/plugins.zh-CN.md index c91ff2c60..299d11b10 100644 --- a/docs/plugins.zh-CN.md +++ b/docs/plugins.zh-CN.md @@ -201,6 +201,7 @@ host.service.onEvent(({ serviceId, event, data }) => { /* … */ }); - Claude Code 只应用最后一个 `--settings`,因此插件的内联 `--settings` JSON 会合并进 CanvasTTY 自己的 JSON(`env` 等对象按键合并,hook 列表追加);若其中设置了 `permissions`、`hooks`、`disableAllHooks`、`sandbox`、`defaultMode` 或 `apiKeyHelper`,启动会被拒绝。`--settings ` 与 `--settings=` 两种形式都会检查;设置文件只接受贡献自身的启动文件(`{launchFiles}/…`),CanvasTTY 读取并以同样方式检查后以内联 JSON 传递;其他文件路径一律拒绝。`--bare`、`--safe-mode`、`--allowedTools`、`--permission-prompt-tool` 和 `--permission-prompts` 也只由 CanvasTTY 传递。 - 5 秒内无应答、出错、应答无效、缺少机密,或插件被禁用、删除或不再受信任,都会拒绝启动并在卡片上显示原因。智能体绝不会在缺少用户所选贡献的情况下启动。插件不可用的恢复卡片以停止状态返回并显示该原因,记录保留到插件恢复或卡片被关闭。 - 普通终端不接受启动选项。 +- 隔离的智能体无法读取任何插件的数据文件夹(`/plugin-data`)。如果某个 `env` 值把该智能体 CLI 自己的主目录变量设为该插件自身数据文件夹内的一个精确绝对路径文件夹(不能是数据文件夹本身;末尾的 `/` 会被忽略),就只为这个智能体重新开放这一个文件夹。只有以下变量算数,且各自只对应自己的 CLI:`CODEX_HOME`(Codex)、`CLAUDE_CONFIG_DIR`(Claude Code)、`GROK_HOME`(Grok)、`HERMES_HOME`(Hermes)、`KIMI_CODE_HOME`、`KIMI_SHARE_DIR` 和 `KIMI_HOME`(Kimi)、`OPENCODE_CONFIG_DIR`(OpenCode)和 `QWEN_HOME`(Qwen Code);`HOME` 和 `XDG_*` 永远不算。它必须是真实的文件夹,路径上不能有符号链接。插件数据中的其他内容都不会被重新开放,插件也不能指定其他要开放的文件夹。 **启动配置档。** `profile` 为 `normal`(默认)、`yolo` 或 `auto`。`auto` 仅适用于 CLI 自带自动模式的智能体,均按各 CLI 的 `--help` 核实:Codex `--approve-for-me`(其自身审查,运行于 `workspace-write` 沙箱),Claude Code `--permission-mode auto` 及其沙箱(`{"sandbox":{"enabled":true,"autoAllowBashIfSandboxed":false}}` 合并进唯一的 `--settings`),以及 Grok `--permission-mode auto`(不依赖 Grok 沙箱)。基础保护和决策服务仍在其前面作答(Codex、Claude Code)。只要任一贡献者回答 `thirdPartyModel: true`,`auto` 就改为同一沙箱中 CLI 的“仅接受编辑”模式(Codex `--sandbox workspace-write --ask-for-approval on-request`,Claude Code 和 Grok `--permission-mode acceptEdits`):原生审查者将是同一个模型,而弱模型自己的分类器不是安全边界。 @@ -262,7 +263,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 { @@ -382,7 +383,9 @@ interface PluginSessionEvent { 两项安全功能内置,无需插件: -- **基础保护**(设置 → Agents → Base protection,默认开启;用户可以关闭)通过同一个 hook 拒绝:sudo 及其他提权、把下载或生成的文本管道给 shell、下载后直接运行、磁盘和格式化命令、fork 炸弹,以及在工作文件夹之外写入或删除(包括主目录、其他项目和 `/tmp`),以及删除工作文件夹本身。agent 自己的计划和记忆文件夹(`~/.claude/plans`、`~/.claude/projects//memory`,以及本次运行 `CLAUDE_CONFIG_DIR` 中的相同位置)不算"外部"。它还会拒绝任何程序(包括解释器单行命令和套接字客户端)使用 CanvasTTY 自己的私有数据(来自应用的 userData 文件夹:agent-control 令牌与描述文件、各网关的连接记录与套接字、密钥存储、账户主目录;以及临时文件夹下的控制/运行时套接字文件夹);拒绝原因会引导模型改用 **Orchestrator** 启动和 `canvastty_agents` 工具。内置控制 CLI 可以引用它的描述文件。它只会拒绝;每条原因都告诉模型应当改做什么(写入 `/tmp` 时建议在项目内建立临时文件夹)。 +- **基础保护**(设置 → Agents → Base protection,默认开启;用户可以关闭)通过同一个 hook 拒绝:sudo 及其他提权、把下载或生成的文本管道给 shell、下载后直接运行、磁盘和格式化命令、fork 炸弹,以及在工作文件夹之外写入或删除(包括主目录、其他项目和 `/tmp`),以及删除工作文件夹本身。agent 自己的计划和记忆文件夹(`~/.claude/plans`、`~/.claude/projects//memory`,以及本次运行 `CLAUDE_CONFIG_DIR` 中的相同位置)不算"外部"。它还会拒绝使用 CanvasTTY 自己的私有数据(来自应用的 userData 文件夹:agent-control 令牌与描述文件、各网关的连接记录与套接字、密钥存储、账户主目录、插件数据(`plugin-data`,但本卡片的启动插件为它选定的账户主目录除外);以及临时文件夹下的控制/运行时套接字文件夹),即使工作文件夹包含它们(例如项目就是主目录):包括点名这些路径的命令(含它能识别的解释器单行命令和套接字客户端)、文件写入,以及只读文件工具(Claude Code 的 Read、NotebookRead、Grep、Glob、LS,以及 Qwen Code 和 OpenCode 的对应工具;只读工具不检查其他内容,且仅在 agent 启动时基础保护已开启的情况下才检查);拒绝原因会引导模型改用 **Orchestrator** 启动和 `canvastty_agents` 工具。内置控制 CLI 可以引用它的描述文件。它只会拒绝;每条原因都告诉模型应当改做什么(写入 `/tmp` 时建议在项目内建立临时文件夹)。 + + 基础保护是尽力而为的检查:它在工具调用运行前读取调用内容,因此无法发现运行时才拼出的路径(命令替换、无法解析的变量、像 Python `os.walk` 这样自行遍历文件夹的脚本),也不会拒绝从应用数据文件夹上层开始的递归搜索(`grep -r token ~`、对主目录的 Grep)。如果一次调用包含的路径数量、路径长度或嵌套 shell 层数超过它的检查范围,该调用会被拒绝,而不是只检查一部分;检查过程出错的 shell、文件编辑或读取调用同样会被拒绝。因此,把项目设为主目录或任何包含 CanvasTTY 数据的文件夹,本身并不安全。请在 agent 隔离下运行这类 agent(macOS 上的 sandbox-exec,Linux 上的 bubblewrap):它会对 agent 进程树中的所有程序隐藏私有数据,包括遍历。 - **密钥遮蔽**:CanvasTTY 从一个 agent 交给另一个 agent 的所有文本(`observe_agent`、`wait_for_agent`、`get_agent_result`,以及 control CLI 的 `screen`、`result` 和失败详情)都会被遮蔽:CanvasTTY 保存的服务商密钥、启动时的 `secretEnv` 值、服务通过 `redaction.register` 注册的值(包括被终端折行拆开的情况),以及常见密钥形式(`sk-…`、GitHub、Slack、AWS、Google、JWT、`Bearer …`、`"apiKey": "…"`、PEM 私钥、长随机串)。插件工具的回答、会话事件中的 `screen`、卡片标记和卡片动作消息也以同样方式遮蔽。 host.onStorageChange(listener) 会把 host.storage.set 的写入通知给同一插件的所有活动界面——画布卡片、HOME 小组件和独立窗口——从而避免轮询。 diff --git a/native/canvastty-helper/catalog.json b/native/canvastty-helper/catalog.json index d3ec39a27..a02ac59ed 100644 --- a/native/canvastty-helper/catalog.json +++ b/native/canvastty-helper/catalog.json @@ -964,7 +964,7 @@ }, { "name": "spawn_agent", - "description": "Launch another provider's agent as a CanvasTTY subagent of this session and optionally deliver a first prompt. Returns the new session id. provider must be an id from list_providers (known ids: codex, claude, qwen, kimi, opencode, hermes, grok, omp, pi, cursor, minimax, devin, antigravity); call list_providers first to see which are installed and signed in. Give each subagent one self-contained part of the task and an absolute cwd. If the person names a model, pass it as model in the format list_providers gives for that provider (OpenCode: provider/model); effort sets the reasoning effort where that CLI has one (list_providers shows its efforts). An unsupported model or effort is refused with the reason. profile: without it the subagent gets this session's launch profile, or the next lower one its CLI has; a subagent never gets more than this session (plan < normal < acceptEdits < auto) and never YOLO. \"auto\" lets it work without asking the person for each edit or command inside the project; CanvasTTY's outer layers (agent isolation, base protection) still stop writes outside the project, secrets and system changes. The answer says the profile it got. cwd must be this project's folder or a folder inside it. launchOptions passes plugin launch options exactly as a plugin tool gives them (for example the account a plugin picked), for plugins that allow an orchestrator to choose them. Refusals say why (a limit the person set, a folder outside the project, a profile above this session's): change the request instead of retrying it. Then call wait_for_agent and get_agent_result.", + "description": "Launch another provider's agent as a CanvasTTY subagent of this session and optionally deliver a first prompt. Returns the new session id. provider must be an id from list_providers (known ids: codex, claude, qwen, kimi, opencode, hermes, grok, omp, pi, cursor, minimax, devin, antigravity); call list_providers first to see which are installed and signed in. Give each subagent one self-contained part of the task and an absolute cwd. If the person names a model, pass it as model in the format list_providers gives for that provider (OpenCode: provider/model); effort sets the reasoning effort where that CLI has one (list_providers shows its efforts). An unsupported model or effort is refused with the reason. profile: without it the subagent gets this session's launch profile, or the next lower one its CLI has; a subagent never gets more than this session (plan < normal < acceptEdits < auto) and never YOLO. \"auto\" lets it work without asking the person for each edit or command inside the project; CanvasTTY's outer layers (agent isolation, base protection) still stop writes outside the project, secrets and system changes. The answer says the profile it got. cwd must be this project's folder or a folder inside it. launchOptions passes plugin launch options exactly as a plugin tool gives them (for example the account a plugin picked), for plugins that allow an orchestrator to choose them. A plugin this call does not name keeps this session's own launch options (for example its model account), so the subagent never falls back silently to its CLI's default model; a subagent of another CLI is refused while this session's model comes from a plugin's options (spawn one of this CLI instead), and model next to inherited options that set the model is refused. The answer's servedBy says which CLI and requested model serve it and where each plugin's options came from. Refusals say why (a limit the person set, a folder outside the project, a profile above this session's): change the request instead of retrying it. Then call wait_for_agent and get_agent_result.", "inputSchema": { "type": "object", "properties": { @@ -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": { @@ -1072,7 +1072,7 @@ }, { "name": "observe_agent", - "description": "Read the capped terminal tail and status of one of this session's subagents.", + "description": "Read the status of one of this session's subagents and the end of its screen as masked plain text (once its process exited, also exitLines).", "inputSchema": { "type": "object", "properties": { @@ -1095,7 +1095,7 @@ }, { "name": "wait_for_agent", - "description": "Wait until one of this session's subagents stops working, instead of polling observe_agent or get_agent_result; nothing is sent to it while it waits. Returns reason \"idle\" (the turn that answers your latest prompt ended and it waits for input; an idle before that turn started does not count), \"needs_approval\" (its card shows a prompt only the person may answer; never answer it yourself), \"done\" or \"failed\" (its process exited), \"quiet\" (it reports no status or no turn start and its screen stopped changing, so judge from output), \"closed\" (its card was closed) or \"timeout\" after timeoutSeconds (default 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 50, at most 100; unless this session is Claude Code, a call returns after at most 50 seconds, before its MCP client would end the call), with status, exitCode, waitedMs, the end of its screen as masked plain text as output, and answer (the final reply of the turn that ended, for Codex and OpenCode subagents; when CanvasTTY status hooks are off it cannot be captured and answerUnavailable says so instead: then read output); when the subagent's process exited (for example at once, on a model its CLI does not know), exitLines holds the last lines of its screen as plain text, which say why. A timeout means it is still running: read output as its progress and call wait_for_agent again. Then read get_agent_result.", "inputSchema": { "type": "object", "properties": { @@ -1107,7 +1107,7 @@ "timeoutSeconds": { "type": "integer", "minimum": 1, - "maximum": 600 + "maximum": 100 } }, "required": [ @@ -1118,7 +1118,7 @@ }, { "name": "get_agent_result", - "description": "Get one of this session's subagents' result: answer (its last turn's final reply as the agent reported it, for Codex and OpenCode subagents; truncated:true when only the end was kept), status (idle once its turn ended), the exit state (running | done | failed; an interactive CLI stays running after a task) and the masked terminal tail as output. Prefer answer; the terminal tail is raw screen output.", + "description": "Get one of this session's subagents' result: answer (its last turn's final reply as the agent reported it, for Codex and OpenCode subagents; truncated:true when only the end was kept), status (idle once its turn ended), the exit state (running | done | failed; an interactive CLI stays running after a task) and the end of its screen as masked plain text as output (what the terminal shows, without control sequences). Prefer answer. answerUnavailable (reason status_hooks_off) means no answer can be captured because CanvasTTY status hooks are off: read output instead.", "inputSchema": { "type": "object", "properties": { @@ -1178,6 +1178,6 @@ ], "maxPluginToolNameLength": 64, "maxLaunchOptionsBytes": 16384, - "defaultAgentWaitSeconds": 55 + "defaultAgentWaitSeconds": 50 } } 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/agent-browser/orchestration-catalog.d.mts b/src/agent-browser/orchestration-catalog.d.mts index 48685eb78..1af1e815c 100644 --- a/src/agent-browser/orchestration-catalog.d.mts +++ b/src/agent-browser/orchestration-catalog.d.mts @@ -3,6 +3,7 @@ export const MAX_ORCHESTRATION_PAYLOAD_BYTES: number; export const AGENT_PROVIDER_IDS: readonly string[]; export const MAX_AGENT_WAIT_SECONDS: number; export const DEFAULT_AGENT_WAIT_SECONDS: number; +export const CLIENT_SAFE_AGENT_WAIT_SECONDS: number; export const REASONING_EFFORT_IDS: readonly string[]; export function unknownProviderMessage(value: unknown): string; diff --git a/src/agent-browser/orchestration-catalog.mjs b/src/agent-browser/orchestration-catalog.mjs index ad7b53d22..7233b08fc 100644 --- a/src/agent-browser/orchestration-catalog.mjs +++ b/src/agent-browser/orchestration-catalog.mjs @@ -28,10 +28,13 @@ const prompt = string({ minLength: 1, maxLength: 65_536 }); export const AGENT_PROVIDER_IDS = Object.freeze([ "codex", "claude", "qwen", "kimi", "opencode", "hermes", "grok", "omp", "pi", "cursor", "minimax", "devin", "antigravity" ]); -/** wait_for_agent: the longest wait one call may ask for, and the wait without timeoutSeconds (some MCP clients - * end a tool call after 60 seconds). */ -export const MAX_AGENT_WAIT_SECONDS = 600; -export const DEFAULT_AGENT_WAIT_SECONDS = 55; +/** wait_for_agent: the longest wait one call may ask for, and the wait without timeoutSeconds. Most MCP clients end a + * tool call after 60 seconds (Codex's default tool_timeout_sec, OpenCode's client), so a call from any orchestrator + * but Claude Code (whose client waits far longer) returns after at most CLIENT_SAFE_AGENT_WAIT_SECONDS: a "timeout" + * that says the subagent still runs, never a call its client cut off. */ +export const MAX_AGENT_WAIT_SECONDS = 100; +export const DEFAULT_AGENT_WAIT_SECONDS = 50; +export const CLIENT_SAFE_AGENT_WAIT_SECONDS = 50; /** spawn_agent.effort: every level some CLI takes (src/shared/launchModel.ts REASONING_EFFORTS; a test keeps them equal). */ export const REASONING_EFFORT_IDS = Object.freeze(["minimal", "low", "medium", "high", "xhigh", "max"]); const provider = string({ minLength: 1, maxLength: 32, enum: [...AGENT_PROVIDER_IDS] }); @@ -58,7 +61,7 @@ export const ORCHESTRATION_TOOL_DEFINITIONS = Object.freeze([ ), tool( "spawn_agent", - `Launch another provider's agent as a CanvasTTY subagent of this session and optionally deliver a first prompt. Returns the new session id. provider must be an id from list_providers (known ids: ${AGENT_PROVIDER_IDS.join(", ")}); call list_providers first to see which are installed and signed in. Give each subagent one self-contained part of the task and an absolute cwd. If the person names a model, pass it as model in the format list_providers gives for that provider (OpenCode: provider/model); effort sets the reasoning effort where that CLI has one (list_providers shows its efforts). An unsupported model or effort is refused with the reason. profile: without it the subagent gets this session's launch profile, or the next lower one its CLI has; a subagent never gets more than this session (plan < normal < acceptEdits < auto) and never YOLO. "auto" lets it work without asking the person for each edit or command inside the project; CanvasTTY's outer layers (agent isolation, base protection) still stop writes outside the project, secrets and system changes. The answer says the profile it got. cwd must be this project's folder or a folder inside it. launchOptions passes plugin launch options exactly as a plugin tool gives them (for example the account a plugin picked), for plugins that allow an orchestrator to choose them. Refusals say why (a limit the person set, a folder outside the project, a profile above this session's): change the request instead of retrying it. Then call wait_for_agent and get_agent_result.`, + `Launch another provider's agent as a CanvasTTY subagent of this session and optionally deliver a first prompt. Returns the new session id. provider must be an id from list_providers (known ids: ${AGENT_PROVIDER_IDS.join(", ")}); call list_providers first to see which are installed and signed in. Give each subagent one self-contained part of the task and an absolute cwd. If the person names a model, pass it as model in the format list_providers gives for that provider (OpenCode: provider/model); effort sets the reasoning effort where that CLI has one (list_providers shows its efforts). An unsupported model or effort is refused with the reason. profile: without it the subagent gets this session's launch profile, or the next lower one its CLI has; a subagent never gets more than this session (plan < normal < acceptEdits < auto) and never YOLO. "auto" lets it work without asking the person for each edit or command inside the project; CanvasTTY's outer layers (agent isolation, base protection) still stop writes outside the project, secrets and system changes. The answer says the profile it got. cwd must be this project's folder or a folder inside it. launchOptions passes plugin launch options exactly as a plugin tool gives them (for example the account a plugin picked), for plugins that allow an orchestrator to choose them. A plugin this call does not name keeps this session's own launch options (for example its model account), so the subagent never falls back silently to its CLI's default model; a subagent of another CLI is refused while this session's model comes from a plugin's options (spawn one of this CLI instead), and model next to inherited options that set the model is refused. The answer's servedBy says which CLI and requested model serve it and where each plugin's options came from. Refusals say why (a limit the person set, a folder outside the project, a profile above this session's): change the request instead of retrying it. Then call wait_for_agent and get_agent_result.`, { provider, cwd: string({ minLength: 1, maxLength: 4_096 }), @@ -73,25 +76,25 @@ 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"] ), tool( "observe_agent", - "Read the capped terminal tail and status of one of this session's subagents.", + "Read the status of one of this session's subagents and the end of its screen as masked plain text (once its process exited, also exitLines).", { sessionId, maxChars: integer({ minimum: 256, maximum: 8_192 }) }, ["sessionId"] ), tool( "wait_for_agent", - `Wait until one of this session's subagents stops working, instead of polling observe_agent or get_agent_result; nothing is sent to it while it waits. Returns reason "idle" (the turn that answers your latest prompt ended and it waits for input; an idle before that turn started does not count), "needs_approval" (its card shows a prompt only the person may answer; never answer it yourself), "done" or "failed" (its process exited), "quiet" (it reports no status or no turn start and its screen stopped changing, so judge from output), "closed" (its card was closed) or "timeout" after timeoutSeconds (default ${DEFAULT_AGENT_WAIT_SECONDS}, at most ${MAX_AGENT_WAIT_SECONDS}), 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}; unless this session is Claude Code, a call returns after at most ${CLIENT_SAFE_AGENT_WAIT_SECONDS} seconds, before its MCP client would end the call), with status, exitCode, waitedMs, the end of its screen as masked plain text as output, and answer (the final reply of the turn that ended, for Codex and OpenCode subagents; when CanvasTTY status hooks are off it cannot be captured and answerUnavailable says so instead: then read output); when the subagent's process exited (for example at once, on a model its CLI does not know), exitLines holds the last lines of its screen as plain text, which say why. A timeout means it is still running: read output as its progress and call wait_for_agent again. Then read get_agent_result.`, { sessionId, timeoutSeconds: integer({ minimum: 1, maximum: MAX_AGENT_WAIT_SECONDS }) }, ["sessionId"] ), tool( "get_agent_result", - "Get one of this session's subagents' result: answer (its last turn's final reply as the agent reported it, for Codex and OpenCode subagents; truncated:true when only the end was kept), status (idle once its turn ended), the exit state (running | done | failed; an interactive CLI stays running after a task) and the masked terminal tail as output. Prefer answer; the terminal tail is raw screen output.", + "Get one of this session's subagents' result: answer (its last turn's final reply as the agent reported it, for Codex and OpenCode subagents; truncated:true when only the end was kept), status (idle once its turn ended), the exit state (running | done | failed; an interactive CLI stays running after a task) and the end of its screen as masked plain text as output (what the terminal shows, without control sequences). Prefer answer. answerUnavailable (reason status_hooks_off) means no answer can be captured because CanvasTTY status hooks are off: read output instead.", { sessionId }, ["sessionId"] ), diff --git a/src/agent-runtime/opencode-decisions.mjs b/src/agent-runtime/opencode-decisions.mjs index d9e9c4ca3..6ef4b26e1 100644 --- a/src/agent-runtime/opencode-decisions.mjs +++ b/src/agent-runtime/opencode-decisions.mjs @@ -1,12 +1,13 @@ import { FAIL_CLOSED_MESSAGE, buildRequest, exchange, identityFrom } from "./permission-gate.mjs"; -import { OPENCODE_DECISIONS_ENV, helperDeadlineMs } from "./runtime-protocol.mjs"; +import { OPENCODE_DECISIONS_ENV, OPENCODE_DECISION_READS_ENV, helperDeadlineMs } from "./runtime-protocol.mjs"; /** * Decision hooks for OpenCode, used by opencode-plugin.mjs while `CANVASTTY_RUNTIME_DECISIONS` is "1". * * `guard` runs in `tool.execute.before` (before OpenCode's own permission check, in every mode, YOLO included) * and sends each shell and file-writing call (bash, write, edit, multiedit, apply_patch/patch) to CanvasTTY the way - * the Claude/Codex/Qwen PreToolUse hook does. A deny throws, which fails that tool call with the reason as its error + * the Claude/Codex/Qwen PreToolUse hook does; with `CANVASTTY_RUNTIME_DECISION_READS` "1" (base protection on at + * launch) also read, grep, glob and list, for base protection's private-data rule. A deny throws, which fails that tool call with the reason as its error * text for the model. An allow (only from plugins the person let allow) is remembered by call id: when OpenCode then * asks for that call (`permission.asked`), `permissionAsked` answers `once` through OpenCode's own API * (POST /permission/{requestID}/reply), never `always`. Anything else (ask, no verdict, an error) leaves OpenCode's @@ -23,6 +24,9 @@ const MAX_ALLOWED = 64; export function createOpenCodeDecisions({ client, env = process.env, send = exchange } = {}) { const identity = identityFrom(env); const enabled = env[OPENCODE_DECISIONS_ENV] === "1" && identity?.provider === "opencode"; + // Read-only file tools are sent only when the launch asked for it (base protection was on): otherwise a read never + // waits on, or fails closed because of, the gateway. + const reads = enabled && env[OPENCODE_DECISION_READS_ENV] === "1"; /** callIDs a plugin allowed; their permission request is answered `once`. */ const allowed = new Set(); @@ -30,7 +34,7 @@ export function createOpenCodeDecisions({ client, env = process.env, send = exch if (!enabled) return; let decision = null; try { - const call = guardedCall(stringOf(input?.tool), output && typeof output === "object" ? output.args : null); + const call = guardedCall(stringOf(input?.tool), output && typeof output === "object" ? output.args : null, reads); if (!call) return; const message = buildRequest({ tool_name: call.toolName, tool_input: call.toolInput }, identity); if (message) decision = await send(identity.address, message, helperDeadlineMs(env)); @@ -62,11 +66,16 @@ 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, writes files nor reads them. 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. Read-only tools (read, grep, + * glob, list) go out as Read, Grep, Glob and LS, only when `reads` is set. */ -export function guardedCall(tool, args) { +export function guardedCall(tool, args, reads = false) { if (!tool || !args || typeof args !== "object") return null; if (tool === "bash") { if (typeof args.command !== "string" || args.command.length === 0) return null; @@ -74,13 +83,29 @@ 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 } }; + } + // Read-only tools, only when the launch checks them (base protection on): only CanvasTTY's private data is checked + // (read {filePath}, grep {pattern, path, include}, glob {pattern, path}, list {path}). A grep without a path + // searches the current folder, so it is always sent, as `.`. + if (reads && (tool === "read" || tool === "grep" || tool === "glob" || tool === "list")) { + const text = (value) => typeof value === "string" && value.length > 0 ? value : null; + const toolInput = {}; + const filePath = text(args.filePath) ?? text(args.file_path); + if (filePath) toolInput.file_path = filePath; + const path = text(args.path) ?? (tool === "grep" ? "." : null); + if (path) toolInput.path = path; + if (tool === "glob" && text(args.pattern)) toolInput.pattern = args.pattern; + if (Object.keys(toolInput).length === 0) return null; + return { toolName: { read: "Read", grep: "Grep", glob: "Glob", list: "LS" }[tool], toolInput }; } if (tool === "apply_patch" || tool === "patch") { const patch = typeof args.patchText === "string" ? args.patchText : typeof args.patch === "string" ? args.patch : null; @@ -89,11 +114,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/src/agent-runtime/opencode-plugin.mjs b/src/agent-runtime/opencode-plugin.mjs index bd8151b86..69ae5f00c 100644 --- a/src/agent-runtime/opencode-plugin.mjs +++ b/src/agent-runtime/opencode-plugin.mjs @@ -3,10 +3,13 @@ import { CAPTURE_RESULT_ENV } from "./runtime-protocol.mjs"; import { finalAnswer } from "./opencode-final-answer.mjs"; import { createOpenCodeDecisions } from "./opencode-decisions.mjs"; import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; import { HOOK_TIMEOUT_MS, preparePluginHook } from "./plugin-hook-dispatch.mjs"; let rootSessionId = null; let rootWorking = false; +// One id per root turn (not the session id), so a late answer of an earlier turn is never taken for the current one. +let rootTurnId = null; const lifecycleEnabled = process.env.CANVASTTY_LIFECYCLE_HOOKS_ENABLED !== "0"; const pluginHookRegistry = process.env.CANVASTTY_PLUGIN_HOOK_REGISTRY ?? ""; const pluginHookRunnerCommand = process.env.CANVASTTY_PLUGIN_HOOK_RUNNER_COMMAND ?? ""; @@ -45,6 +48,7 @@ async function lifecycleEvent(event, decisions, client) { if (session.parentID || session.parentId) return; rootSessionId = stringField(session.id, sessionId); rootWorking = false; + rootTurnId = null; if (!rootSessionId) return; if (lifecycleEnabled) { await reportLifecycle({ state: "idle", event: event.type, threadId: rootSessionId }); @@ -63,44 +67,55 @@ async function lifecycleEvent(event, decisions, client) { ? statusValue.type : null; if (status === "busy" || status === "retry") { + const startsTurn = status === "busy" && !rootWorking; + if (startsTurn || !rootTurnId) rootTurnId = randomUUID(); + rootWorking = true; if (lifecycleEnabled) { - await reportLifecycle({ state: "working", event: `session.status:${status}`, turnId: rootSessionId }); + await reportLifecycle({ state: "working", event: `session.status:${status}`, turnId: rootTurnId }); } - if (status === "busy" && !rootWorking) { + if (startsTurn) { runPluginHooks("prompt-submit", `session.status:${status}`, event); } - rootWorking = true; } else if (status === "idle") { rootWorking = false; - if (lifecycleEnabled) await reportLifecycle({ state: "idle", event: "session.status:idle", turnId: rootSessionId }); + // A result-capturing turn ends with session.idle, once its final answer was read: an idle reported here would + // let a waiting orchestrator take the turn as finished before its answer arrived. + if (lifecycleEnabled && !captureResult) await reportLifecycle({ state: "idle", event: "session.status:idle", turnId: rootTurnId }); } return; } if (event.type === "session.idle") { rootWorking = false; - const result = captureResult ? await finalAnswer(client, rootSessionId) : undefined; - if (lifecycleEnabled || result) { - await reportLifecycle({ state: "idle", event: event.type, turnId: rootSessionId, ...(result ? { result } : {}) }); + const endingSessionId = rootSessionId, endingTurnId = rootTurnId; + // With CanvasTTY status hooks off nothing is reported, the answer included: it is never read. + const result = lifecycleEnabled && captureResult && endingTurnId ? await finalAnswer(client, endingSessionId) : undefined; + // The read is asynchronous: when the next turn started meanwhile, this answer and this idle are no longer current. + const superseded = endingSessionId !== rootSessionId || endingTurnId !== rootTurnId || rootWorking; + if (lifecycleEnabled && !superseded) { + await reportLifecycle({ state: "idle", event: event.type, turnId: endingTurnId, ...(result ? { result } : {}) }); } runPluginHooks("stop", event.type, event); } else if (event.type === "permission.asked") { - if (lifecycleEnabled) await reportLifecycle({ state: "needs_approval", event: event.type, turnId: rootSessionId }); + if (lifecycleEnabled) await reportLifecycle({ state: "needs_approval", event: event.type, turnId: rootTurnId }); runPluginHooks("permission-request", event.type, event); } else if (event.type === "permission.replied") { - if (lifecycleEnabled) await reportLifecycle({ state: "working", event: event.type, turnId: rootSessionId }); + rootWorking = true; + if (lifecycleEnabled) await reportLifecycle({ state: "working", event: event.type, turnId: rootTurnId }); runPluginHooks("permission-result", event.type, event); } else if (event.type === "question.asked") { - if (lifecycleEnabled) await reportLifecycle({ state: "needs_approval", event: event.type, turnId: rootSessionId }); + if (lifecycleEnabled) await reportLifecycle({ state: "needs_approval", event: event.type, turnId: rootTurnId }); } else if (event.type === "question.replied" || event.type === "question.rejected") { - if (lifecycleEnabled) await reportLifecycle({ state: "working", event: event.type, turnId: rootSessionId }); + rootWorking = true; + if (lifecycleEnabled) await reportLifecycle({ state: "working", event: event.type, turnId: rootTurnId }); } else if (event.type === "session.error") { rootWorking = false; - if (lifecycleEnabled) await reportLifecycle({ state: "idle", event: event.type, turnId: rootSessionId }); + if (lifecycleEnabled) await reportLifecycle({ state: "idle", event: event.type, turnId: rootTurnId }); runPluginHooks("stop", event.type, event); } else if (event.type === "session.deleted") { runPluginHooks("session-end", event.type, event); rootWorking = false; rootSessionId = null; + rootTurnId = null; } else if (event.type === "tool.execute.after") { runPluginHooks("after-tool", event.type, event); } diff --git a/src/agent-runtime/runtime-protocol.d.mts b/src/agent-runtime/runtime-protocol.d.mts index cdf16b466..833491282 100644 --- a/src/agent-runtime/runtime-protocol.d.mts +++ b/src/agent-runtime/runtime-protocol.d.mts @@ -25,6 +25,7 @@ export const PERMISSION_GATE: Readonly<{ messageChars: number; }>; export const OPENCODE_DECISIONS_ENV: "CANVASTTY_RUNTIME_DECISIONS"; +export const OPENCODE_DECISION_READS_ENV: "CANVASTTY_RUNTIME_DECISION_READS"; export const DECISION_BUDGET_ENV: "CANVASTTY_RUNTIME_DECISION_MS"; export const DECISION_FAIL_CLOSED_ENV: "CANVASTTY_RUNTIME_FAIL_CLOSED"; export const DEFAULT_DECIDE_TIMEOUT_MS: number; diff --git a/src/agent-runtime/runtime-protocol.mjs b/src/agent-runtime/runtime-protocol.mjs index 4aad87536..ff1beb369 100644 --- a/src/agent-runtime/runtime-protocol.mjs +++ b/src/agent-runtime/runtime-protocol.mjs @@ -59,6 +59,8 @@ export const PERMISSION_GATE = Object.freeze({ }); // Set to "1" for an OpenCode session launched with decision hooks: its plugin then checks each shell and file call. export const OPENCODE_DECISIONS_ENV = "CANVASTTY_RUNTIME_DECISIONS"; +// Set to "1" as well when base protection was on at launch: the plugin then also checks read-only file tools. +export const OPENCODE_DECISION_READS_ENV = "CANVASTTY_RUNTIME_DECISION_READS"; // A decision service may ask for more time than the default 3 s (`decide.timeoutMs`, 1-60 s), for example to ask a // local model. The session's hook, helper and gateway deadlines are set at launch from the longest such budget and // passed to the helper in this variable; without it the defaults above hold. diff --git a/src/main/index.ts b/src/main/index.ts index b9832dcee..c5cb0158e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -36,6 +36,7 @@ import { AgentChatHistoryService } from "./services/AgentChatHistoryService"; import { LimitsService, withoutAccountScope } from "./services/LimitsService"; import { UsageHistoryService } from "./services/UsageHistoryService"; import { LocalUsageCollector } from "./services/LocalUsageCollector"; +import { runShutdownSteps } from "./services/shutdownSteps"; import { createProviderCliRegistry, providerCliAvailability, @@ -91,6 +92,7 @@ import { RuntimeGateway } from "./services/agent-runtime"; import type { RuntimeHookHelperLaunch } from "./services/agent-runtime/ProviderRuntimeLaunch"; +import { applyRuntimeSignal } from "./services/agent-runtime/applyRuntimeSignal"; import { agentHelperLaunches } from "./services/agentHelpers"; import { recoverHermesConfigurationOnStartup, @@ -518,7 +520,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; @@ -529,15 +532,7 @@ async function initializeServices(ipc: IpcRegistrar): Promise { runtimeDirectory: lifecycleRuntimeDirectory, windowsHostPath, onSignal: (terminalSessionId, signal) => { - terminalManager?.applyProviderSignal(terminalSessionId, { - kind: "lifecycle", - state: signal.state, - event: signal.event, - ...(signal.turnId ? { requestId: signal.turnId } : {}), - ...(signal.threadId ? { threadId: signal.threadId } : {}) - }); - // A subagent's final answer (Codex Stop hook, OpenCode plugin) for get_agent_result and wait_for_agent. - if (signal.result) terminalManager?.recordAnswer(terminalSessionId, signal.result); + if (terminalManager) applyRuntimeSignal(terminalManager, terminalSessionId, signal); agentControl?.onSignal(terminalSessionId, signal); if (signal.lastAssistantMessage !== undefined && signal.answerCaptureGrantExpiresAt !== undefined) { evenG2?.answer( @@ -568,11 +563,14 @@ 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, wantsDecisions: (provider) => decisionHooks.wanted(provider), decisionBudgetMs: (provider) => decisionHooks.budgetMs(provider), + decisionReads: () => decisionHooks.checksReads(), claudeHttpHooks: (facts) => claudeHttpHookPolicy.verdict(facts), pluginHooks: { runner: { @@ -695,7 +693,8 @@ async function initializeServices(ipc: IpcRegistrar): Promise { const managedTerminals = terminalManager; const agentControlService = new AgentControlService(managedTerminals, { limits: () => ({ maxDepth: settings.get().orchestrationMaxDepth, maxSubagents: settings.get().orchestrationMaxSubagents }), - containment: () => managedTerminals.containment() + containment: () => managedTerminals.containment(), + userDataPath }); orchestrationGateway = new OrchestrationGateway({ runtimeDirectory: join(userDataPath, "orchestration", "runtime"), @@ -1275,30 +1274,36 @@ void IPC.terminalData; async function shutdownServices(): Promise { diagnostics.record("info", "application", "shutdown.started"); - mascotManager?.dispose(); - agentChatHistory?.dispose(); - if (agentControl) await Promise.allSettled([agentControl.close()]); if (updateTimer) clearTimeout(updateTimer); if (updateInterval) clearInterval(updateInterval); for (const request of browserRequests.values()) { clearTimeout(request.timer); request.reject(new Error("App closing")); } browserRequests.clear(); - await evenG2?.close(); - if (terminalManager) await terminalManager.shutdown(); + // Each step runs even when an earlier one fails, so the terminals are always hung up and saved. + await runShutdownSteps([ + { name: "mascots", run: () => mascotManager?.dispose() }, + { name: "agent chat history", run: () => agentChatHistory?.dispose() }, + { name: "agent control", run: () => agentControl?.close() }, + { name: "Even G2", run: () => evenG2?.close() }, + { name: "terminal processes", run: async () => { if (terminalManager) await terminalManager.shutdown(); } } + ]); // The hung-up PTYs exit while the other services close; quitting waits for them (see waitForProcessExits). const ptyExits = terminalManager?.waitForProcessExits().then((left) => { if (left > 0) console.warn(`CanvasTTY quit with ${left} terminal process(es) that did not exit after SIGKILL.`); }); - // Stop the timer first, then abort in-flight limit requests so the final save is prompt. - const usageHistoryClosed = usageHistory?.dispose(); - limitsService?.dispose(); - await usageHistoryClosed; - if (agentGateway) await Promise.allSettled([agentGateway.close()]); - if (runtimeGateway) await Promise.allSettled([runtimeGateway.close()]); - if (browserService) await Promise.allSettled([browserService.dispose()]); - if (pluginServices) await Promise.allSettled([pluginServices.dispose()]); - if (pluginManager) await Promise.allSettled([pluginManager.dispose()]); - await handoffService?.dispose(); - if (materialService) await Promise.allSettled([materialService.dispose()]); + // Stop the usage-history timer first, then abort in-flight limit requests so its final save is prompt. + let usageHistoryClosed: Promise | undefined; + await runShutdownSteps([ + { name: "usage history", run: () => { usageHistoryClosed = usageHistory?.dispose(); } }, + { name: "limits", run: () => limitsService?.dispose() }, + { name: "usage history save", run: () => usageHistoryClosed }, + { name: "agent gateway", run: () => agentGateway?.close() }, + { name: "runtime gateway", run: () => runtimeGateway?.close() }, + { name: "browser", run: () => browserService?.dispose() }, + { name: "plugin services", run: () => pluginServices?.dispose() }, + { name: "plugins", run: () => pluginManager?.dispose() }, + { name: "handoff", run: () => handoffService?.dispose() }, + { name: "materials", run: () => materialService?.dispose() } + ]); await ptyExits; diagnostics.record("info", "application", "shutdown.completed"); await diagnostics.flush(); diff --git a/src/main/ipc/registerIpc.ts b/src/main/ipc/registerIpc.ts index 00d3feb6c..9702686f1 100644 --- a/src/main/ipc/registerIpc.ts +++ b/src/main/ipc/registerIpc.ts @@ -919,6 +919,10 @@ export function registerIpc(ipcMain: IpcRegistrar, { assertMainRenderer(event, getMainWindow); return githubAuth.signOut(); }); + ipcMain.handle(IPC.githubAuthCancel, (event) => { + assertMainRenderer(event, getMainWindow); + githubAuth.cancelDeviceFlow(); + }); ipcMain.handle(IPC.githubAuthOpenUrl, (event, value: unknown) => { assertMainRenderer(event, getMainWindow); if (typeof value !== "string") throw new Error("URL is required."); diff --git a/src/main/services/AgentControlService.ts b/src/main/services/AgentControlService.ts index 34a5fda11..57e716d1b 100644 --- a/src/main/services/AgentControlService.ts +++ b/src/main/services/AgentControlService.ts @@ -7,7 +7,7 @@ import type { SessionSnapshot } from "../../shared/contracts.ts"; import { realpathSync } from "node:fs"; -import { isAbsolute, resolve } from "node:path"; +import { isAbsolute, join, relative, resolve, sep } from "node:path"; import { PROVIDER_CAPABILITIES } from "../../shared/contracts.ts"; import { isLaunchProfile, PROFILE_RANK, profileAvailable, profileCeiling, type LaunchProfile } from "../../shared/autoMode.ts"; import { isPathInside } from "../../agent-runtime/path-inside.mjs"; @@ -15,7 +15,9 @@ import type { TerminalManager } from "./TerminalManager.ts"; import { onDiskPath, otherSpellings } from "./onDiskPath.ts"; import { LaunchRefusal } from "./launchRefusal.ts"; import { terminalFailureDetails } from "./terminalFailureDetails.ts"; +import { renderedTerminalText } from "./renderedTerminalText.ts"; import { RESULT_CAPTURE_PROVIDERS } from "./resultCapture.ts"; +import { PLUGIN_DATA_FOLDER, privateAppData } from "./isolation/isolationPaths.ts"; // Cards one parent may have in total, live or exited (the live limit is the person's setting). const MAX_CHILDREN_PER_PARENT = 16; @@ -30,8 +32,12 @@ export interface DelegationLimits { } const MAX_OBSERVE_CHARS = 8_192; // An exited agent's reason is in its last screen lines (OpenCode: "Error: Unexpected server error" for an unknown model). -const MAX_EXIT_WINDOW_CHARS = 16_384; const MAX_EXIT_LINES = 20; +/** + * How much of the scrollback (masked first) the plain-text output is rendered from: several full redraws of a + * full-screen TUI, and many screens of a line-based CLI, while each call still masks a bounded window. + */ +const MAX_SCREEN_SOURCE_CHARS = 65_536; const CHILD_POSITION_STEP = { x: 60, y: 60 }; export interface SpawnAgentRequest { @@ -52,7 +58,7 @@ export interface SpawnAgentRequest { export interface AgentObservation { sessionId: string; status: SessionSnapshot["status"]; - /** Raw terminal tail, capped; capabilities with result \"none\" see nothing. */ + /** The screen's text as a terminal shows it (no control sequences), masked, capped to its last characters. */ output: string; /** Once the process exited: its exit code and the last lines of its screen as plain text, masked. */ exitCode?: number | null; @@ -70,12 +76,14 @@ export interface AgentWaitResult { status?: SessionSnapshot["status"]; exitCode: number | null; waitedMs: number; - /** Masked terminal tail (masked before the cut). */ + /** The screen's text as a terminal shows it (no control sequences), masked, capped to its last characters. */ output: string; /** Once the process exited: the last lines of its screen as plain text, masked (why it stopped, e.g. a bad model). */ exitLines?: string; /** The final answer of the turn that ended (Codex, OpenCode subagents), masked. */ answer?: AgentAnswer; + /** Why a Codex or OpenCode subagent has no answer and never will in this launch (read its output instead). */ + answerUnavailable?: AgentAnswerUnavailable; } export interface AgentWaitTiming { @@ -94,6 +102,8 @@ export interface AgentControlOptions { limits?: () => DelegationLimits; /** CanvasTTY's isolation layer can contain an agent on this computer now (a "contained" auto needs it). */ containment?: () => boolean; + /** CanvasTTY's userData folder: a subagent never works in its private data (tokens, account homes, plugin data). */ + userDataPath?: string; } /** The longest wait one call may ask for (wait_for_agent's timeoutSeconds maximum). */ @@ -106,13 +116,26 @@ export interface AgentResult { /** The session's status (idle once its turn ended; state stays "running" while the CLI is open). */ status: SessionSnapshot["status"]; exitCode: number | null; + /** The screen's text as a terminal shows it (no control sequences), masked, capped to its last characters. */ output: string; /** Once the process exited: the last lines of its screen as plain text, masked. */ exitLines?: string; /** The last turn's final answer as the agent reported it (Codex, OpenCode subagents), masked; absent otherwise. */ answer?: AgentAnswer; + /** Why a Codex or OpenCode subagent has no answer and never will in this launch (read its output instead). */ + answerUnavailable?: AgentAnswerUnavailable; +} + +export interface AgentAnswerUnavailable { + /** "status_hooks_off": CanvasTTY status hooks were off for its launch, so it has no hooks to report an answer. */ + reason: "status_hooks_off"; + message: string; } +const STATUS_HOOKS_OFF_MESSAGE = "This subagent's final answer cannot be captured because CanvasTTY status hooks are off " + + "(Settings → Agents; turning them on applies to agents started or restarted afterwards). Read its output instead: " + + "the output field here, or observe_agent."; + export interface AgentAnswer { text: string; /** Only the end of a longer answer was kept. */ @@ -133,6 +156,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; @@ -142,9 +170,10 @@ export class AgentControlService { /** * Creates the subagent card. With an initial prompt it resolves only once the prompt reached the agent's PTY * (after an asynchronous launch has started) and rejects with PromptNotDeliveredError when that launch did not - * start; the card stays, so the caller can inspect or cancel it. + * start; the card stays, so the caller can inspect or cancel it. `promptWaitMs` bounds that wait (the launch and + * the CLI's readiness together; LAUNCH_INPUT_WAIT_MS without it). */ - spawn(request: SpawnAgentRequest, signal?: AbortSignal): Promise { + spawn(request: SpawnAgentRequest, signal?: AbortSignal, promptWaitMs?: number): Promise { if (!request || typeof request.parentSessionId !== "string") { throw new Error("A parent session id is required."); } @@ -152,6 +181,8 @@ export class AgentControlService { const capabilities = PROVIDER_CAPABILITIES[request.provider]; if (!capabilities) throw new Error("Unknown agent provider."); if (!capabilities.send) throw new Error(`${request.provider} cannot receive prompts.`); + const inheritance = this.subagentLaunchOptions(parent.id, request.provider, request.launchOptions, request.model); + if (inheritance.launchOptions !== request.launchOptions) request = { ...request, launchOptions: inheritance.launchOptions }; const children = this.children(parent.id); if (children.length >= MAX_CHILDREN_PER_PARENT) { @@ -160,7 +191,7 @@ export class AgentControlService { const lineage = this.lineage(parent.id); const root = lineage.at(-1)!; // What the request asks for first (its folder, its profile), then the person's limits. - const cwd = subagentFolder(root.cwd, parent.cwd, request.cwd); + const cwd = subagentFolder(root.cwd, parent.cwd, request.cwd, this.options.userDataPath); if ("error" in cwd) throw new DelegationRefusal(cwd.error); const profile = subagentProfile(parent.profile, request.provider, request.profile, this.containment()); if ("error" in profile) throw new DelegationRefusal(profile.error); @@ -194,7 +225,7 @@ export class AgentControlService { if (request.initialPrompt === undefined || request.initialPrompt.length === 0) return Promise.resolve(created); const input = request.provider === "codex" ? codexPromptInput(request.initialPrompt) : `${request.initialPrompt}\r`; - return this.deliver(created.id, input, "prompt", signal) + return this.deliver(created.id, input, "prompt", signal, promptWaitMs) .then(() => this.terminals.getMetadata(created.id) ?? created, (error: unknown) => { // Cancelled while it started: nobody receives its id, so the card is closed instead of left running. if (!signal?.aborted) throw error; @@ -203,21 +234,75 @@ export class AgentControlService { }); } + /** + * The plugin launch options a subagent of this parent starts with. A plugin the spawn does not name keeps the + * orchestrator's own values, which the person chose: a subagent never falls back silently to its CLI's default model + * and sign-in (for OpenCode that is OpenCode Zen, a third-party service) while its orchestrator runs on a model the + * person picked through a plugin. Naming a plugin is an explicit choice (checked by the launch: another value needs + * the plugin's delegable declaration). Another CLI cannot inherit a plugin choice that set the orchestrator's model, + * and is refused unless the spawn names that plugin. An explicit model next to an inherited choice that sets the model is refused. + */ + subagentLaunchOptions( + parentSessionId: string, + provider: AgentProviderId, + requested: SpawnAgentRequest["launchOptions"], + model?: string + ): { launchOptions: SpawnAgentRequest["launchOptions"]; sources: Record } { + const parent = this.requireSession(parentSessionId); + const sources: Record = {}; + for (const pluginId of Object.keys(requested ?? {})) sources[pluginId] = "explicit"; + const parentOptions = this.terminals.launchOptionsOf(parent.id); + const unnamed = Object.keys(parentOptions ?? {}).filter((pluginId) => !Object.hasOwn(sources, pluginId)); + if (!parentOptions || unnamed.length === 0) return { launchOptions: requested, sources }; + const modelSetBy = new Set(this.terminals.modelSetByOf(parent.id)); + const modelPlugins = unnamed.filter((pluginId) => modelSetBy.has(pluginId)); + if (parent.provider !== provider) { + if (modelPlugins.length === 0) return { launchOptions: requested, sources }; + const named = modelPlugins.map((pluginId) => pluginId.slice(0, 80)).join(", "); + // The person chose those options for this CLI; another CLI would run on its own default model and sign-in. + throw new DelegationRefusal(`This orchestrator's model is set by its ${named} launch options, which the person chose for ${parent.provider}; ` + + `a ${provider} subagent would not get that model. Spawn a ${parent.provider} subagent instead.`); + } + if (model !== undefined && modelPlugins.length > 0) { + const named = modelPlugins.map((pluginId) => pluginId.slice(0, 80)).join(", "); + throw new DelegationRefusal(`This subagent inherits its orchestrator's ${named} launch options, which set its model; it cannot also take model ${model.slice(0, 80)}. ` + + `Omit model, or pass launchOptions for ${named} that choose the model you want.`); + } + // A plugin turned off since the orchestrator started is left out, unless it set the model: then the subagent would + // run on its CLI's own default model and sign-in, so it is refused instead. + const gone = new Set(this.terminals.unavailableLaunchPlugins(Object.fromEntries(unnamed.map((pluginId) => [pluginId, parentOptions[pluginId]])))); + const goneModel = modelPlugins.filter((pluginId) => gone.has(pluginId)); + if (goneModel.length > 0) { + const named = goneModel.map((pluginId) => pluginId.slice(0, 80)).join(", "); + throw new DelegationRefusal(`This orchestrator's model is set by its ${named} launch options, and ${named} is now disabled, removed or not trusted; ` + + "a subagent would run on its CLI's own default model instead. The person can turn the plugin back on, or start the orchestrator again."); + } + const launchOptions: NonNullable = { ...(requested ?? {}) }; + for (const pluginId of unnamed.filter((id) => !gone.has(id))) { + launchOptions[pluginId] = parentOptions[pluginId]!; + sources[pluginId] = "inherited"; + } + return { launchOptions, sources }; + } + /** The profile a subagent of this parent gets for this request (what spawn will use), or why it gets none. */ profileFor(parentSessionId: string, provider: AgentProviderId, requested?: unknown): { profile: LaunchProfile; inherited: boolean } | { error: string } { return subagentProfile(this.requireSession(parentSessionId).profile, provider, requested, this.containment()); } /** Validates at once (throws); resolves once the text reached the agent, and rejects when it did not. */ - send(sessionId: string, text: string, submit = true, signal?: AbortSignal): Promise { + send(sessionId: string, text: string, submit = true, signal?: AbortSignal, waitMs?: number): Promise { const session = this.requireSession(sessionId); if (session.provider === "terminal") throw new Error("Plain terminals are not agents."); const capabilities = PROVIDER_CAPABILITIES[session.provider as AgentProviderId]; if (!capabilities.send) throw new Error(`${session.provider} cannot receive prompts.`); if (typeof text !== "string" || text.length === 0) throw new Error("Prompt text is required."); if (session.exitCode !== null) throw new Error("Agent session has already exited."); + // 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); + return this.deliver(sessionId, input, "text", signal, waitMs); } status(sessionId: string): SessionMetadata { @@ -288,25 +373,28 @@ export class AgentControlService { return false; } - observe(sessionId: string, maxChars = MAX_OBSERVE_CHARS): AgentObservation { - const session = this.requireSession(sessionId); - if (session.provider === "terminal") throw new Error("Plain terminals are not agents."); - const capabilities = PROVIDER_CAPABILITIES[session.provider as AgentProviderId]; - if (!capabilities.observe) throw new Error(`${session.provider} cannot be observed.`); + async observe(sessionId: string, maxChars = MAX_OBSERVE_CHARS): Promise { + const session = this.observableSession(sessionId); const buffer = this.terminals.readBuffer(sessionId).buffer; - const exitLines = session.exitCode === null ? null : this.exitLines(buffer); + const exitLines = session.exitCode === null ? null : await this.screenExitLines(sessionId, buffer); return { sessionId: session.id, status: session.status, - // Masked before the cut (a cut inside a secret would leave a tail no pattern recognizes), over a window - // wider than any match rather than the whole scrollback. - output: this.redactTail(buffer, maxChars), + output: await this.screenText(sessionId, buffer, maxChars), ...(session.exitCode === null ? {} : { exitCode: session.exitCode }), ...(exitLines ? { exitLines } : {}) }; } - result(sessionId: string): AgentResult { + private observableSession(sessionId: string): SessionMetadata { + const session = this.requireSession(sessionId); + if (session.provider === "terminal") throw new Error("Plain terminals are not agents."); + const capabilities = PROVIDER_CAPABILITIES[session.provider as AgentProviderId]; + if (!capabilities.observe) throw new Error(`${session.provider} cannot be observed.`); + return session; + } + + async result(sessionId: string): Promise { const session = this.requireSession(sessionId); if (session.provider === "terminal") throw new Error("Plain terminals are not agents."); const capabilities = PROVIDER_CAPABILITIES[session.provider as AgentProviderId]; @@ -317,6 +405,7 @@ export class AgentControlService { const buffer = capabilities.result === "terminal" ? this.terminals.readBuffer(sessionId).buffer : ""; + const exitLines = session.exitCode === null ? null : await this.screenExitLines(sessionId, buffer); return { sessionId: session.id, state: session.exitCode === null @@ -324,9 +413,9 @@ export class AgentControlService { : session.exitCode === 0 ? "done" : "failed", status: session.status, exitCode: session.exitCode, - output: this.redactTail(buffer, MAX_OBSERVE_CHARS), - ...(session.exitCode !== null && this.exitLines(buffer) ? { exitLines: this.exitLines(buffer)! } : {}), - ...(answer ? { answer } : {}) + output: buffer ? await this.screenText(sessionId, buffer, MAX_OBSERVE_CHARS) : "", + ...(exitLines ? { exitLines } : {}), + ...(answer ? { answer } : this.answerUnavailable(sessionId)) }; } @@ -344,14 +433,25 @@ export class AgentControlService { const timing = this.options.waitTiming ?? AGENT_WAIT_TIMING; const timeoutMs = Math.min(MAX_AGENT_WAIT_MS, Math.max(0, request.timeoutMs)); const started = Date.now(); - const answer = (session: SessionMetadata | null, reason: AgentWaitReason): AgentWaitResult => { + const answer = async (session: SessionMetadata | null, reason: AgentWaitReason): Promise => { const waitedMs = Date.now() - started; + if (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; } + let observable = false; + try { observable = Boolean(this.observableSession(sessionId)); } catch { observable = false; } + let output = ""; + let exitLines: string | null = null; + if (observable) { + try { + const buffer = this.terminals.readBuffer(sessionId).buffer; + output = await this.screenText(sessionId, buffer, MAX_OBSERVE_CHARS); + if (session.exitCode !== null) exitLines = await this.screenExitLines(sessionId, buffer); + } catch { output = ""; } + } const answer = reason === "timeout" || reason === "needs_approval" ? null : this.answer(sessionId); - return { sessionId, reason, status: session.status, exitCode: session.exitCode, waitedMs, output: observation?.output ?? "", - ...(observation?.exitLines ? { exitLines: observation.exitLines } : {}), ...(answer ? { answer } : {}) }; + return { sessionId, reason, status: session.status, exitCode: session.exitCode, waitedMs, output, + ...(exitLines ? { exitLines } : {}), ...(answer ? { answer } : this.answerUnavailable(sessionId)) }; }; let offset = this.outputOffset(sessionId); let changedAt = started; @@ -363,7 +463,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. @@ -385,20 +488,53 @@ export class AgentControlService { } /** Through the terminal manager's one delivery rule: exactly once, into the launch that is starting now. */ - private async deliver(sessionId: string, data: string, what: "prompt" | "text", signal?: AbortSignal): Promise { - const delivery = await this.terminals.deliverInput(sessionId, data, undefined, signal, this.requireSession(sessionId).provider === "codex"); + private async deliver(sessionId: string, data: string, what: "prompt" | "text", signal?: AbortSignal, waitMs?: number): Promise { + // waitMs (AGENT_TOOL_DELIVERY_MS for the orchestration tools) also caps the wait for Codex's composer. + const codex = this.requireSession(sessionId).provider === "codex"; + const delivery = await this.terminals.deliverInput(sessionId, data, waitMs, signal, codex); if (!delivery.delivered) { throw new PromptNotDeliveredError(sessionId, `The ${what} for agent ${sessionId} was not delivered: ${delivery.reason}`); } } - /** The last lines of an exited agent's screen as plain text: masked over the whole tail window before it is cut. */ - private exitLines(buffer: string): string | null { - const text = terminalFailureDetails(this.redactTail(buffer, MAX_EXIT_WINDOW_CHARS)); + /** + * The last lines of the screen, rendered like `output` (a TUI that redraws only the cells that changed would + * otherwise read as its redraws joined together) from the same window as `output` (a line drawn early and never + * redrawn, such as the model's status line, is still on the screen), masked before it is cut and again after. + */ + private async screenExitLines(sessionId: string, buffer: string): Promise { + const source = fromSequenceBoundary(this.redactTail(buffer, MAX_SCREEN_SOURCE_CHARS), buffer.length > MAX_SCREEN_SOURCE_CHARS); + let screen: string; + try { + screen = this.redactTail(await renderedTerminalText(source, this.geometry(sessionId)), MAX_SCREEN_SOURCE_CHARS); + } catch { + screen = source; + } + const text = terminalFailureDetails(screen); if (!text) return null; return text.split("\n").slice(-MAX_EXIT_LINES).join("\n"); } + /** + * The text the agent's terminal shows, as plain text: a TUI's redraws (cursor moves, erases, colors) rendered by a + * headless terminal instead of returned as escape sequences. Masked twice: the scrollback before it is rendered + * (a held value the TUI wrote whole is gone before any redraw can split it), and the rendered text before its + * last `maxChars` are cut (a value the screen shows across wrapped rows or box sides). Should the rendering fail, + * the masked raw tail is returned as before. + */ + private async screenText(sessionId: string, buffer: string, maxChars: number): Promise { + const source = fromSequenceBoundary(this.redactTail(buffer, MAX_SCREEN_SOURCE_CHARS), buffer.length > MAX_SCREEN_SOURCE_CHARS); + try { + return this.redactTail(await renderedTerminalText(source, this.geometry(sessionId)), maxChars); + } catch { + return this.redactTail(buffer, maxChars); + } + } + + private geometry(sessionId: string): { cols: number; rows: number } | null { + try { return typeof this.terminals.geometry === "function" ? this.terminals.geometry(sessionId) : null; } catch { return null; } + } + /** Plugin launch secrets never reach another agent through observed output: the tail as masking the whole text leaves it. */ private redactTail(text: string, maxChars: number): string { if (typeof this.terminals.redactSecretsTail === "function") return this.terminals.redactSecretsTail(text, maxChars); @@ -409,6 +545,14 @@ export class AgentControlService { try { return typeof this.terminals.turnProgress === "function" ? this.terminals.turnProgress(sessionId) : null; } catch { return null; } } + /** Says so when no answer can be captured for this agent, so an orchestrator reads its output instead of waiting for one. */ + private answerUnavailable(sessionId: string): { answerUnavailable?: AgentAnswerUnavailable } { + try { + const reason = typeof this.terminals.answerUnavailable === "function" ? this.terminals.answerUnavailable(sessionId) : null; + return reason === "status_hooks_off" ? { answerUnavailable: { reason, message: STATUS_HOOKS_OFF_MESSAGE } } : {}; + } catch { return {}; } + } + private answer(sessionId: string): AgentAnswer | null { try { const answer = typeof this.terminals.answer === "function" ? this.terminals.answer(sessionId) : null; @@ -435,6 +579,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) { @@ -481,9 +630,11 @@ export function subagentProfile( /** * Where a subagent may work: its orchestrator's project folder (the folder the person chose for the agent it * descends from) or a folder inside it, compared as real paths in the spelling the disk uses (NFC and NFD name the - * same folder on macOS). A relative folder is taken from the orchestrator's own folder. + * same folder on macOS). A relative folder is taken from the orchestrator's own folder. Never CanvasTTY's private + * data under `userDataPath` (a project such as the home folder contains it): a folder inside one plugin's data only + * when the project itself is a folder inside that same plugin's data (an environment's worktree). */ -export function subagentFolder(projectRoot: string, parentCwd: string, requested: unknown): { cwd: string } | { error: string } { +export function subagentFolder(projectRoot: string, parentCwd: string, requested: unknown, userDataPath?: string): { cwd: string } | { error: string } { if (typeof requested !== "string" || requested.trim().length === 0) return { error: "cwd is required: a folder inside this project." }; const wanted = onDiskPath(isAbsolute(requested) ? requested : resolve(parentCwd, requested)); const real = (path: string): string | null => { @@ -498,9 +649,32 @@ export function subagentFolder(projectRoot: string, parentCwd: string, requested if (!root || !(isPathInside(root, folder) || isPathInside(root.normalize("NFC"), folder.normalize("NFC")))) { return { error: `A subagent works only inside this project's folder (${projectRoot}); ${requested} is outside it. Only the person can start an agent in another folder.` }; } + if (userDataPath && insidePrivateData(root, folder, userDataPath, real)) { + return { error: `A subagent cannot work in ${requested}: it is CanvasTTY's private data (tokens, account homes, plugins' data). Only the person can start an agent there.` }; + } return { cwd: folder }; } +/** + * `folder` (a real path) is inside CanvasTTY's private data, and not inside a project that is itself a folder within + * one plugin's data (an environment's worktree; then that plugin folder is the project's own). + */ +function insidePrivateData(root: string, folder: string, userDataPath: string, real: (path: string) => string | null): boolean { + const inside = (base: string, path: string): boolean => isPathInside(base, path) || isPathInside(base.normalize("NFC"), path.normalize("NFC")); + const data = real(resolve(userDataPath)) ?? resolve(userDataPath); + for (const hidden of privateAppData(data)) { + const base = real(hidden) ?? hidden; + if (!inside(base, folder)) continue; + if (hidden !== join(data, PLUGIN_DATA_FOLDER)) return true; + // //…: the project must be strictly inside that same plugin's folder. + const plugin = relative(base.normalize("NFC"), folder.normalize("NFC")).split(sep)[0]; + if (!plugin) return true; + const pluginFolder = join(base, plugin); + if (!inside(pluginFolder, root) || inside(root, pluginFolder)) return true; + } + return false; +} + /** Sleeps, or rejects with an AbortError as soon as `signal` aborts. */ function pause(ms: number, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { @@ -525,3 +699,13 @@ function tail(text: string, maxChars: number): string { function spawnCanceled(): Error { return new DOMException("The spawn was canceled before its agent started.", "AbortError"); } + +/** + * A window cut from the end of a terminal stream may begin inside an escape sequence, whose rest (`18;9H`, `m`) a + * terminal would print as text. A cut window therefore starts at its first escape or line break instead. + */ +function fromSequenceBoundary(window: string, cut: boolean): string { + if (!cut) return window; + const starts = [window.indexOf("\u001b"), window.indexOf("\n")].filter((index) => index >= 0); + return starts.length > 0 ? window.slice(Math.min(...starts)) : window; +} diff --git a/src/main/services/DecisionHooks.ts b/src/main/services/DecisionHooks.ts index a4c910731..b768fbb0d 100644 --- a/src/main/services/DecisionHooks.ts +++ b/src/main/services/DecisionHooks.ts @@ -25,7 +25,10 @@ export interface DecisionSession { role: SessionRole; /** The session's working folder: "outside" is measured from here. */ cwd: string; - /** The agent's own config folders (CLAUDE_CONFIG_DIR of this run), whose plans and memory are not "outside". */ + /** + * The agent's own config folders (CLAUDE_CONFIG_DIR of this run, and the account home a launch contributor selected + * for it): their plans and memory are not "outside", and CanvasTTY's private data does not include them. + */ configDirs: string[]; /** The profile the card runs in. */ profile?: LaunchProfileId; @@ -85,7 +88,8 @@ const MAX_SERVICES = 8; * first and its deny is final. Then every trusted decision service that applies answers deny, ask, allow or * nothing, in parallel, within 3 s. Any deny wins; else any ask (a timeout, an error or an unreadable answer is an * ask); else an allow counts only from a plugin the person separately let allow; else no verdict and the agent goes - * on as it would without CanvasTTY. Nothing here ever turns a failure into an allow. + * on as it would without CanvasTTY. Nothing here ever turns a failure into an allow. A read-only file tool call is + * checked by base protection's private-data rule only; decision services are not asked about it. */ export class DecisionHooks { private readonly deps: DecisionHooksDependencies; @@ -101,6 +105,11 @@ export class DecisionHooks { return services === null || services.length > 0; } + /** Whether a launch's hook should also see read-only file tools: only base protection checks them. */ + checksReads(): boolean { + return this.protects(); + } + /** The longest wait a decision service of this agent asked for: the session's gate is sized for it at launch. */ budgetMs(provider: AgentProviderId): number { return Math.max(DECIDE_TIMEOUT_MS, ...(this.applicable(provider) ?? []).map((service) => this.timeoutFor(service))); @@ -129,15 +138,18 @@ export class DecisionHooks { // Cut input: the rules could not see the command, or where a file tool writes. Unchecked is not allowed. if (request.truncated) { const cut = actionFromHook(request.toolName, request.toolInput, request.toolInputPreview); - if (cut.kind === "shell" || cut.kind === "edit" && cut.paths.length === 0) return { behavior: "deny", message: TOO_LARGE_MESSAGE }; + // A cut read-only call (a read_many_files list, a huge Grep pattern) may hide the path it reads. + if (cut.kind === "shell" || cut.kind === "read" || cut.kind === "edit" && cut.paths.length === 0) return { behavior: "deny", message: TOO_LARGE_MESSAGE }; } } + // Read-only file tools reach the hook only for base protection's private-data rule: plugins are not asked. + const action = actionFromHook(request.toolName, request.toolInput, request.toolInputPreview); + if (action.kind === "read") return { behavior: "none" }; const services = this.applicable(session.provider); // The plugin list itself failed: that is the gateway's failure (the person is asked, or a fail-closed gate // denies), never "no plugin has an opinion". if (services === null) throw new Error("decision services unavailable"); if (services.length === 0) return { behavior: "none" }; - const action = actionFromHook(request.toolName, request.toolInput, request.toolInputPreview); const params: Omit = { event: "pre-tool", sessionId, diff --git a/src/main/services/FileAccessService.ts b/src/main/services/FileAccessService.ts index 07c735a0e..6005c761b 100644 --- a/src/main/services/FileAccessService.ts +++ b/src/main/services/FileAccessService.ts @@ -204,9 +204,13 @@ export class FileAccessService { if (!isContainedPath(root.path, resolvedAfterOpen)) { return { kind: "unsupported", reason: "not-permitted" }; } + // Bind the descriptor to that contained path: the file it names now must be the very file that + // was opened. Where no descriptor path is exposed (macOS, Windows) this is what catches an open + // redirected outside the root by a swapped intermediate directory. Inode numbers are compared as + // bigints: 64-bit file ids (NTFS, some network file systems) lose precision as numbers. try { - const current = await stat(resolvedAfterOpen); - if (current.dev !== metadata.dev || current.ino !== metadata.ino) { + const [current, opened] = [await stat(resolvedAfterOpen, { bigint: true }), await handle.stat({ bigint: true })]; + if (current.dev !== opened.dev || current.ino !== opened.ino) { return { kind: "unsupported", reason: "not-permitted" }; } } catch { diff --git a/src/main/services/GithubAuthService.ts b/src/main/services/GithubAuthService.ts index defbdb6b7..66055a245 100644 --- a/src/main/services/GithubAuthService.ts +++ b/src/main/services/GithubAuthService.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import * as electron from "electron"; import { readFile, writeFile, mkdir, rename, rm } from "node:fs/promises"; import { join, dirname } from "node:path"; +import type { GithubDeviceFlowState } from "../../shared/contracts"; /** GitHub OAuth Device Flow for the plugin showcase. */ @@ -10,6 +11,7 @@ export interface GithubAuthStatus { authorized: boolean; login: string | null; tokenExpiresAt: number | null; + deviceFlowState: GithubDeviceFlowState; } interface StoredTokens { @@ -60,6 +62,7 @@ export class GithubAuthService { private readonly pollTimeoutMs: number; private generation = 0; private deviceFlow: { generation: number; controller: AbortController } | null = null; + private deviceFlowState: GithubDeviceFlowState = "idle"; private storeWrite = Promise.resolve(); constructor(userDataPath: string, clientId?: string, options: GithubAuthServiceOptions = {}) { @@ -126,13 +129,20 @@ export class GithubAuthService { async status(): Promise { if (!this.tokens) { - return { configured: this.clientConfigured, authorized: false, login: null, tokenExpiresAt: null }; + return { + configured: this.clientConfigured, + authorized: false, + login: null, + tokenExpiresAt: null, + deviceFlowState: this.deviceFlowState + }; } return { configured: this.clientConfigured, authorized: true, login: this.tokens.login, - tokenExpiresAt: this.tokens.expiresAt + tokenExpiresAt: this.tokens.expiresAt, + deviceFlowState: this.deviceFlowState }; } @@ -146,10 +156,11 @@ export class GithubAuthService { throw new Error("GitHub OAuth is not configured (missing client id)."); } - this.cancelDeviceFlow(); + this.abortDeviceFlow(); const generation = ++this.generation; const controller = new AbortController(); this.deviceFlow = { generation, controller }; + this.deviceFlowState = "pending"; try { const body = new URLSearchParams({ client_id: this.clientId }); @@ -176,7 +187,11 @@ export class GithubAuthService { void this.pollDeviceCode(deviceCode, interval, flowLifetimeMs, generation, controller.signal) .catch((error) => { - if (!isAbortError(error)) console.warn("CanvasTTY GitHub OAuth device poll failed.", error); + // Only this flow's own abort (Cancel, sign-out, a new sign-in) is a cancellation; a request + // that hit its timeout also throws an AbortError, and that is a failure the person must see. + if (controller.signal.aborted) return; + this.finishDeviceFlow(generation, "failed"); + console.warn("CanvasTTY GitHub OAuth device poll failed.", error); }) .finally(() => { if (this.deviceFlow?.generation === generation) this.deviceFlow = null; @@ -184,14 +199,26 @@ export class GithubAuthService { return { userCode, verificationUri, interval, expiresAt: this.now() + flowLifetimeMs }; } catch (error) { - if (this.deviceFlow?.generation === generation) this.deviceFlow = null; + if (this.deviceFlow?.generation === generation) { + this.deviceFlow = null; + // The caller receives this error directly, so no separate outcome is kept. + this.deviceFlowState = "idle"; + } throw error; } } + /** Stops a pending device sign-in. A completed sign-in or an existing session is left untouched. */ + cancelDeviceFlow(): void { + if (!this.deviceFlow || this.deviceFlowState !== "pending") return; + this.abortDeviceFlow(); + this.deviceFlowState = "cancelled"; + } + async signOut(): Promise { this.generation += 1; - this.cancelDeviceFlow(); + this.abortDeviceFlow(); + this.deviceFlowState = "idle"; this.tokens = null; this.refreshPromise = null; await this.enqueueStoreWrite(async () => { @@ -199,9 +226,17 @@ export class GithubAuthService { }); } - private cancelDeviceFlow(): void { - this.deviceFlow?.controller.abort(); + private abortDeviceFlow(): void { + const flow = this.deviceFlow; + // Clear the flow before aborting so a fetcher that ignores its AbortSignal + // cannot apply a late response: every step re-checks isCurrentFlow(). this.deviceFlow = null; + flow?.controller.abort(); + } + + private finishDeviceFlow(generation: number, state: GithubDeviceFlowState): void { + if (this.deviceFlow?.generation !== generation || this.deviceFlowState !== "pending") return; + this.deviceFlowState = state; } private isCurrentFlow(generation: number, signal: AbortSignal): boolean { @@ -217,51 +252,88 @@ export class GithubAuthService { ): Promise { const started = this.now(); let interval = initialInterval; + let transientFailures = 0; + // A dropped connection, the request timeout, HTTP 429/5xx or an unreadable + // body is not the end of the flow: the person may still approve the code. + // Back off (RFC 8628 section 3.5) and poll again, but give up after a few + // failures in a row instead of polling a broken endpoint until the code expires. + const retryOrFail = (): boolean => { + transientFailures += 1; + if (transientFailures >= MAX_CONSECUTIVE_POLL_FAILURES) { + this.finishDeviceFlow(generation, "failed"); + return false; + } + interval = backedOff(interval); + return true; + }; while (this.now() - started < lifetimeMs) { await this.wait(interval * 1000, signal); if (!this.isCurrentFlow(generation, signal)) return; + if (this.now() - started >= lifetimeMs) break; const body = new URLSearchParams({ client_id: this.clientId, device_code: deviceCode, grant_type: "urn:ietf:params:oauth:grant-type:device_code" }); - // A dropped connection, the 15 s request timeout or a GitHub 5xx is not - // the end of the flow: the person may still approve the code. Back off - // (RFC 8628 section 3.5) and poll again until the code expires. - let payload: unknown; + let response: Response; + let payload: unknown = null; try { - const response = await this.request("https://github.com/login/oauth/access_token", { + response = await this.request("https://github.com/login/oauth/access_token", { method: "POST", headers: oauthHeaders(), body: body.toString() }, signal); - if (!response.ok) { - interval = backedOff(interval); - continue; - } - payload = await response.json(); + // OAuth errors may arrive with HTTP 400 (RFC 6749 section 5.2), so read the body either way. + payload = await response.json().catch(() => null); } catch (error) { if (signal.aborted) throw error; - interval = backedOff(interval); - continue; + if (!this.isCurrentFlow(generation, signal)) return; + if (retryOrFail()) continue; + return; } - if (!isRecord(payload)) { - interval = backedOff(interval); - continue; + if (!this.isCurrentFlow(generation, signal)) return; + const providerError = isRecord(payload) && typeof payload.error === "string" ? payload.error : null; + // HTTP 429/5xx is transient whatever its body says (e.g. {"error":"temporarily_unavailable"}); + // slow_down keeps its own handling below, which also raises the interval. + if (!response.ok && isTransientStatus(response.status) && providerError !== "slow_down") { + if (retryOrFail()) continue; + return; } - if (payload.error === "authorization_pending" || payload.error === "slow_down") { - if (payload.error === "slow_down") { + if (providerError === null && (!response.ok || !isRecord(payload))) { + if (response.ok) { + if (retryOrFail()) continue; + return; + } + this.finishDeviceFlow(generation, "failed"); + return; + } + if (providerError === "authorization_pending" || providerError === "slow_down") { + transientFailures = 0; + if (providerError === "slow_down") { // +5 s for this and later polls; GitHub may name a longer interval. - const requested = typeof payload.interval === "number" && Number.isFinite(payload.interval) ? payload.interval : 0; + const requested = isRecord(payload) && typeof payload.interval === "number" && Number.isFinite(payload.interval) + ? payload.interval + : 0; interval = Math.max(interval + 5, Math.min(requested, MAX_POLL_INTERVAL_SECONDS)); } continue; } - if (payload.error === "access_denied" || payload.error === "expired_token") return; - if (typeof payload.access_token === "string") { + if (providerError === "access_denied") { + this.finishDeviceFlow(generation, "denied"); + return; + } + if (providerError === "expired_token") { + this.finishDeviceFlow(generation, "expired"); + return; + } + if (providerError === null && isRecord(payload) && typeof payload.access_token === "string") { const login = await this.fetchLogin(payload.access_token, signal); - if (!login || !this.isCurrentFlow(generation, signal)) return; + if (!this.isCurrentFlow(generation, signal)) return; + if (!login) { + this.finishDeviceFlow(generation, "failed"); + return; + } const expiresIn = typeof payload.expires_in === "number" && payload.expires_in > 0 ? payload.expires_in : null; @@ -272,11 +344,15 @@ export class GithubAuthService { login }; this.tokens = next; + this.deviceFlowState = "idle"; await this.persist(next, generation); return; } + // Any other provider error (device_flow_disabled, incorrect_client_credentials, ...) is final. + this.finishDeviceFlow(generation, "failed"); return; } + this.finishDeviceFlow(generation, "expired"); } private async refreshAccessToken(expected: StoredTokens, generation: number): Promise { @@ -326,7 +402,8 @@ export class GithubAuthService { const payload: unknown = await response.json(); return isRecord(payload) && typeof payload.login === "string" ? payload.login : null; } catch (error) { - if (isAbortError(error)) throw error; + // Rethrow only a cancelled flow; a timed-out or failed profile request is a failed sign-in. + if (signal.aborted && isAbortError(error)) throw error; return null; } } @@ -433,6 +510,12 @@ function isMissingFile(error: unknown): boolean { } const MAX_POLL_INTERVAL_SECONDS = 60; +/** Consecutive network, timeout, HTTP 429/5xx or unreadable answers before the sign-in is reported as failed. */ +const MAX_CONSECUTIVE_POLL_FAILURES = 5; + +function isTransientStatus(status: number): boolean { + return status === 408 || status === 429 || status >= 500; +} function backedOff(interval: number): number { return Math.min(Math.max(interval * 2, interval + 1), MAX_POLL_INTERVAL_SECONDS); diff --git a/src/main/services/LaunchPipeline.ts b/src/main/services/LaunchPipeline.ts index 439370a56..bd3e2e62a 100644 --- a/src/main/services/LaunchPipeline.ts +++ b/src/main/services/LaunchPipeline.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { mkdir, rm, writeFile } from "node:fs/promises"; +import { mkdir, rm, rmdir, writeFile } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; import type { LaunchProfileId, @@ -13,6 +13,7 @@ import { claudeCoreSettingsKey, coreOwnedLaunchArgument, parseInlineSettings } f import { MAX_PLUGIN_SLOT_BYTES } from "./TerminalSessionStore.ts"; import { LaunchRefusal } from "./launchRefusal.ts"; import { MAX_INSPECTED_CONFIG_BYTES, parseJsonc, readInspectedFile } from "./inspectedConfig.ts"; +import { contributedAccountHome } from "./isolation/accountHomeIsolation.ts"; /** A trusted plugin service that declared launch options (PluginManager.launchContributors). */ export interface LaunchContributor { @@ -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,17 @@ export type PreparedLaunch = envSources: Record; /** A contributor runs the agent on another model than its vendor's: "auto" becomes accept-edits. */ thirdPartyModel: boolean; + /** Plugin ids whose contribution decides the model: it passes the CLI's model flag or runs a third-party model. */ + modelSetBy: string[]; + /** This run's own folder below the card's folder; `cleanup` removes it. */ + runDirectory: string; + /** + * The CLI home a chosen contributor started this launch with inside its own data folder (an account it selected, + * contributedAccountHome): the one folder of plugin data the isolation layer reopens for this agent, and for Grok/Kimi + * (`GROK_HOME` / `KIMI_CODE_HOME`) where CanvasTTY writes that CLI's temporary configuration. Derived by the host, + * never a grant a plugin can name. + */ + accountHome?: string; cleanup(): Promise; } | { ok: false; reason: string }; @@ -118,9 +132,25 @@ export class LaunchPipeline { return rm(this.dependencies.runsRoot, { recursive: true, force: true }); } - /** Removes a closed session's file folders. */ - forgetSession(sessionId: string): Promise { - return rm(join(this.dependencies.runsRoot, safeSegment(sessionId)), { recursive: true, force: true }); + /** + * Removes a closed card's run folders that this pipeline prepared for it, then the card's folder if nothing else + * is left in it. A card brought back under the same id may already be writing a new run folder there; it is kept. + */ + async forgetSession(sessionId: string, runDirectories: Iterable): Promise { + const parent = join(this.dependencies.runsRoot, safeSegment(sessionId)); + const errors: unknown[] = []; + for (const runDirectory of new Set(runDirectories)) { + // Only a run folder directly below this card's folder is ever removed here. + if (dirname(resolve(runDirectory)) !== resolve(parent)) continue; + try { await removeRunFolder(runDirectory); } catch (error) { errors.push(error); } + } + try { + await rmdir(parent); + } catch (error) { + // Still in use by a newer run of the same card, or already gone; the startup sweep removes what is left. + if (!PARENT_KEPT_CODES.has((error as NodeJS.ErrnoException).code ?? "")) errors.push(error); + } + if (errors.length) throw new AggregateError(errors, "Launch run folders could not be removed."); } /** Launch policies that apply to this agent: every launch of it asks them, chosen or not. */ @@ -145,45 +175,45 @@ export class LaunchPipeline { * Checks launcher values against each plugin's declared fields and fills defaults. * Throws with a person-readable reason; returns undefined when nothing was chosen. */ - normalizeOptions(provider: ProviderId, candidate: unknown, context?: { delegated?: boolean }): Record | undefined { + normalizeOptions(provider: ProviderId, candidate: unknown, context?: { + delegated?: boolean; + /** The orchestrator's own options: repeating them for its subagent is the person's choice, not the orchestrator's. */ + inherited?: Readonly>>; + }): Record | undefined { if (candidate === undefined) return undefined; if (!isRecord(candidate) || Object.keys(candidate).length > MAX_OPTION_PLUGINS) throw new Error("Launch options are invalid."); if (Object.keys(candidate).length === 0) return undefined; if (provider === "terminal") throw new Error("A plain terminal takes no launch options."); const contributors = new Map(this.dependencies.contributors().map((contributor) => [contributor.pluginId, contributor])); const options: Record = {}; - for (const [pluginId, raw] of Object.entries(candidate)) { + for (const [pluginId, requested] of Object.entries(candidate)) { + let raw = requested; const contributor = contributors.get(pluginId); if (!contributor) throw new Error(unavailableReason(pluginId)); // An orchestrator picks options for its subagents only where the plugin said that is safe. + const notDelegable = `${contributor.pluginName} has not declared its launch options safe for an orchestrator to choose; only the person chooses them, in the launcher.`; + // Without the declaration a subagent may only repeat the orchestrator's own values: the keys it leaves out are + // the inherited ones, never the plugin's defaults, and the result is checked against them below. + const inherited = context?.delegated && contributor.launch.delegable !== true ? context.inherited?.[pluginId] : undefined; if (context?.delegated && contributor.launch.delegable !== true) { - throw new LaunchRefusal(`${contributor.pluginName} has not declared its launch options safe for an orchestrator to choose; only the person chooses them, in the launcher.`); + if (!inherited || !sameAsInherited(raw, inherited)) throw new LaunchRefusal(notDelegable); + raw = { ...inherited, ...(raw as Record) }; } - const { appliesTo, fields } = contributor.launch; - if (appliesTo && !appliesTo.includes(provider as never)) { - throw new Error(`${contributor.pluginName} launch options do not apply to ${provider}.`); + if (!inherited) { + options[pluginId] = launchValues(contributor, provider, raw); + continue; } - if (!isRecord(raw)) throw new Error(`${contributor.pluginName} launch options are invalid.`); - const known = new Set(fields.map((field) => field.key)); - const unknown = Object.keys(raw).find((key) => !known.has(key)); - if (unknown) throw new Error(`${contributor.pluginName} has no launch option ${unknown.slice(0, 40)}.`); - const values: PluginLaunchValues = {}; - for (const field of fields) { - const value = raw[field.key] ?? field.default - ?? (field.kind === "boolean" ? false : field.kind === "select" ? field.options?.[0]?.value ?? "" : ""); - const plainText = (limit: number): boolean => typeof value === "string" && value.length <= limit && !/[\u0000-\u001f\u007f]/.test(value); - // A service-provided choice may have changed since the launcher showed it: the service checks it when it prepares. - const valid = field.kind === "boolean" - ? typeof value === "boolean" - : field.kind === "select" - ? typeof value === "string" && (Boolean(field.options?.some((option) => option.value === value)) || (field.optionsFrom === "service" && plainText(200))) - : plainText(field.maxLength ?? 200); - if (!valid) throw new Error(`${contributor.pluginName} launch option ${field.label} is invalid.`); - values[field.key] = value as boolean | string; - } - if (Buffer.byteLength(JSON.stringify(values), "utf8") > MAX_PLUGIN_SLOT_BYTES) { - throw new Error(`${contributor.pluginName} launch options exceed 4 KB.`); + // The person's earlier choice no longer fits the plugin (it changed its fields or what it applies to): a subagent + // never gets other values than its orchestrator's, so it is refused with that reason. + const changed = `${contributor.pluginName} launch options changed since this orchestrator started, so its subagents cannot repeat the person's choice. ` + + "The person can start the orchestrator again with the new options."; + let values: PluginLaunchValues; + try { + values = launchValues(contributor, provider, raw); + } catch { + throw new LaunchRefusal(changed); } + if (!sameValues(values, inherited)) throw new LaunchRefusal(changed); options[pluginId] = values; } return options; @@ -239,8 +269,14 @@ export class LaunchPipeline { * refuses the whole launch with a reason; nothing is ever launched without a contribution the person selected, * or past a policy that did not answer. */ - async prepare(context: LaunchSessionContext): Promise { + async prepare(context: LaunchSessionContext, launch: { + model?: string; + /** Plugins whose options a subagent repeats unchanged from its orchestrator of the same CLI: the person chose them. */ + personChosen?: readonly string[]; + } = {}): Promise { const contributors = new Map(this.dependencies.contributors().map((contributor) => [contributor.pluginId, contributor])); + // Only a subagent's launch is refused for a second model; it was chosen for it, not by the person. + const delegated = context.role === "subagent"; const selected = Object.keys(context.options).sort(); for (const pluginId of selected) { if (!contributors.has(pluginId)) return { ok: false, reason: unavailableReason(pluginId) }; @@ -251,7 +287,7 @@ export class LaunchPipeline { ...policies.map((contributor) => this.ask(contributor, context, false)) ]); const runDirectory = join(this.dependencies.runsRoot, safeSegment(context.sessionId), randomUUID()); - const cleanup = (): Promise => rm(runDirectory, { recursive: true, force: true }); + const cleanup = (): Promise => removeRunFolder(runDirectory); const refuse = async (reason: string): Promise => { await cleanup().catch(() => undefined); return { ok: false, reason }; @@ -267,6 +303,9 @@ export class LaunchPipeline { const args: string[] = []; const secrets: string[] = []; let thirdPartyModel = false; + const modelSetBy: string[] = []; + let modelArgumentBy: string | undefined; + let accountHome: string | undefined; for (const answer of answers) { if ("refuse" in answer) return refuse(answer.refuse); } @@ -285,6 +324,25 @@ export class LaunchPipeline { } const forbidden = contributedArgs.find((argument) => coreOwnedLaunchArgument(context.provider, argument)); if (forbidden) return refuse(`${name} added ${forbidden.slice(0, 60)}, which only CanvasTTY may pass.`); + const modelFlag = modelSettingArgument(context.provider, contributedArgs); + if (modelFlag !== undefined || contribution.thirdPartyModel === true) { + // A subagent's CLI gets one model: a second model flag next to the launch's own made OpenCode crash at start. + // A person's own launches (and their restores) start as they always did; the person chose both. + if (delegated && modelFlag !== undefined && launch.model !== undefined) { + return refuse(`${name} sets the model of this launch, and the launch also asks for model ${launch.model.slice(0, 80)}. Omit the model, or choose ${name} options for the model you want.`); + } + // Two model arguments are the crash, and are always refused. A plugin that only routes the CLI to another service + // (thirdPartyModel) next to one that picks the model is allowed only as the person's own combination: both + // repeated unchanged from the orchestrator. A pair the orchestrator chose itself is refused as before. + const personPair = !(modelFlag !== undefined && modelArgumentBy !== undefined) + && [...modelSetBy, contributor.pluginId].every((pluginId) => launch.personChosen?.includes(pluginId) === true); + if (delegated && modelSetBy.length > 0 && !personPair) { + const first = contributors.get(modelSetBy[0]!)?.pluginName ?? modelSetBy[0]!; + return refuse(`${first} and ${name} both set the model of this launch; choose launch options from only one of them.`); + } + if (modelFlag !== undefined) modelArgumentBy ??= contributor.pluginId; + modelSetBy.push(contributor.pluginId); + } // A configuration a CLI reads from the environment or an argument may not decide its approvals either. const widening = permissionConfigProblem(context.provider, contribution, context.cwd); if (widening) return refuse(`${name} ${widening}`); @@ -313,6 +371,7 @@ export class LaunchPipeline { const conflict = claim(key); if (conflict) return refuse(conflict); env[key] = expand(value); + accountHome = contributedAccountHome(context.provider, contributor.dataDir, key, env[key]) ?? accountHome; } for (const [key, secretKey] of Object.entries(contribution.secretEnv)) { const conflict = claim(key); @@ -327,7 +386,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, modelSetBy, runDirectory, ...(accountHome ? { accountHome } : {}), cleanup }; } private async ask( @@ -562,6 +621,17 @@ function unavailableReason(pluginId: string): string { return `Needs plugin ${pluginId} for its launch options; it is disabled, removed, or its native code is not trusted.`; } +/** Errors that leave a card's folder in place on purpose: another run still uses it, or it is gone. */ +const PARENT_KEPT_CODES = new Set(["ENOENT", "ENOTEMPTY", "EEXIST", "EBUSY", "EPERM"]); + +/** + * Removes one run folder. On Windows the agent may still hold a file there for a moment after its PTY exits, so + * busy/permission errors are retried briefly before giving up. + */ +function removeRunFolder(runDirectory: string): Promise { + return rm(runDirectory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +} + function safeSegment(value: string): string { return value.replace(/[^A-Za-z0-9._-]/g, "_").replace(/^\.+/, "_").slice(0, 128) || "_"; } @@ -575,3 +645,92 @@ export function errorText(error: unknown): string { export function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } + +/** A plugin's launch values checked against its declared fields, with defaults filled in. Throws with a person-readable reason. */ +function launchValues(contributor: LaunchContributor, provider: ProviderId, raw: unknown): PluginLaunchValues { + const { appliesTo, fields } = contributor.launch; + if (appliesTo && !appliesTo.includes(provider as never)) { + throw new Error(`${contributor.pluginName} launch options do not apply to ${provider}.`); + } + if (!isRecord(raw)) throw new Error(`${contributor.pluginName} launch options are invalid.`); + const known = new Set(fields.map((field) => field.key)); + const unknown = Object.keys(raw).find((key) => !known.has(key)); + if (unknown) throw new Error(`${contributor.pluginName} has no launch option ${unknown.slice(0, 40)}.`); + const values: PluginLaunchValues = {}; + for (const field of fields) { + const value = raw[field.key] ?? field.default + ?? (field.kind === "boolean" ? false : field.kind === "select" ? field.options?.[0]?.value ?? "" : ""); + const plainText = (limit: number): boolean => typeof value === "string" && value.length <= limit && !/[\u0000-\u001f\u007f]/.test(value); + // A service-provided choice may have changed since the launcher showed it: the service checks it when it prepares. + const valid = field.kind === "boolean" + ? typeof value === "boolean" + : field.kind === "select" + ? typeof value === "string" && (Boolean(field.options?.some((option) => option.value === value)) || (field.optionsFrom === "service" && plainText(200))) + : plainText(field.maxLength ?? 200); + if (!valid) throw new Error(`${contributor.pluginName} launch option ${field.label} is invalid.`); + values[field.key] = value as boolean | string; + } + if (Buffer.byteLength(JSON.stringify(values), "utf8") > MAX_PLUGIN_SLOT_BYTES) { + throw new Error(`${contributor.pluginName} launch options exceed 4 KB.`); + } + return values; +} + +/** The orchestrator's own choice repeated for its subagent: only keys it chose, each with the same value. */ +function sameAsInherited(raw: unknown, inherited: Readonly | undefined): boolean { + if (!inherited || !isRecord(raw)) return false; + const keys = Object.keys(raw); + // An empty choice repeats only an empty one (a plugin without fields, chosen in the launcher). + if (keys.length === 0) return Object.keys(inherited).length === 0; + return keys.every((key) => Object.hasOwn(inherited, key) && inherited[key] === raw[key]); +} + +/** Exactly the same keys and values. */ +function sameValues(values: Readonly, inherited: Readonly): boolean { + const keys = Object.keys(values); + return keys.length === Object.keys(inherited).length + && keys.every((key) => Object.hasOwn(inherited, key) && inherited[key] === values[key]); +} + +/** + * How each CLI's arguments choose its model. Every CLI here takes `--model`; `short` are its short model flags, + * `profile` flags that select a configuration profile (which may name a model), and `config` flags that set a + * configuration key, with the keys that decide the model. + */ +const MODEL_ARGUMENTS: Readonly>> = { + codex: { short: ["-m"], profile: ["--profile", "-p"], config: { flags: ["-c", "--config"], keys: ["model", "model_provider", "profile"] } }, + opencode: { short: ["-m"] }, + qwen: { short: ["-m"] }, + kimi: { short: ["-m"] }, + grok: { short: ["-m"] } +}; + +/** + * The first argument that chooses the CLI's model, in any form: `--model x`, `--model=x`, a short `-m x` or `-mx`, + * and for Codex `--profile`/`-p` and `-c`/`--config` setting `model`, `model_provider` or `profile`. Undefined when none does. + */ +export function modelSettingArgument(provider: ProviderId, args: readonly string[]): string | undefined { + const table = MODEL_ARGUMENTS[provider] ?? {}; + const valueFlags = ["--model", ...(table.short ?? []), ...(table.profile ?? [])]; + const configKeys = new Set(table.config?.keys ?? []); + const configSetsModel = (value: string | undefined): boolean => { + if (value === undefined) return false; + const key = value.split("=", 1)[0]!.trim().replace(/^["']|["']$/gu, ""); + return value.includes("=") && configKeys.has(key); + }; + for (let index = 0; index < args.length; index++) { + const argument = args[index]!; + if (valueFlags.some((flag) => argument === flag || attachedValue(argument, flag) !== undefined)) return argument; + for (const flag of table.config?.flags ?? []) { + const value = argument === flag ? args[index + 1] : attachedValue(argument, flag); + if (configSetsModel(value)) return argument === flag ? `${argument} ${value}` : argument; + } + } + return undefined; +} + +/** The value written into the flag's own argument: `--flag=value`, or `-fvalue` for a short flag. */ +function attachedValue(argument: string, flag: string): string | undefined { + if (flag.startsWith("--")) return argument.startsWith(`${flag}=`) ? argument.slice(flag.length + 1) : undefined; + return argument.length > flag.length && argument.startsWith(flag) ? argument.slice(flag.length) : undefined; +} diff --git a/src/main/services/LocalUsageCollector.ts b/src/main/services/LocalUsageCollector.ts index 2ab948d52..14331003c 100644 --- a/src/main/services/LocalUsageCollector.ts +++ b/src/main/services/LocalUsageCollector.ts @@ -149,6 +149,28 @@ export function sanitizeCollectorState(input: unknown): Clean { async function sideFile(path: string): Promise { try { return (await stat(path)).size; } catch { return null; } } +/** Attempts of a rollback-journal read while a writer holds the database, each after the CLI's busy timeout. */ +const BUSY_ATTEMPTS = 3; +const BUSY_RETRY_MS = 250; +const isBusy = (error: unknown): boolean => /database (?:table )?is locked|SQLITE_BUSY|\bbusy\b/i + .test(String((error as { stderr?: unknown }).stderr ?? (error as Error)?.message ?? '')); +/** + * Whether the database header marks WAL mode: file format write and read versions (bytes 18 and 19) are both 2. + * A rollback-journal database has 1 there; an unreadable or foreign header counts as not WAL. + */ +async function walHeader(path: string): Promise { + let handle: FileHandle | undefined; + try { + handle = await open(path, 'r'); + const header = Buffer.alloc(100); + const { bytesRead } = await handle.read(header, 0, header.length, 0); + return bytesRead === header.length && header.toString('latin1', 0, 16) === 'SQLite format 3\0' && header[18] === 2 && header[19] === 2; + } catch { + return false; + } finally { + await handle?.close().catch(() => undefined); + } +} function sqliteProblem(error: unknown): string { if (error instanceof SourceProblem) return error.message; const e = error as NodeJS.ErrnoException & { stderr?: unknown; killed?: boolean }; @@ -396,11 +418,16 @@ export class LocalUsageCollector { } /** - * A plain read-only connection creates a missing -shm file; readonly_shm never does. When no WAL - * frames can exist (no or empty -wal) an immutable read takes no locks and creates nothing. + * A rollback-journal database (header not WAL) is read through a plain read-only connection: it takes a shared + * lock, so a writer's half-written pages are never read, and it creates no side files. A busy database is + * retried a few times, then skipped for this poll. For a WAL database a plain read-only connection creates a + * missing -shm file; readonly_shm never does. When no WAL frames can exist (no or empty -wal) an immutable read + * takes no locks and creates nothing; only a WAL database may be read that way, since its writer always keeps + * -wal and -shm beside it while it runs. */ private async sqlite(database: string, sql: string): Promise[]> { const target = (query: string): string => `${pathToFileURL(database).href}?${query}`; + if (!await walHeader(database)) return await this.lockedQuery(target('mode=ro'), sql); const [wal, shm] = await Promise.all([sideFile(`${database}-wal`), sideFile(`${database}-shm`)]); if (shm === null && wal) throw new SourceProblem('WAL without shared-memory index (writer stopped uncleanly); skipped to avoid a stale read'); // A read-only WAL connection can still create side files beside a stopped writer. @@ -414,6 +441,17 @@ export class LocalUsageCollector { } } + private async lockedQuery(target: string, sql: string): Promise[]> { + for (let attempt = 1; ; attempt++) { + try { + return await this.query(target, sql); + } catch (error) { + if (attempt >= BUSY_ATTEMPTS || !isBusy(error)) throw error; + await new Promise(done => setTimeout(done, BUSY_RETRY_MS * attempt)); + } + } + } + private async query(target: string, sql: string): Promise[]> { const executable = this.sqlitePath ?? await discoverSqlite(this.sqliteEnvironment); if (!executable) throw new SourceProblem('sqlite3 is unavailable (not found on PATH)'); diff --git a/src/main/services/PluginManager.ts b/src/main/services/PluginManager.ts index 0e86e7192..f68238a79 100644 --- a/src/main/services/PluginManager.ts +++ b/src/main/services/PluginManager.ts @@ -48,6 +48,7 @@ import { } from "../../shared/contracts.ts"; import { isValidSemver } from "../../shared/hostVersion.ts"; import { PLUGIN_VISIBILITY_BRIDGE_SOURCE } from "../../shared/pluginVisibility.ts"; +import { PLUGIN_NATIVE_CODE_OFF_ERROR, PLUGIN_SERVICE_MODULE_OFF_ERROR } from "../../shared/pluginServiceNotice.ts"; import type { PluginServiceSpec } from "./PluginServiceSupervisor.ts"; import type { LaunchContributor } from "./LaunchPipeline.ts"; import type { EnvironmentProvider } from "./EnvironmentRegistry.ts"; @@ -654,7 +655,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; @@ -1203,11 +1205,21 @@ export class PluginManager { return structuredClone(contribution); } + /** + * The plugin's own UI may ask `serviceId` now: it is in the active manifest and its native code is trusted. A + * service whose module is off or whose native code is not trusted gets a fixed text the renderer turns into a + * localized notice with a way to the right settings section (pluginServiceNotice). + */ assertService(pluginId: string, serviceId: string): void { - const plugin = activePlugin(this.requireEnabledPlugin(pluginId)); + const installed = this.requireEnabledPlugin(pluginId); + const plugin = activePlugin(installed); if (!plugin.manifest.services?.some((service) => service.id === serviceId)) { + if (installed.manifest.services?.some((service) => service.id === serviceId)) throw new Error(PLUGIN_SERVICE_MODULE_OFF_ERROR); throw new Error("Plugin service does not exist."); } + if (!installed.nativeCodeTrusted || !this.serviceTrust.get(pluginId)?.[serviceId]) { + throw new Error(PLUGIN_NATIVE_CODE_OFF_ERROR); + } } hasPermission(pluginId: string, permission: PluginPermission): boolean { diff --git a/src/main/services/PluginServiceSupervisor.ts b/src/main/services/PluginServiceSupervisor.ts index 1c1b061dc..1236d301b 100644 --- a/src/main/services/PluginServiceSupervisor.ts +++ b/src/main/services/PluginServiceSupervisor.ts @@ -288,7 +288,8 @@ export class PluginServiceSupervisor { private send(pluginId: string, serviceId: string, method: string, params: unknown, timeoutMs: number): Promise { const record = this.services.get(serviceKey(pluginId, serviceId)); if (!record || !record.child || (record.state !== "running" && record.state !== "starting")) { - return Promise.reject(new Error("Plugin service is not running.")); + // A trusted service that crashed, failed to start or is waiting to restart says why. + return Promise.reject(new Error(`Plugin service is not running.${record?.lastError ? ` Last error: ${record.lastError}` : ""}`)); } if (record.pending.size >= MAX_PENDING_REQUESTS) { return Promise.reject(new Error("Plugin service is busy.")); diff --git a/src/main/services/TerminalManager.ts b/src/main/services/TerminalManager.ts index 7688dcc1e..84d51df9b 100644 --- a/src/main/services/TerminalManager.ts +++ b/src/main/services/TerminalManager.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { constants as osConstants } from "node:os"; import { realpathSync, statSync } from "node:fs"; import { basename, dirname } from "node:path"; import { isPathInside } from "../../agent-runtime/path-inside.mjs"; @@ -8,6 +9,7 @@ import type { CreateSessionRequest, GitRiskReport, Point, + PluginLaunchValues, ProviderId, SessionBounds, SessionEnvironmentChoice, @@ -53,6 +55,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"; @@ -66,12 +69,14 @@ import { terminalFailureDetails } from "./terminalFailureDetails.ts"; import { canResumeThreadById, resolveTerminalLaunch } from "./terminalLaunch.ts"; import { isLaunchProfile, PROFILE_RANK, profileAvailable, profileCeiling, type LaunchProfile } from "../../shared/autoMode.ts"; import type { AgentIsolation, IsolationDecision } from "./isolation/AgentIsolation.ts"; -import { controlGrantFolder } from "./isolation/AgentIsolation.ts"; +import { controlGrantFolder, gatewaySocketAddresses } from "./isolation/AgentIsolation.ts"; import { auditRepositories, neutralizeRepositories, type GitRiskRepository } from "./isolation/gitAudit.ts"; +import { providerHomeVariables, type PlacedProject } from "./isolation/isolationPaths.ts"; import { LaunchRefusal } from "./launchRefusal.ts"; import { configuredMode } from "./configuredMode.ts"; import { envKey, RESERVED_ENV, type LaunchPipeline, type PreparedLaunch } from "./LaunchPipeline.ts"; import type { EnvironmentRegistry } from "./EnvironmentRegistry.ts"; +import { agentEnvironment, shellEnvironment } from "./ambientProviderKeys.ts"; import { persistedTerminalSession, type PersistedEnvironmentRef, @@ -128,6 +133,20 @@ interface ManagedSession { promptTurnMark?: number; /** The last turn's final answer its hook or plugin reported (captureResult only); cleared when a turn starts. */ answer?: { text: string; truncated: boolean; at: number }; + /** + * Status hooks were off when this launch was planned, or were turned off since: it has no lifecycle hooks, so its + * card stays unavailable and no answer is captured until a restart with status hooks on. + */ + statusHooksOff?: boolean; + /** Something other than Enter was typed since the last submitted input (an Enter alone then submits nothing). */ + typedSinceSubmit?: boolean; + /** Bumped by every submitted input (except an approval answer) and every launch: the turn an answer must belong to. */ + inputGeneration?: number; + /** inputGeneration when the current turn started. */ + answerTurnGeneration?: number; + /** OpenCode's current turn id and the inputGeneration each recent turn id started in. */ + providerTurnId?: string; + providerTurnGenerations?: Map; /** * Plugin options, environment ref (or, until the plugin has prepared it, the launcher's environment choice) * and owning plugin carried into the saved record. @@ -137,10 +156,21 @@ interface ManagedSession { launchToken: number; /** Removes the current run's plugin files; called when the process exits. */ launchCleanup: (() => Promise) | null; + /** Cleanup already started by an earlier exit; closing the card must drain it before deleting its parent. */ + launchCleanupPending?: Promise; + /** Whole contributed launches, including asynchronous preparation/wrapping and abandoned-run cleanup. */ + launchTasks?: Set>; + /** + * The pipelines that prepared launches for this card, each with the run folders it created for this card object, + * retained across host reconfiguration/restart. Closing removes only these, never a later card's under the same id. + */ + launchRunFolders?: Map, Set>; /** A restored grok card waits for its grid before launching; plugins still learn it is a restore. */ restoringLaunch: boolean; /** The environment was prepared or resumed in this run of the app, so it can be wrapped now. */ environmentReady: boolean; + /** Plugins whose launch contribution decided this run's model (LaunchPipeline.prepare), once prepared. */ + modelSetBy?: string[]; /** Bumped by every launch the person or the app asks for (create, restart, restore); input waits for one. */ launchEpoch: number; /** An automation-owned paste-only draft may be extended in this launch; submit or person input clears it. */ @@ -157,6 +187,17 @@ interface ManagedSession { hookSignals?: number; /** Set after the person answered a hooked Claude prompt; see settleAnsweredPrompt. */ answeredPromptTimer?: ReturnType; + /** The last deliverInput of this card: the next one waits for it, so sends arrive in order. */ + inputQueue?: Promise; + /** This launch's CLI turned its text cursor on in its full-screen view: its input field is up (watchInputReadiness). */ + inputReady?: boolean; + /** While watching for inputReady: this launch's CLI is on its alternate (full-screen) buffer. */ + alternateScreen?: boolean; + /** While watching for inputReady: an escape sequence cut off at the end of the last output chunk. */ + readinessCarry?: string; + /** When this launch's first lifecycle hook stops waiting for inputReady (INPUT_READY_HOOK_GRACE_MS after it). */ + hookReadyAt?: number; + hookReadyTimer?: ReturnType; } type EnvironmentService = Pick = new Set(["opencode"]); + +/** + * Whether input for this CLI waits for its input field. Only on macOS and Linux: Windows' pseudo console (ConPTY, + * winpty) may not pass the alternate-screen and cursor sequences through, so there the gate could never open, and + * input is typed as soon as the PTY exists, as before. + */ +function waitsForInputReady(provider: ProviderId): boolean { + return (process.platform === "darwin" || process.platform === "linux") && WAITS_FOR_INPUT_READY.has(provider); +} + +/** + * A lifecycle hook alone is not proof that the input field is up: OpenCode's plugin reports (for a resumed session, + * idle on session.updated) about 0.6 s before typed keys reach its prompt. The cursor signal decides; a hook counts + * only this long after the launch's first one, for a CLI whose cursor sequence never arrives. + */ +export const INPUT_READY_HOOK_GRACE_MS = 2_000; + /** What happened to input handed to deliverInput. */ export type InputDelivery = { delivered: true } | { delivered: false; reason: string }; @@ -252,7 +319,11 @@ export class TerminalManager { // Every text an agent reads from another card passes through it (EP-8). private redaction = new SecretRedactionRegistry(); // Where each running card was actually started (an environment may move it) and its agent config folder. - private readonly launchContexts = new Map(); + /** + * Where each running card was started: its folder, and its own config folders (CLAUDE_CONFIG_DIR, and the account + * home a launch contributor selected inside its plugin data), which base protection treats as the agent's own. + */ + private readonly launchContexts = new Map(); private modelCheck: (provider: ProviderId, model: string) => string | null = () => null; // Whether base protection is on (Settings → Agents); OpenCode's auto profile lets shell commands run without asking // only then. Unknown counts as off. @@ -368,7 +439,7 @@ export class TerminalManager { provider: session.metadata.provider, role: session.metadata.role ?? "agent", cwd: launched?.cwd ?? session.metadata.cwd, - configDirs: launched?.configDir ? [launched.configDir] : [], + configDirs: launched ? [...launched.configDirs] : [], profile: session.metadata.profile }; } @@ -660,8 +731,15 @@ export class TerminalManager { } const delegated = origin === "subagent" || control.ownerPluginId !== undefined; + // The orchestrator's own options are the person's choice: its subagent may repeat them even where the plugin did + // not declare its options delegable. Any other choice needs the plugin's delegable declaration. + // Only a subagent of the same CLI: the person chose them for that CLI (as AgentControlService.subagentLaunchOptions). + const parent = origin === "subagent" && request.parentSessionId !== undefined ? this.sessions.get(request.parentSessionId) : undefined; + const parentOptions = parent && parent.metadata.provider === request.provider + ? parent.extras.options as Record | undefined : undefined; const launchOptions = this.launchPipeline - ? this.launchPipeline.normalizeOptions(request.provider, request.launchOptions, origin === "subagent" ? { delegated: true } : undefined) + ? this.launchPipeline.normalizeOptions(request.provider, request.launchOptions, origin === "subagent" + ? { delegated: true, ...(parentOptions ? { inherited: parentOptions } : {}) } : undefined) : request.launchOptions === undefined ? undefined : failWith("Plugin launch options are not available."); // The isolation layer decides before anything starts: a subagent without it runs in normal, a launch that // needs it and cannot have it is refused. @@ -902,18 +980,42 @@ export class TerminalManager { * The one delivery rule for text another agent, a plugin or a controller sends to a card (spawn_agent's first * prompt, send_to_agent, plugin sessions.send). A running card gets it at once. A card whose launch plugins are * still preparing (launch options, a launch policy, an environment) or that waits for its grid gets it exactly - * once, when that launch has started. A launch that is refused, fails, is cancelled or superseded (closed, - * restarted), or does not start within LAUNCH_INPUT_WAIT_MS delivers nothing, says why, and drops the text: - * it never reaches a later launch of the card. An aborted `signal` (the sender cancelled) delivers nothing either. - * Automated Codex prompts additionally wait for an empty task composer in this launch; a PTY alone is not ready. + * once, when that launch has started. A CLI that drops keys typed while it starts (WAITS_FOR_INPUT_READY) gets it + * only once its input field is up; automated Codex prompts (`waitForCodexComposer`) wait for an empty task composer + * in this launch, since a PTY alone is not ready. A launch that is refused, fails, is cancelled or superseded + * (closed, restarted), or does not start or become ready within `waitMs` delivers nothing, says why, and drops the + * text: it never reaches a later launch of the card. An aborted `signal` (the sender cancelled) delivers nothing + * either. Sends to one card are delivered in the order they were made; the wait of each counts from its own call. */ - async deliverInput(id: string, data: string, waitMs = LAUNCH_INPUT_WAIT_MS, signal?: AbortSignal, waitForCodexComposer = false): Promise { - const canceled: InputDelivery = { delivered: false, reason: "The delivery was cancelled." }; - if (signal?.aborted) return canceled; + deliverInput(id: string, data: string, waitMs = LAUNCH_INPUT_WAIT_MS, signal?: AbortSignal, waitForCodexComposer = false): Promise { const session = this.sessions.get(id); - if (!session) return { delivered: false, reason: "The session does not exist." }; + if (!session) return Promise.resolve({ delivered: false, reason: "The session does not exist." }); + // The launch this call was made for: a send queued behind another never reaches a later launch either. const epoch = session.launchEpoch; const deadline = Date.now() + waitMs; + const previous = session.inputQueue; + const run = (): Promise => + this.deliverQueuedInput(id, session, epoch, data, waitMs, deadline, signal, waitForCodexComposer); + const pending = previous ? previous.then(run, run) : run(); + session.inputQueue = pending; + void pending.finally(() => { if (session.inputQueue === pending) delete session.inputQueue; }).catch(() => undefined); + if (!signal || !previous) return pending; + // A send cancelled while it waits behind another settles at once; its turn in the queue then types nothing. + const canceled: InputDelivery = { delivered: false, reason: "The delivery was cancelled." }; + if (signal.aborted) return Promise.resolve(canceled); + return new Promise((resolve) => { + const abort = (): void => resolve(canceled); + signal.addEventListener("abort", abort, { once: true }); + void pending.then(resolve, () => resolve(canceled)).finally(() => signal.removeEventListener("abort", abort)); + }); + } + + private async deliverQueuedInput( + id: string, session: ManagedSession, epoch: number, data: string, waitMs: number, deadline: number, + signal?: AbortSignal, waitForCodexComposer = false + ): Promise { + const canceled: InputDelivery = { delivered: false, reason: "The delivery was cancelled." }; + if (signal?.aborted) return canceled; const launchOutput = (): TerminalBufferSnapshot => { const snapshot = this.readBuffer(id); const start = snapshot.outputOffset - snapshot.buffer.length; @@ -922,31 +1024,44 @@ export class TerminalManager { // Build the screen only after this launch has a PTY: a pending restart // still has its previous process's scrollback until bindProcess records the new offset. let screen: SessionScreen | null = null; - let inputReady = !waitForCodexComposer || session.codexDraftEpoch === epoch; + // Per call, unlike session.inputReady (OpenCode's cursor signal): checked when this send's turn in the queue + // comes, so an automation draft extended by the send before it counts. + let codexComposerSeen = !waitForCodexComposer || session.codexDraftEpoch === epoch; + // What the text still waits for: the launch to start, or (once its PTY exists) the CLI's input field. + const awaiting = (): "launch" | "input" | null => { + if (this.sessions.get(id) !== session || session.launchEpoch !== epoch || session.metadata.exitCode !== null) return null; + if (!session.process) return "launch"; + return cliAcceptsInput(session) && codexComposerSeen ? null : "input"; + }; try { - const waiting = (): boolean => this.sessions.get(id) === session && session.launchEpoch === epoch - && session.metadata.exitCode === null && (!session.process || !inputReady); - while (waiting() && !signal?.aborted) { - if (session.process && !inputReady) { + for (let stage = awaiting(); stage && !signal?.aborted; stage = awaiting()) { + const composerPending = stage === "input" && !codexComposerSeen; + if (composerPending) { screen ??= new SessionScreen({ geometry: (id) => this.geometry(id), readBuffer: launchOutput }, id); const snapshot = launchOutput(); screen.feed({ id, data: snapshot.buffer, outputOffset: snapshot.outputOffset }); if (await screen.inspect((terminal) => codexComposerReady( terminalScreenText(terminal, true), codexPlaceholderIsDim(terminal), codexContinuationIsEmpty(terminal)))) { - inputReady = true; - break; + codexComposerSeen = true; + continue; } } const remaining = deadline - Date.now(); if (remaining <= 0) { - if (session.process && !inputReady) return { delivered: false, reason: `Codex did not become ready for input within ${Math.round(waitMs / 1000)} s.` }; - return { delivered: false, reason: `The session did not start within ${Math.round(waitMs / 1000)} s.` }; + // A caller that does not wait (0) is told the state, not a wait it never made. + const within = waitMs <= 0 ? "yet" : waitMs >= 1_000 ? `within ${Math.round(waitMs / 1_000)} s` : `within ${waitMs} ms`; + return { delivered: false, reason: stage === "launch" + ? `The session did not start ${within}.` + : composerPending + ? `Codex did not become ready for input ${within}.` + : `The agent's CLI did not show its input field ${within}; nothing was typed. Look at the card, then send the prompt again.` }; } await new Promise((resolve) => { - const timer = setTimeout(wake, session.process && !inputReady ? Math.min(100, remaining) : remaining); + // Codex draws its composer without a structured signal: its screen is polled while the deadline runs. + const timer = setTimeout(wake, composerPending ? Math.min(100, remaining) : remaining); function wake(): void { clearTimeout(timer); - session!.launchWaiters.delete(wake); + session.launchWaiters.delete(wake); signal?.removeEventListener("abort", wake); resolve(); } @@ -974,6 +1089,23 @@ export class TerminalManager { } } + /** + * Submitted text starts a new task (an approval answer continues the current turn), so an earlier answer is not its. + * An Enter with nothing typed before it in an idle card submits nothing: the stored answer stays. + */ + private noteSubmittedInput(session: ManagedSession, data: string): void { + const typed = /[^\r\n]/u.test(data); + if (!data.endsWith("\r")) { + if (typed) session.typedSinceSubmit = true; + return; + } + const empty = !typed && !session.typedSinceSubmit; + session.typedSinceSubmit = false; + if (empty && session.metadata.status === "idle") return; + if (session.metadata.status !== "needs_approval") session.inputGeneration = (session.inputGeneration ?? 0) + 1; + delete session.answer; + } + private wakeLaunchWaiters(session: ManagedSession): void { for (const wake of [...session.launchWaiters]) wake(); } @@ -982,9 +1114,19 @@ export class TerminalManager { if (typeof data !== "string" || data.length === 0) return false; const session = this.sessions.get(id); if (!session || session.metadata.exitCode !== null || !session.process) return false; - const process = session.process; + return this.writeInput(id, session, session.process, data); + } + + /** + * The one write of person or agent input to a card's CLI, and what it means for turns and answers. Everything that + * types into a card goes through here: input(), deliverInput, and any writer that holds input and replays it later, + * so a replayed Enter is a submitted prompt like a typed one. Nothing is written to a process the card no longer runs. + */ + private writeInput(id: string, session: ManagedSession, process: IPty, data: string): boolean { + if (this.sessions.get(id) !== session || session.process !== process || session.metadata.exitCode !== null) return false; const written = tryPtyOperation(() => process.write(data)); if (written) delete session.codexDraftEpoch; + if (written) this.noteSubmittedInput(session, data); if (written && ANSWERS_PROMPT.test(data)) this.settleAnsweredPrompt(id, session); return written; } @@ -1055,11 +1197,53 @@ export class TerminalManager { return publicSessionMetadata(session); } - /** `source` "hook" is the agent's own lifecycle hook (through the runtime gateway); "title" is its terminal title. */ - applyProviderSignal(id: string, signal: ProviderLifecycleSignal, source: "hook" | "title" = "hook"): void { + /** + * `source` "hook" is the agent's own lifecycle hook (through the runtime gateway); "title" is its terminal title. + * Returns whether the signal belongs to the current turn and the last submitted input, so the answer it carries may + * be kept (applyRuntimeSignal). With status hooks off for this launch nothing applies and no answer is kept. + */ + applyProviderSignal(id: string, signal: ProviderLifecycleSignal, source: "hook" | "title" = "hook"): boolean { const session = this.sessions.get(id); - if (!this.lifecycleHooksEnabled || !session || session.metadata.status === "done" || session.metadata.status === "failed") return; - if (source === "hook") session.hookSignals = (session.hookSignals ?? 0) + 1; + if (!this.lifecycleHooksEnabled || !session || session.statusHooksOff) return false; + // A result-capturing OpenCode names each turn. A signal of an earlier turn is stale and changes nothing; a signal + // of the current turn that started before the last submitted input still moves the status (so a wait ends), but + // is not that input's turn: it neither counts as a turn start nor brings an answer. + const exited = session.metadata.status === "done" || session.metadata.status === "failed"; + let current = true; + let startsProviderTurn = false; + if (source === "hook" && session.captureResult && session.metadata.provider === "opencode" && signal.requestId) { + const turns = session.providerTurnGenerations ??= new Map(); + const generation = session.inputGeneration ?? 0; + const known = turns.get(signal.requestId); + if (known === undefined) { + // A turn id not seen yet opens the next turn, also when its start was lost (as the gateway counts it); after + // the exit no turn opens. + if (exited) return false; + startsProviderTurn = true; + turns.set(signal.requestId, generation); + session.providerTurnId = signal.requestId; + while (turns.size > 64) turns.delete(turns.keys().next().value!); + } else if (session.providerTurnId !== signal.requestId) { + return false; + } else { + current = known === generation; + } + } + // The process already exited: its status stays, but the answer its last turn reported as it ended still counts. + if (exited) return current; + if (source === "hook") { + session.hookSignals = (session.hookSignals ?? 0) + 1; + // The CLI runs, but its input field may not be up yet: input waiting for it (deliverInput) goes on the cursor + // signal, or once the grace after this first hook has passed. + if (session.hookSignals === 1 && waitsForInputReady(session.metadata.provider) && !session.inputReady) { + session.hookReadyAt = Date.now() + INPUT_READY_HOOK_GRACE_MS; + session.hookReadyTimer = setTimeout(() => { + session.hookReadyTimer = undefined; + this.wakeLaunchWaiters(session); + }, INPUT_READY_HOOK_GRACE_MS); + session.hookReadyTimer.unref?.(); + } + } const threadId = normalizeThreadId(session.metadata.provider, signal.threadId); const threadChanged = Boolean(threadId && threadId !== session.threadId); @@ -1070,24 +1254,44 @@ export class TerminalManager { const nextStatus = signal.state; // A turn starts when the agent moves to working; wait_for_agent compares it with the last delivered prompt. - if (nextStatus === "working" && session.metadata.status !== "working") session.turnStarts = (session.turnStarts ?? 0) + 1; - // A new turn: the previous answer is no longer this turn's. - if (nextStatus === "working") session.answer = undefined; + if (current && nextStatus === "working") { + if (startsProviderTurn || session.metadata.status !== "working") session.turnStarts = (session.turnStarts ?? 0) + 1; + // A new turn: the previous answer is no longer this turn's. + session.answer = undefined; + session.answerTurnGeneration = session.inputGeneration ?? 0; + } const completed = nextStatus === "idle" && ["Stop", "StopFailure", "StopCancelled"].includes(signal.event ?? ""); const nextTurnCompleted = nextStatus === "working" ? false : completed || Boolean(session.metadata.turnCompleted); - if (!threadChanged && session.metadata.status === nextStatus && Boolean(session.metadata.turnCompleted) === nextTurnCompleted) return; + if (!threadChanged && session.metadata.status === nextStatus && Boolean(session.metadata.turnCompleted) === nextTurnCompleted) return current; session.metadata.status = nextStatus; session.metadata.turnCompleted = nextTurnCompleted; this.emitSession(session.metadata); + return current; + } + + /** + * Why a result-capturing session's final answer cannot be captured, or null when it can (or the session does not + * capture one). With CanvasTTY status hooks off for its launch it gets no hooks, so nothing reports the answer. + */ + answerUnavailable(id: string): "status_hooks_off" | null { + const session = this.sessions.get(id); + if (!session?.captureResult) return null; + return !this.lifecycleHooksEnabled || session.statusHooksOff ? "status_hooks_off" : null; } /** * Keeps the final answer a result-capturing session reported with its turn's end (the Codex Stop hook, the OpenCode - * plugin's session.idle); read back masked by answer(). In memory only, never saved. + * plugin's session.idle), only while it belongs to the current turn and the last submitted input; read back masked + * by answer(). OpenCode passes its turn id. In memory only, never saved. */ - recordAnswer(id: string, result: { text: string; truncated: boolean }): void { + recordAnswer(id: string, result: { text: string; truncated: boolean }, correlation?: { turnId?: string | null }): void { const session = this.sessions.get(id); if (!session?.captureResult || typeof result?.text !== "string") return; + const generation = session.inputGeneration ?? 0; + if (session.metadata.provider === "opencode" && correlation && "turnId" in correlation) { + if (!correlation.turnId || correlation.turnId !== session.providerTurnId + || session.providerTurnGenerations?.get(correlation.turnId) !== generation) return; + } else if (session.promptTurnMark !== undefined && session.answerTurnGeneration !== generation) return; session.answer = { text: result.text, truncated: result.truncated === true, at: Date.now() }; } @@ -1105,6 +1309,7 @@ export class TerminalManager { if (next) return; for (const session of this.sessions.values()) { session.lifecycle = null; + session.statusHooksOff = true; if ( session.metadata.provider === "terminal" || session.metadata.status === "done" @@ -1164,6 +1369,37 @@ export class TerminalManager { } } + /** Serializes this card's run cleanup across exit/restart; callbacks retain their specific launch ownership. */ + private cleanupLaunchFiles(session: ManagedSession): Promise { + const cleanup = session.launchCleanup; + session.launchCleanup = null; + const previous = session.launchCleanupPending; + if (!cleanup) return previous ?? Promise.resolve(); + const pending = (async (): Promise => { + if (previous) await previous.catch(() => undefined); + await cleanup(); + })(); + session.launchCleanupPending = pending; + void pending.finally(() => { + if (session.launchCleanupPending === pending) delete session.launchCleanupPending; + }).catch(() => undefined); + return pending; + } + + /** Closing a card waits for producers of run files, not just the current process's adopted cleanup. */ + private async forgetLaunchFiles(session: ManagedSession): Promise { + while (session.launchTasks?.size) await Promise.allSettled([...session.launchTasks]); + try { await this.cleanupLaunchFiles(session); } + finally { + const errors: unknown[] = []; + // Different pipeline instances may share runsRoot; even their parent deletions must not overlap. + for (const [pipeline, runDirectories] of session.launchRunFolders ?? []) { + try { await pipeline.forgetSession(session.metadata.id, runDirectories); } catch (error) { errors.push(error); } + } + if (errors.length) throw new AggregateError(errors, "Launch file folders could not be removed."); + } + } + /** * Closes a card. A card in a plugin environment releases it: `keepEnvironmentData` is the person's * answer to "Keep environment data?" (kept unless they said no). Quitting releases nothing. @@ -1197,9 +1433,11 @@ export class TerminalManager { this.redaction.clear(`session:${id}`); this.releaseIsolation(id); session.launchToken += 1; - void session.launchCleanup?.().catch(() => undefined); - session.launchCleanup = null; - if (session.extras.options) void this.launchPipeline?.forgetSession(id).catch(() => undefined); + // launchToken and launch waiters were invalidated above: pending preparation/wrapping can now unwind before + // parent removal. Its original pipeline remains the owner even if the host configuration has since changed. + void this.forgetLaunchFiles(session).catch((error: unknown) => { + console.warn(`CanvasTTY could not remove the launch files of card ${id}.`, error); + }); session.agentBrowser?.cleanup(); session.agentRuntime?.cleanup(); session.agentOrchestration?.cleanup(); @@ -1603,7 +1841,10 @@ export class TerminalManager { let spawn: { command: string; args: string[] | string; env: Record } = planned; if (decision.apply) { try { - spawn = this.wrapIsolated(id, provider, profile, planned); + spawn = this.wrapIsolated(id, provider, profile, planned, planned, { + ...(contribution?.accountHome ? { accountHome: contribution.accountHome } : {}), + placedProjects: this.placedProjects(id, parentSessionId, planned.cwd, undefined) + }); } catch (error) { planned.cleanup(); return { ...none, failure: { diagnostic: `Launch refused: ${error instanceof Error ? error.message : String(error)}`, exitCode: 1 } }; @@ -1613,7 +1854,7 @@ export class TerminalManager { const process = this.spawnPty(spawn.command, spawn.args, { name: "xterm-256color", cols, rows, cwd: planned.cwd, env: spawn.env }); - this.launchContexts.set(id, { cwd: planned.cwd, configDir: planned.env.CLAUDE_CONFIG_DIR ?? null }); + this.launchContexts.set(id, launchContext(planned.cwd, provider, planned.env, contribution?.accountHome)); return { process, agentBrowser: planned.agentBrowser, @@ -1667,10 +1908,13 @@ export class TerminalManager { } /** Wraps a planned launch in the isolation layer (throws when the layer cannot start: the launch is refused). */ - private wrapIsolated(id: string, provider: ProviderId, profile: LaunchProfile, planned: { command: string; args: string[] | string; cwd: string; env: Record }): { command: string; args: string[]; env: Record } { + private wrapIsolated(id: string, provider: ProviderId, profile: LaunchProfile, planned: { command: string; args: string[] | string; cwd: string; env: Record }, + gateways: Pick, + placement: { accountHome?: string; placedProjects: readonly PlacedProject[] }): { command: string; args: string[]; env: Record } { if (!this.isolation) throw new LaunchRefusal("agent isolation is not configured; the agent was not started without it."); if (typeof planned.args === "string") throw new LaunchRefusal("a Windows batch launcher cannot run inside agent isolation."); - const grant = controlGrantFolder(planned.env); + // Only the grant CanvasTTY handed this launch: a CANVASTTY_CONTROL_CONNECTION a plugin set reopens nothing. + const grant = gateways.controlGrant; const wrapped = this.isolation.wrap({ sessionId: id, provider, @@ -1679,7 +1923,12 @@ export class TerminalManager { args: planned.args, env: planned.env, profile, - ...(grant ? { grantedPrivate: [grant] } : {}) + ...(placement.accountHome ? { accountHome: placement.accountHome } : {}), + placedProjects: placement.placedProjects, + ...(grant ? { grantedPrivate: [grant] } : {}), + // Only the sockets CanvasTTY's own gateways prepared; a CANVASTTY_*_ADDRESS from elsewhere is never bound in. + gatewaySockets: gatewaySocketAddresses(gateways.agentBrowser?.environment, gateways.agentRuntime?.environment, + gateways.agentOrchestration?.environment) }); this.releaseIsolation(id); this.isolationCleanups.set(id, wrapped.cleanup); @@ -1721,19 +1970,32 @@ 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; + // 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, + : this.agentRuntime?.prepareLaunch({ terminalSessionId: id, provider, cwd, environment: baseEnvironment, ...(captureResult ? { captureResult: true } : {}), + ...(accountHome ? { accountHome } : {}), ...(claudeHttp ? { claudeHttp } : {}), ...(answerCaptureGrantExpiresAt === undefined ? {} : { answerCaptureGrantExpiresAt }) }) ?? null; let pluginTools: string[] = []; @@ -1765,22 +2027,24 @@ export class TerminalManager { terminalSessionId: id, 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 ?? {}; + const hostControlEnvironment = controlEnvironment(role, this.controlConnection, id); const providerEnvironment: Record = { ...(provider === "opencode" ? mergeOpenCodeLaunchEnvironment(browserEnvironment, runtimeEnvironment) : { ...browserEnvironment, ...runtimeEnvironment }), ...orchestrationEnvironment, // Orchestrators alone learn where the control descriptor and CLI are. - ...controlEnvironment(role, this.controlConnection, id) + ...hostControlEnvironment }; // OpenCode: the project folder in its other Unicode spelling is still this folder, not an external one. if (provider === "opencode") Object.assign(providerEnvironment, openCodeProjectFolderEnvironment({ ...baseEnvironment, ...providerEnvironment }, cwd)); @@ -1828,6 +2092,7 @@ export class TerminalManager { agentBrowser, agentRuntime, agentOrchestration, + controlGrant: controlGrantFolder(hostControlEnvironment), cleanup }; } catch (error) { @@ -1836,6 +2101,37 @@ export class TerminalManager { } } + /** + * The plugin launch options this card was started with (a copy), or undefined without any. A subagent inherits them + * unless its spawn names the plugin (AgentControlService.subagentLaunchOptions). + */ + launchOptionsOf(id: string): Record | undefined { + const options = this.sessions.get(id)?.extras.options; + return options ? structuredClone(options) as Record : undefined; + } + + /** + * The plugins whose options a subagent repeats exactly from its orchestrator of the same CLI (inherited, or named + * with the same values): the person chose them. Empty for any other card. + */ + private personChosenLaunchPlugins(metadata: SessionMetadata, options: Record | undefined): string[] { + if (metadata.role !== "subagent" || metadata.parentSessionId === undefined || !options) return []; + const parent = this.sessions.get(metadata.parentSessionId); + if (!parent || parent.metadata.provider !== metadata.provider) return []; + const parentOptions = (parent.extras.options ?? {}) as Record>; + return Object.entries(options as Record>).filter(([pluginId, values]) => { + const own = parentOptions[pluginId]; + if (!own || !values) return false; + const keys = Object.keys(values); + return keys.length === Object.keys(own).length && keys.every((key) => Object.hasOwn(own, key) && own[key] === values[key]); + }).map(([pluginId]) => pluginId); + } + + /** Plugins whose launch contribution decided this card's model in its current run; empty until it is prepared. */ + modelSetByOf(id: string): string[] { + return [...(this.sessions.get(id)?.modelSetBy ?? [])]; + } + /** The model and effort this card's launches ask the CLI for. */ private launchModelOf(id: string): LaunchModelChoice { const starting = this.startingModels.get(id); @@ -1868,6 +2164,30 @@ export class TerminalManager { } } + /** + * The folders CanvasTTY itself placed a launch in, the only ones the isolation layer opens inside plugin data: the + * folder an environment prepared for this card (inside that plugin's own data), or, for a card the person started + * and its subagents, the top card's folder (the person's choice, or its environment's). A subagent's own folder is + * never one by itself: an agent asked for it. A plugin's own card is placed by nothing but an environment. + */ + private placedProjects(id: string, parentSessionId: string | undefined, cwd: string, + environment: { pluginId: string } | undefined): PlacedProject[] { + if (environment) return [{ folder: cwd, pluginId: environment.pluginId }]; + if (parentSessionId === undefined) { + return (this.sessions.get(id)?.extras.ownerPluginId ?? this.startingOwners.get(id)) === undefined ? [{ folder: cwd }] : []; + } + let root: ManagedSession | undefined; + let next: string | undefined = parentSessionId; + for (let depth = 0; next !== undefined && depth < 64; depth++) { + root = this.sessions.get(next); + if (!root) return []; + next = root.metadata.parentSessionId; + } + if (!root || next !== undefined) return []; + if (root.extras.environment) return [{ folder: root.metadata.cwd, pluginId: root.extras.environment.pluginId }]; + return root.extras.ownerPluginId === undefined ? [{ folder: root.metadata.cwd }] : []; + } + private contributed(session: ManagedSession): boolean { return Boolean(session.extras.options) || Boolean(session.extras.environment) || Boolean(session.extras.environmentChoice) || this.policyApplies(session.metadata.provider); @@ -1886,7 +2206,8 @@ export class TerminalManager { return this.environments?.available(environment) ?? false; } - private unavailableLaunchPlugins(options: Record | undefined): string[] { + /** The plugins among these launch options that are disabled, removed or not trusted now. */ + unavailableLaunchPlugins(options: Record | undefined): string[] { if (!options) return []; return this.launchPipeline ? this.launchPipeline.unavailable(options) : Object.keys(options).sort(); } @@ -1906,7 +2227,11 @@ export class TerminalManager { ): void { const token = ++session.launchToken; const { metadata } = session; - void this.runContributedLaunch(id, session, token, resume, restoring, answerCaptureGrantExpiresAt) + // Each launch decides its model again; until it is prepared no plugin has. + session.modelSetBy = []; + const tasks = session.launchTasks ??= new Set(); + // Register before invoking plugin code, which may synchronously close or restart its card. + const task = Promise.resolve().then(() => this.runContributedLaunch(id, session, token, resume, restoring, answerCaptureGrantExpiresAt)) .catch((error: unknown): LaunchOutcome => { metadata.failureDetails = this.redactSecrets(`Launch refused: ${error instanceof Error ? error.message : String(error)}`); return "failed"; @@ -1920,7 +2245,9 @@ export class TerminalManager { this.emitSession(metadata, outcome === "failed" ? failureOrigin : null); this.schedulePersistence(); }) - .finally(() => this.wakeLaunchWaiters(session)); + .finally(() => { tasks.delete(task); this.wakeLaunchWaiters(session); }); + tasks.add(task); + void task.catch(() => undefined); } private async runContributedLaunch( @@ -1939,6 +2266,8 @@ export class TerminalManager { return "failed"; }; const environments = this.environments; + // The launch starts a microtask after it was registered; a card closed or restarted in that same tick is done. + if (!live()) return "superseded"; // 1. Place a new session where the person chose. The choice stays saved with the card until the plugin has // prepared it, so a card whose preparation was cut short (quit, crash) or failed never restores locally. @@ -1980,142 +2309,151 @@ export class TerminalManager { // 3. Chosen launch contributors, and the launch policies that apply. let contribution: LaunchContribution | null = null; - const trustedFolder = session.extras.environment ? undefined : this.personTrustedFolder(metadata.parentSessionId, metadata.cwd); - if (session.extras.options || this.policyApplies(metadata.provider)) { - const pipeline = this.launchPipeline; - if (!pipeline) return refuse(missingLaunchPlugins(Object.keys(session.extras.options ?? {}))); - const placedIn = session.extras.environment; - const prepared = await pipeline.prepare({ - sessionId: id, - provider: metadata.provider, - profile: metadata.profile, - role: metadata.role, - cwd: metadata.cwd, - ...(metadata.parentSessionId !== undefined ? { parentSessionId: metadata.parentSessionId } : {}), - restoring, - resume: resume !== null, - options: structuredClone(session.extras.options ?? {}) as Record>, - environment: placedIn ? { pluginId: placedIn.pluginId, kind: placedIn.kind } : null, - ...(trustedFolder ? { trustedFolder } : {}) - }); - if (!live()) { - if (prepared.ok) void prepared.cleanup().catch(() => undefined); - return "superseded"; + let adopted = false; + let plannedCleanup: (() => void) | undefined; + try { + const trustedFolder = session.extras.environment ? undefined : this.personTrustedFolder(metadata.parentSessionId, metadata.cwd); + if (session.extras.options || this.policyApplies(metadata.provider)) { + const pipeline = this.launchPipeline; + if (!pipeline) return refuse(missingLaunchPlugins(Object.keys(session.extras.options ?? {}))); + // This pipeline may write run files for the card, so closing it later removes its folder there. + const runFolders = session.launchRunFolders ??= new Map(); + const ownFolders = runFolders.get(pipeline) ?? new Set(); + runFolders.set(pipeline, ownFolders); + const placedIn = session.extras.environment; + const prepared = await pipeline.prepare({ + sessionId: id, + provider: metadata.provider, + profile: metadata.profile, + role: metadata.role, + cwd: metadata.cwd, + ...(metadata.parentSessionId !== undefined ? { parentSessionId: metadata.parentSessionId } : {}), + restoring, + resume: resume !== null, + options: structuredClone(session.extras.options ?? {}) as Record>, + environment: placedIn ? { pluginId: placedIn.pluginId, kind: placedIn.kind } : null, + ...(trustedFolder ? { trustedFolder } : {}) + }, { + ...(this.launchModelOf(id).model !== undefined ? { model: this.launchModelOf(id).model } : {}), + personChosen: this.personChosenLaunchPlugins(metadata, session.extras.options) + }); + if (prepared.ok) { + contribution = prepared; + ownFolders.add(prepared.runDirectory); + } + if (!live()) return "superseded"; + if (!prepared.ok) return refuse(prepared.reason); + session.modelSetBy = prepared.modelSetBy; + this.addLaunchSecrets(session, prepared.secrets); } - if (!prepared.ok) return refuse(prepared.reason); - contribution = prepared; - this.addLaunchSecrets(session, prepared.secrets); - } - const dropContribution = (): void => { - void contribution?.cleanup().catch(() => undefined); - }; - // 4. What the environment keeps of CanvasTTY's protection, and the isolation layer for this launch. - const keeps = environment ? this.environments?.keeps?.(environment) ?? {} : {}; - if (environment && keeps.launch !== true && metadata.profile !== "normal") { - dropContribution(); - return refuse(`${environment.label} does not pass the launch on unchanged (the plugin does not declare it), so the ${metadata.profile} profile's settings and CanvasTTY's hooks would not reach the agent there. Launch it in normal, or use an environment that keeps them.`); - } - const decision = this.launchIsolation(id, metadata.provider, metadata.profile, metadata.role, - environment ? { isolated: keeps.isolated === true, label: environment.label } : null); - if (decision.refuse) { - dropContribution(); - return refuse(decision.refuse); - } - if (environment && keeps.launch !== true) { - metadata.isolation = { ...(metadata.isolation ?? { state: "environment" }), - reason: `${metadata.isolation?.reason ? `${metadata.isolation.reason} ` : ""}Base protection and CanvasTTY's hooks do not reach the agent in ${environment.label}.` }; - } + // 4. What the environment keeps of CanvasTTY's protection, and the isolation layer for this launch. + const keeps = environment ? this.environments?.keeps?.(environment) ?? {} : {}; + if (environment && keeps.launch !== true && metadata.profile !== "normal") { + return refuse(`${environment.label} does not pass the launch on unchanged (the plugin does not declare it), so the ${metadata.profile} profile's settings and CanvasTTY's hooks would not reach the agent there. Launch it in normal, or use an environment that keeps them.`); + } + const decision = this.launchIsolation(id, metadata.provider, metadata.profile, metadata.role, + environment ? { isolated: keeps.isolated === true, label: environment.label } : null); + if (decision.refuse) { + return refuse(decision.refuse); + } + if (environment && keeps.launch !== true) { + metadata.isolation = { ...(metadata.isolation ?? { state: "environment" }), + reason: `${metadata.isolation?.reason ? `${metadata.isolation.reason} ` : ""}Base protection and CanvasTTY's hooks do not reach the agent in ${environment.label}.` }; + } - // 5. The host spawns the PTY; an environment only rewrites what is spawned, and the isolation layer wraps that. - let planned: PlannedSpawn | { failure: LaunchFailure }; - try { - planned = this.planSpawn(id, metadata.provider, decision.profile, metadata.cwd, resume, - session.captureResult, metadata.role, answerCaptureGrantExpiresAt, contribution, trustedFolder, Boolean(environment), decision.apply); - } catch (error) { - dropContribution(); - metadata.failureDetails = this.redactSecrets(error instanceof Error ? error.message : String(error)); - return "failed"; - } - if ("failure" in planned) { - dropContribution(); - applyLaunchFailure(metadata, planned.failure); - return "failed"; - } - const abandon = (): void => { - planned.cleanup(); - dropContribution(); - }; - this.noteConfiguredMode(id, metadata.provider, decision.profile, planned.env, planned.cwd); - let spawn: { command: string; args: string[] | string; cwd: string; env: Record } = planned; - if (environment && environments) { - if (typeof planned.args === "string") { - abandon(); - return refuse("this provider's Windows batch launcher cannot run in a plugin environment."); + // 5. The host spawns the PTY; an environment only rewrites what is spawned, and the isolation layer wraps that. + let planned: PlannedSpawn | { failure: LaunchFailure }; + // Whether this launch gets lifecycle hooks is decided as it is planned; the wrap below may take a while. + const statusHooks = this.lifecycleHooksEnabled; + try { + planned = this.planSpawn(id, metadata.provider, decision.profile, metadata.cwd, resume, + session.captureResult, metadata.role, answerCaptureGrantExpiresAt, contribution, trustedFolder, Boolean(environment), decision.apply); + } catch (error) { + metadata.failureDetails = this.redactSecrets(error instanceof Error ? error.message : String(error)); + return "failed"; } - const secretValues = new Set(contribution?.secrets ?? []); - const secretEnvNames = Object.keys(contribution?.env ?? {}).filter((key) => secretValues.has(contribution!.env[key]!)); - // The environment sees the launch's own variables, never CanvasTTY's reserved ones or secret values. - const visible = Object.fromEntries(Object.entries(planned.launchEnvironment) - .filter(([key]) => !RESERVED_ENV.test(key) && !secretEnvNames.includes(key))); - const wrapped = await environments.wrap(environment, { - sessionId: id, - provider: metadata.provider, - launch: { command: planned.command, args: planned.args, env: visible, cwd: planned.cwd }, - secretEnvNames, - takenEnv: new Set(Object.keys(planned.launchEnvironment)), - path: launchSearchPath(planned.env) - }); - if (!live()) { - abandon(); - return "superseded"; + if ("failure" in planned) { + applyLaunchFailure(metadata, planned.failure); + return "failed"; } - if (!wrapped.ok) { - abandon(); - return refuse(wrapped.reason); + plannedCleanup = planned.cleanup; + this.noteConfiguredMode(id, metadata.provider, decision.profile, planned.env, planned.cwd); + let spawn: { command: string; args: string[] | string; cwd: string; env: Record } = planned; + if (environment && environments) { + if (typeof planned.args === "string") { + return refuse("this provider's Windows batch launcher cannot run in a plugin environment."); + } + const secretValues = new Set(contribution?.secrets ?? []); + const secretEnvNames = Object.keys(contribution?.env ?? {}).filter((key) => secretValues.has(contribution!.env[key]!)); + // The environment sees the launch's own variables, never CanvasTTY's reserved ones or secret values. + const visible = Object.fromEntries(Object.entries(planned.launchEnvironment) + .filter(([key]) => !RESERVED_ENV.test(key) && !secretEnvNames.includes(key))); + const wrapped = await environments.wrap(environment, { + sessionId: id, + provider: metadata.provider, + launch: { command: planned.command, args: planned.args, env: visible, cwd: planned.cwd }, + secretEnvNames, + takenEnv: new Set(Object.keys(planned.launchEnvironment)), + path: launchSearchPath(planned.env) + }); + if (!live()) { + return "superseded"; + } + if (!wrapped.ok) { + return refuse(wrapped.reason); + } + this.addLaunchSecrets(session, wrapped.secrets); + spawn = { command: wrapped.command, args: wrapped.args, cwd: wrapped.cwd, + env: { ...planned.env, ...(wrapped.cwd !== planned.cwd ? { PWD: wrapped.cwd } : {}), ...wrapped.env } }; } - this.addLaunchSecrets(session, wrapped.secrets); - spawn = { command: wrapped.command, args: wrapped.args, cwd: wrapped.cwd, - env: { ...planned.env, ...(wrapped.cwd !== planned.cwd ? { PWD: wrapped.cwd } : {}), ...wrapped.env } }; - } - if (decision.apply) { + if (decision.apply) { + try { + spawn = { ...this.wrapIsolated(id, metadata.provider, decision.profile, spawn, planned, { + ...(contribution?.accountHome ? { accountHome: contribution.accountHome } : {}), + placedProjects: this.placedProjects(id, metadata.parentSessionId, spawn.cwd, environment) + }), cwd: spawn.cwd }; + } catch (error) { + return refuse(error instanceof Error ? error.message : String(error)); + } + } + let process: IPty; try { - spawn = { ...this.wrapIsolated(id, metadata.provider, decision.profile, spawn), cwd: spawn.cwd }; + process = this.spawnPty(spawn.command, spawn.args, { + name: "xterm-256color", cols: session.cols, rows: session.rows, cwd: spawn.cwd, env: spawn.env + }); } catch (error) { - abandon(); - return refuse(error instanceof Error ? error.message : String(error)); + this.releaseIsolation(id); + metadata.failureDetails = this.redactSecrets(error instanceof Error ? error.message : String(error)); + return "failed"; + } + session.process = process; + if (planned.launchEnvironment.CANVASTTY_CODEX_KEYBOARD) { + metadata.nativeEditor = JSON.parse(planned.launchEnvironment.CANVASTTY_CODEX_KEYBOARD); + } else { + delete metadata.nativeEditor; + } + this.launchContexts.set(id, launchContext(spawn.cwd, metadata.provider, spawn.env, contribution?.accountHome)); + session.agentBrowser = planned.agentBrowser; + session.agentRuntime = planned.agentRuntime; + session.agentOrchestration = planned.agentOrchestration; + session.launchCleanup = contribution?.cleanup ?? null; + adopted = true; + metadata.status = initialSessionStatus(metadata.provider); + metadata.exitCode = null; + metadata.failureDetails = null; + this.bindProcess(id, session, process, statusHooks); + const runtimeStatus = this.agentRuntime?.currentStatus(id); + if (runtimeStatus) metadata.status = runtimeStatus; + if (environment) this.describeEnvironment(id, session, environment); + return "launched"; + } finally { + // Unadopted resources remain owned by this tracked launch, including throws during plugin wrapping. + if (!adopted) { + try { plannedCleanup?.(); } + finally { await contribution?.cleanup().catch(() => undefined); } } } - let process: IPty; - try { - process = this.spawnPty(spawn.command, spawn.args, { - name: "xterm-256color", cols: session.cols, rows: session.rows, cwd: spawn.cwd, env: spawn.env - }); - } catch (error) { - abandon(); - this.releaseIsolation(id); - metadata.failureDetails = this.redactSecrets(error instanceof Error ? error.message : String(error)); - return "failed"; - } - session.process = process; - if (planned.launchEnvironment.CANVASTTY_CODEX_KEYBOARD) { - metadata.nativeEditor = JSON.parse(planned.launchEnvironment.CANVASTTY_CODEX_KEYBOARD); - } else { - delete metadata.nativeEditor; - } - this.launchContexts.set(id, { cwd: spawn.cwd, configDir: spawn.env.CLAUDE_CONFIG_DIR ?? null }); - session.agentBrowser = planned.agentBrowser; - session.agentRuntime = planned.agentRuntime; - session.agentOrchestration = planned.agentOrchestration; - session.launchCleanup = contribution?.cleanup ?? null; - metadata.status = initialSessionStatus(metadata.provider); - metadata.exitCode = null; - metadata.failureDetails = null; - this.bindProcess(id, session, process); - const runtimeStatus = this.agentRuntime?.currentStatus(id); - if (runtimeStatus) metadata.status = runtimeStatus; - if (environment) this.describeEnvironment(id, session, environment); - return "launched"; } private addLaunchSecrets(session: ManagedSession, secrets: readonly string[]): void { @@ -2133,13 +2471,22 @@ export class TerminalManager { }).catch(() => undefined); } - private bindProcess(id: string, session: ManagedSession, process: IPty): void { + /** + * `statusHooks`: status hooks were on when this launch was planned (and so got its lifecycle hooks). A launch planned + * and spawned in one step passes nothing; a contributed launch passes what held before its asynchronous steps. + */ + private bindProcess(id: string, session: ManagedSession, process: IPty, statusHooks = this.lifecycleHooksEnabled): void { session.launchOutputOffset = session.outputOffset; + if (statusHooks && this.lifecycleHooksEnabled) delete session.statusHooksOff; + else session.statusHooksOff = true; session.agentBrowser?.retainUntilExit?.(); process.onData((data) => { const current = this.sessions.get(id); if (!current || current !== session || current.process !== process) return; + if (!current.inputReady && waitsForInputReady(current.metadata.provider) && watchInputReadiness(current, data)) { + this.wakeLaunchWaiters(current); + } const lifecycleState = current.lifecycle?.push(data); if (lifecycleState) current.titleState = lifecycleState; if (lifecycleState && !titleDefersToHooks(current, lifecycleState)) { @@ -2153,38 +2500,46 @@ export class TerminalManager { let exited!: () => void; this.liveProcesses.set(process, new Promise((resolve) => { exited = resolve; })); - process.onExit(({ exitCode }) => { + process.onExit(({ exitCode, signal }) => { this.liveProcesses.delete(process); exited(); const current = this.sessions.get(id); if (!current || current !== session || current.process !== process) return; // node-pty calls this from a native callback that aborts the whole app when JavaScript throws in it. try { - this.recordExit(id, current, exitCode); + this.recordExit(id, current, exitCode, signal); } catch (error) { console.warn(`PTY ${id} exit could not be recorded.`, error); } }); } - private recordExit(id: string, current: ManagedSession, exitCode: number): void { + private recordExit(id: string, current: ManagedSession, reportedExitCode: number, signal?: number): void { this.flushOutput(id, current); this.releaseIsolation(id); if (current.extras.gitAuditSince !== undefined) void this.auditGit(id, current); + // node-pty reports a process killed by a signal as exitCode 0 plus the signal. That is a crash, not a clean finish: + // record it the way a shell does, as 128+signal, so the card, wait_for_agent and the saved session all say failed. + const killedBy = typeof signal === "number" && signal > 0 ? signal : 0; + const exitCode = killedBy ? 128 + killedBy : reportedExitCode; current.metadata.exitCode = exitCode; current.metadata.status = exitCode === 0 ? "done" : "failed"; - current.metadata.failureDetails = exitCode === 0 + const details = exitCode === 0 ? null : terminalFailureDetails(this.redactSecrets(current.bufferChunks.slice(current.bufferStart).join(""))); + current.metadata.failureDetails = killedBy + ? `${signalExitText(killedBy)}${details ? `\n${details}` : ""}`.slice(0, 8_000) + : details; current.agentBrowser?.cleanup(); current.agentBrowser = null; current.agentRuntime?.cleanup(); current.agentRuntime = null; current.agentOrchestration?.cleanup(); current.agentOrchestration = null; - void current.launchCleanup?.().catch(() => undefined); - current.launchCleanup = null; + void this.cleanupLaunchFiles(current).catch(() => undefined); this.emitSession(current.metadata); + // Input waiting for this CLI to become ready learns at once that it never will. + this.wakeLaunchWaiters(current); // Recorded at the moment of exit, so a finished agent is never relaunched. this.schedulePersistence(); } @@ -2285,12 +2640,64 @@ function resetLaunchSignals(session: ManagedSession): void { delete session.answer; delete session.turnStarts; delete session.promptTurnMark; + // A late answer of the ended launch never belongs to the new one. + session.inputGeneration = (session.inputGeneration ?? 0) + 1; + delete session.statusHooksOff; + delete session.typedSinceSubmit; + delete session.answerTurnGeneration; + delete session.providerTurnId; + delete session.providerTurnGenerations; delete session.hookSignals; delete session.titleState; + delete session.inputReady; + delete session.alternateScreen; + delete session.readinessCarry; + delete session.hookReadyAt; + if (session.hookReadyTimer) clearTimeout(session.hookReadyTimer); + session.hookReadyTimer = undefined; if (session.answeredPromptTimer) clearTimeout(session.answeredPromptTimer); session.answeredPromptTimer = undefined; } +/** Whether typed text reaches this launch's CLI now (see WAITS_FOR_INPUT_READY). */ +function cliAcceptsInput(session: ManagedSession): boolean { + return !waitsForInputReady(session.metadata.provider) || session.inputReady === true + || (session.hookReadyAt !== undefined && Date.now() >= session.hookReadyAt); +} + +/** DEC private modes set or reset (CSI ? Pm h / l): terminal control sequences, not text the CLI shows. */ +const DEC_PRIVATE_MODES = /\x1b\[\?([\d;]+)([hl])/gu; +/** The longest DEC private mode sequence kept from the end of one chunk for the next. */ +const READINESS_CARRY_CHARS = 32; + +/** + * Reads only terminal control sequences of one output chunk: the alternate screen (modes 1049, 1047, 47) and the text + * cursor (DECTCEM, mode 25). A full-screen CLI hides its cursor while it draws its start-up and shows it once its + * input field has focus; that moment is when typed keys start to reach that field. True once that happened; a + * sequence cut at the chunk's end is completed by the next chunk. + */ +function watchInputReadiness(session: ManagedSession, data: string): boolean { + const text = (session.readinessCarry ?? "") + data; + let consumed = 0; + for (const match of text.matchAll(DEC_PRIVATE_MODES)) { + consumed = match.index + match[0].length; + const set = match[2] === "h"; + for (const mode of match[1]!.split(";")) { + if (mode === "1049" || mode === "1047" || mode === "47") session.alternateScreen = set; + else if (mode === "25" && set && session.alternateScreen) session.inputReady = true; + } + if (session.inputReady) break; + } + if (session.inputReady) { + delete session.readinessCarry; + delete session.alternateScreen; + return true; + } + const escape = text.lastIndexOf("\x1b"); + session.readinessCarry = escape >= consumed && text.length - escape < READINESS_CARRY_CHARS ? text.slice(escape) : ""; + return false; +} + function titleDefersToHooks(session: ManagedSession, state: "idle" | "working" | "needs_approval"): boolean { return state !== "working" && session.metadata.provider === "claude" && session.hookSignals !== undefined; } @@ -2509,3 +2916,21 @@ function appendScrollback(session: ManagedSession, data: string): void { session.bufferStart = 0; } } + +/** "The process was killed by signal SIGKILL (9)." from node-pty's signal number. */ +function signalExitText(signal: number): string { + const name = Object.entries(osConstants.signals).find(([, value]) => value === signal)?.[0]; + return `The process was killed by signal ${name ? `${name} (${signal})` : signal}.`; +} + +/** + * A started card's folder and own config folders. The selected account home counts only while the CLI is still + * started with it (an environment may have replaced the variable): then it is that card's, and only that one's. + */ +function launchContext(cwd: string, provider: ProviderId, env: Readonly>, accountHome: string | undefined): { cwd: string; configDirs: string[] } { + const configDirs = env.CLAUDE_CONFIG_DIR ? [env.CLAUDE_CONFIG_DIR] : []; + const started = accountHome !== undefined && providerHomeVariables(provider) + .some((name) => typeof env[name] === "string" && env[name]!.replace(/[\\/]+$/u, "") === accountHome); + if (started && !configDirs.includes(accountHome)) configDirs.push(accountHome); + return { cwd, configDirs }; +} diff --git a/src/main/services/UsageHistoryService.ts b/src/main/services/UsageHistoryService.ts index c57fc16d4..0b0859127 100644 --- a/src/main/services/UsageHistoryService.ts +++ b/src/main/services/UsageHistoryService.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import { constants } from "node:fs"; -import { copyFile, lstat, open, rename, unlink, type FileHandle } from "node:fs/promises"; +import { lstat, open, rename, unlink, writeFile, type FileHandle } from "node:fs/promises"; import { join } from "node:path"; import { isDeepStrictEqual } from "node:util"; import type { LimitProviderId, LimitsSnapshot } from "../../shared/contracts.ts"; @@ -114,19 +114,21 @@ export class UsageHistoryService { async load(): Promise { const now = this.now(); this.startedAt = now; - let raw: string; + let bytes: Buffer; let handle: FileHandle | undefined; try { - // Windows lacks O_NOFOLLOW; check the path before and after opening too. - if ((await lstat(this.filePath)).isSymbolicLink()) throw Object.assign(new Error("symlink"), { code: "ELOOP" }); + // Windows lacks O_NOFOLLOW; check the path before and after opening too. File ids are compared as + // bigints: 64-bit NTFS ids lose precision as numbers. + const checked = await lstat(this.filePath, { bigint: true }); + if (checked.isSymbolicLink()) throw Object.assign(new Error("symlink"), { code: "ELOOP" }); handle = await open(this.filePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); - const opened = await handle.stat(); - const current = await lstat(this.filePath); - if (current.isSymbolicLink() || opened.dev !== current.dev || opened.ino !== current.ino) { + const opened = await handle.stat({ bigint: true }); + const current = await lstat(this.filePath, { bigint: true }); + if (current.isSymbolicLink() || [checked, current].some((path) => path.dev !== opened.dev || path.ino !== opened.ino)) { throw Object.assign(new Error("history path changed"), { code: "ELOOP" }); } if (!opened.isFile()) throw Object.assign(new Error("not a file"), { code: "ENOTFILE" }); - raw = await handle.readFile("utf8"); + bytes = await handle.readFile(); } catch (error) { if (errorCode(error) === "ENOENT") return; this.block(`Usage history file could not be read (${errorCode(error)}); it was left untouched and new history is not being saved.`); @@ -137,7 +139,7 @@ export class UsageHistoryService { let parsed: unknown; try { - parsed = JSON.parse(raw); + parsed = JSON.parse(bytes.toString("utf8")); } catch { await this.quarantine("invalid JSON", now); return; @@ -160,7 +162,8 @@ export class UsageHistoryService { // The cleaned history will replace the file, so the original is copied aside first. const name = preservedName(now); try { - await copyFile(this.filePath, join(this.directory, name), constants.COPYFILE_EXCL); + // The bytes read through the checked handle, not a second read by path that a swap could redirect. + await writeFile(join(this.directory, name), bytes, { flag: "wx", mode: 0o600 }); } catch (error) { this.block(`Usage history contains ${restored.invalid} invalid records and could not be preserved (${errorCode(error)}); it was left untouched and new history is not being saved.`); return; diff --git a/src/main/services/accountHomes.ts b/src/main/services/accountHomes.ts new file mode 100644 index 000000000..1ed3896eb --- /dev/null +++ b/src/main/services/accountHomes.ts @@ -0,0 +1,91 @@ +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. */ +export 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 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..216663f85 100644 --- a/src/main/services/agent-browser/AgentBrowserBridge.ts +++ b/src/main/services/agent-browser/AgentBrowserBridge.ts @@ -14,6 +14,10 @@ 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; + /** The card's own (already filtered) environment, which the launch builds from instead of the app's. */ + environment?: Readonly>; } export interface PreparedAgentBrowserPtyLaunch { @@ -66,12 +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.orchestrationTools ? { orchestrationTools: input.orchestrationTools } : {}), + ...(input.accountHome ? { accountHome: input.accountHome } : {}), + ...(environment ? { environment } : {}) }); } catch (error) { this.gateway.revokeTerminalSession(input.terminalSessionId); @@ -93,7 +100,9 @@ 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 } : {}), + ...(input.environment ? { environment: input.environment } : {}) }); return this.launchWith(providerLaunch, "", connectionId, () => undefined, {}); } diff --git a/src/main/services/agent-browser/OrchestrationTools.ts b/src/main/services/agent-browser/OrchestrationTools.ts index ba6afebf3..28566b785 100644 --- a/src/main/services/agent-browser/OrchestrationTools.ts +++ b/src/main/services/agent-browser/OrchestrationTools.ts @@ -6,6 +6,7 @@ import { PromptNotDeliveredError, type AgentControlService, type SpawnAgentReque import { LaunchRefusal } from "../launchRefusal.ts"; import type { PluginAgentTools } from "../PluginAgentTools.ts"; import { + CLIENT_SAFE_AGENT_WAIT_SECONDS, DEFAULT_AGENT_WAIT_SECONDS, MAX_AGENT_WAIT_SECONDS, ORCHESTRATION_TOOL_DEFINITIONS, @@ -22,6 +23,13 @@ import type { McpToolDefinition } from "../../../agent-browser/orchestration-cat * a foreign session id is a protocol error, never a filtered result, so an * orchestrator cannot probe sessions it does not own. */ +/** + * How long spawn_agent (its checks, the launch and the CLI's readiness together) and send_to_agent wait for a prompt + * to reach the agent: under the about 60 s after which most MCP clients end a tool call, so the orchestrator gets + * the reason (and the session id) instead of a client timeout. + */ +export const AGENT_TOOL_DELIVERY_MS = 45_000; + export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { private readonly control: AgentControlService; private readonly plugins: Pick | null; @@ -65,9 +73,9 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { case "send_to_agent": return await this.send(sessionId, request.arguments, signal); case "observe_agent": - return this.observe(sessionId, request.arguments); + return await this.observe(sessionId, request.arguments); case "get_agent_result": - return this.result(sessionId, request.arguments); + return await this.result(sessionId, request.arguments); case "cancel_agent": return this.cancel(sessionId, request.arguments); case "list_agents": @@ -118,12 +126,15 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { } this.requireOwned(orchestratorId, target); const seconds = typeof args.timeoutSeconds === "number" ? args.timeoutSeconds : DEFAULT_AGENT_WAIT_SECONDS; + // Shorter than the orchestrator's MCP client lets a tool call run, so it gets "timeout" and progress, not an error. + const limit = this.control.status(orchestratorId).provider === "claude" ? MAX_AGENT_WAIT_SECONDS : CLIENT_SAFE_AGENT_WAIT_SECONDS; try { const result = await this.control.waitFor(target, { - timeoutMs: Math.min(MAX_AGENT_WAIT_SECONDS, Math.max(1, seconds)) * 1_000, + timeoutMs: Math.min(limit, Math.max(1, seconds)) * 1_000, ...(signal ? { signal } : {}) }); - return { ...result }; + return { ...result, ...(result.reason === "timeout" + ? { message: "Still running. Read output for its progress and call wait_for_agent again." } : {}) }; } catch (error) { if (signal?.aborted || (error instanceof Error && error.name === "AbortError")) throw canceledError(); throw error; @@ -131,6 +142,7 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { } private async spawn(orchestratorId: string, args: Record, signal?: AbortSignal): Promise> { + const deadline = Date.now() + AGENT_TOOL_DELIVERY_MS; if (!(AGENT_PROVIDERS as readonly unknown[]).includes(args.provider)) { throw orchestrationBridgeError("INVALID_REQUEST", unknownProviderMessage(args.provider), false); } @@ -146,17 +158,26 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { if (unknown) throw orchestrationBridgeError("INVALID_REQUEST", unknown, false); } if (signal?.aborted) throw canceledError(); + // A plugin the spawn does not name keeps this session's own options (never silently the CLI's default model). + const requested = args.launchOptions as SpawnAgentRequest["launchOptions"]; + let inheritance: ReturnType; + try { + inheritance = this.control.subagentLaunchOptions(orchestratorId, provider, requested, args.model as string | undefined); + } catch (error) { + throw orchestrationBridgeError("INVALID_REQUEST", error instanceof Error ? error.message : String(error), false); + } + const launchOptions = inheritance.launchOptions; const created = await this.control.spawn({ parentSessionId: orchestratorId, provider: args.provider as never, cwd: args.cwd as string, ...(args.title !== undefined ? { title: args.title as string } : {}), ...(args.prompt !== undefined ? { initialPrompt: args.prompt as string } : {}), - ...(args.launchOptions !== undefined ? { launchOptions: args.launchOptions as SpawnAgentRequest["launchOptions"] } : {}), + ...(launchOptions !== undefined ? { launchOptions } : {}), ...(args.model !== undefined ? { model: args.model as string } : {}), ...(args.effort !== undefined ? { effort: args.effort as SpawnAgentRequest["effort"] } : {}), profile: profile.profile - }, signal).catch((error: unknown) => { + }, signal, Math.max(0, deadline - Date.now())).catch((error: unknown) => { if (signal?.aborted) throw canceledError(); throw error; }); @@ -177,7 +198,10 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { profile: created.profile, ...(profile.inherited ? { profileInherited: true } : {}), ...(created.model !== undefined ? { model: created.model } : {}), - ...(created.effort !== undefined ? { effort: created.effort } : {}) + ...(created.effort !== undefined ? { effort: created.effort } : {}), + // Which CLI serves it, the model it was asked for (null: its launch options or the CLI's own default decide), + // and where each plugin's launch options came from. + servedBy: { provider: created.provider, model: created.model ?? null, launchOptions: inheritance.sources } }; } @@ -188,7 +212,8 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { args.sessionId as string, args.prompt as string, args.submit === undefined ? true : Boolean(args.submit), - signal + signal, + AGENT_TOOL_DELIVERY_MS ); } catch (error) { if (signal?.aborted) throw canceledError(); @@ -198,9 +223,9 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { return { sessionId: args.sessionId as string, sent: true }; } - private observe(orchestratorId: string, args: Record): Record { + private async observe(orchestratorId: string, args: Record): Promise> { this.requireOwned(orchestratorId, args.sessionId as string); - const observation = this.control.observe( + const observation = await this.control.observe( args.sessionId as string, args.maxChars as number | undefined ); @@ -213,9 +238,9 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { }; } - private result(orchestratorId: string, args: Record): Record { + private async result(orchestratorId: string, args: Record): Promise> { this.requireOwned(orchestratorId, args.sessionId as string); - const result = this.control.result(args.sessionId as string); + const result = await this.control.result(args.sessionId as string); return { sessionId: result.sessionId, state: result.state, @@ -223,7 +248,8 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { exitCode: result.exitCode, output: result.output, ...(result.exitLines ? { exitLines: result.exitLines } : {}), - ...(result.answer ? { answer: result.answer } : {}) + ...(result.answer ? { answer: result.answer } : {}), + ...(result.answerUnavailable ? { answerUnavailable: result.answerUnavailable } : {}) }; } diff --git a/src/main/services/agent-browser/ProviderLaunch.ts b/src/main/services/agent-browser/ProviderLaunch.ts index f59cbe12c..b5e2fdcf1 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,18 @@ 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; + /** 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); @@ -181,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() {} }; } @@ -192,7 +210,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 +259,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 +277,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 +312,7 @@ export class ProviderLaunchAdapters { } private acquireKimiConfiguration( + home: string, includeMcpEntry: boolean, browserHelper: StdioHelperLaunch | null, orchestrationHelper?: StdioHelperLaunch @@ -293,32 +320,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 +896,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-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/src/main/services/agent-runtime/AgentRuntimeBridge.ts b/src/main/services/agent-runtime/AgentRuntimeBridge.ts index 18b6ef2d2..78e3da1bd 100644 --- a/src/main/services/agent-runtime/AgentRuntimeBridge.ts +++ b/src/main/services/agent-runtime/AgentRuntimeBridge.ts @@ -23,6 +23,10 @@ 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; + /** The card's own (already filtered) environment, which the launch builds from instead of the app's. */ + environment?: Readonly>; } export interface PreparedAgentRuntimePtyLaunch { @@ -47,6 +51,11 @@ export interface AgentRuntimeBridgeOptions extends ProviderRuntimeLaunchOptions wantsDecisions?(provider: Exclude): boolean; /** The longest decision budget for this agent (ms); the session's gate deadlines are sized from it. */ decisionBudgetMs?(provider: Exclude): number; + /** + * Whether the decision hook also sees read-only file tools (base protection on): only then does a read wait on the + * gateway, and fail closed without it. + */ + decisionReads?(): boolean; /** Whether a Claude launch may use HTTP lifecycle hooks; without it every launch uses the command helper. */ claudeHttpHooks?(facts: ClaudeHttpLaunchFacts): ClaudeHttpVerdict; } @@ -60,12 +69,14 @@ export class AgentRuntimeBridge implements AgentRuntimeLaunchCoordinator { private coreHooksEnabled: boolean; private readonly wantsDecisions: AgentRuntimeBridgeOptions["wantsDecisions"]; private readonly decisionBudgetMs: AgentRuntimeBridgeOptions["decisionBudgetMs"]; + private readonly decisionReads: AgentRuntimeBridgeOptions["decisionReads"]; private readonly claudeHttpHooks: AgentRuntimeBridgeOptions["claudeHttpHooks"]; constructor(gateway: RuntimeGateway, options: AgentRuntimeBridgeOptions) { this.gateway = gateway; this.wantsDecisions = options.wantsDecisions; this.decisionBudgetMs = options.decisionBudgetMs; + this.decisionReads = options.decisionReads; this.claudeHttpHooks = options.claudeHttpHooks; this.providers = new ProviderRuntimeLaunchAdapters(options); this.coreHooksEnabled = options.coreHooksEnabled !== false; @@ -78,6 +89,8 @@ export class AgentRuntimeBridge implements AgentRuntimeLaunchCoordinator { && this.providers.decisionsSupported(input.provider); let budgetMs: number | undefined; try { budgetMs = decisions ? this.decisionBudgetMs?.(input.provider) : undefined; } catch { budgetMs = undefined; } + let reads = false; + try { reads = decisions && this.decisionReads?.() === true; } catch { reads = false; } const capability = this.coreHooksEnabled || decisions ? this.gateway.registerSession( input.terminalSessionId, @@ -92,7 +105,7 @@ export class AgentRuntimeBridge implements AgentRuntimeLaunchCoordinator { let prepared; try { prepared = this.providers.prepare(input.provider, input.terminalSessionId, this.coreHooksEnabled, decisions, budgetMs, - httpHookBase ?? undefined); + httpHookBase ?? undefined, reads, 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 a945496fe..243ded093 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, @@ -26,6 +27,7 @@ import { CLAUDE_HTTP_HOOK, DECISION_BUDGET_ENV, OPENCODE_DECISIONS_ENV, + OPENCODE_DECISION_READS_ENV, permissionGateTimings } from "../../../agent-runtime/runtime-protocol.mjs"; @@ -93,6 +95,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 +108,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 +131,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 +142,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 +191,11 @@ export class ProviderRuntimeLaunchAdapters { * `decisionBudgetMs` (a decision service's `decide.timeoutMs`) lengthens the hook's deadlines to fit it. * `claudeHttpHookBase` (Claude Code only) sends its lifecycle events, except SessionStart, as HTTP hooks to the * gateway's loopback listener instead of running the helper; the decision hook stays a command. + * `decisionReads` (base protection on at launch) lets the decision hook see read-only file tools as well. + * `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,20 +203,24 @@ export class ProviderRuntimeLaunchAdapters { coreHooksEnabled = true, decisions = false, decisionBudgetMs?: number, - claudeHttpHookBase?: string + claudeHttpHookBase?: string, + decisionReads = false, + 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); const pluginCommands = [ ...this.pluginHookCommands(provider, pluginRegistrations), - ...(gate && provider !== "opencode" ? decisionHookCommands(provider as DecisionHookProvider, this.options.permissionGate!, this.platform, decisionBudgetMs) : []) + ...(gate && provider !== "opencode" ? decisionHookCommands(provider as DecisionHookProvider, this.options.permissionGate!, this.platform, decisionBudgetMs, decisionReads) : []) ]; const openCodeDecisions = gate && provider === "opencode"; // Only providers with a hook adapter get lifecycle configuration. Cursor, // 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 @@ -204,13 +229,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({ @@ -222,7 +247,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( @@ -230,11 +255,15 @@ export class ProviderRuntimeLaunchAdapters { coreHooksEnabled ); return prepared([], { - ...environment, + ...hookEnvironment, ...pluginEnvironment, - ...(openCodeDecisions ? { [OPENCODE_DECISIONS_ENV]: "1", ...budgetEnvironment(decisionBudgetMs) } : {}), + ...(openCodeDecisions ? { + [OPENCODE_DECISIONS_ENV]: "1", + ...(decisionReads ? { [OPENCODE_DECISION_READS_ENV]: "1" } : {}), + ...budgetEnvironment(decisionBudgetMs) + } : {}), [OPENCODE_CONFIG_CONTENT]: openCodeLifecycleConfig( - this.environment[OPENCODE_CONFIG_CONTENT], + environment[OPENCODE_CONFIG_CONTENT], this.options.openCodePluginPath ) }); @@ -242,16 +271,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([], 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 { @@ -355,6 +388,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)); @@ -406,6 +480,8 @@ const CLAUDE_HOOKS: readonly HookMapping[] = [ { event: "UserPromptSubmit", state: "working" }, { event: "PermissionRequest", state: "needs_approval" }, { event: "PostToolUse", state: "working" }, + // An approved tool that fails runs PostToolUseFailure, not PostToolUse: the turn goes on after the approval. + { event: "PostToolUseFailure", state: "working" }, { event: "Stop", state: "idle" }, { event: "StopFailure", state: "idle" }, { event: "SessionEnd", state: "idle" }, @@ -547,17 +623,28 @@ const DECISION_TOOL_MATCHERS: Readonly> = { qwen: "^(run_shell_command|write_file|edit|replace)$" }; +/** + * With base protection on at launch the hook also matches read-only file tools, for its private-data rule only. + * Codex has no read-only file tool in its hooks: it reads through its shell. + */ +const DECISION_READ_TOOL_MATCHERS: Readonly> = { + claude: "Bash|Write|Edit|MultiEdit|NotebookEdit|Read|NotebookRead|Grep|Glob|LS", + codex: "Bash|apply_patch|Edit|Write", + qwen: "^(run_shell_command|write_file|edit|replace|read_file|read_many_files|grep_search|search_file_content|glob|list_directory)$" +}; + function decisionHookCommands( provider: DecisionHookProvider, gate: RuntimeHookHelperLaunch, platform: NodeJS.Platform, - decisionBudgetMs?: number + decisionBudgetMs?: number, + reads = false ): ProviderHookCommand[] { validateHelper(gate); const { hookSeconds } = permissionGateTimings(decisionBudgetMs); return [{ event: "PreToolUse", - matcher: DECISION_TOOL_MATCHERS[provider], + matcher: (reads ? DECISION_READ_TOOL_MATCHERS : DECISION_TOOL_MATCHERS)[provider], // The hook is installed only when something decides for this session, so it always fails closed. command: commandWithEnvironment( [gate.command, ...gate.args, "pretool"], @@ -794,7 +881,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 +896,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 +982,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 +996,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 +1035,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/src/main/services/agent-runtime/RuntimeGateway.ts b/src/main/services/agent-runtime/RuntimeGateway.ts index 7ad4f9d48..42a27aad8 100644 --- a/src/main/services/agent-runtime/RuntimeGateway.ts +++ b/src/main/services/agent-runtime/RuntimeGateway.ts @@ -79,6 +79,8 @@ interface RuntimeLease { provider: Exclude; tokenDigest: Buffer; activeTurnId: string | null; + /** A turn started and has not reported idle yet; between turns an unseen turn id may open the next one. */ + turnOpen: boolean; /** Turn ids this session already reported (bounded, oldest dropped): a late start of one of them is stale. */ seenTurnIds: Set; latest: RuntimeLifecycleSignal | null; @@ -276,6 +278,7 @@ export class RuntimeGateway { provider, tokenDigest: tokenDigest(capabilityToken), activeTurnId: null, + turnOpen: false, seenTurnIds: new Set(), latest: null, captureResult: captureResultOrGrantExpiresAt === true, @@ -469,21 +472,27 @@ export class RuntimeGateway { throw new Error("Answer capture is not authorized for this session."); } - if (message.turnId && isTurnStart(message.event)) { + const startsTurn = isTurnStart(message.event); + if (message.turnId && startsTurn) { // Each hook runs on its own connection, so a start of an earlier turn can arrive after the next turn began; // it must not make that newer turn's events look stale. if (message.turnId !== lease.activeTurnId && lease.seenTurnIds.has(message.turnId)) return null; lease.activeTurnId = message.turnId; + lease.turnOpen = true; + rememberTurn(lease, message.turnId); + } else if (message.turnId && message.turnId !== lease.activeTurnId) { + // A turn whose start hook was lost (it timed out, or the CLI skipped it) still counts: after an idle, an + // unseen id opens the next turn. During an open turn, or for an id already seen, the event is out of order. + if (lease.activeTurnId && (lease.turnOpen || lease.seenTurnIds.has(message.turnId))) return null; + lease.activeTurnId = message.turnId; + lease.turnOpen = true; rememberTurn(lease, message.turnId); - } else if ( - message.turnId - && lease.activeTurnId - && message.turnId !== lease.activeTurnId - ) { - return null; } else if (message.turnId) { rememberTurn(lease, message.turnId); + } else if (startsTurn) { + lease.turnOpen = true; } + if (message.state === "idle") lease.turnOpen = false; const signal: RuntimeLifecycleSignal = { state: message.state, event: message.event, @@ -877,7 +886,8 @@ function isTurnStart(event: string): boolean { return event === "UserPromptSubmit" || event === "TurnStarted" || event === "pre_llm_call" - || event === "session.status:busy"; + || event === "session.status:busy" + || event === "session.status:retry"; } function isRecord(value: unknown): value is Record { diff --git a/src/main/services/agent-runtime/applyRuntimeSignal.ts b/src/main/services/agent-runtime/applyRuntimeSignal.ts new file mode 100644 index 000000000..47b800df6 --- /dev/null +++ b/src/main/services/agent-runtime/applyRuntimeSignal.ts @@ -0,0 +1,22 @@ +import type { RuntimeLifecycleSignal } from "./RuntimeGateway.ts"; +import type { TerminalManager } from "../TerminalManager.ts"; + +/** + * What main does with one runtime gateway signal: the card's status first, then the final answer it carries (Codex + * Stop hook, OpenCode plugin) for get_agent_result and wait_for_agent, kept only when the signal belongs to the + * current turn and the last submitted input. + */ +export function applyRuntimeSignal( + terminals: Pick, + terminalSessionId: string, + signal: RuntimeLifecycleSignal +): void { + const current = terminals.applyProviderSignal(terminalSessionId, { + kind: "lifecycle", + state: signal.state, + event: signal.event, + ...(signal.turnId ? { requestId: signal.turnId } : {}), + ...(signal.threadId ? { threadId: signal.threadId } : {}) + }); + if (current && signal.result) terminals.recordAnswer(terminalSessionId, signal.result, { turnId: signal.turnId }); +} diff --git a/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/src/main/services/isolation/AgentIsolation.ts b/src/main/services/isolation/AgentIsolation.ts index 5d648c290..58ee5b333 100644 --- a/src/main/services/isolation/AgentIsolation.ts +++ b/src/main/services/isolation/AgentIsolation.ts @@ -1,13 +1,14 @@ import { spawnSync } from "node:child_process"; import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, realpathSync, rmdirSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { delimiter, dirname, join } from "node:path"; +import { delimiter, dirname, isAbsolute, join } from "node:path"; import type { ProviderId, SessionIsolation } from "../../../shared/contracts.ts"; import { autoKind, PROFILE_RANK, type LaunchProfile } from "../../../shared/autoMode.ts"; import { LaunchRefusal } from "../launchRefusal.ts"; -import { isolationPaths } from "./isolationPaths.ts"; +import { isolationPaths, type PlacedProject } from "./isolationPaths.ts"; +import { validateAccountHome } from "./accountHomeIsolation.ts"; import { seatbeltProfile } from "./seatbelt.ts"; -import { bubblewrapArguments, projectHooks } from "./bubblewrap.ts"; +import { bubblewrapArguments, projectHooks, type PathKind } from "./bubblewrap.ts"; import { LinuxHostPaths } from "./linuxHostPaths.ts"; export const SANDBOX_EXEC = "/usr/bin/sandbox-exec"; @@ -75,6 +76,12 @@ export interface IsolationLaunch { env: Record; /** Folders under CanvasTTY's private data this launch was handed (its control grant, its account home). */ grantedPrivate?: readonly string[]; + /** The CLI home a launch contributor selected inside its plugin data (LaunchPipeline); checked again here. */ + accountHome?: string; + /** CanvasTTY's own gateway sockets for this launch (gatewaySocketAddresses), never read from `env`. */ + gatewaySockets?: readonly string[]; + /** Folders CanvasTTY itself placed this launch in (an environment's worktree, the person's chosen folder). */ + placedProjects?: readonly PlacedProject[]; /** The launch profile: in plan the project is not writable. */ profile?: LaunchProfile; } @@ -184,6 +191,10 @@ export class AgentIsolation { const temp = join(folder, "tmp"); mkdirSync(temp, { mode: 0o700 }); const cwd = realpathSync(launch.cwd); + const accountHome = launch.accountHome === undefined + ? undefined + : validateAccountHome(this.options.userDataPath, launch.provider, launch.accountHome, launch.env); + const granted = [...(launch.grantedPrivate ?? []), ...(accountHome ? [accountHome] : [])]; const paths = isolationPaths({ provider: launch.provider, cwd, @@ -192,14 +203,19 @@ export class AgentIsolation { hostEnvironment: this.hostEnvironment, userDataPath: this.options.userDataPath, sessionId: launch.sessionId, - ...(launch.grantedPrivate ? { grantedPrivate: launch.grantedPrivate } : {}), + ...(granted.length > 0 ? { grantedPrivate: granted } : {}), + ...(launch.gatewaySockets ? { gatewaySockets: launch.gatewaySockets } : {}), + ...(launch.placedProjects ? { placedProjects: launch.placedProjects } : {}), ...(launch.profile === "plan" ? { readOnlyProject: true } : {}) }); // `git init` and `git clone` copy git's template, sample hooks included: an empty one writes no hooks. const gitTemplate = join(folder, "git-template"); mkdirSync(gitTemplate, { mode: 0o500 }); // An agent that meets "Operation not permitted" can read why here, instead of trying other ways around it. - const env = { ...launch.env, TMPDIR: `${temp}/`, TMP: temp, TEMP: temp, GIT_TEMPLATE_DIR: gitTemplate, [ISOLATION_ENV]: ISOLATION_NOTE }; + const env: Record = { ...launch.env, TMPDIR: `${temp}/`, TMP: temp, TEMP: temp, GIT_TEMPLATE_DIR: gitTemplate, [ISOLATION_ENV]: ISOLATION_NOTE }; + // Claude Code ignores TMPDIR for its Bash tool and editor files: `${CLAUDE_CODE_TMPDIR:-/tmp}/claude-`, which + // all of the person's Claude sessions share and the layer keeps read-only. Its own folder is the launch's temp. + if (launch.provider === "claude") env.CLAUDE_CODE_TMPDIR = temp; if (available.layer === "seatbelt") { const profilePath = join(folder, "profile.sb"); writeFileSync(profilePath, seatbeltProfile(paths), { mode: 0o600, flag: "wx" }); @@ -208,8 +224,8 @@ export class AgentIsolation { // bubblewrap mounts only what exists: the CLI's own missing folders are created and a missing protected file // gets a placeholder first (LinuxHostPaths), both undone by cleanup(). releaseHostPaths = this.linuxHostPaths.prepare(paths); - const kind = (path: string): "file" | "directory" | null => { - try { const stat = statSync(path); return stat.isDirectory() ? "directory" : stat.isFile() ? "file" : null; } catch { return null; } + const kind = (path: string): PathKind => { + try { const stat = statSync(path); return stat.isDirectory() ? "directory" : stat.isFile() ? "file" : stat.isSocket() ? "socket" : null; } catch { return null; } }; const args = bubblewrapArguments(paths, { command: launch.command, args: launch.args, cwd, ...(launch.env.XDG_RUNTIME_DIR ? { runtimeDir: launch.env.XDG_RUNTIME_DIR } : {}) }, kind); const hooks = projectHooks(cwd); @@ -285,7 +301,20 @@ function findOnPath(name: string, exists: (path: string) => boolean): string | n return null; } -/** The folder of a session's control grant, from the launch environment (it is readable inside the layer). */ +/** + * The Unix socket addresses in environments CanvasTTY's own gateways prepared for a launch (browser, lifecycle, + * orchestration). Pass only those: the launch's full environment also holds the person's variables and plugins'. + */ +export function gatewaySocketAddresses(...environments: ReadonlyArray> | undefined>): string[] { + return [...new Set(environments.flatMap((environment) => Object.entries(environment ?? {}) + .filter(([name, value]) => /^CANVASTTY_.*_ADDRESS$/u.test(name) && isAbsolute(value)) + .map(([, value]) => value)))]; +} + +/** + * The folder of a session's control grant, from the control environment CanvasTTY itself prepared for the launch + * (`controlEnvironment`), never from the launch's full environment: a plugin can set the variable there too. + */ export function controlGrantFolder(env: Readonly>): string | null { const connection = env.CANVASTTY_CONTROL_CONNECTION; return connection ? dirname(connection) : null; diff --git a/src/main/services/isolation/accountHomeIsolation.ts b/src/main/services/isolation/accountHomeIsolation.ts new file mode 100644 index 000000000..3240aaf32 --- /dev/null +++ b/src/main/services/isolation/accountHomeIsolation.ts @@ -0,0 +1,66 @@ +import { lstatSync, realpathSync } from "node:fs"; +import { isAbsolute, join, resolve } from "node:path"; +import type { ProviderId } from "../../../shared/contracts.ts"; +import { segmentsBelow, withoutTrailingSeparator } from "../accountHomes.ts"; +import { PLUGIN_DATA_FOLDER, providerHomeVariables } from "./isolationPaths.ts"; + +/** + * The CLI home a launch contributor handed this launch inside its own data folder: the value of the provider's own + * home variable (providerHomeVariables: CODEX_HOME, CLAUDE_CONFIG_DIR, GROK_HOME, HERMES_HOME, KIMI_CODE_HOME, + * KIMI_SHARE_DIR, KIMI_HOME, OPENCODE_CONFIG_DIR, QWEN_HOME; never HOME or XDG_*) when it is an exact absolute path strictly below the + * contributor's data folder (a trailing separator is dropped: `homes/a/` is `homes/a`). `dataDir` comes from the + * host's contributor record, never from the plugin's answer, so a plugin can only point at a folder of its own data, + * and only the one the CLI is actually started with. + */ +export function contributedAccountHome( + provider: ProviderId, + dataDir: string | undefined, + key: string, + value: string +): string | undefined { + if (!dataDir || !isAbsolute(dataDir) || !providerHomeVariables(provider).includes(key)) return undefined; + const folder = withoutTrailingSeparator(value); + if (!isAbsolute(folder) || resolve(folder) !== folder) return undefined; + return segmentsBelow(resolve(dataDir), folder) ? folder : undefined; +} + +/** + * Checks a contributed account home again when the isolation layer is built. Nothing is reopened when the launch no + * longer starts the CLI with it (an environment replaced the variable). Otherwise it must be a real folder strictly + * inside one plugin's data folder, reached without a symbolic link at any step from `/plugin-data`: the + * path to reopen is returned, and anything else throws (the launch is refused). + */ +export function validateAccountHome( + userDataPath: string, + provider: ProviderId, + candidate: string, + env: Readonly> +): string | undefined { + const started = (value: string | undefined): boolean => typeof value === "string" && withoutTrailingSeparator(value) === candidate; + if (!providerHomeVariables(provider).some((name) => started(env[name]))) return undefined; + if (!isAbsolute(candidate) || resolve(candidate) !== candidate) { + throw new Error("The selected account home must be an exact absolute folder."); + } + const pluginData = join(resolve(userDataPath), PLUGIN_DATA_FOLDER); + const segments = segmentsBelow(pluginData, candidate); + // /: never all of plugin data, never a whole plugin's data folder. + if (!segments || segments.length < 2) { + throw new Error("The selected account home must be a folder inside one plugin's data."); + } + try { + const canonicalRoot = join(realpathSync.native(resolve(userDataPath)), PLUGIN_DATA_FOLDER); + let path = pluginData; + let canonical = canonicalRoot; + for (const segment of ["", ...segments]) { + if (segment) { + path = join(path, segment); + canonical = join(canonical, segment); + } + const info = lstatSync(path); + if (!info.isDirectory() || info.isSymbolicLink() || realpathSync.native(path) !== canonical) throw new Error("not a real folder"); + } + } catch { + throw new Error("The selected account home is missing or crosses a symbolic link."); + } + return candidate; +} diff --git a/src/main/services/isolation/bubblewrap.ts b/src/main/services/isolation/bubblewrap.ts index bd0c47b25..c09506961 100644 --- a/src/main/services/isolation/bubblewrap.ts +++ b/src/main/services/isolation/bubblewrap.ts @@ -1,6 +1,9 @@ import { dirname, join } from "node:path"; import type { IsolationPaths } from "./isolationPaths.ts"; +/** What is at a path, as far as mounting goes; null when nothing is there. */ +export type PathKind = "file" | "directory" | "socket" | null; + /** The project's own git hooks folder. */ export function projectHooks(cwd: string): string { return join(cwd, ".git", "hooks"); @@ -13,8 +16,9 @@ function within(path: string, folder: string): boolean { /** * The bubblewrap arguments of one isolated agent on Linux: the whole file system read-only, the project, this * launch's temporary folder and the CLI's own folders writable, an empty tmpfs over every folder it must not read (and - * /dev/null over such files), its own PID namespace (it cannot signal the person's processes) and the person's user - * runtime folder hidden (the systemd user manager, D-Bus session bus, SSH agent and Podman sockets live there). + * /dev/null over such files and sockets), CanvasTTY's gateway socket folders read-only, its own PID namespace (it + * cannot signal the person's processes) and the person's user runtime folder hidden (the systemd user manager, D-Bus + * session bus, SSH agent and Podman sockets live there). * * Linux cannot filter which Unix sockets a process connects to, so a socket outside those hidden folders (a system * Docker socket the person's account may use) stays reachable; docs/installing-and-security.md says so. `exists` @@ -23,7 +27,7 @@ function within(path: string, folder: string): boolean { export function bubblewrapArguments( paths: IsolationPaths, launch: { command: string; args: readonly string[]; cwd: string; runtimeDir?: string }, - exists: (path: string) => "file" | "directory" | null + exists: (path: string) => PathKind ): string[] { const args = [ "--die-with-parent", @@ -39,19 +43,34 @@ export function bubblewrapArguments( seen.add(path); args.push("--bind", path, path); } + // CanvasTTY's data folder inside a writable project: its parent folders there become mount points (a mount point + // cannot be renamed: EBUSY), the folder itself read-only over it, then its writable folders bound again. + for (const path of paths.fixedFolders) { + if (exists(path) === "directory") args.push("--bind", path, path); + } + for (const path of paths.readOnlyFolders) { + if (exists(path) !== "directory") continue; + args.push("--ro-bind", path, path); + for (const folder of seen) if (folder !== path && within(folder, path)) args.push("--bind", folder, folder); + } for (const path of paths.writableFiles) { if (exists(path) === "file") args.push("--bind", path, path); } for (const path of paths.unreadable) { const kind = exists(path); if (kind === "directory") args.push("--tmpfs", path); - else if (kind === "file") args.push("--ro-bind", "/dev/null", path); + // A socket is hidden like a file: /dev/null over it, so connecting fails. + else if (kind === "file" || kind === "socket") args.push("--ro-bind", "/dev/null", path); } // What this launch was handed stays read-only (as in the macOS profile), except the CLI's own moved home, which is // one of its writable folders. for (const path of paths.readableAgain) { if (exists(path)) args.push(seen.has(path) ? "--bind" : "--ro-bind", path, path); } + // The project's repository folder becomes a mount point before anything is mounted inside it: a mount point cannot + // be renamed (EBUSY), so `.git` cannot be moved away from the read-only mounts below and back. + const repository = join(launch.cwd, ".git"); + if ([...seen].some((folder) => within(launch.cwd, folder)) && exists(repository) === "directory") args.push("--bind", repository, repository); // Last, over every bind above (a rebound home included): the read-only files and the project's git hooks. for (const path of [...paths.gitHooks, ...(paths.protectedDirectories ?? []), ...paths.protectedWrites]) { const kind = exists(path); @@ -83,16 +102,29 @@ export function bubblewrapArguments( else if (!exists(info)) args.push("--tmpfs", info); } if (launch.runtimeDir && exists(launch.runtimeDir) === "directory") args.push("--tmpfs", launch.runtimeDir); - // CanvasTTY's gateway sockets may sit in the hidden runtime folder or in /tmp; bind their folders back. + // CanvasTTY's gateway sockets may sit in the hidden runtime folder or in /tmp; bind their folders back read-only. + // Connecting to a Unix socket needs no write access to its folder, and a writable one would let an agent remove a + // gateway socket and bind its own in its place, receiving other agents' requests. This launch's own temporary + // folder is already bound writable above. + // A bind takes the host folder as it is, so one over a hidden path would show it again: refused, never mounted. + // Over a writable folder (the project, a CLI home) it would make that read-only and undo the throwaway hooks + // folder above: refused too. Core gateways use dedicated folders, so neither happens with them. + const hidden = [...paths.unreadable, ...(launch.runtimeDir ? [launch.runtimeDir] : [])]; + const writable = [...seen, ...paths.writableFiles]; for (const path of paths.socketFolders) { - if (exists(path) === "directory" && !seen.has(path)) args.push("--bind", path, path); + if (exists(path) !== "directory" || seen.has(path)) continue; + const covered = hidden.find((secret) => within(secret, path)); + if (covered) throw new Error(`The gateway socket folder ${path} contains ${covered}, which the agent may not read.`); + const shadowed = writable.find((folder) => within(folder, path)); + if (shadowed) throw new Error(`The gateway socket folder ${path} contains ${shadowed}, which the agent writes; it is not mounted over it.`); + args.push("--ro-bind", path, path); } args.push("--chdir", launch.cwd, "--", launch.command, ...launch.args); return args; } /** A missing `path` whose nearest existing ancestor is one of the writable folders (or inside one). */ -export function creatableInside(path: string, writable: readonly string[], exists: (path: string) => "file" | "directory" | null): boolean { +export function creatableInside(path: string, writable: readonly string[], exists: (path: string) => PathKind): boolean { let current = dirname(path); for (let i = 0; i < 128 && !exists(current); i++) { const parent = dirname(current); diff --git a/src/main/services/isolation/isolationPaths.ts b/src/main/services/isolation/isolationPaths.ts index 5cdcb9a5e..f2bccdbd2 100644 --- a/src/main/services/isolation/isolationPaths.ts +++ b/src/main/services/isolation/isolationPaths.ts @@ -28,20 +28,51 @@ export interface IsolationPathInput { grantedPrivate?: readonly string[]; /** Extra socket folders this launch may connect to (the control grant's endpoint folder). */ socketFolders?: readonly string[]; + /** + * The sockets of CanvasTTY's own gateways for this launch, as the host set them up. Their folders are bound back; + * a CANVASTTY_*_ADDRESS in the launch environment (the person's own or a plugin's) never is. + */ + gatewaySockets?: readonly string[]; + /** + * Folders the host itself placed this launch in (an environment's worktree, the folder the person chose for the + * card the launch descends from). A project inside plugin data is reopened only when it is inside one of them. + */ + placedProjects?: readonly PlacedProject[]; /** Plan: the project is readable only (the CLI's own folders stay writable). */ readOnlyProject?: boolean; } +/** + * A folder the host placed a launch in. `pluginId`: an environment of that plugin placed it, so it counts only inside + * that plugin's own data folder; without it, the person chose the folder. + */ +export interface PlacedProject { + folder: string; + pluginId?: string; +} + export interface IsolationPaths { /** Writable folders (subpaths), every spelling. */ writable: string[]; + /** + * Read-only as a whole although a writable folder holds it: CanvasTTY's own data folder when the project contains it + * (a project at HOME). Its settings and the plugins it runs are not the agent's to change; the writable folders above + * that sit inside it (an account home, this launch's own folders) stay writable. + */ + readOnlyFolders: string[]; + /** + * The folders between a writable folder and a read-only folder inside it (`~/.config` for `~/.config/canvastty` in a + * project at HOME): what is inside stays writable, but they cannot be renamed or replaced, which would move the + * read-only folder out of its protected path and let a different one take its place. + */ + fixedFolders: string[]; /** Writable single files (with their `.lock` / `.tmp` / backup siblings). */ writableFiles: string[]; /** Folders that may be created (empty) on the way to a writable one: `~/.local/state` for `~/.local/state/opencode`. */ creatableFolders: string[]; - /** Inside writable folders, still not writable: the repository's git config, the CLIs' own permission settings. */ + /** Inside writable folders, still not writable: the repository's git config and `commondir`, the CLIs' own permission settings. */ protectedWrites: string[]; - /** Permission-bearing agent directories; empty directories are neutral, unlike a fabricated build.md. */ + /** Permission-bearing agent directories and the repository's linked-worktree records; empty directories are neutral. */ protectedDirectories?: string[]; /** The project's git hook folders: only `*.sample` files may be written there (what `git init` creates). */ gitHooks: string[]; @@ -87,8 +118,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,13 +142,18 @@ 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"] }; /** A CLI's configuration file named by a variable (it may hold keys): unreadable to other CLIs, never writable to its own. */ const CONFIG_FILE_VARIABLES: Partial> = { opencode: ["OPENCODE_CONFIG"] }; +/** The environment variables that name this provider's CLI home (empty when it has none). */ +export function providerHomeVariables(provider: ProviderId): readonly string[] { + return HOME_VARIABLES[provider] ?? []; +} + function movedProviderHomes(provider: ProviderId, env: IsolationPathInput["env"], variables = HOME_VARIABLES): string[] { return (variables[provider] ?? []) .map((name) => env[name]) @@ -138,13 +179,16 @@ function sensitiveHomeFolders(home: string, xdgConfig: string): string[] { ].map((name) => join(home, name)).concat([join(xdgConfig, "gh"), join(xdgConfig, "gcloud")]); } +/** Plugins' private data below userData (PluginManager's `/plugin-data/`). */ +export const PLUGIN_DATA_FOLDER = "plugin-data"; + /** - * CanvasTTY's own private data under userData (the same list base protection refuses): tokens, connection records, - * secret stores, account homes and prepared launch files. Its gateways' runtime folders stay readable: they hold - * token-authenticated sockets and per-run hook settings the CLI must read. + * CanvasTTY's own private data under userData: tokens, connection records, secret stores, account homes, prepared + * launch files and every plugin's private data (an accounts plugin keeps all of its CLI homes there). Its gateways' + * runtime folders stay readable: they hold token-authenticated sockets and per-run hook settings the CLI must read. */ export function privateAppData(userDataPath: string): string[] { - return ["agent-control", "provider-secrets.bin", "plugin-secrets", "account-homes", "github-oauth.json", "launch-runs"] + return ["agent-control", "provider-secrets.bin", "plugin-secrets", "account-homes", "github-oauth.json", "launch-runs", PLUGIN_DATA_FOLDER] .map((name) => join(userDataPath, name)); } @@ -212,8 +256,18 @@ export function isolationPaths(input: IsolationPathInput): IsolationPaths { // variable pointing at HOME or above the project would hide them, so it is not followed. .filter((folder) => !ownFolders.includes(folder) && !hides(folder)); const privateData = privateAppData(input.userDataPath); - const grants = [...(input.grantedPrivate ?? []), join(input.userDataPath, "launch-runs", safeSegment(input.sessionId))]; const project = input.cwd; + // A project inside plugin data (a plugin environment's worktree) stays this launch's project: that exact folder is + // reopened, never its siblings, its plugin's other data or all plugin data. Only when the host placed the launch + // there: a folder an agent asked for (a subagent's cwd) could otherwise be a plugin's account homes. + const pluginDataRoot = join(input.userDataPath, PLUGIN_DATA_FOLDER); + const projectDepth = pluginDataDepth(pathSpellings, pluginDataRoot, project); + if (projectDepth >= 0 && projectDepth < 2) throw new Error(`The project ${project} is CanvasTTY's private plugin data.`); + if (projectDepth >= 2 && !(input.placedProjects ?? []).some((placed) => placesProject(pathSpellings, pluginDataRoot, placed, project))) { + throw new Error(`The project ${project} is inside CanvasTTY's private plugin data, and CanvasTTY did not place this launch there. Only a plugin environment's own folder or a folder the person chose is opened inside plugin data.`); + } + const grants = [...(input.grantedPrivate ?? []), join(input.userDataPath, "launch-runs", safeSegment(input.sessionId)), + ...(projectDepth >= 2 ? [project] : [])]; const trustedOwn = providerFolders(input.provider, {}, hostHome); const trustedOthers = AGENT_PROVIDERS.filter((provider) => provider !== input.provider).flatMap((provider) => { const defaults = providerFolders(provider, {}, hostHome); @@ -243,35 +297,48 @@ export function isolationPaths(input: IsolationPathInput): IsolationPaths { if (!withinGrant && !ownAccount) throw new Error(`CLI home ${folder} overlaps protected CanvasTTY data.`); } } + const gitDir = join(project, ".git"); + const repository = existsSync(join(gitDir, "config")); const configSources = input.provider === "opencode" ? openCodeConfigPaths(input.env, input.cwd) : { jsonFiles: [], agentDirectories: [] }; const socketFolders = [ - ...Object.entries(input.env) - .filter(([name, value]) => /^CANVASTTY_.*_ADDRESS$/u.test(name) && typeof value === "string" && isAbsolute(value)) - .map(([, value]) => dirname(value!)), + ...(input.gatewaySockets ?? []).filter((path) => isAbsolute(path)).map((path) => dirname(path)), ...(input.socketFolders ?? []), input.sessionTemp, join(input.userDataPath, "browser", "runtime"), join(input.userDataPath, "lifecycle", "runtime"), join(input.userDataPath, "orchestration", "runtime") ]; + const writable = all([ + ...(input.readOnlyProject ? [] : [project]), + input.sessionTemp, + ...ownFolders, + join(home, ".npm"), join(home, ".bun"), join(xdg.cache, "npm"), join(xdg.cache, "bun") + ]); + const userData = pathSpellings(input.userDataPath); + const readOnlyFolders = writable.some((folder) => userData.some((path) => isWithin(path, folder))) ? userData : []; + const fixedFolders = readOnlyFolders.flatMap((path) => writable.filter((folder) => folder !== path && isWithin(path, folder)) + .flatMap((folder) => ancestorsBelow(folder, path))); return { - writable: all([ - ...(input.readOnlyProject ? [] : [project]), - input.sessionTemp, - ...ownFolders, - join(home, ".npm"), join(home, ".bun"), join(xdg.cache, "npm"), join(xdg.cache, "bun") - ]), + writable, + readOnlyFolders, + fixedFolders: [...new Set(fixedFolders)], writableFiles: all(own.files), creatableFolders: all([...ownFolders, join(home, ".npm"), join(home, ".bun")].flatMap((folder) => ancestorsBelow(home, folder))), protectedWrites: all([ // 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")) : []), + // later, outside the layer), and its per-worktree config; a new one may be created, which writes its config. + ...(repository ? [join(gitDir, "config"), join(gitDir, "config.lock"), join(gitDir, "config.worktree"), join(gitDir, "config.worktree.lock")] : []), + // Where git reads the shared config and hooks from. Protected where it exists: an empty placeholder (Linux) would + // stop git from working; seatbelt also denies creating it (projectRoots). + ...(existsSync(join(gitDir, "commondir")) ? [join(gitDir, "commondir")] : []), + // 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 ]), - protectedDirectories: all(configSources.agentDirectories.flatMap((folder) => [join(folder, "agent"), join(folder, "agents")])), + // Linked worktrees' records (their git folder, `commondir` and config): what git reads when the person works there. + protectedDirectories: all([...configSources.agentDirectories.flatMap((folder) => [join(folder, "agent"), join(folder, "agents")]), + ...(repository ? [join(gitDir, "worktrees")] : [])]), gitHooks: all([join(project, ".git", "hooks")]), projectRoots: input.readOnlyProject ? [] : all([project]), unreadable: all([...sensitive, ...others, ...privateData]), @@ -283,6 +350,30 @@ export function isolationPaths(input: IsolationPathInput): IsolationPaths { }; } +/** How many folders below plugin data `path` is (0: plugin data itself), in any spelling; -1 when it is not inside. */ +function pluginDataDepth(spellingsOf: (path: string) => string[], pluginDataRoot: string, path: string): number { + const roots = spellingsOf(pluginDataRoot); + return Math.max(-1, ...spellingsOf(path).flatMap((candidate) => roots + .filter((root) => isWithin(candidate, root)) + .map((root) => relative(root, candidate).split(/[\\/]+/u).filter(Boolean).length))); +} + +/** + * `project` is inside a folder the host placed the launch in, and that folder is itself a folder inside one plugin's + * data (never all of it or a whole plugin's data); an environment's folder counts only inside its own plugin's data. + */ +function placesProject(spellingsOf: (path: string) => string[], pluginDataRoot: string, placed: PlacedProject, project: string): boolean { + if (!isAbsolute(placed.folder)) return false; + if (pluginDataDepth(spellingsOf, pluginDataRoot, placed.folder) < 2) return false; + if (placed.pluginId !== undefined) { + // One folder name (PluginManager's `/`), and the placed folder strictly inside it. + if (!/^[^/\\.][^/\\]*$/u.test(placed.pluginId)) return false; + if (pluginDataDepth(spellingsOf, join(pluginDataRoot, placed.pluginId), placed.folder) < 1) return false; + } + const folders = spellingsOf(placed.folder); + return spellingsOf(project).some((candidate) => folders.some((folder) => isWithin(candidate, folder))); +} + /** `path` is `folder` or inside it, by the host's path rules (separators, a drive on Windows). */ function isWithin(path: string, folder: string): boolean { const rest = relative(folder, path); diff --git a/src/main/services/isolation/seatbelt.ts b/src/main/services/isolation/seatbelt.ts index 1342435f0..31a8422f3 100644 --- a/src/main/services/isolation/seatbelt.ts +++ b/src/main/services/isolation/seatbelt.ts @@ -5,7 +5,8 @@ import type { IsolationPaths } from "./isolationPaths.ts"; * except what is listed, and a later rule wins over an earlier one: * * - writes: nowhere, then the project (every spelling), this launch's own temporary folder, the CLI's own state, - * config and cache folders and the package caches, then again nowhere in the project's git hooks and git config + * config and cache folders and the package caches, then again nowhere in CanvasTTY's own data folder when the project + * contains it (its writable folders inside excepted), the project's git hooks and git config * (a hook runs outside the layer the next time the person commits) and the CLI's own permission settings; * - reads: not of other CLIs' credential folders, SSH/cloud keys and CanvasTTY's own tokens and secret stores, except * what this launch was handed (its control grant, its account home, its plugin launch files); @@ -31,6 +32,16 @@ export function seatbeltProfile(paths: IsolationPaths): string { if (paths.writableFiles.length > 0) { lines.push(`(allow file-write*${paths.writableFiles.map((path) => ` (regex ${regex(`^${escapeRegex(path)}(\\.[^/]*)?$`)})`).join("")})`); } + // CanvasTTY's data folder inside a writable project: read-only again, except the writable folders inside it. + if (paths.readOnlyFolders.length > 0) { + lines.push(`(deny file-write*${paths.readOnlyFolders.map((path) => ` (subpath ${quote(path)})`).join("")})`); + const inside = paths.writable.filter((path) => paths.readOnlyFolders.some((folder) => path.startsWith(`${folder}/`))); + if (inside.length > 0) lines.push(`(allow file-write*${inside.map((path) => ` (subpath ${quote(path)})`).join("")})`); + } + // Its parent folders inside the project: entries can be created in them, but they cannot be renamed or replaced. + if (paths.fixedFolders.length > 0) { + lines.push(`(deny file-write-unlink${paths.fixedFolders.map((path) => ` (literal ${quote(path)})`).join("")})`); + } if (paths.unreadable.length > 0) { lines.push(`(deny file-read* file-write*${paths.unreadable.map((path) => ` (subpath ${quote(path)})`).join("")})`); } @@ -53,8 +64,15 @@ export function seatbeltProfile(paths: IsolationPaths): string { lines.push(`(deny file-write*${hookRules.map((pattern) => ` (regex ${regex(pattern)})`).join("")})`); lines.push(`(allow file-write*${hookRules.map((pattern) => ` (regex ${regex(`${pattern}[^/]+\\.sample$`)})`).join("")})`); } + // Also what git reads its config and paths from, existing or not: `commondir`, `config.worktree` and the linked + // worktrees' records (`worktrees/`). Objects, refs, the index, logs and HEAD stay writable: add, commit, checkout work. if (paths.projectRoots.length > 0) { - lines.push(`(deny file-write*${paths.projectRoots.map((path) => ` (regex ${regex(`^${escapeRegex(path)}/(.*/)?\\.git/info/attributes$`)})`).join("")})`); + const files = "(info/attributes|commondir|config\\.worktree(\\.lock)?|worktrees(/.*)?)"; + lines.push(`(deny file-write*${paths.projectRoots.map((path) => ` (regex ${regex(`^${escapeRegex(path)}/(.*/)?\\.git/${files}$`)})`).join("")})`); + // These rules match by path: a `.git`, `hooks` or `info` folder renamed away and back would carry changes past + // them, and so would the project folder itself moved into a writable folder outside it (the launch's temp) and + // back. They cannot be renamed or removed; entries inside them can still be created where allowed. + lines.push(`(deny file-write-unlink${paths.projectRoots.map((path) => ` (regex ${regex(`^${escapeRegex(path)}(/(.*/)?\\.git(/hooks|/info)?)?$`)})`).join("")})`); } const protectedPaths = [...paths.protectedWrites, ...(paths.protectedDirectories ?? [])]; if (protectedPaths.length > 0) { diff --git a/src/main/services/materials/HandoffService.ts b/src/main/services/materials/HandoffService.ts index 043e00877..6f0dba0ef 100644 --- a/src/main/services/materials/HandoffService.ts +++ b/src/main/services/materials/HandoffService.ts @@ -63,6 +63,8 @@ export interface HandoffImageOps { } interface HandoffTiming { + /** How long a paste waits for the agent's CLI to take input (OpenCode drops keys until its input field is up). */ + readyMs: number; attachMs: number; pasteMs: number; pollMs: number; @@ -114,7 +116,7 @@ type Checked = { ok: true; plan: HandoffPlan } | { ok: false; reason: HandoffBlo type Reserved = { ok: true; plan: HandoffPlan; screen: SessionScreen | null } | { ok: false; reason: HandoffBlock | MaterialFailure }; -const DEFAULT_TIMING: HandoffTiming = { attachMs: 8_000, pasteMs: 6_000, pollMs: 120 }; +const DEFAULT_TIMING: HandoffTiming = { readyMs: 15_000, attachMs: 8_000, pasteMs: 6_000, pollMs: 120 }; export class HandoffService { private readonly options: HandoffServiceOptions; @@ -509,7 +511,7 @@ export class HandoffService { const paste = async (data: string): Promise => { const error = this.deliveryError(plan, screen); if (error) return error; - const written = await this.options.terminals.deliverInput(id, `${PASTE_START}${data}${PASTE_END}`, 0); + const written = await this.options.terminals.deliverInput(id, `${PASTE_START}${data}${PASTE_END}`, this.timing.readyMs); return written.delivered ? null : written.reason; }; if (!screen) { @@ -554,7 +556,7 @@ export class HandoffService { ? failed(error) : { state: "pasted", note: "enter-failed", sentAt: this.now(), imagesAttached: attached, error }; this.submitting.set(plan.id, { sessionId: id, turnStartedAt: null }); - const submitted = await this.options.terminals.deliverInput(id, "\r", 0); + const submitted = await this.options.terminals.deliverInput(id, "\r", this.timing.readyMs); return submitted.delivered ? { state: "submitted", sentAt: this.now(), imagesAttached: attached } : { state: "pasted", note: "enter-failed", sentAt: this.now(), imagesAttached: attached, error: submitted.reason }; diff --git a/src/main/services/materials/sessionScreen.ts b/src/main/services/materials/sessionScreen.ts index e39b33f9f..b9f78f8f1 100644 --- a/src/main/services/materials/sessionScreen.ts +++ b/src/main/services/materials/sessionScreen.ts @@ -115,9 +115,18 @@ export function pasteMarkers(screen: string): number { /** Visible screen rows, optionally reconstructing xterm's soft-wrapped logical lines. */ export function terminalScreenText(terminal: import("@xterm/headless").Terminal, unwrap = false): string { + const buffer = terminal.buffer.active; + return terminalRowsText(terminal, buffer.baseY, buffer.baseY + terminal.rows, unwrap); +} + +/** + * Rows `from` to `to` (exclusive) of the active buffer as text. `unwrap` joins a row xterm wrapped onto the line it + * continues (untrimmed, so a space the wrap fell on stays between the words) and trims each line once it is whole. + */ +export function terminalRowsText(terminal: import("@xterm/headless").Terminal, from: number, to: number, unwrap = false): string { const buffer = terminal.buffer.active; const lines: string[] = []; - for (let row = buffer.baseY; row < buffer.baseY + terminal.rows; row++) { + for (let row = from; row < to; row++) { const line = buffer.getLine(row); const text = line?.translateToString(!unwrap) ?? ""; if (unwrap && line?.isWrapped && lines.length) lines[lines.length - 1] += text; 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/src/main/services/renderedTerminalText.ts b/src/main/services/renderedTerminalText.ts new file mode 100644 index 000000000..bff948199 --- /dev/null +++ b/src/main/services/renderedTerminalText.ts @@ -0,0 +1,39 @@ +import { lazyRequire } from "../lazyRequire.ts"; +import { terminalRowsText } from "./materials/sessionScreen.ts"; + +const xterm = lazyRequire("@xterm/headless"); + +/** Rows of scrollback the rendering keeps above the screen: the tail an orchestrator reads is far shorter. */ +const SCROLLBACK_ROWS = 500; +const DEFAULT_GEOMETRY = { cols: 120, rows: 40 }; + +/** + * What a terminal shows for `data` as plain text: the scrollback rows and the screen (the alternate screen of a + * full-screen TUI such as OpenCode, which keeps no scrollback), as a headless xterm renders them, so cursor moves, + * redraws, erases and colors leave only the text a person sees. A row the terminal wrapped continues its line; + * trailing blanks and empty rows at the end are dropped. + */ +export async function renderedTerminalText(data: string, geometry: { cols: number; rows: number } | null): Promise { + const { cols, rows } = geometry && geometry.cols > 0 && geometry.rows > 0 ? geometry : DEFAULT_GEOMETRY; + const terminal = new (xterm().Terminal)({ cols, rows, scrollback: SCROLLBACK_ROWS, allowProposedApi: true }); + const rowsText = (): string => terminalRowsText(terminal, 0, terminal.buffer.active.length, true).replace(/\n+$/u, ""); + // A full-screen TUI leaves its alternate screen as it exits, which discards that screen: its last frame (often why it + // stopped) is kept from the moment it leaves, before what it printed afterwards. + let leftScreen = ""; + const leaving = terminal.parser.registerCsiHandler({ prefix: "?", final: "l" }, (params) => { + if (terminal.buffer.active.type === "alternate" && params.some((mode) => mode === 1049 || mode === 1047 || mode === 47)) { + leftScreen = rowsText(); + } + return false; + }); + try { + await new Promise((resolve) => terminal.write(data, resolve)); + // Every row, scrollback included (sessionScreen's terminalScreenText reads only the visible screen of a live one). + const shown = rowsText(); + if (terminal.buffer.active.type === "alternate" || !leftScreen) return shown; + return shown ? `${leftScreen}\n${shown}` : leftScreen; + } finally { + leaving.dispose(); + terminal.dispose(); + } +} diff --git a/src/main/services/safety/SecretRedaction.ts b/src/main/services/safety/SecretRedaction.ts index 7a0df5608..7edc1dc20 100644 --- a/src/main/services/safety/SecretRedaction.ts +++ b/src/main/services/safety/SecretRedaction.ts @@ -16,15 +16,21 @@ * `Authorization:` values, URL credentials, secret-looking query values and assignments, and long * high-entropy runs. * + * All three are found in the text as it came, and the union of their source ranges is masked: overlapping held + * values become one marker, so a later value that overlaps an earlier one shows none of its suffix, and a held + * value that happens to be a label (`password`, `Authorization`, `PRIVATE KEY`) cannot hide the credential the + * label introduces. With no held values the passes run one after another as before. + * * Each match becomes ``. Idempotent: a marker is never masked again. Values live in memory only * and are never logged, sent or shown. */ -const SECRET_MARKER = ''; /** Shorter values are not keys, and removing them would only garble ordinary text. */ const MIN_SECRET_CHARS = 8; /** Long enough for a PEM key or a service-account JSON; the search costs the same for any length. */ const MAX_SECRET_CHARS = 65_536; +/** Held forms up to this length are found with the native `indexOf`; longer ones with Knuth-Morris-Pratt. */ +const MAX_NATIVE_INDEXOF_PATTERN_CHARS = 128; const MAX_VALUES_PER_OWNER = 64; const MAX_OWNERS = 512; /** What wrapping may put between two characters of a key: whitespace and line breaks, box-drawing sides. */ @@ -108,9 +114,11 @@ export class SecretRedactionRegistry { redact(text: string): string { if (typeof text !== 'string' || text.length === 0) return typeof text === 'string' ? text : ''; - let result = maskKnownValues(text, this.knownForms()); - result = result.replace(JSON_SECRET_VALUE, (_match, prefix: string, closing: string) => `${prefix}${SECRET_MARKER}${closing}`); - return redactCredentials(result); + const forms = this.knownForms(); + // With no held values there is no structural text for a known match to erase, so reuse the direct generic + // pipeline instead of allocating and merging empty-registry source-range streams on ordinary output. + if (forms.bare.length === 0 && forms.exact.length === 0) return redactJsonAndCredentials(text); + return redactKnownAndCredentials(text, forms); } /** @@ -185,82 +193,211 @@ function withoutWrapCharacters(text: string): string { return from === 0 ? text : result + text.slice(from); } -/** - * Replaces every held value in `text`: the wrap-free forms also where wrapping put up to MAX_WRAP_GAP wrap - * characters between two of their characters, the exact forms as written. Matches are taken leftmost first, the - * longest at a position, and never overlap. - */ -function maskKnownValues(text: string, forms: KnownForms): string { - const endAt = knownMatchEnds(text, forms); - if (!endAt) return text; - let result = ""; - let kept = 0; - for (let at = 0; at < text.length;) { - const end = endAt[at]; - if (end === 0) { at++; continue; } - result += text.slice(kept, at) + SECRET_MARKER; - kept = end; - at = end; +/** All held-value matches in start order, keeping only the longest valid match at each start. */ +type KnownMatch = { start: number; end: number; kind?: string; priority?: number }; +type MatchStream = { iterator: Generator; current: KnownMatch }; + +function* knownMatches(text: string, forms: KnownForms): Generator { + if (forms.bare.length === 0 && forms.exact.length === 0) return; + const bareText = forms.bare.length ? withoutWrapCharacters(text) : ''; + const hasWrapCharacters = forms.bare.length > 0 && bareText !== text; + + const streams = forms.exact.map(form => candidateOccurrences(text, form)); + if (hasWrapCharacters) streams.push(wrappedMatches(text, bareText, forms.bare)); + else for (const form of forms.bare) streams.push(candidateOccurrences(text, form)); + yield* mergeMatches(streams); +} + +/** Merge already ordered hit streams by source start, choosing the longest at each start. */ +function* mergeMatches(streams: Generator[]): Generator { + const occurrences = orderedOccurrences(streams); + let next = occurrences.next(); + while (!next.done) { + const start = next.value.start; + let end = next.value.end; + next = occurrences.next(); + while (!next.done && next.value.start === start) { + end = Math.max(end, next.value.end); + next = occurrences.next(); + } + yield { start, end }; } - return kept === 0 ? text : result + text.slice(kept); } -/** - * For each position of `text`, the end of the longest held-value match that starts there (0: none), or null when - * nothing matches. The wrap-free forms are searched in the text with its wrap characters taken out (a map leads - * back to the original positions), the exact forms in the text itself; each with Knuth-Morris-Pratt, linear in - * the text plus the form, whatever either holds. - */ -function knownMatchEnds(text: string, forms: KnownForms): Int32Array | null { - if (forms.bare.length === 0 && forms.exact.length === 0) return null; - const exact = forms.exact.filter(form => text.includes(form)); - const bare = forms.bare.length ? withoutWrapCharacters(text) : ''; - const present = forms.bare.filter(form => bare.includes(form)); - if (present.length === 0 && exact.length === 0) return null; - const endAt = new Int32Array(text.length); - for (const form of exact) { - for (const start of occurrences(text, form)) endAt[start] = Math.max(endAt[start], start + form.length); +/** Merge hit streams into start order without discarding same-start candidates. */ +function* orderedOccurrences(streams: Generator[]): Generator { + if (streams.length === 1) { + yield* streams[0]!; + return; } - if (present.length === 0) return endAt; - // Where each character of `bare` stands in `text`, and how many oversized gaps lie before it (a match may - // not cross one). - const positions = new Int32Array(bare.length); - const oversized = new Int32Array(bare.length + 1); - for (let i = 0, count = 0, previous = -1; i < text.length; i++) { - if (isWrapCharacter(text.charCodeAt(i))) continue; - oversized[count + 1] = oversized[count] + (previous >= 0 && i - previous - 1 > MAX_WRAP_GAP ? 1 : 0); - positions[count++] = i; - previous = i; + const heap: MatchStream[] = []; + const push = (stream: MatchStream): void => { + let index = heap.length; + heap.push(stream); + while (index > 0) { + const parent = (index - 1) >> 1; + if (heap[parent]!.current.start <= stream.current.start) break; + heap[index] = heap[parent]!; + index = parent; + } + heap[index] = stream; + }; + const pop = (): MatchStream => { + const first = heap[0]!; + const last = heap.pop()!; + if (heap.length > 0) { + let index = 0; + while (true) { + const left = index * 2 + 1; + if (left >= heap.length) break; + const right = left + 1; + const child = right < heap.length && heap[right]!.current.start < heap[left]!.current.start ? right : left; + if (heap[child]!.current.start >= last.current.start) break; + heap[index] = heap[child]!; + index = child; + } + heap[index] = last; + } + return first; + }; + const enqueueFirst = (iterator: Generator): void => { + const next = iterator.next(); + if (!next.done) push({ iterator, current: next.value }); + }; + const advance = (stream: MatchStream): void => { + const next = stream.iterator.next(); + if (!next.done) { + stream.current = next.value; + push(stream); + } + }; + + for (const iterator of streams) enqueueFirst(iterator); + while (heap.length > 0) { + const first = pop(); + const current = first.current; + advance(first); + yield current; } - for (const form of present) { - for (const start of occurrences(bare, form)) { - // Only the gaps inside the match count, not the one before its first character. - if (oversized[start + form.length] - oversized[start + 1] !== 0) continue; - const from = positions[start]; - endAt[from] = Math.max(endAt[from], positions[start + form.length - 1] + 1); +} + +function* candidateOccurrences(text: string, pattern: string): Generator { + if (pattern.length > MAX_NATIVE_INDEXOF_PATTERN_CHARS) { + yield* kmpOccurrences(text, pattern); + return; + } + yield* exactOccurrences(text, pattern); +} + +function* exactOccurrences(text: string, pattern: string): Generator { + for (let from = 0; from <= text.length - pattern.length;) { + const start = text.indexOf(pattern, from); + if (start < 0) return; + yield { start, end: start + pattern.length }; + // Advancing by one preserves overlapping hits for the leftmost/longest merger. + from = start + 1; + } +} + +/** Keep worst-case scan cost linear for long patterns whose repetitive near-matches fool native indexOf. */ +function* kmpOccurrences(text: string, pattern: string): Generator { + const firstCode = pattern.charCodeAt(0); + const patternLength = pattern.length; + const searchPrefix = pattern.slice(0, MAX_NATIVE_INDEXOF_PATTERN_CHARS); + const prefixLength = searchPrefix.length; + const firstStart = text.indexOf(searchPrefix); + if (firstStart < 0) return; + + const failure = kmpFailure(pattern); + for (let i = firstStart + prefixLength - 1, k = prefixLength - 1; i < text.length; i++) { + // Skip ordinary spans in native code when no KMP prefix is active. The bounded prefix stays code-unit based. + if (k === 0) { + const start = text.indexOf(searchPrefix, i); + if (start < 0) return; + i = start + prefixLength - 1; + k = prefixLength - 1; + } + const textCode = text.charCodeAt(i); + let patternCode = k === 0 ? firstCode : pattern.charCodeAt(k); + while (k > 0 && textCode !== patternCode) { + k = failure[k - 1]!; + patternCode = k === 0 ? firstCode : pattern.charCodeAt(k); + } + if (textCode === patternCode) k++; + if (k === patternLength) { + yield { start: i - k + 1, end: i + 1 }; + k = failure[k - 1]!; } } - return endAt; } -/** Every start of `pattern` in `text`, overlapping ones included (Knuth-Morris-Pratt). */ -function* occurrences(text: string, pattern: string): Generator { +/** Prefix lengths for Knuth-Morris-Pratt. Long candidate patterns use this to retain linear scan cost. */ +function kmpFailure(pattern: string): Int32Array { const failure = new Int32Array(pattern.length); for (let i = 1, k = 0; i < pattern.length; i++) { - while (k > 0 && pattern.charCodeAt(i) !== pattern.charCodeAt(k)) k = failure[k - 1]; + while (k > 0 && pattern.charCodeAt(i) !== pattern.charCodeAt(k)) k = failure[k - 1]!; if (pattern.charCodeAt(i) === pattern.charCodeAt(k)) k++; failure[i] = k; } - for (let i = 0, k = 0; i < text.length; i++) { - while (k > 0 && text.charCodeAt(i) !== pattern.charCodeAt(k)) k = failure[k - 1]; - if (text.charCodeAt(i) === pattern.charCodeAt(k)) k++; - if (k === pattern.length) { - yield i - k + 1; - k = failure[k - 1]; + return failure; +} + +/** + * Find wrap-tolerant matches from one shared compact-text merge and one source-offset ring. All bare starts are + * processed monotonically; a ring as long as the longest form plus one retains every start through the farthest + * end considered at that start, including overlapping candidates at later starts. + */ +function* wrappedMatches(text: string, bareText: string, patterns: readonly string[]): Generator { + const occurrences = orderedOccurrences(patterns.map(pattern => candidateOccurrences(bareText, pattern))); + let next = occurrences.next(); + if (next.done) return; + + const longestPattern = patterns[0]!.length; // knownForms sorts patterns longest first + const ringLength = longestPattern + 1; + const positions = new Uint32Array(ringLength); + const oversizedAfter = new Uint32Array(ringLength); + let raw = 0; + let bare = 0; + let previous = -1; + let oversizedGaps = 0; + + const translate = (start: number, end: number): KnownMatch | null => { + while (bare < end) { + const code = text.charCodeAt(raw); + if (!isWrapCharacter(code)) { + if (previous >= 0 && raw - previous - 1 > MAX_WRAP_GAP) oversizedGaps++; + const slot = bare % ringLength; + positions[slot] = raw; + oversizedAfter[slot] = oversizedGaps; + bare++; + previous = raw; + } + raw++; + } + const startSlot = start % ringLength; + const endSlot = (end - 1) % ringLength; + // Exclude the gap before the first matched character; only interior gaps can invalidate this candidate. + if (oversizedAfter[endSlot]! - oversizedAfter[startSlot]! > 0) return null; + return { start: positions[startSlot]!, end: positions[endSlot]! + 1 }; + }; + + while (!next.done) { + const compactStart = next.value.start; + let rawStart = -1; + let rawEnd = -1; + while (!next.done && next.value.start === compactStart) { + // Check every candidate before selecting the longest. A longer form can cross an oversized gap while a + // shorter form at the same start remains valid. + const translated = translate(next.value.start, next.value.end); + if (translated) { + rawStart = translated.start; + rawEnd = Math.max(rawEnd, translated.end); + } + next = occurrences.next(); } + if (rawEnd >= 0) yield { start: rawStart, end: rawEnd }; } } - function lastChars(text: string, maxChars: number): string { return text.length <= maxChars ? text : text.slice(text.length - maxChars); } @@ -346,15 +483,15 @@ function isLineStartUncrossed(text: string, b: number, last: number, forms: Know // Held-value matches that start before `b` lie within knownSpan of it. const checkFrom = Math.max(0, Math.min(last, quote)); const from = Math.max(0, checkFrom - knownSpan); - const endAt = knownMatchEnds(text.slice(from, Math.min(text.length, b + knownSpan)), forms); - if (!endAt) return true; - for (let at = 0; from + at < b; at++) { - if (endAt[at] !== 0 && from + endAt[at] > checkFrom) return false; + const window = text.slice(from, Math.min(text.length, b + knownSpan)); + for (const match of knownMatches(window, forms)) { + if (from + match.start >= b) break; + if (from + match.end > checkFrom) return false; } return true; } -type Rule = { kind: string; pattern: RegExp; replace?: (match: string, ...groups: string[]) => string }; +type Rule = { kind: string; pattern?: RegExp; transform?: (text: string) => string; replace?: (match: string, ...groups: string[]) => string }; const marker = (kind: string): string => ``; const NOT_MASKED = '(?!|[:=])\\s*)(?:"${NOT_MASKED}[^"\\r\\n]{4,}"|'${NOT_MASKED}[^'\\r\\n]{4,}'|\`${NOT_MASKED}[^\`\\r\\n]{4,}\`|${NOT_MASKED}[^\\s"'\`<>,;]{4,})`, 'gu'), replace: (_match, prefix) => `${prefix}${marker('assignment')}` }, // A random run the terminal wrapped over lines, judged as one run. - { kind: 'high-entropy', pattern: new RegExp(`(? { const joined = match.replace(/[\s\u2500-\u257f|]/gu, ''); return joined.length >= 32 && looksRandom(joined) ? marker('high-entropy') : match; } }, + { kind: 'high-entropy', transform: text => maskEntropyRanges(text, wrappedEntropyRanges(text)) }, // A long run that mixes upper case, lower case and digits with high entropy. Pure hex (a commit SHA) has no // upper case and survives; paths never form one run because `/` and `.` end it. - { kind: 'high-entropy', pattern: /(? looksRandom(match) ? marker('high-entropy') : match } + { kind: 'high-entropy', transform: text => maskEntropyRanges(text, unwrappedEntropyRanges(text)) } ]; -function looksRandom(value: string): boolean { - const upper = value.match(/[A-Z]/gu)?.length ?? 0, lower = value.match(/[a-z]/gu)?.length ?? 0, digits = value.match(/[0-9]/gu)?.length ?? 0; +/** + * Redact registered values and credential shapes against their original source ranges together. Applying the + * registry first can destroy a structural label (for example `PRIVATE KEY`, `password`, or `Authorization`) + * before its shape is recognized. Applying generic rules first has the inverse problem: a generic match can + * split a longer registered value. Keeping both sets of source ranges lets the renderer mask their union. + */ +function redactKnownAndCredentials(text: string, forms: KnownForms): string { + const streams: Generator[] = []; + const known = forms.bare.length > 0 || forms.exact.length > 0 ? [...knownMatches(text, forms)] : []; + if (known.length > 0) { + streams.push((function* (): Generator { + for (const match of known) yield { ...match, kind: 'secret', priority: 0 }; + })()); + } + streams.push(jsonSecretRanges(text)); + for (let index = 0; index < RULES.length; index++) { + const rule = RULES[index]!; + if (rule.pattern) streams.push(ruleSecretRanges(text, rule, 10 + index)); + } + // A random run is judged without the held values in it, as when they were masked first: otherwise a name joined + // to a held value (`PROVIDER_API_KEY=`) would form one random-looking run and lose its name. + const entropyText = withHeldValuesBroken(text, known); + streams.push(wrappedEntropyRanges(entropyText)); + streams.push(unwrappedEntropyRanges(entropyText)); + + let result = ''; + let copied = 0; + let active: KnownMatch | null = null; + let chosen: KnownMatch | null = null; + for (const candidate of orderedOccurrences(streams)) { + if (!candidate.kind || candidate.end <= candidate.start) continue; + if (active && candidate.start < active.end) { + active.end = Math.max(active.end, candidate.end); + if (preferMask(candidate, chosen!)) chosen = candidate; + continue; + } + if (active && chosen) { + result += text.slice(copied, active.start) + marker(chosen.kind!); + copied = active.end; + } + active = { start: candidate.start, end: candidate.end }; + chosen = candidate; + } + if (!active || !chosen) return text; + return result + text.slice(copied, active.start) + marker(chosen.kind!) + text.slice(active.end); +} + +/** `text` with every held-value match replaced by as many characters that end a random run (offsets unchanged). */ +function withHeldValuesBroken(text: string, known: readonly KnownMatch[]): string { + if (known.length === 0) return text; + let result = ''; + let copied = 0; + for (const { start, end } of known) { + if (end <= copied) continue; + const from = Math.max(start, copied); + result += text.slice(copied, from) + '\u0001'.repeat(end - from); + copied = end; + } + return result + text.slice(copied); +} + +/** Prefer the most complete source match, while preserving JSON's earlier guard against later generic rules. */ +function preferMask(candidate: KnownMatch, current: KnownMatch): boolean { + const candidateIsJson = candidate.priority === 1; + const currentIsJson = current.priority === 1; + const candidateIsGeneric = (candidate.priority ?? 0) >= 10; + const currentIsGeneric = (current.priority ?? 0) >= 10; + if (candidateIsJson && currentIsGeneric) return !privateKeyContainsJson(current, candidate); + if (currentIsJson && candidateIsGeneric) return privateKeyContainsJson(candidate, current); + const candidateLength = candidate.end - candidate.start; + const currentLength = current.end - current.start; + if (candidateLength !== currentLength) return candidateLength > currentLength; + return (candidate.priority ?? Number.MAX_SAFE_INTEGER) < (current.priority ?? Number.MAX_SAFE_INTEGER); +} + +/** PEM masking runs after the JSON-value pass and can still consume a nested JSON marker inside its body. */ +function privateKeyContainsJson(privateKey: KnownMatch, json: KnownMatch): boolean { + return privateKey.kind === 'private-key' && privateKey.start <= json.start && privateKey.end >= json.end + && (privateKey.start < json.start || privateKey.end > json.end); +} + +function* jsonSecretRanges(text: string): Generator { + const pattern = new RegExp(JSON_SECRET_VALUE.source, JSON_SECRET_VALUE.flags); + for (let match = pattern.exec(text); match; match = pattern.exec(text)) { + const prefix = match[1] ?? ''; + const closing = match[2] ?? ''; + const start = match.index + prefix.length; + const end = match.index + match[0].length - closing.length; + if (end > start) yield { start, end, kind: 'secret', priority: 1 }; + } +} + +function* ruleSecretRanges(text: string, rule: Rule, priority: number): Generator { + const originalPattern = rule.pattern!; + const flags = originalPattern.flags.includes('g') ? originalPattern.flags : `${originalPattern.flags}g`; + const pattern = new RegExp(originalPattern.source, flags); + for (let match = pattern.exec(text); match; match = pattern.exec(text)) { + let start = match.index; + let end = match.index + match[0].length; + switch (rule.kind) { + case 'authorization': + // Its replacement keeps the Authorization label and masks an optional Bearer/Basic/etc. scheme too. + start += (match[1] ?? '').length; + break; + case 'bearer': { + const prefix = /^Bearer\s+/u.exec(match[0]); + start += prefix?.[0].length ?? 0; + break; + } + case 'url-credentials': { + const scheme = match[1] ?? ''; + const userinfo = match[2] ?? ''; + // Keep the existing rule's allowance for a short, plain user name without a password. + if (!userinfo.includes(':') && userinfo.length < 16) continue; + start += scheme.length; + end = start + userinfo.length; + break; + } + case 'url-secret': + start += (match[1] ?? '').length; + break; + case 'assignment': { + // JSON_SECRET_VALUE runs first in the public pipeline. When it recognizes the same quoted key/value, + // its marker blocks this later assignment match and preserves the JSON quotes. + const jsonMatch = new RegExp(JSON_SECRET_VALUE.source, JSON_SECRET_VALUE.flags).exec(match[0]); + if (jsonMatch?.index === 0 && jsonMatch[0].length === match[0].length) continue; + start += (match[1] ?? '').length; + break; + } + } + if (end > start) yield { start, end, kind: rule.kind, priority }; + } +} + +function* wrappedEntropyRanges(text: string): Generator { + for (let at = 0; at < text.length;) { + if (!isEntropyRunCharacter(text.charCodeAt(at)) || (at > 0 && isEntropyRunCharacter(text.charCodeAt(at - 1)))) { + at++; + continue; + } + const firstEnd = entropyRunEnd(text, at); + if (firstEnd - at < 12) { + at = firstEnd; + continue; + } + let end = firstEnd; + let joinedLength = firstEnd - at; + let continuations = 0; + while (true) { + let next = end; + if (text.charCodeAt(next) === 0x0d && text.charCodeAt(next + 1) === 0x0a) next += 2; + else if (text.charCodeAt(next) === 0x0a) next++; + else break; + let decoration = 0; + while (decoration < 8 && next < text.length) { + const code = text.charCodeAt(next); + if (!(code === 0x20 || code === 0x09 || code === 0x7c || (code >= 0x2500 && code <= 0x257f))) break; + next++; + decoration++; + } + const nextEnd = entropyRunEnd(text, next); + if (nextEnd - next < 4 || !hasDigit(text, next, nextEnd)) break; + joinedLength += nextEnd - next; + continuations++; + end = nextEnd; + } + if (continuations > 0) { + if (joinedLength >= 32 && looksRandomRange(text, at, end, true)) { + yield { start: at, end, kind: 'high-entropy', priority: 10 + RULES.length }; + } + // A low-entropy wrapped candidate consumes its continuation too. + at = end; + } else at = firstEnd; + } +} + +function* unwrappedEntropyRanges(text: string): Generator { + for (let at = 0; at < text.length;) { + if (!isEntropyRunCharacter(text.charCodeAt(at)) || (at > 0 && isEntropyRunCharacter(text.charCodeAt(at - 1)))) { + at++; + continue; + } + const end = entropyRunEnd(text, at); + if (end - at >= 32 && looksRandomRange(text, at, end, false)) { + yield { start: at, end, kind: 'high-entropy', priority: 11 + RULES.length }; + } + at = end; + } +} + +function looksRandomRange(value: string, from: number, to: number, skipWrap: boolean): boolean { + let upper = 0, lower = 0, digits = 0; + for (let i = from; i < to && (upper < 2 || lower < 2 || digits < 2); i++) { + const code = value.charCodeAt(i); + if (skipWrap && (code === 0x7c || isWrapCharacter(code))) continue; + if (code >= 0x41 && code <= 0x5a) upper++; + else if (code >= 0x61 && code <= 0x7a) lower++; + else if (code >= 0x30 && code <= 0x39) digits++; + } if (upper < 2 || lower < 2 || digits < 2) return false; - const counts = new Map(); - for (const character of value) counts.set(character, (counts.get(character) ?? 0) + 1); + const counts = new Map(); + let length = 0; + for (let i = from; i < to; i++) { + const code = value.charCodeAt(i); + if (skipWrap && (code === 0x7c || isWrapCharacter(code))) continue; + counts.set(code, (counts.get(code) ?? 0) + 1); + length++; + } let entropy = 0; - for (const count of counts.values()) { const p = count / value.length; entropy -= p * Math.log2(p); } + for (const count of counts.values()) { const p = count / length; entropy -= p * Math.log2(p); } return entropy >= 4.2; } +const isEntropyRunCharacter = (code: number): boolean => code >= 0x30 && code <= 0x39 + || code >= 0x41 && code <= 0x5a || code >= 0x61 && code <= 0x7a + || code === 0x2b || code === 0x3d || code === 0x5f || code === 0x2d; + +function entropyRunEnd(text: string, from: number): number { + let end = from; + while (end < text.length && isEntropyRunCharacter(text.charCodeAt(end))) end++; + return end; +} + +function hasDigit(text: string, from: number, to: number): boolean { + for (let i = from; i < to; i++) { + const code = text.charCodeAt(i); + if (code >= 0x30 && code <= 0x39) return true; + } + return false; +} + +/** Render the same bounded entropy scans used when registered values overlap credential shapes. */ +function maskEntropyRanges(text: string, ranges: Iterable): string { + let result = ''; + let copied = 0; + for (const { start, end } of ranges) { + result += text.slice(copied, start) + marker('high-entropy'); + copied = end; + } + return copied === 0 ? text : result + text.slice(copied); +} + /** The generic shapes alone (no registered values). */ export function redactCredentials(text: string): string { if (typeof text !== 'string' || text.length === 0) return typeof text === 'string' ? text : ''; let result = text; for (const rule of RULES) { + if (rule.transform) { + result = rule.transform(result); + continue; + } + if (!rule.pattern) continue; result = result.replace(rule.pattern, (match: string, ...rest: unknown[]) => { // Arguments after the match: the capture groups, then the numeric offset and the whole input. const end = rest.findIndex(value => typeof value === 'number'); @@ -420,3 +792,9 @@ export function redactCredentials(text: string): string { } return result; } + +/** The registry's pre-range pipeline when it has no held values to mask. */ +function redactJsonAndCredentials(text: string): string { + const withJsonValues = text.replace(JSON_SECRET_VALUE, (_match, prefix: string, closing: string) => `${prefix}${marker('secret')}${closing}`); + return redactCredentials(withJsonValues); +} diff --git a/src/main/services/safety/baseProtection.ts b/src/main/services/safety/baseProtection.ts index 2675e022d..ee1116da3 100644 --- a/src/main/services/safety/baseProtection.ts +++ b/src/main/services/safety/baseProtection.ts @@ -1,5 +1,5 @@ import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { isAbsolute, join, normalize } from 'node:path'; import { isPathInside } from '../../../agent-runtime/path-inside.mjs'; import { analyzeAction, commandFromArgv, realish, type HardFacts, type PrivateData, type ToolAction } from './commandFacts.ts'; @@ -10,7 +10,7 @@ import { analyzeAction, commandFromArgv, realish, type HardFacts, type PrivateDa * model: local rules only, no git, no network. */ -const BASE_DENY_RULES = ['app-private', 'elevation', 'pipe-to-shell', 'download-exec', 'disk', 'fork-bomb', 'delete-outside', 'write-outside', 'unknown-target'] as const; +const BASE_DENY_RULES = ['app-private', 'elevation', 'pipe-to-shell', 'download-exec', 'disk', 'fork-bomb', 'delete-outside', 'write-outside', 'unknown-target', 'uncheckable'] as const; export type BaseDenyRule = typeof BASE_DENY_RULES[number]; /** What the model reads: why the call was refused and what to do instead. */ @@ -23,27 +23,57 @@ const DENY_MESSAGES: Readonly> = { 'fork-bomb': 'CanvasTTY blocked this command: it would exhaust the computer\'s processes. Do not run it.', 'delete-outside': 'CanvasTTY blocked this command: it deletes files outside the project folder (or the folder itself). Delete only inside the project; if something elsewhere must go, ask the person.', 'write-outside': 'CanvasTTY blocked this command: it writes outside the project folder. Keep changes inside the project; if a file elsewhere must change, ask the person.', - 'unknown-target': 'CanvasTTY blocked this command: it deletes or moves a path held in a shell variable or command output that CanvasTTY cannot resolve, so it cannot tell whether the path is inside the project folder. Write the path out, or set the variable in the same command to a path inside the project, and run it again.' + 'unknown-target': 'CanvasTTY blocked this command: it deletes or moves a path held in a shell variable or command output that CanvasTTY cannot resolve, so it cannot tell whether the path is inside the project folder. Write the path out, or set the variable in the same command to a path inside the project, and run it again.', + uncheckable: 'CanvasTTY blocked this tool call: it names more paths, longer paths or more nested commands than base protection checks in one call, so it cannot tell what the call reaches. Split it into smaller calls (for example at most 32 files per read), write paths out plainly, and run it again.' }; const TEMP_WRITE_MESSAGE = 'CanvasTTY blocked this command: it writes to the temporary folder (/tmp or $TMPDIR), which is outside the project folder. Make a scratch folder inside the project instead (for example ./tmp, added to .gitignore if needed) and use that; if a file elsewhere must change, ask the person.'; +const ANALYZER_FAILURE_MESSAGE = 'CanvasTTY blocked this tool call: base protection failed while checking it, so it cannot tell what the call reaches. Write the command and paths out plainly and run it again; if it keeps failing, ask the person.'; + export interface BaseVerdict { rule: BaseDenyRule; message: string } /** Tool names of the CLIs' hooks: shells and file writes. Anything else is not checked. */ const SHELL_TOOLS = new Set(['Bash', 'bash', 'run_shell_command', 'shell', 'local_shell', 'exec_command']); const EDIT_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'write_file', 'edit', 'replace', 'apply_patch']); +/** + * Read-only file tools: Claude Code's Read, NotebookRead, Grep, Glob and LS; Qwen Code's read_file, read_many_files, + * grep_search (search_file_content), glob and list_directory; OpenCode's read, grep, glob and list arrive under the + * Claude names. They are checked only against CanvasTTY's private data, never against the project folder. + */ +const READ_TOOLS = new Set(['Read', 'NotebookRead', 'Grep', 'Glob', 'LS', 'read_file', 'read_many_files', 'grep_search', 'search_file_content', 'glob', 'list_directory']); +/** Read-only tools whose `path` is a folder they walk (search), rather than one file or one listing. */ +const SEARCH_TOOLS = new Set(['Grep', 'Glob', 'grep_search', 'search_file_content', 'glob']); const MAX_PATHS = 32; +/** Files one `apply_patch` may touch and still be checked. */ +const MAX_PATCH_PATHS = 256; const MAX_PATH_CHARS = 4_096; -/** The files an `apply_patch` envelope touches. */ -export function patchPaths(patch: string): string[] { +/** + * A path field of a tool's input: the path, null when it is absent, or `false` when it is there but cannot be checked + * (not a string, or longer than MAX_PATH_CHARS even after `./` and `a/..` padding is folded away). + */ +function pathField(value: unknown): string | null | false { + if (value === undefined || value === null || value === '') return null; + if (typeof value !== 'string') return false; + if (value.length <= MAX_PATH_CHARS) return value; + const folded = normalize(value); + return folded.length <= MAX_PATH_CHARS ? folded : false; +} + +/** The files an `apply_patch` envelope touches; `uncheckable` when there are more, or longer ones, than are checked. */ +function patchTargets(patch: string): { paths: string[]; uncheckable: boolean } { const paths: string[] = []; for (const match of patch.matchAll(/^\*\*\* (?:Add|Update|Delete) File: (.+)$|^\*\*\* Move to: (.+)$/gmu)) { - const path = (match[1] ?? match[2] ?? '').trim(); - if (path && path.length <= MAX_PATH_CHARS) paths.push(path); - if (paths.length >= MAX_PATHS) break; + const path = pathField((match[1] ?? match[2] ?? '').trim()); + if (path === false || paths.length >= MAX_PATCH_PATHS) return { paths, uncheckable: true }; + if (path) paths.push(path); } - return paths; + return { paths, uncheckable: false }; +} + +/** The files an `apply_patch` envelope touches. */ +export function patchPaths(patch: string): string[] { + return patchTargets(patch).paths; } const PREVIEW_PATH = /"(?:file_path|filePath|path|notebook_path|absolute_path)"\s*:\s*"((?:[^"\\]|\\.){1,4096})"/gu; @@ -55,14 +85,24 @@ const PREVIEW_PATH = /"(?:file_path|filePath|path|notebook_path|absolute_path)"\ */ export function actionFromHook(toolName: string, toolInput: unknown, preview: string | null = null): ToolAction { const raw = toolInput && typeof toolInput === 'object' && !Array.isArray(toolInput) ? toolInput as Record : null; - const text = (value: unknown): string | null => typeof value === 'string' && value.length > 0 && value.length <= MAX_PATH_CHARS ? value : null; - const commandCwd = text(raw?.cwd) ?? text(raw?.workdir) ?? text(raw?.directory); + // A path field that is there but cannot be checked makes the call uncheckable: it is refused, never skipped. + let uncheckable = false; + const field = (...values: unknown[]): string | null => { + for (const value of values) { + const path = pathField(value); + if (path === false) uncheckable = true; + else if (path) return path; + } + return null; + }; + const commandCwd = field(raw?.cwd, raw?.workdir, raw?.directory); if (SHELL_TOOLS.has(toolName)) { const candidate = raw?.command ?? raw?.cmd; const command = typeof candidate === 'string' ? candidate : Array.isArray(candidate) && candidate.length && candidate.every(item => typeof item === 'string') ? commandFromArgv(candidate as string[]) : null; - return { kind: 'shell', command, commandCwd, paths: [] }; + return { kind: 'shell', command, commandCwd, paths: [], ...(uncheckable ? { uncheckable } : {}) }; } + if (READ_TOOLS.has(toolName)) return readAction(toolName, raw, preview); if (!EDIT_TOOLS.has(toolName)) return { kind: null, command: null, commandCwd: null, paths: [] }; if (!raw && preview && toolName !== 'apply_patch') { const paths: string[] = []; @@ -73,31 +113,71 @@ export function actionFromHook(toolName: string, toolInput: unknown, preview: st return { kind: 'edit', command: null, commandCwd: null, paths }; } if (toolName === 'apply_patch') { - const patch = text(raw?.command) ?? (typeof raw?.patch === 'string' ? raw.patch : typeof raw?.input === 'string' ? raw.input : typeof raw?.patchText === 'string' ? raw.patchText : ''); - return { kind: 'edit', command: null, commandCwd, paths: patchPaths(patch ?? '') }; + const patch = (typeof raw?.command === 'string' && raw.command ? raw.command : null) ?? (typeof raw?.patch === 'string' ? raw.patch : typeof raw?.input === 'string' ? raw.input : typeof raw?.patchText === 'string' ? raw.patchText : ''); + const targets = patchTargets(patch ?? ''); + return { kind: 'edit', command: null, commandCwd, paths: targets.paths, ...(uncheckable || targets.uncheckable ? { uncheckable: true } : {}) }; + } + const path = field(raw?.file_path, raw?.filePath, raw?.path, raw?.notebook_path, raw?.absolute_path); + return { kind: 'edit', command: null, commandCwd: null, paths: path ? [path] : [], ...(uncheckable ? { uncheckable } : {}) }; +} + +/** A read-only tool's files, glob patterns and searched folders. Cut input: the paths at the start of its preview. */ +function readAction(toolName: string, raw: Record | null, preview: string | null): ToolAction { + const action: ToolAction = { kind: 'read', command: null, commandCwd: null, paths: [], searched: [] }; + if (!raw) { + for (const match of (preview ?? '').matchAll(PREVIEW_PATH)) { + try { const value: unknown = JSON.parse(`"${match[1]}"`); if (typeof value === 'string' && value) action.searched!.push(value); } catch { /* a cut escape */ } + if (action.searched!.length >= 4) break; + } + return action; } - const path = text(raw?.file_path) ?? text(raw?.filePath) ?? text(raw?.path) ?? text(raw?.notebook_path) ?? text(raw?.absolute_path); - return { kind: 'edit', command: null, commandCwd: null, paths: path ? [path] : [] }; + // A path field that is there but cannot be checked (not a string, too long) makes the call uncheckable: refused. + const field = (...values: unknown[]): string | null => { + for (const value of values) { + const path = pathField(value); + if (path === false) action.uncheckable = true; + else if (path) return path; + } + return null; + }; + const file = field(raw.file_path, raw.filePath, raw.notebook_path, raw.absolute_path); + if (file) action.paths.push(file); + const folder = field(raw.path, raw.dir_path, raw.directory); + if (folder) (SEARCH_TOOLS.has(toolName) ? action.searched! : action.paths).push(folder); + // A glob pattern names files itself (`/…/canvastty/provider-*`); relative ones resolve against the searched folder. + const pattern = toolName === 'Glob' || toolName === 'glob' ? field(raw.pattern) : null; + if (pattern) action.paths.push(folder && !isAbsolute(pattern) && !pattern.startsWith('~') && !pattern.startsWith('$') ? join(folder, pattern) : pattern); + // Qwen Code's read_many_files: `paths` and `include` are both globs it expands and reads. More than are checked is + // refused, not cut short. + const many = [raw.paths, raw.include].flatMap(list => list === undefined || list === null ? [] : Array.isArray(list) ? list : [list]); + if (many.length > MAX_PATHS) { action.uncheckable = true; return action; } + for (const item of many) { const path = field(item); if (path) action.searched!.push(path); } + return action; } /** * CanvasTTY's own private data under its userData folder (the app passes `app.getPath('userData')`; tests pass a * temporary folder): the control token and descriptor, the gateways' connection records and sockets, the secret - * stores, the per-account homes and the prepared launch runs. Its settings and layouts are not listed. + * stores, the per-account homes, the prepared launch runs, the per-card Claude Code draft folders (claude-draft, where + * the app and one card exchange that card's prompt draft) and the plugins' data (plugin-data: an accounts plugin keeps + * every account's sign-in there). Its settings and layouts are not listed. A card's own account home or project inside + * them stays its own (agentRoots, the project). plugin-data is not a marker: a project or account home inside it names + * that folder in its own paths. */ export function canvasTtyPrivateData(userDataPath: string): PrivateData { const names = ['agent-control', join('browser', 'runtime'), join('lifecycle', 'runtime'), join('orchestration', 'runtime'), - 'provider-secrets.bin', 'plugin-secrets', 'account-homes', 'github-oauth.json', 'launch-runs']; + 'provider-secrets.bin', 'plugin-secrets', 'account-homes', 'github-oauth.json', 'launch-runs', 'claude-draft', 'plugin-data']; return { appRoots: [userDataPath], paths: names.map(name => join(userDataPath, name)), - markers: ['agent-control', 'provider-secrets', 'plugin-secrets', 'account-homes', 'github-oauth'] + markers: ['agent-control', 'provider-secrets', 'plugin-secrets', 'account-homes', 'github-oauth', 'claude-draft'] }; } /** The first deny rule a set of facts breaks, in a fixed order. */ export function denyRule(facts: HardFacts): BaseDenyRule | null { if (facts.appPrivate) return 'app-private'; + if (facts.uncheckable) return 'uncheckable'; if (facts.elevation) return 'elevation'; if (facts.pipeToShell) return 'pipe-to-shell'; if (facts.downloadExec) return 'download-exec'; @@ -112,7 +192,8 @@ export function denyRule(facts: HardFacts): BaseDenyRule | null { /** * Base protection for one hook call: a deny with its message, or null (no opinion). `root` is the session's * working folder; `agentRoots` the agent's own config folders, whose plan and memory folders are not "outside". - * Any failure is null: the rules only ever add a deny. + * A failure while checking a shell, file-edit or read call denies it as `uncheckable` (fail closed); any other tool + * has no opinion (null). */ export function checkBaseProtection(input: { toolName: string; toolInput: unknown; preview?: string | null; root: string; commandCwd?: string | null; @@ -123,6 +204,7 @@ export function checkBaseProtection(input: { try { const action = actionFromHook(input.toolName, input.toolInput, input.preview ?? null); if (action.kind === null) return null; + if (action.uncheckable) return { rule: 'uncheckable', message: DENY_MESSAGES.uncheckable }; // The agent's current folder (the hook reports it) resolves relative paths; the working folder stays the root. if (!action.commandCwd && input.commandCwd) action.commandCwd = input.commandCwd; const facts = analyzeAction(action, input.root, { @@ -134,7 +216,8 @@ export function checkBaseProtection(input: { if (!rule) return null; return { rule, message: rule === 'write-outside' && writesOnlyToTemp(facts) ? TEMP_WRITE_MESSAGE : DENY_MESSAGES[rule] }; } catch { - return null; + const checked = SHELL_TOOLS.has(input.toolName) || EDIT_TOOLS.has(input.toolName) || READ_TOOLS.has(input.toolName); + return checked ? { rule: 'uncheckable', message: ANALYZER_FAILURE_MESSAGE } : null; } } diff --git a/src/main/services/safety/commandFacts.ts b/src/main/services/safety/commandFacts.ts index fe515213d..8260378b6 100644 --- a/src/main/services/safety/commandFacts.ts +++ b/src/main/services/safety/commandFacts.ts @@ -12,15 +12,19 @@ import { lexShell, shellQuote, type Segment, type Word } from './shellParse.ts'; * data (its access tokens, secret stores and control sockets). */ -/** One tool call, source-neutral: a shell command or the files a file tool writes. */ +/** One tool call, source-neutral: a shell command, the files a file tool writes, or the files a read-only tool reads. */ export interface ToolAction { - kind: 'shell' | 'edit' | null; + kind: 'shell' | 'edit' | 'read' | null; /** The shell command; null when none could be read. */ command: string | null; /** The command's own working directory, if the agent said one. */ commandCwd: string | null; - /** Paths a file tool writes. */ + /** Paths a file tool writes, or (`read`) the files and glob patterns a read-only tool reads. */ paths: string[]; + /** `read` only: folders a search or listing tool walks (Grep's or Glob's `path`). */ + searched?: string[]; + /** The call names more paths, or longer ones, than are checked: it is refused, not checked in part. */ + uncheckable?: boolean; } export type Where = 'inside' | 'outside' | 'unresolved'; @@ -51,6 +55,11 @@ export interface HardFacts { * (inside the project or not) is unknown. */ unknownTarget: boolean; + /** + * More than the rules check in one call (paths against the private data, brace expansions, nested shells): the rest + * was not looked at, so the call is refused rather than let through unchecked. + */ + uncheckable: boolean; } /** @@ -228,7 +237,7 @@ interface Acc { ctx: PathContext; writes: Target[]; deletes: Target[]; - flags: { elevation: boolean; pipeToShell: boolean; downloadExec: boolean; disk: boolean; forkBomb: boolean; appPrivate: boolean; unknownTarget: boolean }; + flags: { elevation: boolean; pipeToShell: boolean; downloadExec: boolean; disk: boolean; forkBomb: boolean; appPrivate: boolean; unknownTarget: boolean; uncheckable: boolean }; depth: number; budget: number; /** Nesting of argv inside wrappers (nohup, env, xargs, su -c, busybox …): bounded like substitutions. */ @@ -243,7 +252,8 @@ function wordOf(text: string): Word { return { text, quoted: false, vars: [], su /** Analyses one shell command string (recursively for `bash -c`, `eval`, substitutions). */ function analyzeText(command: string, cwd: string | null, acc: Acc): string | null { - if (acc.depth > 4 || --acc.budget < 0) return cwd; + // Deeper or more nested commands than are followed: the rest is not checked, so the call is refused. + if (acc.depth > 4 || --acc.budget < 0) { acc.flags.uncheckable = true; return cwd; } acc.depth++; try { const lexed = lexShell(command); @@ -543,7 +553,11 @@ function runInterpreter(program: string, argWords: Word[], cwd: string | null, a } if (stdin.pipeIn) acc.flags.pipeToShell = true; // A program read from a heredoc (`python3 - < privateHit(text, cwd, acc, false)) || codeNamesPrivate(stdin.heredoc, acc.ctx))) acc.flags.appPrivate = true; + if (stdin.heredoc !== null) { + const found = privateCandidates(stdin.heredoc, acc.ctx); + if (found === null) acc.flags.uncheckable = true; + else if (found.some(text => privateHit(text, cwd, acc, false)) || codeNamesPrivate(stdin.heredoc, acc.ctx)) acc.flags.appPrivate = true; + } return cwd; } @@ -560,14 +574,20 @@ const CONTROL_CLI = /^canvastty-control(?:\.mjs)?$/u; /** Programs that walk folders by themselves: naming a folder that holds private data reads it. */ const RECURSIVE = new Set(['rg', 'ag', 'ack', 'find', 'fd', 'tar', 'bsdtar', 'zip', '7z', 'rsync', 'ditto', 'scp', 'rclone']); const GLOB_CHAR = /[*?[]/u; -const MAX_PRIVATE_CHECKS = 256; +const MAX_PRIVATE_CHECKS = 1024; +/** Paths found inside one word or program text (`--opt=/a:/b`, interpreter code). */ +const MAX_CANDIDATES = 256; const MAX_SCANNED_TEXT = 64 * 1024; const parts = (path: string): string[] => path.split(/[\\/]+/u).filter(Boolean); +/** macOS (APFS, HFS+) and Windows file systems ignore case by default: a pattern there matches names in any case. */ +const FOLD_CASE = process.platform === 'darwin' || process.platform === 'win32'; +const MAX_BRACE_EXPANSIONS = 64; + /** A glob component (`token-*`, `ctty-control-????`) against one real name. */ function componentMatches(pattern: string, name: string): boolean { - if (!GLOB_CHAR.test(pattern)) return pattern === name; + if (!GLOB_CHAR.test(pattern)) return FOLD_CASE ? pattern.toLowerCase() === name.toLowerCase() : pattern === name; let source = ''; for (let i = 0; i < pattern.length; i++) { const char = pattern[i]!; @@ -580,7 +600,112 @@ function componentMatches(pattern: string, name: string): boolean { i = close; } else source += char.replace(/[.+^${}()|\\\]]/gu, '\\$&'); } - try { return new RegExp(`^${source}$`, 'u').test(name); } catch { return true; } + try { return new RegExp(`^${source}$`, FOLD_CASE ? 'iu' : 'u').test(name); } catch { return true; } +} + +/** + * A glob's components against a real path's: `full` when the glob names the path or something inside it, `ancestor` + * when the whole glob already matched a folder above it. `**` matches zero or more components; a glob that only + * passes through the path on its way to other names (`~/**` then `*.ts`, past a private folder) does not name it. + */ +function globAgainst(pattern: readonly string[], target: readonly string[]): { full: boolean; ancestor: boolean } { + const close = (states: Set): Set => { + for (const state of [...states]) for (let i = state; i < pattern.length && pattern[i] === '**'; i++) states.add(i + 1); + return states; + }; + let states = close(new Set([0])); + let named = new Set(); + let ancestor = false; + for (const name of target) { + if (states.has(pattern.length)) ancestor = true; + const matched = new Set(); + const within = new Set(); + for (const state of states) { + if (state >= pattern.length) continue; + if (pattern[state] === '**') within.add(state); + else if (componentMatches(pattern[state]!, name)) matched.add(state + 1); + } + named = close(matched); + states = close(new Set([...named, ...within])); + if (!states.size) return { full: false, ancestor }; + } + // Named: the last name matched a component of the glob, or all that is left of the glob is `**`. + const full = [...states].some(state => named.has(state) || pattern.slice(state).every(part => part === '**')); + return { full, ancestor }; +} + +/** + * The words a shell's brace expansion makes of `text` (`a.{bin,json}` is `a.bin` and `a.json`); `text` itself when it + * has none. Sequences are expanded too: `provider-secrets.bi{n..n}` is `provider-secrets.bin` to bash and zsh. Null + * when it makes more than MAX_BRACE_EXPANSIONS words: they are not checked, and the call is refused. + */ +function braceExpansions(text: string): string[] | null { + const out: string[] = []; + let over = false; + const walk = (value: string): void => { + if (over) return; + if (out.length >= MAX_BRACE_EXPANSIONS) { over = true; return; } + for (let open = value.indexOf('{'); open >= 0; open = value.indexOf('{', open + 1)) { + let depth = 0; + let end = -1; + const commas: number[] = []; + for (let i = open; i < value.length; i++) { + if (value[i] === '{') depth++; + else if (value[i] === '}' && --depth === 0) { end = i; break; } + else if (value[i] === ',' && depth === 1) commas.push(i); + } + if (end < 0) break; + if (!commas.length) { + const sequence = braceSequence(value.slice(open + 1, end)); + if (sequence === undefined) continue; + for (const item of sequence) walk(value.slice(0, open) + item + value.slice(end + 1)); + return; + } + const bounds = [open, ...commas, end]; + for (let k = 0; k + 1 < bounds.length; k++) walk(value.slice(0, open) + value.slice(bounds[k]! + 1, bounds[k + 1]) + value.slice(end + 1)); + return; + } + out.push(value); + }; + walk(text); + return over ? null : out; +} + +const BRACE_SEQUENCE = /^(?:([+-]?\d+)\.\.([+-]?\d+)|([A-Za-z])\.\.([A-Za-z]))(?:\.\.([+-]?\d+))?$/u; + +/** + * The words of a brace sequence body (`1..3`, `a..e`, `01..10..2`), undefined when `body` is not one. A longer one + * than MAX_BRACE_EXPANSIONS stands for any name there (`*`): `for i in {1..100}` stays allowed, and a private name it + * could make is still matched, as a glob. + */ +function braceSequence(body: string): string[] | undefined { + const match = BRACE_SEQUENCE.exec(body); + if (!match) return undefined; + const step = Math.abs(Number(match[5] ?? 1)) || 1; + const numeric = match[1] !== undefined; + const from = numeric ? Number(match[1]) : match[3]!.charCodeAt(0); + const to = numeric ? Number(match[2]) : match[4]!.charCodeAt(0); + const count = Math.floor(Math.abs(to - from) / step) + 1; + if (!Number.isSafeInteger(from) || !Number.isSafeInteger(to) || !Number.isSafeInteger(step) || count > MAX_BRACE_EXPANSIONS) return ['*']; + // `{01..10}`: a leading zero pads every number to the wider end. + const width = numeric && (/^[+-]?0\d/u.test(match[1]!) || /^[+-]?0\d/u.test(match[2]!)) ? Math.max(match[1]!.length, match[2]!.length) : 0; + const direction = to >= from ? 1 : -1; + return Array.from({ length: count }, (_, k) => { + const value = from + direction * k * step; + if (!numeric) return String.fromCharCode(value); + const digits = String(Math.abs(value)).padStart(Math.max(0, width - (value < 0 ? 1 : 0)), '0'); + return value < 0 ? `-${digits}` : digits; + }); +} + +/** The gateway socket folder under a temporary folder that holds `abs` (`$TMPDIR/ctty-control-XXXX`), or null. */ +function socketFolder(abs: string, ctx: PathContext): string | null { + for (const temp of ctx.tempRoots) { + if (!isPathInside(temp, abs, { allowRoot: false })) continue; + const first = parts(relative(temp, abs))[0] ?? ''; + if (SOCKET_DIR.test(first)) return join(temp, first); + } + return null; } /** @@ -589,33 +714,31 @@ function componentMatches(pattern: string, name: string): boolean { */ function privatePath(abs: string, glob: boolean, recursive: boolean, ctx: PathContext): boolean { if (!glob) { - // The project, and the agent's own config folder (an account home it was launched with), are its own. - if (isPathInside(ctx.rootReal, abs) || ctx.agentRoots.some(dir => isPathInside(dir, abs))) return false; - if (ctx.privatePaths.some(path => isPathInside(path, abs))) return true; if (recursive && ctx.privatePaths.some(path => isPathInside(abs, path)) && ctx.appRoots.some(root => isPathInside(root, abs))) return true; - return ctx.tempRoots.some(temp => isPathInside(temp, abs, { allowRoot: false }) && SOCKET_DIR.test(parts(relative(temp, abs))[0] ?? '')); + const socket = socketFolder(abs, ctx); + const holders = [...ctx.privatePaths.filter(path => isPathInside(path, abs)), ...(socket ? [socket] : [])]; + if (!holders.length) return false; + // The project, and the agent's own config folder (an account home it was launched with), are its own, but only + // below the private data: a broad folder (HOME, or one that holds the app's data or a temporary folder) does not + // make the private data inside it the agent's. A folder inside a private path (that account home) stays its own. + const owns = (root: string): boolean => isPathInside(root, abs) && !holders.some(holder => isPathInside(root, holder)); + return !(owns(ctx.rootReal) || ctx.agentRoots.some(owns)); } // A glob: the folders before its first wildcard resolved, the rest matched name by name. const all = parts(abs); const first = all.findIndex(part => GLOB_CHAR.test(part)); const prefix = realish((abs.startsWith('/') ? '/' : '') + all.slice(0, first).join('/')); const pattern = [...parts(prefix), ...all.slice(first)]; - const matchesFrom = (target: string[]): number => { - let i = 0; - while (i < pattern.length && i < target.length && componentMatches(pattern[i]!, target[i]!)) i++; - return i; - }; for (const path of ctx.privatePaths) { - const target = parts(path); - const matched = matchesFrom(target); - if (matched >= target.length) return true; - if (matched === pattern.length && recursive && ctx.appRoots.some(root => parts(root).length <= pattern.length)) return true; + const { full, ancestor } = globAgainst(pattern, parts(path)); + if (full) return true; + if (ancestor && recursive && ctx.appRoots.some(root => parts(root).length <= pattern.length)) return true; } return ctx.tempRoots.some(temp => { const target = parts(temp); - if (matchesFrom(target) < target.length || pattern.length <= target.length) return false; + if (pattern.length <= target.length || !globAgainst(pattern.slice(0, target.length), target).full) return false; const next = pattern[target.length]!; - return SOCKET_DIR.test(next) || next.startsWith('ctty') && GLOB_CHAR.test(next); + return next === '**' || SOCKET_DIR.test(next) || next.startsWith('ctty') && GLOB_CHAR.test(next); }); } @@ -627,11 +750,19 @@ function codePath(text: string, ctx: PathContext): string | null { return isAbsolute(value) && value.length > 1 ? value : null; } -/** Paths inside a word or a program text: after `=` or `:` (`--unix-socket=P`, `UNIX-CONNECT:P`), quoted strings, bare tokens. */ -function privateCandidates(text: string, ctx: PathContext): string[] { - if (text.length > MAX_SCANNED_TEXT) text = text.slice(0, MAX_SCANNED_TEXT); +/** + * Paths inside a word or a program text: after `=` or `:` (`--unix-socket=P`, `UNIX-CONNECT:P`), quoted strings, bare + * tokens. Null when the text is longer, or holds more paths, than are checked. + */ +function privateCandidates(text: string, ctx: PathContext): string[] | null { + if (text.length > MAX_SCANNED_TEXT) return null; const found = new Set(); - const add = (value: string | undefined): void => { const path = value ? codePath(value, ctx) : null; if (path && found.size < 64) found.add(path); }; + let over = false; + const add = (value: string | undefined): void => { + const path = value ? codePath(value, ctx) : null; + if (!path || found.has(path)) return; + if (found.size >= MAX_CANDIDATES) over = true; else found.add(path); + }; for (const match of text.matchAll(/[=:]((?:~|\$\{?(?:HOME|TMPDIR)\}?|[A-Za-z]:[\\/]|\\\\|\/)[^\s,;'"`()<>|&]*)/gu)) { // An HTTP(S) authority is not a UNC path. Keep scanning other paths in the same text. if (match[0].startsWith('://') && /(?:^|[^A-Za-z0-9+.-])https?$/iu.test(text.slice(0, match.index))) continue; @@ -639,7 +770,7 @@ function privateCandidates(text: string, ctx: PathContext): string[] { } for (const match of text.matchAll(/(['"`])([^'"`\n]{1,4096}?)\1/gu)) add(match[2]); for (const token of text.split(/[\s,;()[\]{}<>|&'"`=]+/u)) if (/^(?:~|\$\{?(?:HOME|TMPDIR)\}?|[A-Za-z]:[\\/]|\\\\|\/)/u.test(token)) add(token); - return [...found]; + return over ? null : [...found]; } /** Interpreter code that builds a private path from pieces: an app folder's name together with a private name. */ @@ -650,7 +781,8 @@ function codeNamesPrivate(code: string, ctx: PathContext): boolean { } function privateHit(text: string, cwd: string | null, acc: Acc, recursive: boolean, glob = GLOB_CHAR.test(text)): boolean { - if (--acc.privateBudget < 0) return false; + // More paths than are checked: the call is refused rather than checked in part. + if (--acc.privateBudget < 0) { acc.flags.uncheckable = true; return false; } if (!isAbsolute(text) && cwd === null) return false; return privatePath(glob ? resolve(cwd ?? acc.ctx.rootReal, text) : realish(resolve(cwd ?? acc.ctx.rootReal, text)), glob, recursive, acc.ctx); } @@ -669,7 +801,8 @@ function runsControlCli(words: readonly Word[]): boolean { * the command uses them. CanvasTTY's own control CLI is the one program that may name its descriptor. */ function checkPrivate(segment: Segment, words: Word[], cwd: string | null, acc: Acc): void { - if (acc.flags.appPrivate || acc.privateBudget <= 0) return; + if (acc.flags.appPrivate) return; + if (acc.privateBudget <= 0) { acc.flags.uncheckable = true; return; } if (runsControlCli(words)) return; const recursive = words.some(word => RECURSIVE.has(programName(word.text)) || /^(?:-[a-zA-Z]*[rR][a-zA-Z]*|--recursive|--archive)$/u.test(word.text)) || programName(words[0]?.text ?? '') === 'cp' && words.some(word => /^-[a-zA-Z]*a/u.test(word.text)); @@ -679,7 +812,11 @@ function checkPrivate(segment: Segment, words: Word[], cwd: string | null, acc: if (word.substitution) continue; const text = expand(word, cwd, acc.ctx); if (text === null || text === '') continue; - const candidates = new Set([text, ...(text.length > 1 && /[=:'"\s]/u.test(text) ? privateCandidates(text, acc.ctx) : [])]); + // `provider-secrets.{bin,}`: the shell expands braces before the program sees the words. + const expanded = word.brace ? braceExpansions(text) : []; + const inner = text.length > 1 && /[=:'"\s]/u.test(text) ? privateCandidates(text, acc.ctx) : []; + if (expanded === null || inner === null) { acc.flags.uncheckable = true; return; } + const candidates = new Set([text, ...expanded, ...inner]); for (const candidate of candidates) { if (!privateHit(candidate, cwd, acc, recursive, candidate === text ? word.glob : GLOB_CHAR.test(candidate))) continue; acc.flags.appPrivate = true; @@ -1118,13 +1255,22 @@ export function commandFromArgv(argv: readonly string[]): string { return argv.m export function analyzeAction(action: ToolAction, root: string, options: { home?: string; agentRoots?: readonly string[]; privateData?: PrivateData } = {}): HardFacts { const ctx = pathContext(root, options.home, options.agentRoots, options.privateData); const acc: Acc = { ctx, writes: [], deletes: [], depth: 0, budget: 64, argvDepth: 0, privateBudget: MAX_PRIVATE_CHECKS, - flags: { elevation: false, pipeToShell: false, downloadExec: false, disk: false, forkBomb: false, appPrivate: false, unknownTarget: false } }; + flags: { elevation: false, pipeToShell: false, downloadExec: false, disk: false, forkBomb: false, appPrivate: false, unknownTarget: false, uncheckable: Boolean(action.uncheckable) } }; const commandCwd = action.commandCwd ? resolveTarget(action.commandCwd, ctx.rootReal, ctx) : null; const cwd = commandCwd ? commandCwd.abs : ctx.rootReal; if (action.kind === 'shell' && action.command) analyzeText(action.command, cwd, acc); else if (action.kind === 'edit') { acc.writes.push(...action.paths.map(path => resolveTarget(path, cwd, ctx))); if (action.paths.some(path => { const text = codePath(path, ctx) ?? path; return privateHit(text, cwd, acc, false, false); })) acc.flags.appPrivate = true; + } else if (action.kind === 'read') { + // A read-only tool is only checked against CanvasTTY's private data: reading elsewhere is not "outside". + // Glob tools expand braces (`provider-secrets.{bin,json}`); a literal name is checked as written too. + const hit = (path: string, recursive: boolean): boolean => { + const expanded = braceExpansions(path); + if (expanded === null) { acc.flags.uncheckable = true; return false; } + return [...new Set([path, ...expanded])].some(item => privateHit(codePath(item, ctx) ?? item, cwd, acc, recursive)); + }; + if (action.paths.some(path => hit(path, false)) || (action.searched ?? []).some(path => hit(path, true))) acc.flags.appPrivate = true; } const outside = acc.writes.filter(t => t.where === 'outside' && !t.device && !(t.abs && isAgentServicePath(t.abs, ctx))); return { diff --git a/src/main/services/safety/shellParse.ts b/src/main/services/safety/shellParse.ts index 71cb47430..e46c50fb0 100644 --- a/src/main/services/safety/shellParse.ts +++ b/src/main/services/safety/shellParse.ts @@ -23,6 +23,8 @@ export interface Word { glob: boolean; /** Starts with an unquoted `~`. */ tilde: boolean; + /** An unquoted `{` (a brace expansion such as `a.{bin,json}` may follow). */ + brace?: boolean; } export interface Redirect { op: string; target: Word | null; fdDup: boolean } @@ -223,6 +225,7 @@ export function lexShell(input: string): Lexed { const w = current(); if (!w.quoted && w.text === '' && c === '~') w.tilde = true; if (c === '*' || c === '?' || c === '[') w.glob = true; + if (c === '{') w.brace = true; // cmd.exe variables: %NAME%. if (c === '%') { const m = /^%([A-Za-z_][A-Za-z0-9_()]*)%/u.exec(text.slice(i)); diff --git a/src/main/services/shutdownSteps.ts b/src/main/services/shutdownSteps.ts new file mode 100644 index 000000000..670249712 --- /dev/null +++ b/src/main/services/shutdownSteps.ts @@ -0,0 +1,13 @@ +export interface ShutdownStep { + name: string; + run(): void | Promise; +} + +/** Cleanup remains ordered, but one failed save must not skip independent services. */ +export async function runShutdownSteps(steps: readonly ShutdownStep[], + warn: (message: string, error: unknown) => void = console.warn): Promise { + for (const step of steps) { + try { await step.run(); } + catch (error) { warn(`CanvasTTY shutdown: ${step.name} failed.`, error); } + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 32f38cf65..dbde3cf91 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -254,6 +254,7 @@ const api: CanvasTTYApi = { status: () => ipcRenderer.invoke(IPC.githubAuthStatus), start: () => ipcRenderer.invoke(IPC.githubAuthStart), signOut: () => ipcRenderer.invoke(IPC.githubAuthSignOut), + cancel: () => ipcRenderer.invoke(IPC.githubAuthCancel), openUrl: (url: string) => ipcRenderer.invoke(IPC.githubAuthOpenUrl, url) }, browser: { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index cb72f6485..362f1bd2c 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -56,6 +56,7 @@ import { } from "./lib/skinStyles"; import { TitleBar } from "./components/TitleBar"; import { Toast } from "./components/Toast"; +import { pluginServiceNoticeKind, pluginServiceNoticeSection, pluginServiceNoticeText } from "../../shared/pluginServiceNotice.ts"; import { environmentOptions } from "./features/launcher/LaunchOptionsSection"; import { UpdateNotice } from "./components/UpdateNotice"; import { resolveAppearanceSettings } from "./features/settings/appearanceSettings"; @@ -362,6 +363,7 @@ export function App(): React.JSX.Element { const [launchPosition, setLaunchPosition] = useState(null); const [settingsOpen, setSettingsOpen] = useState(false); const [openUpdatesRequest, setOpenUpdatesRequest] = useState(0); + const [openSectionRequest, setOpenSectionRequest] = useState<{ section: "agents" | "plugins"; id: number } | null>(null); const [shortcutReferenceOpen, setShortcutReferenceOpen] = useState(false); const [usageHistoryOpen, setUsageHistoryOpen] = useState(false); /** App root outside the canvas transform; overlays portal here to inherit the theme tokens. */ @@ -373,6 +375,7 @@ export function App(): React.JSX.Element { const [fullscreenSessionId, setFullscreenSessionId] = useState(null); const [pendingTerminalUrl, setPendingTerminalUrl] = useState(null); const [toast, setToast] = useState(null); + const [toastAction, setToastAction] = useState<{ label: string; run(): void } | null>(null); const [updateStatus, setUpdateStatus] = useState({ type: "idle" }); const [dismissedUpdateNotice, setDismissedUpdateNotice] = useState(null); const [updateNoticePending, setUpdateNoticePending] = useState(false); @@ -391,7 +394,29 @@ export function App(): React.JSX.Element { fullscreen: false }); - const showToast = useCallback((message: string): void => setToast(message), []); + const showToast = useCallback((message: string): void => { + setToastAction(null); + setToast(message); + }, []); + // A plugin page could not reach its service because its native code or module is off: say so with the way there. + const showPluginError = useCallback((message: string): void => { + const notice = pluginServiceNoticeKind(message); + if (!notice) { + showToast(message); + return; + } + const section = pluginServiceNoticeSection(notice); + setToast(pluginServiceNoticeText(notice, settings.locale)); + setToastAction({ + label: pluginServiceNoticeText("open", settings.locale), + run: () => { + setToast(null); + setToastAction(null); + setSettingsOpen(true); + setOpenSectionRequest((current) => ({ section, id: (current?.id ?? 0) + 1 })); + } + }); + }, [settings.locale, showToast]); const fileSessionOptions = useMemo( () => sessions.map((session) => ({ sessionId: session.id, label: session.title })), [sessions] @@ -468,9 +493,13 @@ export function App(): React.JSX.Element { useEffect(() => { if (!toast) return; - const timer = window.setTimeout(() => setToast(null), 2_600); + // A toast with a button stays long enough to reach it. + const timer = window.setTimeout(() => { + setToast(null); + setToastAction(null); + }, toastAction ? 8_000 : 2_600); return () => window.clearTimeout(timer); - }, [toast]); + }, [toast, toastAction]); useEffect(() => { const unsubscribe = window.canvasTTY.window.onState(setWindowState); @@ -2005,7 +2034,7 @@ export function App(): React.JSX.Element { onHomeGridSizeChange={changeHomeGridSize} onFinishHomeEdit={() => void finishHomeEditor()} onResetHomeLayout={resetHomeLayout} - onPluginError={showToast} + onPluginError={showPluginError} onPluginCanvasBoundsChange={changePluginCanvasBounds} onDisposePluginCanvas={disposePluginCanvas} onFocusPluginCanvas={focusPluginCanvas} @@ -2111,6 +2140,7 @@ export function App(): React.JSX.Element { {t(settings.locale, "materialsLoadFailed")} )} - + ); } 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/components/Toast.tsx b/src/renderer/src/components/Toast.tsx index 190760921..f25700112 100644 --- a/src/renderer/src/components/Toast.tsx +++ b/src/renderer/src/components/Toast.tsx @@ -1,7 +1,14 @@ interface ToastProps { message: string | null; + /** One button next to the text (for example "Open settings"); the toast takes clicks only while it has one. */ + action?: { label: string; run(): void } | null; } -export function Toast({ message }: ToastProps): React.JSX.Element { - return
{message}
; +export function Toast({ message, action }: ToastProps): React.JSX.Element { + return ( +
+ {message} + {message && action && } +
+ ); } diff --git a/src/renderer/src/features/home/HomeZone.tsx b/src/renderer/src/features/home/HomeZone.tsx index 3366a235a..7aa4c0ed7 100644 --- a/src/renderer/src/features/home/HomeZone.tsx +++ b/src/renderer/src/features/home/HomeZone.tsx @@ -533,8 +533,11 @@ function LimitRow({ row, locale, now }: { row: HomeLimitRow; locale: LocaleId; n {row.window ? ( - {formatPercent(row.window.usedPercent, locale)} - {formatResetCountdown(row.window.resetsAt, now, locale)} + + {formatPercent(row.window.usedPercent, locale)} + {t(locale, "limitUsed")} · {limitLeft(row.window.usedPercent, locale)} + + {formatResetCountdown(row.window.resetsAt, now, locale)} )} + {!githubAuthorized && !githubCode && githubFlowNotice && ( + // A cancelled sign-in is the person's own choice; a denied, expired or failed one is an error. + githubStatus?.deviceFlowState === "cancelled" + ?

{githubFlowNotice}

+ :

{githubFlowNotice}

+ )} {!githubAuthorized && !githubCode &&

{t(locale, "githubAuthOptional")}

} {githubStatus?.authorized ? (
@@ -882,6 +920,9 @@ export function PluginSettingsSection({ {t(locale, "githubAuthOpenExternal")}
+ ) : githubStatus && !githubConfigured ? (

{t(locale, "githubAuthNotConfigured")}

@@ -1154,3 +1195,39 @@ function errorMessage(error: unknown, fallback: string): string { .replace(/[\u0000-\u001f\u007f]/g, " ") .slice(0, 320); } + +function isGithubFlowOutcome(state: GithubDeviceFlowState | undefined): boolean { + return state === "denied" || state === "expired" || state === "failed" || state === "cancelled"; +} + +// The sign-in outcome lines live here, in the lazily loaded Settings chunk, not in the startup string table. +const GITHUB_SIGNIN_TEXT = { + ru: { + denied: "Запрос на вход в GitHub отклонён. Можно попробовать снова.", + failed: "Не удалось завершить вход в GitHub. Можно попробовать снова.", + cancelled: "Вход в GitHub отменён.", + cancel: "Отменить вход", + cancelFailed: "Не удалось отменить вход в GitHub. Подробности записаны в журнал." + }, + en: { + denied: "The GitHub sign-in request was denied. You can try again.", + failed: "GitHub sign-in could not be completed. You can try again.", + cancelled: "GitHub sign-in was cancelled.", + cancel: "Cancel sign-in", + cancelFailed: "GitHub sign-in could not be cancelled. Details are in the log." + } +} satisfies Record>; + +function githubSigninText(locale: LocaleId, key: keyof typeof GITHUB_SIGNIN_TEXT.en): string { + return GITHUB_SIGNIN_TEXT[locale === "ru" ? "ru" : "en"][key]; +} + +function githubFlowNoticeText(locale: LocaleId, state: GithubDeviceFlowState | undefined): string | null { + switch (state) { + case "denied": return githubSigninText(locale, "denied"); + case "expired": return t(locale, "githubAuthExpired"); + case "failed": return githubSigninText(locale, "failed"); + case "cancelled": return githubSigninText(locale, "cancelled"); + default: return null; + } +} diff --git a/src/renderer/src/features/settings/SettingsPanel.tsx b/src/renderer/src/features/settings/SettingsPanel.tsx index db4619189..e1ae02ff4 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, @@ -172,6 +173,8 @@ const DEFAULT_PROFILE_LABELS: Record = { interface SettingsPanelProps { open: boolean; openUpdatesRequest: number; + /** Opens a section from outside (a plugin notice's "Open settings"); "agents" scrolls to Extension native code. */ + openSectionRequest?: { section: "agents" | "plugins"; id: number } | null; settings: AppSettings; agentAvailability: AgentCliAvailability | null; onRecheckAgentClis(): Promise; @@ -208,6 +211,7 @@ interface SettingsPanelProps { export function SettingsPanel({ open, openUpdatesRequest, + openSectionRequest = null, settings, agentAvailability, onRecheckAgentClis, @@ -289,6 +293,19 @@ export function SettingsPanel({ if (open && contentRef.current) contentRef.current.scrollTop = 0; }, [open, section]); + const scrollToNativeCode = useRef(false); + useEffect(() => { + if (!openSectionRequest) return; + scrollToNativeCode.current = openSectionRequest.section === "agents"; + setSection(openSectionRequest.section); + }, [openSectionRequest]); + // Once, after the section is shown (and its scroll reset): bring Extension native code into view. + useEffect(() => { + if (!open || section !== "agents" || !scrollToNativeCode.current) return; + scrollToNativeCode.current = false; + contentRef.current?.querySelector(".plugin-services")?.scrollIntoView({ block: "start" }); + }, [open, openSectionRequest, section]); + const openAgentInstall = (provider: AgentProviderId): void => { const url = PROVIDERS[provider].installUrl; if (!url) return; @@ -626,20 +643,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/usage/UsageHistoryView.tsx b/src/renderer/src/features/usage/UsageHistoryView.tsx index a5e06e05d..83847feba 100644 --- a/src/renderer/src/features/usage/UsageHistoryView.tsx +++ b/src/renderer/src/features/usage/UsageHistoryView.tsx @@ -23,6 +23,7 @@ import { formatTimeZone, formatTokens, providerName, + quotaShareText, shortScope, usageText } from "./usageText.ts"; @@ -264,7 +265,7 @@ function QuotaWindow({ quota, format }: { quota: WindowReport; format: Format }) {quota.accounts.length === 0 && quota.latest !== null && (

{fill(text.noObservationsInPeriod, { - percent: format.points(quota.latest.percent), + share: quotaShareText(format.locale, quota.latest.percent), time: format.time(quota.latest.at), account: quota.latest.scope ? shortScope(quota.latest.scope) : text.accountUnknown })} @@ -305,7 +306,7 @@ function AccountQuota({ account, format }: { account: AccountSeries; format: For

{text.latest}
-
{account.latest ? fill(text.latestValue, { percent: format.points(account.latest.percent), time: format.time(account.latest.at) }) : "—"}
+
{account.latest ? fill(text.latestValue, { share: quotaShareText(format.locale, account.latest.percent), time: format.time(account.latest.at) }) : "—"}
{text.reset}
diff --git a/src/renderer/src/features/usage/usageText.ts b/src/renderer/src/features/usage/usageText.ts index 8b96bb9ed..1470a0380 100644 --- a/src/renderer/src/features/usage/usageText.ts +++ b/src/renderer/src/features/usage/usageText.ts @@ -45,14 +45,15 @@ const RU = { account: "Аккаунт (хеш)", accountUnknown: "аккаунт неизвестен", latest: "Последнее наблюдение", - latestValue: "{percent}% · {time}", + latestValue: "{share} · {time}", + quotaShare: "использовано {used}% · осталось {left}%", reset: "Сброс окна", resetUnknown: "неизвестен", resetsInPeriod: "Сбросов окна за период: {count}", resetPassed: "Сброс уже наступил после последнего наблюдения — текущее значение неизвестно.", windowStale: "Последнее наблюдение {age} назад — ряд устарел, новые изменения не измеряются.", windowNeverObserved: "Нет наблюдений до конца периода.", - noObservationsInPeriod: "За период наблюдений нет. Последнее до него: {percent}% · {time} · {account}.", + noObservationsInPeriod: "За период наблюдений нет. Последнее до него: {share} · {time} · {account}.", unknownProviderName: "Провайдер не определён", measured: "Измерено", measuredValue: "+{points} п. п.", @@ -206,14 +207,15 @@ const EN: UsageText = { account: "Account (hash)", accountUnknown: "account unknown", latest: "Latest observation", - latestValue: "{percent}% · {time}", + latestValue: "{share} · {time}", + quotaShare: "{used}% used · {left}% left", reset: "Window reset", resetUnknown: "unknown", resetsInPeriod: "Window resets in period: {count}", resetPassed: "The reset has passed since the latest observation — the current value is unknown.", windowStale: "Latest observation {age} ago — the series is stale and new changes are not measured.", windowNeverObserved: "No observations up to the end of the period.", - noObservationsInPeriod: "No observations in this period. Latest before it: {percent}% · {time} · {account}.", + noObservationsInPeriod: "No observations in this period. Latest before it: {share} · {time} · {account}.", unknownProviderName: "Provider not identified", measured: "Measured", measuredValue: "+{points} pp", @@ -329,6 +331,12 @@ export function fill(template: string, values: Record): return template.replace(/\{(\w+)\}/g, (match, key: string) => (key in values ? String(values[key]) : match)); } +/** A quota observation (the share of the window already used) with what is left of it: never a bare percent. */ +export function quotaShareText(locale: LocaleId, usedPercent: number): string { + const used = Math.min(100, Math.max(0, usedPercent)); + return fill(usageText(locale).quotaShare, { used: formatPoints(locale, used), left: formatPoints(locale, 100 - used) }); +} + export function formatPoints(locale: LocaleId, value: number): string { return new Intl.NumberFormat(locale, { maximumFractionDigits: 2, minimumFractionDigits: 0 }).format(value); } diff --git a/src/renderer/src/features/workspace/WorkspaceCanvas.tsx b/src/renderer/src/features/workspace/WorkspaceCanvas.tsx index 6fef8d6a7..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 && ( )} diff --git a/src/renderer/src/lib/i18n.ts b/src/renderer/src/lib/i18n.ts index b1a05d81e..bf65bce59 100644 --- a/src/renderer/src/lib/i18n.ts +++ b/src/renderer/src/lib/i18n.ts @@ -35,7 +35,9 @@ const ru = { clock: "Часы", launcher: "Панель запуска", modelLimits: "Лимиты моделей", - limitUsed: "Использовано", + limitUsed: "использовано", + limitUsedPercent: "использовано {percent}", + limitLeftPercent: "осталось {percent}", limitStale: "Устарело", limitLoading: "Получаем лимит…", limitUnavailable: "Данные недоступны", @@ -492,16 +494,11 @@ const ru = { zoomSensitivity: "Чувствительность зума", useScrollWheelToZoom: "Зум обычным скроллом", useScrollWheelToZoomDescription: "Если выключено, обычный скролл перемещает канвас, а Ctrl/Command + скролл и pinch масштабируют.", - canvasWheelCapture: "Перехват wheel/pinch над виджетами", - canvasWheelCaptureDescription: "Off оставляет обычный wheel/pinch focused input-виджету, включая live Browser page. On и активный бинд передают события канвасу; pinch и Ctrl/Command + скролл над Browser всегда масштабируют canvas. Клики и drag не перехватываются.", - canvasWheelOverride: "Комбинация wheel/pinch", - canvasWheelOverrideHint: "Wheel/pinch поверх виджетов", + canvasWheelOverrideHint: "Колесо и жест масштаба поверх виджетов", canvasOverrideShortcutInvalid: "Нужен модификатор, комбинация с ним или Mouse3/Mouse4/Mouse5.", canvasNavigationOverride: "Перехват навигации канвасом", - canvasNavigationOverrideDescription: "Пока комбинация или кнопка мыши удерживается, wheel, pinch и drag над любым виджетом принадлежат канвасу. Drag колёсиком работает всегда.", canvasNavigationCaptureHint: "Удержите модификатор, нажмите комбинацию или Mouse3/Mouse4/Mouse5. Escape отменяет запись.", canvasNavigationOverrideHint: "Навигация поверх виджетов", - canvasOverrideBindingsMatch: "Комбинации совпадают: полный перехват имеет приоритет и также забирает drag.", disabled: "Отключено", terminalWheelDirection: "Направление колеса терминала", copyOnSelect: "Копировать при выделении", @@ -770,7 +767,8 @@ const ru = { terminalSearchClose: "Закрыть поиск", fitCanvas: "Показать всё", focusWindowHint: "Фокус на соседнее окно", - marqueeSelectionHint: "Shift + тянуть — выделить группу", + marqueeSelectionHint: "Выделить группу", + shortcutShiftDrag: "Shift + перетаскивание", remarkAnchorStep: "Замечание к шагу", remarkAnchorPage: "Замечание к странице", remarkAnchorTime: "Замечание ко времени", @@ -1067,7 +1065,9 @@ const en: Record = { clock: "Clock", launcher: "Launcher", modelLimits: "Model limits", - limitUsed: "Used", + limitUsed: "used", + limitUsedPercent: "{percent} used", + limitLeftPercent: "{percent} left", limitStale: "Stale", limitLoading: "Checking limit…", limitUnavailable: "Data unavailable", @@ -1524,16 +1524,11 @@ const en: Record = { zoomSensitivity: "Zoom sensitivity", useScrollWheelToZoom: "Use scroll wheel to zoom", useScrollWheelToZoomDescription: "When off, plain scrolling pans the canvas while Ctrl/Command + scroll and pinch zoom.", - canvasWheelCapture: "Wheel/pinch capture over widgets", - canvasWheelCaptureDescription: "Off leaves plain wheel/pinch with the focused input widget, including a live Browser page. On and an active binding route it to the canvas; Browser pinch and Ctrl/Command + scroll always zoom the canvas. Clicks and drag are not captured.", - canvasWheelOverride: "Wheel/pinch shortcut", canvasWheelOverrideHint: "Wheel/pinch over widgets", canvasOverrideShortcutInvalid: "Use a modifier, a modifier-based chord, or Mouse3/Mouse4/Mouse5.", canvasNavigationOverride: "Canvas navigation override", - canvasNavigationOverrideDescription: "While the shortcut or mouse button is held, wheel, pinch, and drag over every widget belong to the canvas. Middle-button drag always pans.", canvasNavigationCaptureHint: "Hold a modifier, press a chord, or press Mouse3/Mouse4/Mouse5. Escape cancels capture.", canvasNavigationOverrideHint: "Navigate over widgets", - canvasOverrideBindingsMatch: "The shortcuts match: full navigation takes priority and also captures drag.", disabled: "Disabled", terminalWheelDirection: "Terminal wheel direction", copyOnSelect: "Copy on selection", @@ -1802,7 +1797,8 @@ const en: Record = { terminalSearchClose: "Close search", fitCanvas: "Fit to content", focusWindowHint: "Focus the neighbouring window", - marqueeSelectionHint: "Shift + drag selects a group", + marqueeSelectionHint: "Select a group", + shortcutShiftDrag: "Shift + drag", remarkAnchorStep: "Remark on step", remarkAnchorPage: "Remark on page", remarkAnchorTime: "Remark on the time", diff --git a/src/renderer/src/lib/shortcuts.ts b/src/renderer/src/lib/shortcuts.ts index 0b037c770..c1044a5ff 100644 --- a/src/renderer/src/lib/shortcuts.ts +++ b/src/renderer/src/lib/shortcuts.ts @@ -123,6 +123,15 @@ export function handleMacNativeSelectAll(event: KeyboardEvent, isMacOS: boolean) return true; } +/** + * An action shortcut as the hints panel and the shortcut reference show it: arrows as glyphs, Meta as Command or + * Super. Key by key, so the Comma key is not looked for inside "Command". + */ +export function displayShortcutBinding(binding: string, isMacOS: boolean): string { + const names: Record = { Meta: isMacOS ? "Command" : "Super", Comma: ",", ArrowUp: "↑", ArrowDown: "↓", ArrowLeft: "←", ArrowRight: "→" }; + return binding.split("+").map((key) => names[key] ?? key).join("+"); +} + export function displayCanvasNavigationBinding(binding: string, isMacOS: boolean): string { if (!isMacOS) return binding; return binding.split("+").map((part) => { diff --git a/src/renderer/src/styles/app.css b/src/renderer/src/styles/app.css index f4786c22b..3b72f650e 100644 --- a/src/renderer/src/styles/app.css +++ b/src/renderer/src/styles/app.css @@ -190,6 +190,9 @@ button { border: 0; } .limit-row__summary { min-width: 0; display: flex; align-items: baseline; justify-content: space-between; gap: 10px; } .limit-row__summary strong { flex: 0 0 auto; overflow: hidden; color: var(--text); font: 900 27px/1 var(--font-mono); letter-spacing: -.045em; text-overflow: ellipsis; white-space: nowrap; } .limit-row__summary > span { min-width: 0; overflow: hidden; color: var(--text-muted-on-dark); font: 800 14px/1 var(--font-mono); text-overflow: ellipsis; white-space: nowrap; } +.limit-row__summary > .limit-row__share { flex: 1 1 auto; display: flex; align-items: baseline; gap: 8px; } +.limit-row__share > span { min-width: 0; overflow: hidden; font-size: 12px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; } +.limit-row__summary > .limit-row__countdown { flex: 0 0 auto; } .limit-row__track { position: relative; width: 100%; height: 6px; overflow: hidden; border-radius: 999px; background: rgba(255,255,255,.12); } .limit-row__fill { position: absolute; inset: 0 auto 0 0; max-width: 100%; border-radius: inherit; background: rgba(248,247,241,.78); } .limit-row__empty { overflow: hidden; color: var(--text-muted-on-dark); font-size: 13px; font-weight: 800; text-overflow: ellipsis; white-space: nowrap; } @@ -203,6 +206,7 @@ button { border: 0; } .limit-row__summary { gap: 7px; } .limit-row__summary strong { font-size: 22px; } .limit-row__summary > span { font-size: 11px; } + .limit-row__share > span { font-size: 10px; } .limit-row__track { height: 4px; } .limit-row__empty { font-size: 11px; } } @@ -324,11 +328,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))); } @@ -901,6 +905,8 @@ button { border: 0; } .segmented__button--active { color: var(--text-dark); background: var(--primary); } .segmented__button:focus-visible { box-shadow: inset 0 0 0 2px color-mix(in srgb, currentColor 42%, transparent); } .setting-group__control > .segmented + .shortcut-editor { margin-top: .6em; } +/* The canvas navigation rows sit in the narrow control column: their (longer localized) labels wrap instead of being cut off. */ +.setting-group__control .shortcut-editor__row > span { overflow-wrap: break-word; text-overflow: clip; white-space: normal; line-height: 1.3; } .swatch-choices { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: .4em; } .swatch-choices--four { grid-template-columns: repeat(4, minmax(0, 1fr)); } .swatch-choice { min-width: 0; min-height: 4.6em; padding: .48em; display: grid; grid-template-rows: 2em auto; align-items: center; gap: .45em; border: .08em solid transparent; border-radius: .72em; outline: 0; color: var(--text-muted-on-dark); background: var(--surface-soft); cursor: pointer; font-size: .76em; font-weight: 700; transition: color .15s ease, background .15s ease, border-color .15s ease, box-shadow .15s ease; } @@ -1061,34 +1067,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 +1198,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 +1215,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); } @@ -1320,6 +1326,8 @@ button { border: 0; } .update-notice__dismiss:hover, .update-notice__dismiss:focus-visible { color: var(--text); background: var(--surface-soft); } .toast { position: fixed; z-index: 9999; left: 50%; bottom: 24px; max-width: min(540px, calc(100% - 40px)); padding: 12px 17px; border-radius: 11px; color: white; background: rgba(37,38,48,.94); box-shadow: var(--shadow-md); opacity: 0; transform: translate(-50%, 18px); pointer-events: none; transition: all .2s ease; } .toast--visible { opacity: 1; transform: translate(-50%, 0); } +.toast--action { display: flex; align-items: center; gap: 12px; pointer-events: auto; } +.toast__action { flex: 0 0 auto; padding: 6px 8px; border-radius: 6px; color: var(--text-dark); background: var(--primary); cursor: pointer; font-size: 11px; font-weight: 700; } .materials-load-error { position: fixed; z-index: 9998; left: 50%; bottom: 76px; width: max-content; max-width: min(540px, calc(100% - 40px)); padding: 12px 17px; border-radius: 11px; color: var(--text); background: var(--surface); box-shadow: inset 3px 0 0 var(--danger), var(--shadow-md); transform: translateX(-50%); } /* Shown until the critical snapshot is in, usually for a few frames only. It keeps the window's own native background (main/index.ts backgroundColor) so startup does not flash a dark panel between the empty window and @@ -1368,9 +1376,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/src/shared/contracts.ts b/src/shared/contracts.ts index e406aa1c2..6f5f67f02 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -1136,8 +1136,12 @@ export interface GithubAuthStatus { authorized: boolean; login: string | null; tokenExpiresAt: number | null; + /** Outcome of the most recent device sign-in; "idle" when none is pending or to report. */ + deviceFlowState: GithubDeviceFlowState; } +export type GithubDeviceFlowState = "idle" | "pending" | "denied" | "expired" | "failed" | "cancelled"; + export interface GithubDeviceFlowStart { userCode: string; verificationUri: string; @@ -2049,6 +2053,7 @@ export interface CanvasTTYApi { githubAuth: { status(): Promise; start(): Promise; + cancel(): Promise; signOut(): Promise; openUrl(url: string): Promise; }; @@ -2245,6 +2250,7 @@ export const IPC = { githubAuthStatus: "github-auth:status", githubAuthStart: "github-auth:start", githubAuthSignOut: "github-auth:sign-out", + githubAuthCancel: "github-auth:cancel", githubAuthOpenUrl: "github-auth:open-url", terminalList: "terminal:list", terminalOpenFile: "terminal:open-file", diff --git a/src/shared/pluginServiceNotice.ts b/src/shared/pluginServiceNotice.ts new file mode 100644 index 000000000..4bbf9d570 --- /dev/null +++ b/src/shared/pluginServiceNotice.ts @@ -0,0 +1,48 @@ +import type { LocaleId } from "./contracts.ts"; + +/** + * Why a plugin's own UI cannot reach its service although the service exists. The main process refuses with one of + * these fixed texts (plugins see them as the request's error); the renderer shows the localized text with a button + * that opens the settings section where the person can change it. A service that is trusted but not running keeps the + * supervisor's own error ("Plugin service is not running." with the last error, if any). + */ +export const PLUGIN_NATIVE_CODE_OFF_ERROR = + "This plugin's native code is turned off. Turn it on in Settings → Agents → Extension native code."; +export const PLUGIN_SERVICE_MODULE_OFF_ERROR = + "The plugin module that runs this service is turned off. Turn it on in Settings → Plugins."; + +export type PluginServiceNoticeKind = "native-code-off" | "module-off"; + +const NOTICE_TEXT: Record> = { + ru: { + "native-code-off": "Нативный код этого плагина выключен — включите его в Настройки → Агенты → Нативный код расширений.", + "module-off": "Модуль этого плагина с нативным кодом выключен — включите его в Настройки → Плагины.", + open: "Открыть настройки" + }, + en: { + "native-code-off": "This plugin's native code is turned off — turn it on in Settings → Agents → Extension native code.", + "module-off": "The plugin module that runs this service is turned off — turn it on in Settings → Plugins.", + open: "Open settings" + } +}; + +/** The notice an error text stands for (the main process's fixed text or an already localized one), or null. */ +export function pluginServiceNoticeKind(message: string): PluginServiceNoticeKind | null { + const text = message.trim(); + if (text === PLUGIN_NATIVE_CODE_OFF_ERROR || text === NOTICE_TEXT.ru["native-code-off"] || text === NOTICE_TEXT.en["native-code-off"]) { + return "native-code-off"; + } + if (text === PLUGIN_SERVICE_MODULE_OFF_ERROR || text === NOTICE_TEXT.ru["module-off"] || text === NOTICE_TEXT.en["module-off"]) { + return "module-off"; + } + return null; +} + +export function pluginServiceNoticeText(kind: PluginServiceNoticeKind | "open", locale: LocaleId): string { + return (NOTICE_TEXT[locale] ?? NOTICE_TEXT.en)[kind]; +} + +/** The settings section that holds the switch for this notice. */ +export function pluginServiceNoticeSection(kind: PluginServiceNoticeKind): "agents" | "plugins" { + return kind === "module-off" ? "plugins" : "agents"; +} diff --git a/tests/account-home-isolation.test.mjs b/tests/account-home-isolation.test.mjs new file mode 100644 index 000000000..ee8690675 --- /dev/null +++ b/tests/account-home-isolation.test.mjs @@ -0,0 +1,371 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { LaunchPipeline } from "../src/main/services/LaunchPipeline.ts"; +import { AgentIsolation, SANDBOX_EXEC } from "../src/main/services/isolation/AgentIsolation.ts"; +import { isolationPaths } from "../src/main/services/isolation/isolationPaths.ts"; +import { bubblewrapArguments } from "../src/main/services/isolation/bubblewrap.ts"; +import { TerminalManager } from "../src/main/services/TerminalManager.ts"; +import { DecisionHooks } from "../src/main/services/DecisionHooks.ts"; +import { canvasTtyPrivateData } from "../src/main/services/safety/baseProtection.ts"; + +// Any plugin id: the host treats every plugin's data the same way. +const PLUGIN = "com.example.accounts"; +const OTHER = "com.example.other"; +const mac = process.platform === "darwin" && existsSync(SANDBOX_EXEC); +const onMac = { skip: mac ? false : "macOS seatbelt (sandbox-exec) only" }; + +async function fixture(t) { + // Short: Unix socket paths are limited to ~104 bytes, so /tmp rather than macOS's long per-user folder. + const root = await realpath(await mkdtemp(join(process.platform === "win32" ? tmpdir() : "/tmp", "ctty-acct-"))); + t.after(() => rm(root, { recursive: true, force: true })); + const userData = join(root, "u"); + const pluginData = join(userData, "plugin-data"); + const accountData = join(pluginData, PLUGIN); + const otherData = join(pluginData, OTHER); + const homes = join(accountData, "homes"); + const project = join(root, "p"); + const temp = join(root, "t"); + const home = join(root, "h"); + await Promise.all([homes, otherData, project, temp, home].map((path) => mkdir(path, { recursive: true }))); + return { root, userData, pluginData, accountData, otherData, homes, project, temp, home }; +} + +async function prepare(f, options = {}) { + const provider = options.provider ?? "codex"; + const pluginId = options.pluginId ?? PLUGIN; + const dataDir = Object.hasOwn(options, "dataDir") ? options.dataDir : join(f.pluginData, pluginId); + const env = options.env ?? { CODEX_HOME: join(f.homes, "codex-alternate") }; + const requests = []; + const pipeline = new LaunchPipeline({ + contributors: () => [{ pluginId, pluginName: "Accounts", serviceId: "accounts", launch: { fields: [] }, secrets: false, + ...(dataDir === undefined ? {} : { dataDir }) }], + call: async (calledId, _serviceId, _method, params) => { + requests.push({ calledId, params }); + return { env, ...(options.answerExtra ?? {}) }; + }, + secret: async () => null, + runsRoot: join(f.userData, "launch-runs") + }); + const result = await pipeline.prepare({ sessionId: "fixture", provider, profile: "normal", role: "agent", cwd: f.project, + restoring: false, resume: false, options: { [pluginId]: { account: "alternate" } }, environment: null }); + return { result, requests }; +} + +function isolation(f, extra = {}) { + return new AgentIsolation({ + userDataPath: f.userData, + enabled: () => true, + tempRoot: f.temp, + hostEnvironment: { HOME: f.home, PATH: "/usr/bin:/bin" }, + ...extra + }); +} + +const fakeSeatbelt = { platform: "darwin", exists: (path) => path === SANDBOX_EXEC }; + +test("the selected account home is the exact CLI home a chosen contributor set inside its own data, and stays out of plugin RPC", async (t) => { + const f = await fixture(t); + const accountHome = join(f.homes, "codex-alternate"); + const { result, requests } = await prepare(f, { env: { CODEX_HOME: accountHome } }); + assert.equal(result.ok, true); + assert.equal(result.accountHome, accountHome); + assert.deepEqual(result.env, { CODEX_HOME: accountHome }); + assert.equal(requests.length, 1); + assert.equal(requests[0].calledId, PLUGIN); + for (const hostOnly of ["dataDir", "accountHome", "grantedPrivate"]) { + assert.equal(Object.hasOwn(requests[0].params, hostOnly), false, `${hostOnly} is host-only`); + } + await result.cleanup(); + + for (const [provider, key] of [["claude", "CLAUDE_CONFIG_DIR"], ["grok", "GROK_HOME"]]) { + const own = join(f.homes, `${provider}-alternate`); + const other = await prepare(f, { provider, env: { [key]: own } }); + assert.equal(other.result.ok, true); + assert.equal(other.result.accountHome, own, provider); + await other.result.cleanup(); + } +}); + +test("no account home without host authority, outside the contributor's own data, at its data root or through another variable", async (t) => { + const f = await fixture(t); + const cases = [ + { name: "no host data folder", dataDir: undefined, env: { CODEX_HOME: join(f.homes, "codex-a") } }, + { name: "another plugin's data", env: { CODEX_HOME: join(f.otherData, "codex-a") } }, + { name: "the plugin's whole data folder", env: { CODEX_HOME: f.accountData } }, + { name: "all plugin data", env: { CODEX_HOME: f.pluginData } }, + { name: "a path that climbs out", env: { CODEX_HOME: `${f.homes}/../../${OTHER}/codex-a` } }, + { name: "another provider's variable", env: { CLAUDE_CONFIG_DIR: join(f.homes, "claude-a") } }, + { name: "a variable that is not a CLI home", env: { SOME_DIR: join(f.homes, "codex-a") } }, + { name: "a Claude home for Codex", provider: "claude", env: { CODEX_HOME: join(f.homes, "codex-a") } }, + { name: "outside plugin data", env: { CODEX_HOME: join(f.root, "elsewhere") } } + ]; + for (const row of cases) { + const { result } = await prepare(f, row); + assert.equal(result.ok, true, row.name); + assert.equal(result.accountHome, undefined, row.name); + await result.cleanup(); + } +}); + +test("a plugin cannot name an isolation grant in its answer", async (t) => { + const f = await fixture(t); + const accountHome = join(f.homes, "codex-alternate"); + for (const key of ["accountHome", "grantedPrivate", "dataDir"]) { + const { result } = await prepare(f, { env: { CODEX_HOME: accountHome }, answerExtra: { [key]: [f.pluginData] } }); + assert.equal(result.ok, false, key); + assert.match(result.reason, /unknown key/u); + } +}); + +test("an isolated agent's paths hide all plugin data and reopen only the selected home", async (t) => { + const f = await fixture(t); + const selected = join(f.homes, "codex-alternate"); + const sibling = join(f.homes, "codex-other"); + await Promise.all([selected, sibling].map((path) => mkdir(path))); + const base = { provider: "codex", cwd: f.project, sessionTemp: join(f.temp, "s"), userDataPath: f.userData, sessionId: "s", + hostEnvironment: { HOME: f.home } }; + + const plain = isolationPaths({ ...base, env: { HOME: f.home } }); + assert.ok(plain.unreadable.includes(f.pluginData), "plugin data is private app data"); + assert.ok(!plain.readableAgain.some((path) => path.startsWith(f.pluginData)), "nothing of it is reopened without a selection"); + + const paths = isolationPaths({ ...base, env: { HOME: f.home, CODEX_HOME: selected }, grantedPrivate: [selected] }); + assert.ok(paths.unreadable.includes(f.pluginData)); + assert.deepEqual(paths.readableAgain.filter((path) => path.startsWith(f.pluginData)), [selected]); + assert.ok(paths.writable.includes(selected), "the CLI's own home stays writable"); + assert.ok(!paths.readableAgain.includes(sibling) && !paths.readableAgain.includes(f.homes) && !paths.readableAgain.includes(f.accountData)); + + // The CLI started with a home in plugin data that the launch was not handed: refused, never left readable. + assert.throws(() => isolationPaths({ ...base, env: { HOME: f.home, CODEX_HOME: sibling }, grantedPrivate: [selected] }), + /overlaps protected CanvasTTY data/u); + + // Linux: an empty tmpfs over all plugin data, then only the selected home bound back (writable: it is the CLI's). + // Its config.toml exists (LinuxHostPaths puts a placeholder there before a launch) and is read-only again. + const kinds = new Map([[f.project, "directory"], [f.pluginData, "directory"], [selected, "directory"], [sibling, "directory"], + [join(selected, "config.toml"), "file"]]); + const text = bubblewrapArguments(paths, { command: "/usr/bin/codex", args: [], cwd: f.project }, (path) => kinds.get(path) ?? null).join(" "); + const afterHidden = text.slice(text.indexOf(`--tmpfs ${f.pluginData}`)); + assert.ok(text.includes(`--tmpfs ${f.pluginData}`)); + assert.ok(afterHidden.includes(`--bind ${selected} ${selected}`)); + assert.ok(!text.includes(sibling)); +}); + +test("AgentIsolation.wrap reopens only a real folder inside one plugin's data, never through a symbolic link", async (t) => { + const f = await fixture(t); + const sandbox = isolation(f, fakeSeatbelt); + const wrap = (home, env = { CODEX_HOME: home }) => sandbox.wrap({ sessionId: "a", provider: "codex", cwd: f.project, command: "/bin/sh", + args: [], env: { HOME: f.home, PATH: "/usr/bin:/bin", ...env }, profile: "acceptEdits", accountHome: home }); + + const target = join(f.otherData, "secret"); + const alias = join(f.homes, "codex-alias"); + await mkdir(target, { recursive: true }); + await symlink(target, alias, process.platform === "win32" ? "junction" : "dir"); + // The refusal says once that the agent was not started. + const refusal = (why) => ({ name: "LaunchRefusal", message: `agent isolation could not be set up: ${why} The agent was not started without it.` }); + assert.throws(() => wrap(alias), refusal("The selected account home is missing or crosses a symbolic link.")); + assert.throws(() => wrap(join(f.homes, "codex-missing")), refusal("The selected account home is missing or crosses a symbolic link.")); + assert.throws(() => wrap(f.accountData), refusal("The selected account home must be a folder inside one plugin's data.")); + assert.throws(() => wrap("relative/home"), refusal("The selected account home must be an exact absolute folder.")); + + const selected = join(f.homes, "codex-alternate"); + await mkdir(selected); + const ok = wrap(selected); + try { + const profile = await readFile(ok.args[1], "utf8"); + assert.ok(profile.includes(`(deny file-read* file-write*`) && profile.includes(`(subpath "${f.pluginData}")`)); + assert.match(profile, new RegExp(`\\(allow file-read\\*[^\\n]*\\(subpath "${selected.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")}"\\)`, "u")); + } finally { + ok.cleanup(); + } + // An environment that replaced the CLI's home: nothing of plugin data is reopened, and the launch is not refused for it. + const elsewhere = join(f.root, "elsewhere"); + await mkdir(elsewhere); + const replaced = wrap(selected, { CODEX_HOME: elsewhere }); + try { + assert.ok(!(await readFile(replaced.args[1], "utf8")).includes(`(subpath "${selected}")`)); + } finally { + replaced.cleanup(); + } +}); + +test("a project inside plugin data that CanvasTTY placed there is reopened exactly; all of plugin data or a whole plugin's data is never a project", async (t) => { + const f = await fixture(t); + const worktree = join(f.otherData, "worktrees", "one"); + const sibling = join(f.otherData, "worktrees", "two"); + await Promise.all([worktree, sibling].map((path) => mkdir(path, { recursive: true }))); + const base = { provider: "codex", sessionTemp: join(f.temp, "s"), userDataPath: f.userData, sessionId: "s", env: { HOME: f.home } }; + // An environment of the plugin that owns the folder placed the launch there (TerminalManager.placedProjects). + const placed = { placedProjects: [{ folder: worktree, pluginId: OTHER }] }; + const paths = isolationPaths({ ...base, cwd: worktree, ...placed }); + assert.ok(paths.unreadable.includes(f.pluginData)); + assert.deepEqual(paths.readableAgain.filter((path) => path.startsWith(f.pluginData)), [worktree]); + assert.ok(paths.writable.includes(worktree)); + const plan = isolationPaths({ ...base, cwd: worktree, readOnlyProject: true, ...placed }); + assert.ok(plan.readableAgain.includes(worktree) && !plan.writable.includes(worktree), "plan keeps it read-only"); + for (const cwd of [f.pluginData, f.otherData]) { + assert.throws(() => isolationPaths({ ...base, cwd, placedProjects: [{ folder: cwd }] }), /private plugin data/u, cwd); + } +}); + +test("seatbelt, for real: an isolated agent reads and writes only its selected home in plugin data", onMac, async (t) => { + const f = await fixture(t); + const selected = join(f.homes, "codex-alternate"); + const sibling = join(f.homes, "codex-other"); + const otherPlugin = join(f.otherData, "worktrees", "sibling"); + const settings = join(f.accountData, "accounts.json"); + await Promise.all([selected, sibling, otherPlugin].map((path) => mkdir(path, { recursive: true }))); + await writeFile(join(selected, "marker"), "selected fixture home\n"); + await writeFile(join(sibling, "marker"), "sibling fixture home\n"); + await writeFile(join(otherPlugin, "marker"), "other plugin fixture\n"); + await writeFile(settings, "private account settings\n"); + const wrapped = isolation(f).wrap({ + sessionId: "selected-account", + provider: "codex", + cwd: f.project, + command: "/bin/sh", + args: ["-c", [ + 'cat "$CODEX_HOME/marker" >/dev/null && echo OWN-READ-ok', + 'echo actor > "$CODEX_HOME/actor.txt" && echo OWN-WRITE-ok', + 'echo actor > "$CODEX_HOME/config.toml" 2>/dev/null || echo CLI-SETTINGS-WRITE-denied', + `cat ${JSON.stringify(join(sibling, "marker"))} >/dev/null 2>&1 || echo ACCOUNT-SIBLING-READ-denied`, + `echo actor >> ${JSON.stringify(join(sibling, "marker"))} 2>/dev/null || echo ACCOUNT-SIBLING-WRITE-denied`, + `cat ${JSON.stringify(join(otherPlugin, "marker"))} >/dev/null 2>&1 || echo OTHER-PLUGIN-READ-denied`, + `cat ${JSON.stringify(settings)} >/dev/null 2>&1 || echo SETTINGS-READ-denied`, + `echo actor >> ${JSON.stringify(settings)} 2>/dev/null || echo SETTINGS-WRITE-denied`, + `ls ${JSON.stringify(f.pluginData)} >/dev/null 2>&1 || echo PLUGIN-DATA-LIST-denied` + ].join("; ")], + env: { HOME: f.home, PATH: "/usr/bin:/bin", CODEX_HOME: selected }, + profile: "acceptEdits", + accountHome: selected + }); + try { + const result = spawnSync(wrapped.command, wrapped.args, { cwd: f.project, env: wrapped.env, encoding: "utf8", timeout: 20_000 }); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(result.stdout.split("\n").filter(Boolean), [ + "OWN-READ-ok", "OWN-WRITE-ok", "CLI-SETTINGS-WRITE-denied", "ACCOUNT-SIBLING-READ-denied", "ACCOUNT-SIBLING-WRITE-denied", + "OTHER-PLUGIN-READ-denied", "SETTINGS-READ-denied", "SETTINGS-WRITE-denied", "PLUGIN-DATA-LIST-denied" + ]); + assert.equal(await readFile(join(selected, "actor.txt"), "utf8"), "actor\n"); + assert.equal(existsSync(join(selected, "config.toml")), false); + assert.equal(await readFile(join(sibling, "marker"), "utf8"), "sibling fixture home\n"); + assert.equal(await readFile(settings, "utf8"), "private account settings\n"); + } finally { + wrapped.cleanup(); + } +}); + +test("seatbelt, for real: without a selected home an isolated agent reads no plugin data", onMac, async (t) => { + const f = await fixture(t); + const home = join(f.homes, "codex-a"); + await mkdir(home); + await writeFile(join(home, "auth.json"), "FAKE-TOKEN\n"); + const wrapped = isolation(f).wrap({ sessionId: "plain", provider: "codex", cwd: f.project, command: "/bin/sh", + args: ["-c", `cat ${JSON.stringify(join(home, "auth.json"))} 2>/dev/null || echo TOKEN-READ-denied`], + env: { HOME: f.home, PATH: "/usr/bin:/bin" }, profile: "acceptEdits" }); + try { + const result = spawnSync(wrapped.command, wrapped.args, { cwd: f.project, env: wrapped.env, encoding: "utf8", timeout: 20_000 }); + assert.equal(result.stdout.trim(), "TOKEN-READ-denied"); + } finally { + wrapped.cleanup(); + } +}); + +test("a launch hands the isolation layer the account home its contributor selected", async (t) => { + const f = await fixture(t); + const selected = join(f.homes, "codex-alternate"); + await mkdir(selected); + const pipeline = new LaunchPipeline({ + contributors: () => [{ pluginId: PLUGIN, pluginName: "Accounts", serviceId: "accounts", dataDir: f.accountData, secrets: false, + launch: { fields: [{ key: "account", label: "Account", kind: "text", default: "" }] } }], + call: async () => ({ env: { CODEX_HOME: selected } }), + secret: async () => null, + runsRoot: join(f.userData, "launch-runs") + }); + const calls = []; + const spawner = (command, args, options) => { + calls.push({ command, args, options }); + return { pid: 41_000 + calls.length, process: command, write() {}, resize() {}, kill() {}, pause() {}, resume() {}, + onData() { return { dispose() {} }; }, onExit() { return { dispose() {} }; } }; + }; + const registry = { + get: (provider) => ({ state: "available", provider, executable: `/resolved/${provider}`, launcher: "native", environment: { PATH: "/usr/bin" }, checked: [] }), + snapshot: () => ({}) + }; + const terminals = new TerminalManager(() => undefined, registry, undefined, undefined, true, spawner); + t.after(() => terminals.shutdown()); + terminals.configureLaunchPipeline(pipeline); + const wraps = []; + const layer = isolation(f, fakeSeatbelt); + terminals.configureIsolation({ + containment: () => true, + decide: (input) => layer.decide(input), + wrap: (launch) => { + wraps.push(launch); + return { command: SANDBOX_EXEC, args: ["-f", "/p.sb", launch.command, ...launch.args], env: launch.env, cleanup() {} }; + } + }); + terminals.create({ provider: "codex", profile: "acceptEdits", cwd: f.project, position: { x: 0, y: 0 }, + launchOptions: { [PLUGIN]: { account: "alternate" } } }); + const deadline = Date.now() + 5_000; + while (wraps.length === 0 && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 10)); + assert.equal(wraps.length, 1); + assert.equal(wraps[0].accountHome, selected); + assert.equal(wraps[0].env.CODEX_HOME, selected); + assert.equal(calls.at(-1).command, SANDBOX_EXEC); +}); + +test("base protection: a card's own selected account home is its own; other accounts' homes and the rest of plugin data stay private", async (t) => { + const f = await fixture(t); + const selected = join(f.homes, "claude-alternate"); + const neighbour = join(f.homes, "claude-other"); + await Promise.all([selected, neighbour].map((path) => mkdir(path))); + const pipeline = new LaunchPipeline({ + contributors: () => [{ pluginId: PLUGIN, pluginName: "Accounts", serviceId: "accounts", dataDir: f.accountData, secrets: false, + launch: { fields: [{ key: "account", label: "Account", kind: "text", default: "" }] } }], + call: async (_pluginId, _serviceId, _method, params) => ({ env: params.provider === "codex" ? { CODEX_HOME: `${selected}/` } : { CLAUDE_CONFIG_DIR: selected } }), + secret: async () => null, + runsRoot: join(f.userData, "launch-runs") + }); + const spawned = []; + const spawner = (command) => { + spawned.push(command); + return { pid: 42_000 + spawned.length, process: command, write() {}, resize() {}, kill() {}, pause() {}, resume() {}, + onData() { return { dispose() {} }; }, onExit() { return { dispose() {} }; } }; + }; + const registry = { + get: (provider) => ({ state: "available", provider, executable: `/resolved/${provider}`, launcher: "native", environment: { PATH: "/usr/bin" }, checked: [] }), + snapshot: () => ({}) + }; + const terminals = new TerminalManager(() => undefined, registry, undefined, undefined, true, spawner); + t.after(() => terminals.shutdown()); + terminals.configureLaunchPipeline(pipeline); + const hooks = new DecisionHooks({ baseProtection: () => true, services: () => [], call: async () => null, home: f.home, + privateData: canvasTtyPrivateData(f.userData), session: (id) => terminals.decisionContext(id) }); + const decide = async (id, toolName, toolInput) => + (await hooks.decide(id, { toolName, toolInput, toolInputPreview: null, cwd: null, truncated: false }, new AbortController().signal)).behavior; + // Person-launched (no isolation layer): base protection is the guard here. + const withAccount = (provider) => terminals.create({ provider, profile: "auto", cwd: f.project, position: { x: 0, y: 0 }, + launchOptions: { [PLUGIN]: { account: "alternate" } } }).id; + const plain = terminals.create({ provider: "claude", profile: "auto", cwd: f.project, position: { x: 0, y: 0 } }).id; + const cards = [withAccount("claude"), withAccount("codex")]; + const deadline = Date.now() + 5_000; + while (spawned.length < 3 && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 10)); + assert.equal(spawned.length, 3); + for (const id of cards) { + assert.ok(terminals.decisionContext(id).configDirs.includes(selected), id); + assert.equal(await decide(id, "Read", { file_path: join(selected, "auth.json") }), "none", "own home"); + assert.equal(await decide(id, "Bash", { command: `cat '${join(selected, "auth.json")}'` }), "none", "own home"); + assert.equal(await decide(id, "Read", { file_path: join(neighbour, "auth.json") }), "deny", "another account's home"); + assert.equal(await decide(id, "Bash", { command: `cat '${join(neighbour, "auth.json")}'` }), "deny", "another account's home"); + assert.equal(await decide(id, "Read", { file_path: join(f.otherData, "state.json") }), "deny", "another plugin's data"); + assert.equal(await decide(id, "Read", { file_path: join(f.project, "a.ts") }), "none", "the project"); + } + assert.equal(terminals.decisionContext(plain).configDirs.includes(selected), false); + assert.equal(await decide(plain, "Read", { file_path: join(selected, "auth.json") }), "deny", "not this card's home"); + assert.equal(await decide(plain, "Bash", { command: "cat a.ts && grep -r todo ." }), "none", "a normal project is unaffected"); +}); diff --git a/tests/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/agent-control.test.mjs b/tests/agent-control.test.mjs index 37bd58742..c75afbf89 100644 --- a/tests/agent-control.test.mjs +++ b/tests/agent-control.test.mjs @@ -709,7 +709,7 @@ test("send appends submit unless told otherwise and rejects exited sessions", as terminals.disposeAll(); }); -test("observe returns a capped terminal tail and result reflects exit state", () => { +test("observe returns a capped terminal tail and result reflects exit state", async () => { const { terminals, control } = serviceFixture(); const parent = terminals.create({ provider: "codex", @@ -717,11 +717,11 @@ test("observe returns a capped terminal tail and result reflects exit state", () profile: "normal", position: { x: 0, y: 0 } }); - const observation = control.observe(parent.id); + const observation = await control.observe(parent.id); assert.equal(observation.sessionId, parent.id); assert.equal(observation.output.length <= 8_192, true); - const running = control.result(parent.id); + const running = await control.result(parent.id); assert.equal(running.state, "running"); assert.equal(running.exitCode, null); terminals.disposeAll(); @@ -746,8 +746,8 @@ test("cancel disposes the subagent and plain terminals are not agents", async () assert.equal(terminals.list().some((session) => session.id === child.id), false); assert.throws(() => control.send(terminal.id, "text"), /not agents/u); - assert.throws(() => control.observe(terminal.id), /not agents/u); - assert.throws(() => control.result(terminal.id), /not agents/u); + await assert.rejects(control.observe(terminal.id), /not agents/u); + await assert.rejects(control.result(terminal.id), /not agents/u); terminals.disposeAll(); }); @@ -805,8 +805,8 @@ test("an orchestrator tool call looks its sessions up by id: no other card's scr assert.equal(control.status(child.id).id, child.id); assert.equal(control.isInSubtree(parent.id, child.id), true); assert.deepEqual(control.children(parent.id).map((session) => session.id), [child.id]); - control.observe(child.id); - control.result(child.id); + await control.observe(child.id); + await control.result(child.id); assert.throws(() => control.status("missing"), /does not exist/u); assert.deepEqual(copied, [child.id, child.id], "only the observed card's own scrollback is read, and no list() snapshot of every card"); diff --git a/tests/agent-isolation.test.mjs b/tests/agent-isolation.test.mjs index 89b2266aa..2248c9c78 100644 --- a/tests/agent-isolation.test.mjs +++ b/tests/agent-isolation.test.mjs @@ -1,17 +1,18 @@ import assert from "node:assert/strict"; import { spawn, spawnSync } from "node:child_process"; -import { existsSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { mkdir, mkdtemp, readFile, readdir, realpath, rm, symlink, writeFile } from "node:fs/promises"; import { createServer } from "node:net"; import { tmpdir } from "node:os"; import { delimiter, join } from "node:path"; import test from "node:test"; -import { AgentIsolation, ISOLATION_FOLDER_PREFIX, probeBubblewrap } from "../src/main/services/isolation/AgentIsolation.ts"; +import { AgentIsolation, gatewaySocketAddresses, ISOLATION_FOLDER_PREFIX, probeBubblewrap } from "../src/main/services/isolation/AgentIsolation.ts"; import { isolationPaths } from "../src/main/services/isolation/isolationPaths.ts"; import { seatbeltProfile } from "../src/main/services/isolation/seatbelt.ts"; import { bubblewrapArguments } from "../src/main/services/isolation/bubblewrap.ts"; import { AgentControlService } from "../src/main/services/AgentControlService.ts"; import { codexInsideIsolation } from "../src/main/services/terminalLaunch.ts"; +import { LaunchPipeline } from "../src/main/services/LaunchPipeline.ts"; import { TerminalManager } from "../src/main/services/TerminalManager.ts"; import { availableRegistry, fakeSpawner } from "./helpers/terminal.mjs"; @@ -180,6 +181,53 @@ test("bubblewrap: read-only root, writable project, tmpfs over what may not be r assert.deepEqual(args.slice(-5), ["--chdir", w.project, "--", "/usr/bin/claude", "--x"]); }); +test("bubblewrap: CanvasTTY's gateway socket folders are read-only, a hidden socket is masked", async (t) => { + const w = await world(t); + const lifecycle = join(w.userData, "lifecycle", "runtime"); + const gateway = join(w.base, "ctty-1000-gw"); + const sessionTemp = join(w.temp, "s"); + const paths = isolationPaths({ provider: "codex", cwd: w.project, sessionTemp, + env: w.env, gatewaySockets: [join(lifecycle, "r.sock"), join(gateway, "gateway.sock")], userDataPath: w.userData, sessionId: "s" }); + const ssh = join(w.home, ".ssh", "agent.sock"); + const kinds = new Map([[w.project, "directory"], [sessionTemp, "directory"], [lifecycle, "directory"], [gateway, "directory"], + [join(w.userData, "browser", "runtime"), "directory"], [join(w.userData, "orchestration", "runtime"), "directory"], [ssh, "socket"]]); + const args = bubblewrapArguments({ ...paths, unreadable: [...paths.unreadable, ssh] }, { command: "/usr/bin/codex", args: [], cwd: w.project }, + (path) => kinds.get(path) ?? null); + const pairs = args.map((arg, i) => `${arg} ${args[i + 1]} ${args[i + 2]}`); + // Connecting to a Unix socket needs no write access to its folder; a writable folder would let one agent replace a + // gateway socket and receive another agent's requests. + for (const folder of [lifecycle, gateway, join(w.userData, "browser", "runtime"), join(w.userData, "orchestration", "runtime")]) { + assert.ok(pairs.includes(`--ro-bind ${folder} ${folder}`), `${folder} read-only`); + assert.ok(!pairs.includes(`--bind ${folder} ${folder}`), `${folder} not writable`); + } + // This launch's own temporary folder (also a socket folder) stays writable, bound once. + assert.equal(pairs.filter((pair) => pair === `--bind ${sessionTemp} ${sessionTemp}`).length, 1); + assert.ok(!pairs.includes(`--ro-bind ${sessionTemp} ${sessionTemp}`)); + // A socket it may not reach is hidden like a file, never left in place. + assert.ok(pairs.includes(`--ro-bind /dev/null ${ssh}`)); +}); + +test("the paths: 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); @@ -304,6 +352,86 @@ test("the launch: wrapped when the layer applies, refused (never unwrapped) when assert.deepEqual(cleaned, [auto.id], "its folder goes with the card"); }); +test("the launch: only CanvasTTY's own gateway sockets reach the layer, never a CANVASTTY_*_ADDRESS from the person or a plugin", async (t) => { + const w = await world(t); + const calls = []; + const runtimeSocket = join(w.userData, "lifecycle", "runtime", "r.sock"); + const runtime = { + prepareLaunch: () => ({ args: [], environment: { CANVASTTY_RUNTIME_ADDRESS: runtimeSocket, CANVASTTY_RUNTIME_CAPABILITY: "c" }, cleanup() {} }), + currentStatus: () => null + }; + const terminals = new TerminalManager(() => undefined, availableRegistry(), undefined, runtime, true, fakeSpawner(calls)); + t.after(() => terminals.shutdown()); + const launches = []; + terminals.configureIsolation({ + containment: () => true, + decide: (input) => new AgentIsolation({ userDataPath: w.userData, enabled: () => true, platform: "darwin", exists: () => true }).decide(input), + wrap: (launch) => { launches.push(launch); return { command: "/usr/bin/sandbox-exec", args: [launch.command, ...launch.args], env: launch.env, cleanup() {} }; } + }); + const before = process.env.CANVASTTY_FOO_ADDRESS; + process.env.CANVASTTY_FOO_ADDRESS = join(w.home, "foo.sock"); + t.after(() => { if (before === undefined) delete process.env.CANVASTTY_FOO_ADDRESS; else process.env.CANVASTTY_FOO_ADDRESS = before; }); + terminals.create({ provider: "claude", profile: "auto", cwd: w.project, position: at }); + assert.equal(launches.length, 1); + assert.equal(launches[0].env.CANVASTTY_FOO_ADDRESS, join(w.home, "foo.sock"), "the variable itself still reaches the agent"); + assert.deepEqual(launches[0].gatewaySockets, [runtimeSocket]); + assert.deepEqual(gatewaySocketAddresses({ CANVASTTY_A_ADDRESS: "/a/s.sock", CANVASTTY_B_ADDRESS: "127.0.0.1:1", OTHER_ADDRESS: "/o" }, + undefined, { CANVASTTY_C_ADDRESS: "/a/s.sock" }), ["/a/s.sock"], "absolute CanvasTTY addresses only, once each"); +}); + +test("the launch: only the control grant CanvasTTY gave an orchestrator is reopened, never one a plugin names", async (t) => { + const w = await world(t); + const calls = []; + const terminals = new TerminalManager(() => undefined, availableRegistry(), undefined, undefined, true, fakeSpawner(calls)); + t.after(() => terminals.shutdown()); + const launches = []; + terminals.configureIsolation({ + containment: () => true, + decide: (input) => new AgentIsolation({ userDataPath: w.userData, enabled: () => true, platform: "darwin", exists: () => true }).decide(input), + wrap: (launch) => { launches.push(launch); return { command: "/usr/bin/sandbox-exec", args: [launch.command, ...launch.args], env: launch.env, cleanup() {} }; } + }); + const own = join(w.userData, "agent-control", "sessions", "own", "connection.json"); + terminals.setControlConnection({ connectionPath: join(w.userData, "agent-control", "connection.json"), cliPath: "/cli.mjs", grant: () => own }); + // A launch contributor names another session's grant on an ordinary (non-orchestrator) launch. + const victim = join(w.userData, "agent-control", "sessions", "victim", "connection.json"); + const runsRoot = join(w.temp, "runs"); + await mkdir(runsRoot, { recursive: true }); + const pipeline = new LaunchPipeline({ + contributors: () => [{ pluginId: "p.one", pluginName: "Plugin p.one", serviceId: "launcher", secrets: false, + launch: { fields: [{ key: "on", label: "On", kind: "boolean", default: true }] } }], + call: async () => ({ env: { CANVASTTY_CONTROL_CONNECTION: victim } }), + secret: async () => null, + runsRoot + }); + const waitFor = async (predicate) => { + for (let tries = 0; !predicate() && tries < 500; tries++) await new Promise((resolve) => setTimeout(resolve, 10)); + assert.ok(predicate(), "in time"); + }; + // The launch pipeline refuses the name already... + terminals.configureLaunchPipeline(pipeline); + const refused = terminals.create({ provider: "claude", profile: "auto", cwd: w.project, position: at, launchOptions: { "p.one": {} } }); + const card = () => terminals.list().find((session) => session.id === refused.id); + await waitFor(() => card().status === "failed"); + assert.match(card().failureDetails, /env CANVASTTY_CONTROL_CONNECTION is reserved for CanvasTTY/u); + assert.equal(launches.length, 0); + // ...and the isolation layer does not depend on that: a value that reaches the launch environment reopens nothing. + terminals.configureLaunchPipeline({ + normalizeOptions: (...args) => pipeline.normalizeOptions(...args), + unavailable: (options) => pipeline.unavailable(options), + forgetSession: async () => undefined, + prepare: async () => ({ ok: true, env: { CANVASTTY_CONTROL_CONNECTION: victim }, args: [], secrets: [], + envSources: { CANVASTTY_CONTROL_CONNECTION: "Plugin p.one" }, thirdPartyModel: false, cleanup: async () => undefined }) + }); + terminals.create({ provider: "claude", profile: "auto", cwd: w.project, position: at, launchOptions: { "p.one": {} } }); + await waitFor(() => launches.length === 1); + assert.equal(launches[0].env.CANVASTTY_CONTROL_CONNECTION, victim, "the variable itself reaches the agent"); + assert.equal(launches[0].grantedPrivate, undefined, "but no folder is reopened for it"); + + terminals.create({ provider: "claude", profile: "auto", cwd: w.project, position: at, role: "orchestrator" }); + assert.equal(launches.length, 2); + assert.deepEqual(launches[1].grantedPrivate, [join(w.userData, "agent-control", "sessions", "own")], "an orchestrator's own grant is"); +}); + test("without a layer a subagent runs in normal (it asks) and the card says why", async (t) => { const w = await world(t); const calls = []; @@ -423,6 +551,52 @@ test("an isolated launch gets an empty git template, so git init writes no hooks } finally { wrapped.cleanup(); } }); +test("an isolated Claude keeps its Bash and editor files in the launch's own temp folder, on both layers; other CLIs are untouched", async (t) => { + const w = await world(t); + const uid = typeof process.getuid === "function" ? process.getuid() : 501; + const layers = [ + ["seatbelt", isolation(w, { platform: "darwin", exists: () => true })], + ["bubblewrap", isolation(w, { platform: "linux", bubblewrapPath: "/usr/bin/bwrap", bubblewrapProbe: () => null, exists: () => true })] + ]; + for (const [layer, isolated] of layers) { + // A CLAUDE_CODE_TMPDIR from the person's environment would point Claude back at a shared folder: replaced. + const claude = isolated.wrap({ sessionId: "c", provider: "claude", cwd: w.project, command: "/usr/bin/claude", args: [], env: { ...w.env, CLAUDE_CODE_TMPDIR: "/tmp" } }); + const codex = isolated.wrap({ sessionId: "x", provider: "codex", cwd: w.project, command: "/usr/bin/codex", args: [], env: w.env }); + try { + assert.equal(claude.env.CLAUDE_CODE_TMPDIR, claude.env.TMP, layer); + assert.equal(`${claude.env.CLAUDE_CODE_TMPDIR}/`, claude.env.TMPDIR, layer); + assert.match(claude.env.TMP, new RegExp(`^${w.temp}/${ISOLATION_FOLDER_PREFIX}`, "u"), layer); + assert.equal(codex.env.CLAUDE_CODE_TMPDIR, undefined, `${layer}: only Claude reads it`); + // The folder all of the person's Claude sessions share is not opened for writing. + const text = layer === "seatbelt" ? readFileSync(claude.args[1], "utf8") : claude.args.join(" "); + assert.ok(!text.includes(`claude-${uid}`), `${layer}: no grant of the shared /tmp/claude-${uid}`); + } finally { + claude.cleanup(); + codex.cleanup(); + } + } +}); + +test("seatbelt, for real: Claude's scratch folder is in its own temp folder; the shared /tmp/claude- stays read-only", onMac, async (t) => { + const w = await world(t); + const shared = `/private/tmp/claude-${process.getuid()}/ctty-iso-probe-${process.pid}`; + await mkdir(shared, { recursive: true }); + await writeFile(join(shared, "other-session.output"), "another session's output"); + t.after(() => rm(shared, { recursive: true, force: true })); + t.after(() => rm(`/private/tmp/claude-${process.getuid()}/ctty-iso-new-${process.pid}`, { recursive: true, force: true })); + const { lines, env } = run(w, [ + // What Claude Code does for its Bash tool: ${CLAUDE_CODE_TMPDIR:-/tmp}/claude-///tasks. + 'd="${CLAUDE_CODE_TMPDIR:-/tmp}/claude-$(id -u)/p/s/tasks"; mkdir -p "$d" && echo out > "$d/b.output" && cat "$d/b.output" >/dev/null && echo SCRATCH-ok || echo SCRATCH-denied', + `echo x >> "${shared}/other-session.output" 2>/dev/null && echo SHARED-WRITE-ok || echo SHARED-WRITE-denied`, + `: > "${shared}/planted" 2>/dev/null && echo SHARED-CREATE-ok || echo SHARED-CREATE-denied`, + `mkdir "/private/tmp/claude-$(id -u)/ctty-iso-new-${process.pid}" 2>/dev/null && echo SHARED-MKDIR-ok || echo SHARED-MKDIR-denied` + ].join("; "), { provider: "claude" }); + assert.deepEqual(lines, ["SCRATCH-ok", "SHARED-WRITE-denied", "SHARED-CREATE-denied", "SHARED-MKDIR-denied"]); + assert.equal(env.CLAUDE_CODE_TMPDIR, env.TMP); + assert.equal(await readFile(join(shared, "other-session.output"), "utf8"), "another session's output"); + assert.equal(existsSync(join(shared, "planted")), false); +}); + test("seatbelt, for real: Claude saves a refreshed sign-in in the login keychain; nothing else there; plan is read-only", onMac, async (t) => { const w = await world(t); const folder = join(w.home, "Library", "Keychains"); diff --git a/tests/agent-runtime-account-homes.test.mjs b/tests/agent-runtime-account-homes.test.mjs new file mode 100644 index 000000000..d5615caee --- /dev/null +++ b/tests/agent-runtime-account-homes.test.mjs @@ -0,0 +1,461 @@ +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 } from "../src/main/services/accountHomes.ts"; +import { contributedAccountHome } from "../src/main/services/isolation/accountHomeIsolation.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("account home: only the contributor's own data folder; only GROK_HOME / KIMI_CODE_HOME get the hooks", () => { + const dataDir = "/data/plugin-data/accounts"; + // The launch's account home (one rule, contributedAccountHome) gets the hooks only when it is the hook home too, as + // TerminalManager checks it. + const hookHome = (provider, key, value) => { + const home = contributedAccountHome(provider, dataDir, key, value); + const hook = accountHookHome(provider, { [key]: value }); + return home !== undefined && home === hook ? home : undefined; + }; + assert.equal(hookHome("grok", "GROK_HOME", `${dataDir}/homes/a/`), `${dataDir}/homes/a`); + assert.equal(hookHome("kimi", "KIMI_CODE_HOME", `${dataDir}/homes/k`), `${dataDir}/homes/k`); + assert.equal(hookHome("grok", "GROK_HOME", "/data/plugin-data/other/homes/a"), undefined); + assert.equal(hookHome("grok", "GROK_HOME", dataDir), undefined); + assert.equal(hookHome("grok", "GROK_HOME", `${dataDir}/../other/homes/a`), undefined); + assert.equal(hookHome("grok", "GROK_HOME", "homes/a"), undefined); + assert.equal(hookHome("grok", "KIMI_CODE_HOME", `${dataDir}/homes/a`), undefined); + assert.equal(hookHome("kimi", "KIMI_SHARE_DIR", `${dataDir}/homes/a`), undefined); + assert.equal(hookHome("claude", "CLAUDE_CONFIG_DIR", `${dataDir}/homes/a`), undefined); + assert.equal(contributedAccountHome("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, false, 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, false, 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, false, home); + const second = runtime.prepare("kimi", "card-2", true, false, undefined, undefined, false, `${home}/`); + const third = runtime.prepare("kimi", "card-3", true, false, undefined, undefined, false, 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, false, home); + runtime.prepare("grok", "card-crashed-grok", true, false, undefined, undefined, false, home); + const restarted = adapters(); + const again = restarted.prepare("kimi", "card-again", true, false, undefined, undefined, false, home); + const grokAgain = restarted.prepare("grok", "card-again-grok", true, false, undefined, undefined, false, 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, false, 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, false, 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, false, 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, false, 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, false, 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, false, candidate); + const kimi = runtime.prepare("kimi", "card-k", true, false, undefined, undefined, false, 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, false, 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, false, 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, false, 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(); + } +}); diff --git a/tests/agent-runtime-gateway.test.mjs b/tests/agent-runtime-gateway.test.mjs index 61ec999da..67e8bc0df 100644 --- a/tests/agent-runtime-gateway.test.mjs +++ b/tests/agent-runtime-gateway.test.mjs @@ -158,6 +158,32 @@ test("RuntimeGateway: a late start of an earlier turn does not take over from th assert.equal(gateway.currentStatus("terminal-turns"), "idle"); }); +test("RuntimeGateway: a turn whose start hook was lost still counts after an idle, but not while another turn runs", POSIX_RUNTIME_GATEWAY_TEST, async (t) => { + const root = await fixture(t); + const signals = []; + const gateway = new RuntimeGateway({ runtimeDirectory: root, onSignal: (id, signal) => signals.push({ id, signal }) }); + await gateway.start(); + t.after(() => gateway.close()); + const capability = gateway.registerSession("terminal-lost-start", "codex"); + await send(capability.address, message(capability, "working", "UserPromptSubmit", "turn-1")); + // An event of another turn while turn 1 runs is out of order. + await send(capability.address, message(capability, "working", "PostToolUse", "turn-x")); + await send(capability.address, message(capability, "idle", "Stop", "turn-1")); + // Turn 2's UserPromptSubmit never arrived: its own events still report it. + await send(capability.address, message(capability, "working", "PostToolUse", "turn-2")); + assert.equal(gateway.currentStatus("terminal-lost-start"), "working"); + // A late event of the earlier turn stays stale. + await send(capability.address, message(capability, "idle", "Stop", "turn-1")); + await send(capability.address, message(capability, "idle", "Stop", "turn-2")); + // After that idle, an id already seen does not open a turn again. + await send(capability.address, message(capability, "working", "PostToolUse", "turn-1")); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(signals.map(({ signal }) => `${signal.state}:${signal.event}:${signal.turnId}`), [ + "working:UserPromptSubmit:turn-1", "idle:Stop:turn-1", "working:PostToolUse:turn-2", "idle:Stop:turn-2" + ]); + assert.equal(gateway.currentStatus("terminal-lost-start"), "idle"); +}); + test("RuntimeGateway propagates threadId for codex sessions with canonical UUID", POSIX_RUNTIME_GATEWAY_TEST, async (t) => { const root = await fixture(t); const signals = []; diff --git a/tests/agent-runtime-provider-launch.test.mjs b/tests/agent-runtime-provider-launch.test.mjs index c56a310b2..c3966d872 100644 --- a/tests/agent-runtime-provider-launch.test.mjs +++ b/tests/agent-runtime-provider-launch.test.mjs @@ -46,6 +46,7 @@ test("Claude and Codex receive automatic lifecycle hooks without prompt or respo assert.equal(settings.showStatusInTerminalTab, true); assert.match(settings.hooks.UserPromptSubmit[0].hooks[0].command, /working.*UserPromptSubmit/u); assert.match(settings.hooks.PermissionRequest[0].hooks[0].command, /needs_approval.*PermissionRequest/u); + assert.match(settings.hooks.PostToolUseFailure[0].hooks[0].command, /working.*PostToolUseFailure/u); assert.match(settings.hooks.Stop[0].hooks[0].command, /idle.*Stop/u); assert.match(settings.hooks.Stop[0].hooks[0].command, /^ELECTRON_RUN_AS_NODE='1' /u); assert.equal(JSON.stringify(settings).includes('"prompt":'), false); @@ -497,3 +498,25 @@ function runtimeOptionsFor(root, qwenSystemSettingsPath, pluginHooks) { environment: {} }; } + +test("with status hooks off a result-capturing subagent gets no lease and no lifecycle hooks", async (t) => { + const root = await fixture(t); + const registrations = []; + const gateway = { + registerSession(id, provider, capture, _grant, decisions) { + registrations.push({ id, capture, decisions }); + return { address: join(root, "runtime.sock"), terminalSessionId: id, provider, capabilityToken: "token" }; + }, + revokeTerminalSession() {}, + currentStatus: () => null + }; + const bridge = new AgentRuntimeBridge(gateway, { ...runtimeOptionsFor(root), coreHooksEnabled: false }); + for (const provider of ["codex", "opencode"]) { + const launch = bridge.prepareLaunch({ terminalSessionId: provider, provider, cwd: root, captureResult: true, decisions: false }); + assert.equal(launch.environment[AGENT_RUNTIME_ENV.capabilityToken], undefined, `${provider}: turning status hooks off means no hooks`); + assert.deepEqual(launch.args.filter((arg) => /^hooks\./u.test(arg)), []); + assert.equal(launch.environment.OPENCODE_CONFIG_CONTENT, undefined); + launch.cleanup(); + } + assert.deepEqual(registrations, []); +}); diff --git a/tests/base-protection-app-private.test.mjs b/tests/base-protection-app-private.test.mjs index e19321d0d..51a3acf83 100644 --- a/tests/base-protection-app-private.test.mjs +++ b/tests/base-protection-app-private.test.mjs @@ -11,6 +11,7 @@ import { join } from "node:path"; import { analyzeAction } from "../src/main/services/safety/commandFacts.ts"; import { canvasTtyPrivateData, checkBaseProtection, denyRule } from "../src/main/services/safety/baseProtection.ts"; import { DecisionHooks } from "../src/main/services/DecisionHooks.ts"; +import { guardedCall } from "../src/agent-runtime/opencode-decisions.mjs"; const base = realpathSync(mkdtempSync(join(tmpdir(), "canvastty-app-private-"))); const home = join(base, "home"); @@ -102,10 +103,113 @@ test("CanvasTTY's own tokens, secret stores and sockets are refused for every re for (const command of DENY) assert.equal(rule(command), "app-private", command); }); +test("the per-card Claude Code draft folders are private data too", () => { + const draft = join(userData, "claude-draft", "card-2", "draft.1"); + assert.equal(rule(`cat ${q(draft)}`), "app-private"); + assert.equal(rule(`touch ${q(join(userData, "claude-draft", "card-2", "req.1"))}`), "app-private"); + assert.equal(rule("cat \"$HOME/Library/Application Support/canvastty/claude-draft/card-2/draft.1\""), "app-private"); + assert.equal(rule("cat notes/claude-drafting.md"), null, "a project file with a similar name stays allowed"); +}); + test("look-alikes in the project, the app's other files and other sockets stay allowed", () => { for (const command of ALLOW) assert.equal(rule(command), null, command); }); +test("a broad project (HOME, or a folder holding the app's data) cannot reopen the private data below it", () => { + const secrets = join(userData, "provider-secrets.bin"); + const otherAccount = join(userData, "account-homes", "acct-2", "auth.json"); + for (const root of [home, join(home, "Library"), userData]) { + const broad = (command, extra = {}) => denyRule(analyzeAction(shell(command), root, { home, privateData, ...extra })); + const tool = (toolName, toolInput, extra = {}) => checkBaseProtection({ toolName, toolInput, root, home, privateData, ...extra })?.rule ?? null; + for (const command of [ + `cat ${q(secrets)}`, + "cat ~/Library/Application\\ Support/canvastty/provider-secrets.bin", + `cat ${q(token)}`, + `cat ${q(join(userData, "github-oauth.json"))}`, + `cat ${q(otherAccount)}`, + `ls ${q(join(userData, "plugin-secrets"))}`, + `cat ${q(join(userData, "lifecycle", "runtime", "connection.json"))}`, + `cd ${q(userData)} && cat provider-secrets.bin`, + `python3 -c "print(open('${secrets}','rb').read())"`, + `echo x > ${q(secrets)}`, + `cp ./x ${q(token)}`, + `grep -r token ${q(userData)}` + ]) assert.equal(broad(command), "app-private", `${root}: ${command}`); + assert.equal(tool("Write", { file_path: secrets, content: "x" }), "app-private", `${root}: Write`); + assert.equal(tool("Edit", { file_path: join(userData, "github-oauth.json"), old_string: "a", new_string: "b" }), "app-private", `${root}: Edit`); + assert.equal(tool("apply_patch", { input: `*** Begin Patch\n*** Update File: ${token}\n*** End Patch` }), "app-private", `${root}: apply_patch`); + assert.equal(tool("Bash", { command: `cat ${q(secrets)}` }), "app-private", `${root}: Bash`); + // The app's other files, and the project's own ordinary files, stay usable. + assert.equal(broad(`cat ${q(join(userData, "settings.json"))}`), null, root); + assert.equal(broad(`ls ${q(userData)}`), null, root); + // The agent's own account home (strictly inside account-homes) stays its own; its neighbours do not. + const accountHome = join(userData, "account-homes", "acct-1", ".claude"); + assert.equal(broad(`cat ${q(join(accountHome, "projects", "p", "memory", "MEMORY.md"))}`, { agentRoots: [accountHome] }), null, root); + assert.equal(tool("Write", { file_path: join(accountHome, "plans", "p.md"), content: "x" }, { agentRoots: [accountHome] }), null, root); + assert.equal(broad(`cat ${q(otherAccount)}`, { agentRoots: [accountHome] }), "app-private", root); + assert.equal(broad(`cat ${q(secrets)}`, { agentRoots: [accountHome] }), "app-private", root); + } + const atHome = (command) => denyRule(analyzeAction(shell(command), home, { home, privateData })); + assert.equal(atHome(`cat ${q(join(home, "notes.txt"))}`), null); + assert.equal(atHome("grep -rn todo ."), null, "a recursive search of the project itself is not a read of the app's folder"); +}); + +test("a broad agent config folder does not reopen the private data below it either", () => { + assert.equal(rule(`cat ${q(token)}`, { agentRoots: [home] }), "app-private"); + assert.equal(rule(`cat ${q(join(home, "notes.txt"))}`, { agentRoots: [home] }), null); +}); + +test("a project in a temporary folder does not make the gateways' socket folders its own", () => { + const temp = realpathSync(tmpdir()); + const atTemp = (command) => denyRule(analyzeAction(shell(command), temp, { home, privateData })); + assert.equal(atTemp(`nc -U ${q(join(temp, "ctty-control-Ab12Cd", "c.sock"))}`), "app-private"); + assert.equal(atTemp(`cat ${q(join(temp, "ctty-runtime-501-ab12", "connection.json"))}`), "app-private"); + assert.equal(atTemp(`cat ${q(join(temp, "notes.txt"))}`), null); +}); + +test("a project inside the app's private data keeps its own files, not its neighbours", () => { + const accountProject = join(userData, "account-homes", "acct-1"); + const inside = (command) => denyRule(analyzeAction(shell(command), accountProject, { home, privateData })); + assert.equal(inside("cat .claude/settings.json"), null); + assert.equal(inside(`cat ${q(join(userData, "account-homes", "acct-2", "auth.json"))}`), "app-private"); + assert.equal(inside(`cat ${q(join(userData, "provider-secrets.bin"))}`), "app-private"); +}); + +test("plugin data is private: another account's home is refused; the card's own selected home and a project placed there stay its own", () => { + const pluginData = join(userData, "plugin-data"); + const own = join(pluginData, "com.example.accounts", "homes", "acct-1"); + const other = join(pluginData, "com.example.accounts", "homes", "acct-2"); + const check = (toolName, toolInput, extra = {}) => checkBaseProtection({ toolName, toolInput, root: project, home, privateData, ...extra })?.rule ?? null; + const ownRoots = { agentRoots: [join(home, ".claude"), own] }; + for (const extra of [{}, ownRoots]) { + assert.equal(check("Bash", { command: `cat ${q(join(other, "auth.json"))}` }, extra), "app-private"); + assert.equal(check("Read", { file_path: join(other, "auth.json") }, extra), "app-private"); + assert.equal(check("Bash", { command: `cat ${q(join(pluginData, "com.example.accounts", "state.json"))}` }, extra), "app-private"); + assert.equal(check("LS", { path: pluginData }, extra), "app-private"); + assert.equal(check("Grep", { pattern: "token", path: join(pluginData, "com.example.accounts") }, extra), "app-private"); + assert.equal(check("Bash", { command: "cat ~/Library/Application\\ Support/canvastty/plugin-data/*/homes/*/auth.json" }, extra), "app-private"); + assert.equal(check("Bash", { command: `cat ${q(join(pluginData, "com.example.other", "data.json"))}` }, extra), "app-private"); + } + // Without the launch's record the selected home is plugin data like any other. + assert.equal(check("Read", { file_path: join(own, "auth.json") }), "app-private"); + assert.equal(check("Read", { file_path: join(own, "auth.json") }, ownRoots), null); + assert.equal(check("Bash", { command: `cat ${q(join(own, "auth.json"))} && echo x > ${q(join(own, "plans", "p.md"))}` }, ownRoots), null); + assert.equal(check("Grep", { pattern: "x", path: own }, ownRoots), null); + // A whole plugin's data folder is never one card's own. + assert.equal(check("Read", { file_path: join(other, "auth.json") }, { agentRoots: [pluginData] }), "app-private"); + // A project CanvasTTY placed inside plugin data (an environment's worktree) keeps its own files, code naming them too. + const worktree = join(pluginData, "com.example.environments", "worktrees", "w1"); + const inWorktree = (command) => denyRule(analyzeAction(shell(command), worktree, { home, privateData })); + assert.equal(inWorktree("cat src/a.ts && git status"), null); + assert.equal(inWorktree(`python3 -c "open(${JSON.stringify(join(worktree, "src", "a.ts"))}).read()"`), null); + assert.equal(inWorktree(`cat ${q(join(pluginData, "com.example.environments", "worktrees", "w2", "a.ts"))}`), "app-private"); + // A normal project is unaffected. + for (const command of ["cat src/a.ts", "grep -r todo .", `ls ${q(userData)}`, `cat ${q(join(project, "plugin-data", "x.json"))}`]) { + assert.equal(check("Bash", { command }), null, command); + } + assert.equal(check("Read", { file_path: join(project, "src", "a.ts") }), null); +}); + test("file tools that name private data are refused; the agent's own account home is its own", () => { const check = (toolName, toolInput, extra = {}) => checkBaseProtection({ toolName, toolInput, root: project, home, privateData, ...extra }); assert.equal(check("Write", { file_path: join(control, "token-x"), content: "x" })?.rule, "app-private"); @@ -116,6 +220,60 @@ test("file tools that name private data are refused; the agent's own account hom assert.equal(check("Bash", { command: `cat ${q(token)}` }, { agentRoots: [accountHome] })?.rule, "app-private"); }); +test("read-only file tools are refused only for CanvasTTY's private data", () => { + const secrets = join(userData, "provider-secrets.bin"); + const accountHome = join(userData, "account-homes", "acct-1", ".claude"); + for (const root of [project, home]) { + const read = (toolName, toolInput, extra = {}) => checkBaseProtection({ toolName, toolInput, root, home, privateData, ...extra })?.rule ?? null; + for (const [toolName, toolInput, extra] of [ + ["Read", { file_path: secrets }], + ["Read", { file_path: "~/Library/Application Support/canvastty/agent-control/token-0123abcd" }], + ["NotebookRead", { notebook_path: join(userData, "github-oauth.json") }], + ["Grep", { pattern: "token", path: userData }], + ["Grep", { pattern: "x", path: join(userData, "plugin-secrets") }], + ["Glob", { pattern: "*", path: control }], + ["Glob", { pattern: join(userData, "provider-*") }], + ["LS", { path: join(userData, "account-homes") }], + ["read_file", { absolute_path: secrets }], + ["read_many_files", { paths: [join(userData, "plugin-secrets")] }], + ["grep_search", { pattern: "x", path: userData }], + ["list_directory", { path: control }], + ["Read", { file_path: join(userData, "account-homes", "acct-2", "auth.json") }, { agentRoots: [accountHome] }] + ]) assert.equal(read(toolName, toolInput, extra), "app-private", `${root}: ${toolName} ${JSON.stringify(toolInput)}`); + // Everything else stays as it was: reads outside the project are not "outside", and nothing new is refused. + for (const [toolName, toolInput] of [ + ["Read", { file_path: join(project, "src", "a.ts") }], + ["Read", { file_path: "src/a.ts" }], + ["Read", { file_path: "/etc/hosts" }], + ["Read", { file_path: join(userData, "settings.json") }], + ["Grep", { pattern: "todo", path: "src" }], + ["Grep", { pattern: "todo" }], + ["Grep", { pattern: "todo", path: home }], + ["Glob", { pattern: "**/*.ts" }], + ["LS", { path: userData }], + ["read_file", { absolute_path: join(project, "src", "a.ts") }] + ]) assert.equal(read(toolName, toolInput), null, `${root}: ${toolName} ${JSON.stringify(toolInput)}`); + // The agent's own account home stays its own. + assert.equal(read("Read", { file_path: join(accountHome, "settings.json") }, { agentRoots: [accountHome] }), null, root); + assert.equal(read("Grep", { pattern: "x", path: accountHome }, { agentRoots: [accountHome] }), null, root); + } +}); + +test("decision hooks refuse a private read without asking decision plugins about reads", async () => { + let asked = 0; + const hooks = new DecisionHooks({ + baseProtection: () => true, services: () => [{ pluginId: "p", pluginName: "P", serviceId: "s", mayAllow: false }], + call: async () => { asked++; return { behavior: "deny", message: "plugin" }; }, home, privateData, + session: () => ({ provider: "claude", role: "agent", cwd: home, configDirs: [] }) + }); + const request = (toolInput) => ({ toolName: "Read", toolInput, toolInputPreview: null, cwd: null, truncated: false }); + const denied = await hooks.decide("s1", request({ file_path: join(userData, "provider-secrets.bin") }), new AbortController().signal); + assert.equal(denied.behavior, "deny"); + assert.match(denied.message, /Orchestrator role/u); + assert.equal((await hooks.decide("s1", request({ file_path: join(home, "notes.txt") }), new AbortController().signal)).behavior, "none"); + assert.equal(asked, 0, "plugins are not asked about read-only tools"); +}); + test("without the app's folder the socket folders are still known; the userData paths are not guessed", () => { assert.equal(denyRule(analyzeAction(shell(`nc -U ${sock}`), project, { home })), "app-private"); assert.equal(denyRule(analyzeAction(shell(`cat ${q(token)}`), project, { home })), null); @@ -141,3 +299,137 @@ test("decision hooks pass the app's private data to base protection", async () = const ordinary = await hooks.decide("s1", { toolName: "bash", toolInput: { command: "cat src/a.ts" }, toolInputPreview: null, cwd: null, truncated: false }, new AbortController().signal); assert.equal(ordinary.behavior, "none"); }); + +test("Qwen read_many_files: more than 32 paths is refused, and its include globs are checked", () => { + const secrets = join(userData, "provider-secrets.bin"); + const read = (toolInput) => checkBaseProtection({ toolName: "read_many_files", toolInput, root: project, home, privateData }) ?? null; + const benign = Array.from({ length: 32 }, (_, i) => `src/f${i}.ts`); + assert.equal(read({ paths: benign }), null, "32 project paths are checked and allowed"); + const many = read({ paths: [...benign, secrets] }); + assert.equal(many?.rule, "uncheckable", "the 33rd path is not skipped"); + assert.match(many.message, /at most 32/u); + assert.equal(read({ paths: [...benign, "src/g.ts"] })?.rule, "uncheckable"); + assert.equal(read({ paths: benign.slice(0, 20), include: benign.slice(0, 13) })?.rule, "uncheckable", "include counts toward the limit"); + assert.equal(read({ paths: ["src/a.ts"], include: [secrets] })?.rule, "app-private"); + assert.equal(read({ paths: ["src/a.ts"], include: [join(userData, "account-homes", "**", "*.json")] })?.rule, "app-private"); + assert.equal(read({ paths: ["src/a.ts"], include: ["**/*.ts"] }), null); +}); + +test("OpenCode's read-only tools reach base protection: a grep without a path searches the current folder, a glob's pattern is checked", () => { + const check = (tool, args, root) => { + const call = guardedCall(tool, args, true); + assert.ok(call, `${tool} is forwarded`); + return checkBaseProtection({ toolName: call.toolName, toolInput: call.toolInput, root, home, privateData })?.rule ?? null; + }; + assert.equal(check("grep", { pattern: "token" }, userData), "app-private", "grep with no path, run in the app's folder"); + assert.equal(check("grep", { pattern: "token", include: "*.json" }, control), "app-private"); + assert.equal(check("grep", { pattern: "token" }, project), null); + assert.equal(check("glob", { pattern: join(userData, "provider-*") }, project), "app-private"); + assert.equal(check("glob", { pattern: "agent-control/token-*", path: userData }, project), "app-private"); + assert.equal(check("glob", { pattern: "**/*.ts" }, project), null); +}); + +test("globs: ** matches any number of folders and braces are expanded before matching the private data", () => { + const secrets = q(join(userData, "provider-secrets.bin")).slice(1, -1); + for (const command of [ + "cat ~/Library/**/provider-secrets.bin", + "cat ~/**/provider-secrets.bin", + "cat ~/Library/Application\\ Support/canvastty/**", + "cat ~/Library/**/account-homes/*/auth.json", + `cat '${userData}'/provider-secrets.{bin,}`, + `cat '${userData}'/{settings.json,provider-secrets.bin}`, + `cat '${userData}'/agent-control/{connection.json,token-*}`, + `cp '${userData}'/{provider-secrets,x}.bin ./copy`, + // Sequences are brace expansions too: bash and zsh make `provider-secrets.bin` of these. + `cat '${userData}'/provider-secrets.bi{n..n}`, + `cat '${userData}'/provider-secret{r..t}.bin`, + `cat '${userData}'/agent-control/token-{0..1}123abcd`, + `cat '${userData}'/agent-control/token-{00..01..1}123abcd`, + `cat '${userData}'/agent-control/token-{0..999}123abcd` + ]) assert.equal(rule(command), "app-private", command); + // Read-only tools: a Glob pattern with braces or ** is matched the same way. + const glob = (toolInput, root = project) => checkBaseProtection({ toolName: "Glob", toolInput, root, home, privateData })?.rule ?? null; + assert.equal(glob({ pattern: "provider-secrets.{bin,json}", path: userData }), "app-private"); + assert.equal(glob({ pattern: join(home, "Library", "**", "provider-secrets.bin") }), "app-private"); + // Ordinary globs stay allowed: in a normal project, and ones that only pass by the app's folder on the way. + for (const command of [ + "ls **/*.ts", + "rg todo **/*.ts", + "cat src/{a,b}.ts", + "cat ~/**/*.ts", + "ls ~/Library/**/*.plist", + `cat '${secrets}x'.{md,txt}`, + "echo {a,b}" + ]) assert.equal(rule(command), null, command); + assert.equal(glob({ pattern: "**/*.ts" }), null); + assert.equal(glob({ pattern: "**/*.ts" }, home), null, "a ** glob that only passes through the app's folder"); + assert.equal(glob({ pattern: "src/**/*.{ts,tsx}" }), null); +}); + +test("macOS and Windows: a glob matches the private data in any letter case", { skip: process.platform !== "darwin" && process.platform !== "win32" }, () => { + assert.equal(rule("cat ~/library/application\\ support/CANVASTTY/Provider-Secrets.*"), "app-private"); + assert.equal(rule("cat ~/LIBRARY/**/PROVIDER-SECRETS.BIN"), "app-private"); +}); + +test("what base protection cannot check is refused, not skipped: long or odd path fields, too many paths, expansions or nested shells", () => { + const secrets = join(userData, "provider-secrets.bin"); + const check = (toolName, toolInput, root = project) => checkBaseProtection({ toolName, toolInput, root, home, privateData })?.rule ?? null; + // Padding that folds away is checked as the path it names. + assert.equal(check("Read", { file_path: userData + "/.".repeat(3000) + "/provider-secrets.bin" }), "app-private"); + assert.equal(check("Read", { file_path: userData + "/x/..".repeat(1500) + "/provider-secrets.bin" }), "app-private"); + assert.equal(check("Write", { file_path: userData + "/./".repeat(2000) + "provider-secrets.bin", content: "x" }), "app-private"); + // A path field that cannot be checked: too long after folding, or not a string. + const long = "/" + "a".repeat(5000); + for (const [toolName, toolInput] of [ + ["Read", { file_path: long }], + ["Read", { file_path: 42 }], + ["Grep", { pattern: "x", path: long }], + ["Glob", { pattern: { glob: "*" } }], + ["read_many_files", { paths: ["src/a.ts", 7] }], + ["read_many_files", { paths: ["src/a.ts"], include: long }], + ["Write", { file_path: long, content: "x" }], + ["Bash", { command: "ls", cwd: long }], + ["apply_patch", { command: `*** Begin Patch\n*** Add File: ${"b".repeat(5000)}\n+x\n*** End Patch` }], + ["apply_patch", { command: `*** Begin Patch\n${Array.from({ length: 257 }, (_, i) => `*** Add File: f${i}.txt\n+x`).join("\n")}\n*** End Patch` }] + ]) assert.equal(check(toolName, toolInput), "uncheckable", `${toolName} ${JSON.stringify(toolInput).slice(0, 80)}`); + // A patch longer than one path is still read in full. + assert.equal(check("apply_patch", { command: `*** Begin Patch\n*** Add File: notes.md\n+${"x".repeat(5000)}\n*** Add File: ${secrets}\n+x\n*** End Patch` }), "app-private"); + // More paths, brace expansions or nested shells than are checked. + assert.equal(rule(`echo ${"a ".repeat(1100)}; cat ${q(secrets)}`) !== null, true, "the private path after the budget is not let through"); + assert.equal(rule(`echo ${"a ".repeat(1100)}`), "uncheckable"); + assert.equal(rule(`cat ${q(userData)}/{a,b}{c,d}{e,f}{g,h}{i,j}{k,l}{m,n}`), "uncheckable"); + assert.equal(check("Glob", { pattern: "{a,b}{c,d}{e,f}{g,h}{i,j}{k,l}{m,n}", path: project }), "uncheckable"); + const sq = (text) => `'${text.replace(/'/gu, "'\\''")}'`; + let nested = "ls"; + for (let i = 0; i < 6; i++) nested = `bash -c ${sq(nested)}`; + assert.equal(rule(nested), "uncheckable", "shells nested deeper than are followed"); + assert.equal(rule("bash -c 'bash -c ls'"), null); + // Ordinary large calls stay allowed. + assert.equal(rule(`git add ${Array.from({ length: 300 }, (_, i) => `src/f${i}.ts`).join(" ")}`), null); + assert.equal(rule("cat src/{a,b,c}/{x,y}.ts"), null); + assert.equal(rule("cat src/f{1..9}.ts"), null); + assert.equal(rule(`cat ${q(userData)}/x{1..100}`), null, "a long sequence stands for any name there"); + assert.equal(rule("for i in {1..1000}; do echo $i; done"), null); + assert.equal(check("apply_patch", { command: `*** Begin Patch\n${Array.from({ length: 40 }, (_, i) => `*** Add File: f${i}.txt\n+x`).join("\n")}\n*** End Patch` }), null); +}); + +test("an analyzer failure refuses a shell, edit or read call as uncheckable; other tools keep no opinion", () => { + const throwing = (field) => Object.defineProperty({}, field, { enumerable: true, get() { throw new Error("fixture analyzer failure"); } }); + const brokenPrivateData = { appRoots: [userData], get paths() { throw new Error("fixture analyzer failure"); }, markers: [] }; + const check = (toolName, toolInput, data = privateData) => checkBaseProtection({ toolName, toolInput, root: project, home, privateData: data }); + for (const [toolName, toolInput] of [ + ["Bash", throwing("command")], + ["Read", throwing("file_path")], + ["Write", throwing("file_path")], + ["apply_patch", throwing("command")] + ]) { + const verdict = check(toolName, toolInput); + assert.equal(verdict?.rule, "uncheckable", toolName); + assert.match(verdict.message, /failed while checking it/u); + assert.doesNotMatch(verdict.message, /fixture analyzer failure/u, "the error itself stays out of the message"); + } + for (const [toolName, toolInput] of [["Bash", { command: "ls" }], ["Read", { file_path: "src/a.ts" }], ["Edit", { file_path: "src/a.ts" }]]) { + assert.equal(check(toolName, toolInput, brokenPrivateData)?.rule, "uncheckable", `${toolName}: a failure inside the analysis`); + } + assert.equal(check("WebFetch", throwing("url")), null, "a tool base protection does not check stays without an opinion"); +}); diff --git a/tests/base-protection.test.mjs b/tests/base-protection.test.mjs index 860392347..91ac9adb2 100644 --- a/tests/base-protection.test.mjs +++ b/tests/base-protection.test.mjs @@ -80,7 +80,7 @@ test("ordinary work inside the working folder has no deny", () => { assert.equal(rule(edit("src/new.ts")), null); }); -test("hook tool calls: Claude, Codex, Qwen and OpenCode names; only shells and file writes are checked", () => { +test("hook tool calls: Claude, Codex, Qwen and OpenCode names; shells and file writes are checked, reads only for private data", () => { const cases = [ ["Write", { file_path: join(home, "Downloads", "hello.txt"), content: "hello" }, "write-outside"], ["Edit", { file_path: "/etc/hosts", old_string: "a", new_string: "b" }, "write-outside"], @@ -99,7 +99,9 @@ test("hook tool calls: Claude, Codex, Qwen and OpenCode names; only shells and f assert.equal(check(tool, input), null, `${tool} ${JSON.stringify(input)}`); } assert.deepEqual(patchPaths("*** Begin Patch\n*** Add File: a.txt\n*** Delete File: b.txt\n*** End Patch"), ["a.txt", "b.txt"]); - assert.deepEqual(actionFromHook("Grep", { pattern: "x" }).kind, null); + assert.deepEqual(actionFromHook("Grep", { pattern: "x" }), { kind: "read", command: null, commandCwd: null, paths: [], searched: [] }); + assert.deepEqual(actionFromHook("Read", { file_path: "/etc/passwd" }).paths, ["/etc/passwd"]); + assert.deepEqual(actionFromHook("WebFetch", { url: "https://example.com" }).kind, null); }); test("each deny tells the model what to do instead; a write only to the temporary folder suggests a folder in the project", () => { diff --git a/tests/browser-engine-tabs.test.mjs b/tests/browser-engine-tabs.test.mjs index eec0d8139..7e3f9d288 100644 --- a/tests/browser-engine-tabs.test.mjs +++ b/tests/browser-engine-tabs.test.mjs @@ -162,10 +162,12 @@ async function harness(t, { providers } = {}) { t.after(() => engines.dispose()); let request = 0; const run = (actor, type, args = {}) => core.execute(actor, { type, requestId: `r${++request}`, ...args }); - const ready = async (tabId) => { + // `after`: wait for a navigation the page starts by itself (a click), which the engine reports only after the + // command answered; until its Page.frameNavigated arrives the tab still shows the old document as ready. + const ready = async (tabId, { after } = {}) => { for (let attempt = 0; attempt < 100; attempt += 1) { const tab = host.getTab(tabId); - if (tab?.status === "ready") return tab; + if (tab?.status === "ready" && (after === undefined || tab.documentRevision > after)) return tab; await new Promise((resolve) => setTimeout(resolve, 10)); } throw new Error("tab never became ready"); @@ -233,8 +235,10 @@ test("engines without layout: observe skips the fake geometry and clicks go thro const methods = cdpMethods(h.engine); assert.equal(methods.includes("Input.dispatchMouseEvent"), false, "no coordinate clicks"); assert.equal(methods.includes("DOM.getBoxModel"), false, "no box models asked for"); - await h.ready(opened.tabId); - assert.ok(h.host.getTab(opened.tabId).documentRevision > before, "the click navigated: a new revision"); + // The click answers before the engine reports the navigation it started, as Chromium's does. + const navigated = await h.ready(opened.tabId, { after: before }); + assert.ok(navigated.documentRevision > before, "the click navigated: a new revision"); + assert.equal(navigated.url, DOCS); const hovered = await h.run(agent(), "browser_observe", { tabId: opened.tabId }); assert.equal(hovered.ok, true); }); diff --git a/tests/claude-http-hooks.test.mjs b/tests/claude-http-hooks.test.mjs index fb649ea87..94791bd48 100644 --- a/tests/claude-http-hooks.test.mjs +++ b/tests/claude-http-hooks.test.mjs @@ -262,7 +262,7 @@ test("Claude's lifecycle hooks become HTTP hooks except SessionStart; the decisi assert.equal(settings.hooks.PreToolUse[0].hooks[0].type, "command"); assert.match(settings.hooks.PreToolUse[0].hooks[0].command, /permission-gate\.mjs' 'pretool'/u); for (const [event, state] of [["UserPromptSubmit", "working"], ["PermissionRequest", "needs_approval"], ["PostToolUse", "working"], - ["Stop", "idle"], ["StopFailure", "idle"], ["SessionEnd", "idle"], ["Notification", "needs_approval"]]) { + ["PostToolUseFailure", "working"], ["Stop", "idle"], ["StopFailure", "idle"], ["SessionEnd", "idle"], ["Notification", "needs_approval"]]) { const hook = settings.hooks[event][0].hooks[0]; assert.deepEqual(hook, { type: "http", diff --git a/tests/decision-hooks.test.mjs b/tests/decision-hooks.test.mjs index b7a4a2308..cc4b536b7 100644 --- a/tests/decision-hooks.test.mjs +++ b/tests/decision-hooks.test.mjs @@ -11,7 +11,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; -import { AGENT_RUNTIME_ENV, OPENCODE_DECISIONS_ENV, RUNTIME_PROTOCOL_VERSION } from "../src/agent-runtime/runtime-protocol.mjs"; +import { AGENT_RUNTIME_ENV, OPENCODE_DECISIONS_ENV, OPENCODE_DECISION_READS_ENV, RUNTIME_PROTOCOL_VERSION } from "../src/agent-runtime/runtime-protocol.mjs"; import { buildRequest, hookOutput, parseDecision } from "../src/agent-runtime/permission-gate.mjs"; import { createOpenCodeDecisions, guardedCall } from "../src/agent-runtime/opencode-decisions.mjs"; import { DecisionHooks, TOO_LARGE_MESSAGE, mergeDecisions } from "../src/main/services/DecisionHooks.ts"; @@ -129,8 +129,10 @@ test("cut input under base protection: a shell call, or a file write whose targe // A file write whose path is at the start of the preview is judged by that path, as before. const inside = JSON.stringify({ file_path: join(project, "big.txt"), content: "x".repeat(300) }).slice(0, 200); assert.equal((await decisions.decide("s1", cut("Write", inside), live())).behavior, "none"); + // A cut read-only call may hide the file it reads (a long read_many_files list): denied too. + assert.deepEqual(await decisions.decide("s1", cut("read_many_files", "{\"paths\":[\"src/a.ts\""), live()), { behavior: "deny", message: TOO_LARGE_MESSAGE }); // Tools the rules do not read, and base protection off, are unchanged. - assert.equal((await decisions.decide("s1", cut("Read", "{\"file_path\":"), live())).behavior, "none"); + assert.equal((await decisions.decide("s1", cut("WebFetch", "{\"url\":"), live())).behavior, "none"); assert.equal((await hooks({ protect: false }).decide("s1", cut("Bash", "{\"command\":\"ls"), live())).behavior, "none"); }); @@ -302,6 +304,9 @@ test("launch: the decision hook is added only when wanted, per provider, with or const claude = JSON.parse(adapters.prepare("claude", "t1", false, true).args[1]); assert.deepEqual(Object.keys(claude.hooks), ["PreToolUse"]); assert.equal(claude.hooks.PreToolUse[0].matcher, "Bash|Write|Edit|MultiEdit|NotebookEdit"); + // Read-only file tools only when base protection was on at launch (they are checked for its private-data rule). + const claudeReads = JSON.parse(adapters.prepare("claude", "t1", false, true, undefined, undefined, true).args[1]); + assert.equal(claudeReads.hooks.PreToolUse[0].matcher, "Bash|Write|Edit|MultiEdit|NotebookEdit|Read|NotebookRead|Grep|Glob|LS"); assert.match(claude.hooks.PreToolUse[0].hooks[0].command, /permission-gate\.mjs['"] ['"]pretool['"]$/u); assert.ok(JSON.parse(adapters.prepare("claude", "t1", true, true).args[1]).hooks.Stop, "status hooks stay alongside"); const codex = adapters.prepare("codex", "t2", false, true).args.join(" "); @@ -309,8 +314,15 @@ test("launch: the decision hook is added only when wanted, per provider, with or const qwen = adapters.prepare("qwen", "t3", false, true); const qwenSettings = JSON.parse(await readFile(qwen.environment.QWEN_CODE_SYSTEM_SETTINGS_PATH, "utf8")); assert.equal(qwenSettings.hooks.PreToolUse[0].hooks[0].timeout, 15_000); + assert.equal(qwenSettings.hooks.PreToolUse[0].matcher, "^(run_shell_command|write_file|edit|replace)$"); qwen.releaseConfiguration(); + const qwenReads = adapters.prepare("qwen", "t3", false, true, undefined, undefined, true); + assert.equal(JSON.parse(await readFile(qwenReads.environment.QWEN_CODE_SYSTEM_SETTINGS_PATH, "utf8")).hooks.PreToolUse[0].matcher, + "^(run_shell_command|write_file|edit|replace|read_file|read_many_files|grep_search|search_file_content|glob|list_directory)$"); + qwenReads.releaseConfiguration(); assert.equal(adapters.prepare("opencode", "t4", false, true).environment[OPENCODE_DECISIONS_ENV], "1"); + assert.equal(adapters.prepare("opencode", "t4", false, true).environment[OPENCODE_DECISION_READS_ENV], undefined); + assert.equal(adapters.prepare("opencode", "t4", false, true, undefined, undefined, true).environment[OPENCODE_DECISION_READS_ENV], "1"); assert.equal(adapters.prepare("opencode", "t4", false, false).environment[OPENCODE_DECISIONS_ENV], undefined); assert.equal(adapters.decisionsSupported("kimi"), false); assert.deepEqual(adapters.prepare("kimi", "t5", false, true).args, []); @@ -325,9 +337,10 @@ test("launch: the decision hook is added only when wanted, per provider, with or }; const bridge = new AgentRuntimeBridge(gateway, { helper, runtimeDirectory, openCodePluginPath: "/opt/CanvasTTY/opencode-plugin.mjs", kimiHomeDirectory: join(runtimeDirectory, "kimi"), hermesHomeDirectory: join(runtimeDirectory, "hermes"), grokHomeDirectory: join(runtimeDirectory, "grok"), - permissionGate, coreHooksEnabled: true, wantsDecisions: (provider) => provider === "claude" }); + permissionGate, coreHooksEnabled: true, wantsDecisions: (provider) => provider === "claude", decisionReads: () => true }); const launched = bridge.prepareLaunch({ terminalSessionId: "a", provider: "claude", cwd: project }); assert.equal(launched.decisions, true); + assert.match(JSON.stringify(launched.args), /NotebookEdit\|Read\|NotebookRead/u, "base protection on: the hook sees reads"); assert.equal(registered[0][4], true); assert.equal(bridge.prepareLaunch({ terminalSessionId: "b", provider: "codex", cwd: project }).decisions, false); bridge.setCoreHooksEnabled(false); @@ -336,7 +349,7 @@ test("launch: the decision hook is added only when wanted, per provider, with or test("OpenCode: the guard throws a deny, remembers an allow and answers OpenCode's prompt once", async () => { const env = { - [OPENCODE_DECISIONS_ENV]: "1", [AGENT_RUNTIME_ENV.address]: "/tmp/x.sock", [AGENT_RUNTIME_ENV.terminalSessionId]: "t", + [OPENCODE_DECISIONS_ENV]: "1", [OPENCODE_DECISION_READS_ENV]: "1", [AGENT_RUNTIME_ENV.address]: "/tmp/x.sock", [AGENT_RUNTIME_ENV.terminalSessionId]: "t", [AGENT_RUNTIME_ENV.provider]: "opencode", [AGENT_RUNTIME_ENV.capabilityToken]: "c".repeat(40) }; const replies = []; @@ -345,7 +358,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); @@ -354,13 +367,46 @@ test("OpenCode: the guard throws a deny, remembers an allow and answers OpenCode next = { behavior: "ask", message: "" }; await decisions.guard({ tool: "bash", callID: "c3" }, { args: { command: "ls" } }); assert.equal(await decisions.permissionAsked({ id: "r3", tool: { callID: "c3" } }), false, "ask leaves OpenCode's own prompt"); - await decisions.guard({ tool: "read", callID: "c4" }, { args: { filePath: "/etc/hosts" } }); - assert.equal(sent.length, 3, "tools that neither run commands nor write files are not sent"); + await decisions.guard({ tool: "webfetch", callID: "c4" }, { args: { url: "https://example.com" } }); + assert.equal(sent.length, 3, "tools that neither run commands, write files nor read them are not sent"); + await decisions.guard({ tool: "read", callID: "c5" }, { args: { filePath: "/etc/hosts" } }); + assert.equal(sent.length, 4, "a read-only file tool is sent for base protection's private-data rule"); + assert.equal(sent[3].toolName, "Read"); + // Base protection off at launch: reads are not sent at all. + const noReads = []; + await createOpenCodeDecisions({ client, env: { ...env, [OPENCODE_DECISION_READS_ENV]: undefined }, send: async (_address, message) => { noReads.push(message); return null; } }) + .guard({ tool: "read", callID: "c6" }, { args: { filePath: "/etc/hosts" } }); + assert.equal(noReads.length, 0); assert.equal(createOpenCodeDecisions({ env: { ...env, [OPENCODE_DECISIONS_ENV]: "0" } }).enabled, false); assert.deepEqual(guardedCall("apply_patch", { patchText: "*** Begin Patch" }), { toolName: "apply_patch", toolInput: { patch: "*** Begin Patch" } }); - // 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"); + assert.deepEqual(guardedCall("read", { filePath: "/p/a.ts" }, true), { toolName: "Read", toolInput: { file_path: "/p/a.ts" } }); + assert.deepEqual(guardedCall("grep", { pattern: "x", path: "/p" }, true), { toolName: "Grep", toolInput: { path: "/p" } }); + assert.deepEqual(guardedCall("glob", { pattern: "**/*.ts" }, true), { toolName: "Glob", toolInput: { pattern: "**/*.ts" } }); + assert.deepEqual(guardedCall("glob", { pattern: "*.md", path: "/p" }, true), { toolName: "Glob", toolInput: { path: "/p", pattern: "*.md" } }); + assert.deepEqual(guardedCall("list", { path: "/p" }, true), { toolName: "LS", toolInput: { path: "/p" } }); + assert.deepEqual(guardedCall("grep", { pattern: "x", include: "*.ts" }, true), { toolName: "Grep", toolInput: { path: "." } }, "a grep without a path searches the current folder"); + assert.equal(guardedCall("read", { filePath: "/p/a.ts" }), null, "reads are mapped only when the launch checks them"); }); test("manifest: decide needs decision:provide, lists pre-tool, and at most one service per plugin decides", () => { diff --git a/tests/file-access-service.test.mjs b/tests/file-access-service.test.mjs index 08f30967f..8f95583c4 100644 --- a/tests/file-access-service.test.mjs +++ b/tests/file-access-service.test.mjs @@ -3,7 +3,7 @@ import fs, { realpathSync } from "node:fs"; import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; -import { syncBuiltinESMExports } from "node:module"; +import { createRequire, syncBuiltinESMExports } from "node:module"; import test from "node:test"; import { FileAccessService } from "../src/main/services/FileAccessService.ts"; @@ -11,6 +11,8 @@ async function makeTempDir(prefix) { return mkdtemp(join(tmpdir(), prefix)); } +const fsPromises = createRequire(import.meta.url)("node:fs/promises"); + function createService({ cwd, limits } = {}) { return new FileAccessService({ resolveSessionCwd: (sessionId) => (sessionId === "session-1" ? cwd : undefined), @@ -138,6 +140,35 @@ test("rejects symlinks whose real target escapes the root", async () => { } }); +test("rejects a read whose descriptor is not the file the contained path names", async (t) => { + // Simulates an intermediate directory swapped between realpath and open: the open lands on a file + // outside the root while the path re-resolved afterwards is still inside it. + const root = await makeTempDir("canvastty-files-swap-root-"); + const outside = await makeTempDir("canvastty-files-swap-outside-"); + const realOpen = fsPromises.open; + try { + const service = createService({ cwd: root }); + const { rootId } = service.registerSessionRoot("session-1"); + await writeFile(join(root, "notes.txt"), "inside"); + await writeFile(join(outside, "secret.txt"), "top secret"); + const redirected = join(outside, "secret.txt"); + t.mock.method(fsPromises, "open", (path, ...rest) => + String(path).endsWith("notes.txt") ? realOpen(redirected, ...rest) : realOpen(path, ...rest) + ); + syncBuiltinESMExports(); + + assert.deepEqual(await service.read(rootId, "notes.txt"), { + kind: "unsupported", + reason: "not-permitted" + }); + } finally { + t.mock.restoreAll(); + syncBuiltinESMExports(); + await rm(root, { recursive: true, force: true }); + await rm(outside, { recursive: true, force: true }); + } +}); + test("rejects non-regular files such as directories", async () => { const root = await makeTempDir("canvastty-files-nonregular-"); try { diff --git a/tests/github-auth-integration.test.mjs b/tests/github-auth-integration.test.mjs index 7035b7eea..078a00a1e 100644 --- a/tests/github-auth-integration.test.mjs +++ b/tests/github-auth-integration.test.mjs @@ -9,6 +9,8 @@ const settingsPath = new URL( "../src/renderer/src/features/plugins/PluginSettingsSection.tsx", import.meta.url ); +const ipcPath = new URL("../src/main/ipc/registerIpc.ts", import.meta.url); +const preloadPath = new URL("../src/preload/index.ts", import.meta.url); test("GitHub sign-in supports optional build input and both browser routes", async () => { const [config, main, releaseWorkflow, settings] = await Promise.all([ @@ -31,3 +33,19 @@ test("GitHub sign-in supports optional build input and both browser routes", asy assert.match(settings, /else await window\.canvasTTY\.githubAuth\.openUrl\(url\)/); assert.match(settings, /const url = flow\.verificationUri/); }); + +test("a pending GitHub sign-in can be cancelled through the trusted renderer only", async () => { + const [ipc, preload, settings] = await Promise.all([ + readFile(ipcPath, "utf8"), + readFile(preloadPath, "utf8"), + readFile(settingsPath, "utf8") + ]); + const start = ipc.indexOf("ipcMain.handle(IPC.githubAuthCancel,"); + assert.notEqual(start, -1); + const handler = ipc.slice(start, ipc.indexOf("ipcMain.", start + 1)); + assert.match(handler, /assertMainRenderer\(event, getMainWindow\)/); + assert.match(handler, /githubAuth\.cancelDeviceFlow\(\)/); + assert.match(preload, /cancel: \(\) => ipcRenderer\.invoke\(IPC\.githubAuthCancel\)/); + assert.match(settings, /window\.canvasTTY\.githubAuth\.cancel\(\)/); + assert.match(settings, /githubSigninText\(locale, "cancel"\)/); +}); diff --git a/tests/github-auth.test.mjs b/tests/github-auth.test.mjs index 793039552..9f53255de 100644 --- a/tests/github-auth.test.mjs +++ b/tests/github-auth.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdtemp, readFile, readdir, rm } from "node:fs/promises"; +import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import test from "node:test"; import { GithubAuthService } from "../src/main/services/GithubAuthService.ts"; @@ -63,7 +63,8 @@ test("GitHub device flow persists an encrypted session atomically without reques configured: true, authorized: true, login: "howdeploy", - tokenExpiresAt: null + tokenExpiresAt: null, + deviceFlowState: "idle" }); assert.equal(await restored.getToken(), "access"); await service.signOut(); @@ -157,7 +158,8 @@ test("signOut cancels an in-flight device flow and prevents a late token from be configured: true, authorized: false, login: null, - tokenExpiresAt: null + tokenExpiresAt: null, + deviceFlowState: "idle" }); await assert.rejects(() => readFile(`${userData}/github-oauth.json`, "utf8"), { code: "ENOENT" }); } finally { @@ -211,7 +213,8 @@ test("reports an unconfigured OAuth client before starting device flow", async ( configured: false, authorized: false, login: null, - tokenExpiresAt: null + tokenExpiresAt: null, + deviceFlowState: "idle" }); await assert.rejects( () => service.startDeviceFlow(), @@ -271,3 +274,260 @@ test("device polling survives network errors and timeouts, backs off, and honour await rm(userData, { recursive: true, force: true }); } }); + +const deviceCodeAnswer = () => Response.json({ + device_code: "test-device-code", + user_code: "TEST-CODE", + verification_uri: "https://github.com/login/device", + expires_in: 900, + interval: 1 +}); + +function serviceWith(userData, pollAnswer, options = {}) { + const requests = { polls: 0, profile: 0 }; + const fetcher = async (url, init = {}) => { + if (String(url).endsWith("/login/device/code")) return deviceCodeAnswer(); + if (String(url).endsWith("/login/oauth/access_token")) { + requests.polls += 1; + return pollAnswer(requests.polls, init); + } + if (String(url) === "https://api.github.com/user") { + requests.profile += 1; + return Response.json({ login: "fixture-user" }); + } + return new Response("missing", { status: 404 }); + }; + const service = new GithubAuthService(userData, "client-id", { + fetcher, + safeStorage, + delay: async () => undefined, + ...options + }); + return { service, requests }; +} + +test("access_denied, expired_token and unknown provider errors stop polling with a reported outcome", async () => { + for (const [answer, expected] of [ + [() => Response.json({ error: "access_denied" }), "denied"], + [() => Response.json({ error: "expired_token" }), "expired"], + [() => Response.json({ error: "access_denied" }, { status: 400 }), "denied"], + [() => Response.json({ error: "device_flow_disabled" }), "failed"], + [() => Response.json({ error: "incorrect_client_credentials" }, { status: 400 }), "failed"], + [() => new Response("forbidden", { status: 403 }), "failed"] + ]) { + const userData = await mkdtemp(`${tmpdir()}/canvastty-github-auth-${expected}-`); + try { + const { service, requests } = serviceWith(userData, answer); + await service.startDeviceFlow(); + await waitFor(async () => (await service.status()).deviceFlowState !== "pending"); + const status = await service.status(); + assert.equal(status.authorized, false); + assert.equal(status.deviceFlowState, expected); + // Give a runaway poll loop a chance to show itself. + for (let i = 0; i < 5; i += 1) await new Promise((resolve) => setImmediate(resolve)); + assert.equal(requests.polls, 1, `${expected}: polling stopped after the terminal answer`); + assert.equal(requests.profile, 0); + await assert.rejects(() => readFile(`${userData}/github-oauth.json`, "utf8"), { code: "ENOENT" }); + } finally { + await rm(userData, { recursive: true, force: true }); + } + } +}); + +test("cancelDeviceFlow stops polling, reports cancelled and keeps an existing session", async () => { + const userData = await mkdtemp(`${tmpdir()}/canvastty-github-auth-cancel-flow-`); + const existing = { accessToken: "existing-fx", refreshToken: null, expiresAt: null, login: "existing-user" }; + const storedFile = JSON.stringify({ data: Buffer.from(JSON.stringify(existing)).toString("base64") }); + await writeFile(`${userData}/github-oauth.json`, storedFile); + const pollSignals = []; + const releases = []; + try { + const { service, requests } = serviceWith( + userData, + // The fetcher ignores its AbortSignal on purpose: a late answer must still be discarded. + () => new Promise((resolve) => { releases.push(resolve); }), + { + delay: (_duration, signal) => { + pollSignals.push(signal); + return signal.aborted ? Promise.reject(new DOMException("cancelled", "AbortError")) : Promise.resolve(); + } + } + ); + await service.load(); + await service.startDeviceFlow(); + await waitFor(() => releases.length === 1); + assert.equal((await service.status()).deviceFlowState, "pending"); + + service.cancelDeviceFlow(); + assert.equal(pollSignals[0].aborted, true); + assert.equal((await service.status()).deviceFlowState, "cancelled"); + + releases[0](Response.json({ access_token: "late-fx", token_type: "bearer", scope: "" })); + for (let i = 0; i < 5; i += 1) await new Promise((resolve) => setImmediate(resolve)); + + const status = await service.status(); + assert.equal(status.deviceFlowState, "cancelled"); + assert.equal(status.login, "existing-user"); + assert.equal(requests.polls, 1, "no poll after cancel"); + assert.equal(requests.profile, 0, "the late token was not used"); + assert.equal(await service.getToken(), "existing-fx"); + assert.equal(await readFile(`${userData}/github-oauth.json`, "utf8"), storedFile); + + // Cancelling again, or with nothing pending, changes nothing. + service.cancelDeviceFlow(); + assert.equal((await service.status()).deviceFlowState, "cancelled"); + } finally { + await rm(userData, { recursive: true, force: true }); + } +}); + +test("cancelDeviceFlow after a completed sign-in keeps it", async () => { + const userData = await mkdtemp(`${tmpdir()}/canvastty-github-auth-cancel-done-`); + try { + const { service } = serviceWith(userData, () => Response.json({ access_token: "done-fx", token_type: "bearer", scope: "" })); + await service.startDeviceFlow(); + await waitFor(async () => (await service.status()).authorized); + const completed = await service.status(); + assert.equal(completed.deviceFlowState, "idle"); + service.cancelDeviceFlow(); + assert.deepEqual(await service.status(), completed); + assert.equal(await service.getToken(), "done-fx"); + await service.signOut(); + } finally { + await rm(userData, { recursive: true, force: true }); + } +}); + +test("slow_down adds five seconds to this and every later poll", async () => { + const userData = await mkdtemp(`${tmpdir()}/canvastty-github-auth-slow-down-`); + const waits = []; + const answers = [ + { error: "authorization_pending" }, + { error: "slow_down" }, + { error: "authorization_pending" }, + { error: "slow_down" }, + { error: "authorization_pending" }, + { access_token: "slow-fx", token_type: "bearer", scope: "" } + ]; + try { + const { service } = serviceWith(userData, () => Response.json(answers.shift()), { + delay: async (ms) => { waits.push(ms); } + }); + await service.startDeviceFlow(); + await waitFor(async () => (await service.status()).authorized); + assert.deepEqual(waits, [1_000, 1_000, 6_000, 6_000, 11_000, 11_000]); + await service.signOut(); + } finally { + await rm(userData, { recursive: true, force: true }); + } +}); + +test("transient poll errors retry with back-off, reset on a valid answer, and give up after a bounded run", async () => { + const userData = await mkdtemp(`${tmpdir()}/canvastty-github-auth-bounded-`); + let clock = 1_000_000; + const waits = []; + const transient = [ + () => { throw new TypeError("fetch failed"); }, + () => new Response("unavailable", { status: 503 }), + () => new Response("too many", { status: 429 }), + () => new Response("not json", { status: 200 }), + () => new Response("bad gateway", { status: 502 }) + ]; + // Four failures, one pending answer (resets the count), then five failures in a row. + const answers = [...transient.slice(0, 4), () => Response.json({ error: "authorization_pending" }), ...transient]; + const warn = console.warn; + console.warn = () => undefined; + try { + const { service, requests } = serviceWith(userData, () => answers.shift()(), { + now: () => clock, + delay: async (ms) => { waits.push(ms); clock += ms; } + }); + await service.startDeviceFlow(); + await waitFor(async () => (await service.status()).deviceFlowState !== "pending"); + assert.equal((await service.status()).deviceFlowState, "failed"); + assert.equal(requests.polls, 10); + assert.equal(answers.length, 0); + assert.deepEqual(waits, [1_000, 2_000, 4_000, 8_000, 16_000, 16_000, 32_000, 60_000, 60_000, 60_000]); + } finally { + console.warn = warn; + await rm(userData, { recursive: true, force: true }); + } +}); + +test("the device code expires on the host clock even while GitHub keeps answering pending", async () => { + const userData = await mkdtemp(`${tmpdir()}/canvastty-github-auth-elapsed-`); + let clock = 1_000_000; + try { + const { service, requests } = serviceWith(userData, () => Response.json({ error: "authorization_pending" }), { + now: () => clock, + pollTimeoutMs: 2_500, + delay: async (ms) => { clock += ms; } + }); + await service.startDeviceFlow(); + await waitFor(async () => (await service.status()).deviceFlowState !== "pending"); + assert.equal((await service.status()).deviceFlowState, "expired"); + assert.equal(requests.polls, 2); + } finally { + await rm(userData, { recursive: true, force: true }); + } +}); + +test("signOut clears a reported sign-in outcome", async () => { + const userData = await mkdtemp(`${tmpdir()}/canvastty-github-auth-signout-outcome-`); + try { + const { service } = serviceWith(userData, () => Response.json({ error: "access_denied" })); + await service.startDeviceFlow(); + await waitFor(async () => (await service.status()).deviceFlowState === "denied"); + await service.signOut(); + assert.equal((await service.status()).deviceFlowState, "idle"); + } finally { + await rm(userData, { recursive: true, force: true }); + } +}); + +test("a profile request that times out after the token arrived reports a failed sign-in, not a stuck pending one", async () => { + const userData = await mkdtemp(`${tmpdir()}/canvastty-github-auth-profile-timeout-`); + const fetcher = async (url, init = {}) => { + if (String(url).endsWith("/login/device/code")) return deviceCodeAnswer(); + if (String(url).endsWith("/login/oauth/access_token")) return Response.json({ access_token: "access", token_type: "bearer", scope: "" }); + // The profile request hangs until the service's own request timeout aborts it. + return new Promise((_resolve, reject) => { + init.signal.addEventListener("abort", () => reject(new DOMException("The operation was aborted.", "AbortError")), { once: true }); + }); + }; + const warn = console.warn; + console.warn = () => undefined; + try { + const service = new GithubAuthService(userData, "client-id", { + fetcher, safeStorage, requestTimeoutMs: 10, delay: async () => undefined + }); + await service.startDeviceFlow(); + await waitFor(async () => (await service.status()).deviceFlowState !== "pending"); + const status = await service.status(); + assert.equal(status.deviceFlowState, "failed"); + assert.equal(status.authorized, false); + await assert.rejects(() => readFile(`${userData}/github-oauth.json`, "utf8"), { code: "ENOENT" }); + } finally { + console.warn = warn; + await rm(userData, { recursive: true, force: true }); + } +}); + +test("HTTP 429 and 5xx answers with an OAuth error body are retried like other transient answers", async () => { + for (const status of [429, 503]) { + const userData = await mkdtemp(`${tmpdir()}/canvastty-github-auth-transient-json-`); + try { + const { service, requests } = serviceWith(userData, (poll) => (poll === 1 + ? Response.json({ error: "temporarily_unavailable" }, { status }) + : Response.json({ access_token: "access", token_type: "bearer", scope: "" }))); + await service.startDeviceFlow(); + await waitFor(async () => (await service.status()).deviceFlowState !== "pending" || (await service.status()).authorized); + const result = await service.status(); + assert.equal(result.authorized, true, `HTTP ${status}: the sign-in went on after the transient answer`); + assert.equal(requests.polls, 2); + await service.signOut(); + } finally { + await rm(userData, { recursive: true, force: true }); + } + } +}); diff --git a/tests/github-signin-errors-ui.test.mjs b/tests/github-signin-errors-ui.test.mjs new file mode 100644 index 000000000..db7e6edd9 --- /dev/null +++ b/tests/github-signin-errors-ui.test.mjs @@ -0,0 +1,72 @@ +// The GitHub sign-in buttons show localized outcomes; main-process error text (IPC prefix, HTTP codes) only goes to the log. +import assert from "node:assert/strict"; +import test from "node:test"; +import { findAll, importWithFakeReact, tick } from "./helpers/fake-react.mjs"; + +const section = await importWithFakeReact("src/renderer/src/features/plugins/PluginSettingsSection.tsx", "PluginSettingsSection"); + +function harness(t, locale, githubAuth) { + const oldWindow = globalThis.window; + const warnings = []; + t.mock.method(console, "warn", (...args) => { warnings.push(args); }); + globalThis.window = { setTimeout, clearTimeout, canvasTTY: { githubAuth, appVersion: async () => "1.8.1" } }; + t.after(() => { section.__unmount(); globalThis.window = oldWindow; }); + section.__reset(); + const none = async () => ({}); + const props = { settings: { locale }, plugins: [], open: false, onPreviewPlugin: none, onInstallPlugin: none, onSearchPlugins: async () => [], + onShowcasePlugins: async () => [], onFetchPluginIcons: none, onPreviewManifests: none, onOpenBrowser: none, onCheckPluginUpdates: none, + onUpdatePlugin: none, onSetPluginModules: none, onSetPluginEnabled: none, onUninstallPlugin: none, onOpenPluginContribution: none }; + let tree; + const render = () => { section.__flush(); tree = section.__render(section.PluginSettingsSection, props); return tree; }; + const settle = async () => { for (let i = 0; i < 6; i++) { render(); await tick(); } render(); }; + const button = (label) => findAll(tree, (node) => node.type === "button" && node.props?.children === label)[0]; + return { render, settle, button, warnings, tree: () => tree }; +} + +const ipcFailure = new Error("Error invoking remote method 'github-auth:start': Error: GitHub device flow failed with HTTP 404."); + +test("a sign-in that cannot start shows the localized failure in the GitHub block and logs the details (ru)", async (t) => { + const status = { configured: true, authorized: false, login: null, tokenExpiresAt: null, deviceFlowState: "idle" }; + const ui = harness(t, "ru", { status: async () => status, start: async () => { throw ipcFailure; } }); + await ui.settle(); + const signIn = ui.button("Войти в системном браузере"); + assert.ok(signIn, "the sign-in button"); + signIn.props.onClick(); + await ui.settle(); + const json = JSON.stringify(ui.tree()); + assert.match(json, /Не удалось завершить вход в GitHub/u); + assert.doesNotMatch(json, /Error invoking|github-auth:start|HTTP 404/u); + assert.ok(ui.warnings.some(([, reason]) => reason === ipcFailure), "the details are logged"); +}); + +test("a build without an OAuth client shows its own line, not the raw error", async (t) => { + let configured = true; + const ui = harness(t, "en", { + status: async () => ({ configured, authorized: false, login: null, tokenExpiresAt: null, deviceFlowState: "idle" }), + start: async () => { configured = false; throw new Error("Error invoking remote method 'github-auth:start': Error: GitHub OAuth is not configured (missing client id)."); } + }); + await ui.settle(); + ui.button("Sign in with the system browser").props.onClick(); + await ui.settle(); + const json = JSON.stringify(ui.tree()); + assert.match(json, /this build has no OAuth Client ID/u); + assert.doesNotMatch(json, /Error invoking|missing client id/u); +}); + +test("a failed, denied or expired sign-in reads as an error; a cancelled one as a plain status line", async (t) => { + const line = (tree, text) => findAll(tree, (node) => node.type === "p" && node.props?.children === text)[0]; + for (const [state, text, error] of [ + ["failed", "Не удалось завершить вход в GitHub. Можно попробовать снова.", true], + ["denied", null, true], + ["expired", null, true], + ["cancelled", null, false] + ]) { + const ui = harness(t, "ru", { status: async () => ({ configured: true, authorized: false, login: null, tokenExpiresAt: null, deviceFlowState: state }) }); + await ui.settle(); + const notice = text ? line(ui.tree(), text) + : findAll(ui.tree(), (node) => node.type === "p" && /plugin-github-unavailable|plugin-settings__error/u.test(node.props?.className ?? ""))[0]; + assert.ok(notice, state); + assert.equal(notice.props.className, error ? "plugin-settings__error" : "plugin-github-unavailable", state); + assert.equal(notice.props.role, error ? "alert" : "status", state); + } +}); diff --git a/tests/helpers/terminal.mjs b/tests/helpers/terminal.mjs index c36d9d628..08a8b35c4 100644 --- a/tests/helpers/terminal.mjs +++ b/tests/helpers/terminal.mjs @@ -25,6 +25,18 @@ export function availableRegistry() { * returns a fake PTY: `emitData`/`emitExit` drive its listeners, `lastResize` * holds the last size, and `onWrite(data, options)` sees what was written. */ +/** + * What OpenCode prints once its prompt has focus: the alternate screen, its hidden cursor while it draws, then the + * cursor shown in the input field (TerminalManager types queued input only after that for OpenCode). + */ +export const OPENCODE_INPUT_READY = "\x1b[?1049h\x1b[?2004h\x1b[?25l Ask anything\x1b[?25h"; + +/** Shows OpenCode's input field on the fake PTY OpenCode was spawned on, once that PTY exists. */ +export async function openCodeInputReady(calls, index = calls.length) { + for (let i = 0; i < 200 && calls.length <= index; i++) await new Promise((resolve) => setTimeout(resolve, 5)); + calls[index].process.emitData(OPENCODE_INPUT_READY); +} + export function fakeSpawner(calls, { pidBase = 20_000, onWrite } = {}) { return (command, args, options) => { let dataListener = () => undefined; @@ -53,3 +65,20 @@ export function fakeSpawner(calls, { pidBase = 20_000, onWrite } = {}) { return process; }; } + +/** Codex's empty task composer with its dim placeholder (automated Codex prompts wait for it). */ +export const CODEX_COMPOSER_READY = "\x1b[2J\x1b[H>_ OpenAI Codex\r\n\r\n› \x1b[2mAsk Codex to do anything\x1b[22m\r\n"; + +/** + * fakeSpawner whose PTYs show their CLI's input field shortly after they start: OpenCode's cursor in its full-screen + * view, Codex's empty task composer (input waits for those for OpenCode and for automated Codex prompts). + */ +export function inputReadySpawner(calls, options) { + const spawner = fakeSpawner(calls, options); + return (command, args, spawnOptions) => { + const process = spawner(command, args, spawnOptions); + const ready = /(?:^|\/)codex$/u.test(command) ? CODEX_COMPOSER_READY : OPENCODE_INPUT_READY; + setTimeout(() => process.emitData(ready), 10); + return process; + }; +} diff --git a/tests/isolation-git-metadata.test.mjs b/tests/isolation-git-metadata.test.mjs new file mode 100644 index 000000000..4a8ad8ce9 --- /dev/null +++ b/tests/isolation-git-metadata.test.mjs @@ -0,0 +1,163 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync, statSync } from "node:fs"; +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { AgentIsolation } from "../src/main/services/isolation/AgentIsolation.ts"; +import { isolationPaths } from "../src/main/services/isolation/isolationPaths.ts"; +import { seatbeltProfile } from "../src/main/services/isolation/seatbelt.ts"; +import { bubblewrapArguments } from "../src/main/services/isolation/bubblewrap.ts"; +import { LinuxHostPaths } from "../src/main/services/isolation/linuxHostPaths.ts"; + +const mac = process.platform === "darwin" && existsSync("/usr/bin/sandbox-exec"); +const onMac = { skip: mac ? false : "macOS seatbelt (sandbox-exec) only" }; +const hasGit = spawnSync("git", ["--version"]).status === 0; +const withGit = { skip: process.platform === "win32" ? "POSIX paths" : hasGit ? false : "git is not installed" }; +const GIT_IDENTITY = "-c user.email=a@b -c user.name=n"; + +async function world(t) { + const base = await realpath(await mkdtemp(join(process.platform === "win32" ? tmpdir() : "/tmp", "ctty-git-test-"))); + t.after(() => rm(base, { recursive: true, force: true })); + const home = join(base, "h"); + const userData = join(base, "u"); + const project = join(base, "p"); + const temp = join(base, "t"); + const template = join(base, "tpl"); + for (const dir of [home, userData, project, temp, template]) await mkdir(dir, { recursive: true }); + const env = { HOME: home, PATH: "/usr/bin:/bin:/usr/sbin:/sbin", LANG: "en_US.UTF-8", GIT_CONFIG_NOSYSTEM: "1" }; + return { base, home, userData, project, temp, template, env }; +} + +function git(w, ...args) { + const result = spawnSync("git", args, { cwd: w.project, env: { ...w.env, GIT_TEMPLATE_DIR: w.template }, encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); + return result.stdout; +} + +function pathsFor(w, options = {}) { + return isolationPaths({ provider: "codex", cwd: w.project, sessionTemp: join(w.temp, "s"), env: w.env, userDataPath: w.userData, sessionId: "s", ...options }); +} + +test("the paths: an existing repository's per-worktree config, commondir and worktree records are protected", withGit, async (t) => { + const w = await world(t); + const gitDir = join(w.project, ".git"); + // No repository yet: nothing to protect by path (seatbelt's pattern still covers what `git init` makes). + const fresh = pathsFor(w); + assert.ok(!fresh.protectedWrites.some((path) => path.startsWith(`${gitDir}/`))); + assert.match(seatbeltProfile(fresh), /\\\.git\/\(info\/attributes\|commondir\|config\\\.worktree\(\\\.lock\)\?\|worktrees\(\/\.\*\)\?\)\$/u); + git(w, "init", "-q"); + const paths = pathsFor(w); + for (const file of ["config", "config.worktree", "config.worktree.lock"]) assert.ok(paths.protectedWrites.includes(join(gitDir, file)), file); + assert.ok(!paths.protectedWrites.includes(join(gitDir, "commondir")), "a missing commondir gets no placeholder: git would refuse an empty one"); + assert.ok(paths.protectedDirectories.includes(join(gitDir, "worktrees"))); + await writeFile(join(gitDir, "commondir"), ".\n"); + assert.ok(pathsFor(w).protectedWrites.includes(join(gitDir, "commondir")), "an existing one is protected"); + // Objects, refs, the index, logs and HEAD stay writable. + for (const name of ["objects", "refs", "index", "logs", "HEAD", "packed-refs", "ORIG_HEAD"]) { + const path = join(gitDir, name); + assert.ok(![...paths.protectedWrites, ...paths.protectedDirectories].some((p) => path === p || path.startsWith(`${p}/`)), name); + } +}); + +test("bubblewrap: the records are mounted read-only; a missing config.worktree and worktrees folder get neutral placeholders", withGit, async (t) => { + const w = await world(t); + git(w, "init", "-q"); + const gitDir = join(w.project, ".git"); + const kind = (path) => { + try { const stat = statSync(path); return stat.isDirectory() ? "directory" : stat.isFile() ? "file" : null; } catch { return null; } + }; + const paths = pathsFor(w); + const hostPaths = new LinuxHostPaths(); + const release = hostPaths.prepare(paths); + try { + assert.equal(await readFile(join(gitDir, "config.worktree"), "utf8"), "", "an empty config.worktree means no per-worktree config"); + assert.equal(kind(join(gitDir, "worktrees")), "directory"); + assert.equal(existsSync(join(gitDir, "commondir")), false, "no commondir placeholder"); + // The person's git outside the layer keeps working with them in place. + git(w, "status", "--short"); + assert.equal(git(w, "rev-parse", "--git-common-dir").trim(), ".git"); + const args = bubblewrapArguments(paths, { command: "/usr/bin/codex", args: [], cwd: w.project }, kind).join(" "); + const project = args.indexOf(`--bind ${w.project} ${w.project}`); + const repository = args.indexOf(`--bind ${gitDir} ${gitDir}`); + assert.ok(repository > project, ".git is a mount point of its own: it cannot be renamed away and back"); + for (const path of [join(gitDir, "config"), join(gitDir, "config.worktree"), join(gitDir, "worktrees")]) { + assert.ok(args.indexOf(`--ro-bind ${path} ${path}`) > repository, path); + } + } finally { + release(); + } + assert.equal(existsSync(join(gitDir, "config.worktree")), false, "placeholders removed"); + assert.equal(existsSync(join(gitDir, "worktrees")), false); + // An existing commondir is mounted read-only. + await writeFile(join(gitDir, "commondir"), ".\n"); + const placeholders = new Map([[join(gitDir, "config.worktree"), "file"], [join(gitDir, "worktrees"), "directory"]]); + const withCommon = bubblewrapArguments(pathsFor(w), { command: "/usr/bin/codex", args: [], cwd: w.project }, + (path) => kind(path) ?? placeholders.get(path) ?? null).join(" "); + assert.ok(withCommon.includes(`--ro-bind ${join(gitDir, "commondir")} ${join(gitDir, "commondir")}`)); +}); + +function run(w, script) { + const wrapped = new AgentIsolation({ userDataPath: w.userData, enabled: () => true, tempRoot: w.temp }) + .wrap({ sessionId: "s1", provider: "codex", cwd: w.project, command: "/bin/sh", args: ["-c", script], env: w.env }); + try { + const result = spawnSync(wrapped.command, wrapped.args, { cwd: w.project, env: wrapped.env, encoding: "utf8", timeout: 30_000 }); + return result.stdout.split("\n").filter(Boolean); + } finally { + wrapped.cleanup(); + } +} + +const WORKFLOW = [ + `git init -q . && echo a > a.txt && git add a.txt && git ${GIT_IDENTITY} commit -q -m one && echo GIT-COMMIT-ok`, + `git checkout -q -b x && echo b > b.txt && git add b.txt && git ${GIT_IDENTITY} commit -q -m two && git checkout -q - && git branch -q y && echo GIT-BRANCH-ok`, + `echo c >> a.txt && git ${GIT_IDENTITY} stash -q && git ${GIT_IDENTITY} stash pop -q && git ${GIT_IDENTITY} commit -q -am three && echo GIT-STASH-ok` +]; +const PROTECTED = [ + 'echo .. > .git/commondir 2>/dev/null || echo W-commondir-denied', + '(printf "[core]\\n\\thooksPath = /tmp\\n" > .git/config.worktree) 2>/dev/null || echo W-config-worktree-denied', + 'mkdir .git/worktrees 2>/dev/null || echo W-worktrees-denied', + 'git worktree add -q wt 2>/dev/null || echo GIT-WORKTREE-ADD-denied', + `git init -q deep/n && (echo .. > deep/n/.git/commondir) 2>/dev/null || echo W-nested-commondir-denied`, + 'mv .git moved 2>/dev/null || echo MV-git-denied', + 'mkdir -p .git/hooks .git/info && mv .git/hooks .git/h2 2>/dev/null || echo MV-hooks-denied', + 'mv .git/info .git/i2 2>/dev/null || echo MV-info-denied', + 'mv deep/n/.git deep/n/moved 2>/dev/null || echo MV-nested-git-denied', + // The project itself moved into the launch's writable temp, changed there, and moved back. + 'P="$PWD"; (cd / && mv "$P" "$TMPDIR/moved-project") 2>/dev/null || echo MV-project-denied' +]; + +test("seatbelt, for real: init, add, commit, checkout, branch and stash work; git's pointers and worktree records cannot be created", onMac, async (t) => { + const w = await world(t); + const lines = run(w, [...WORKFLOW, ...PROTECTED, 'git log --oneline | wc -l | tr -d " "'].join("; ")); + assert.deepEqual(lines, ["GIT-COMMIT-ok", "GIT-BRANCH-ok", "GIT-STASH-ok", "W-commondir-denied", "W-config-worktree-denied", "W-worktrees-denied", + "GIT-WORKTREE-ADD-denied", "W-nested-commondir-denied", "MV-git-denied", "MV-hooks-denied", "MV-info-denied", "MV-nested-git-denied", + "MV-project-denied", "2"]); + for (const path of [".git/commondir", ".git/config.worktree", ".git/worktrees", "deep/n/.git/commondir", "moved", ".git/h2", ".git/i2"]) { + assert.equal(existsSync(join(w.project, path)), false, path); + } +}); + +test("seatbelt, for real: an existing repository's records stay as they are; ordinary work continues", onMac, async (t) => { + const w = await world(t); + git(w, "init", "-q"); + await writeFile(join(w.project, "a.txt"), "a\n"); + git(w, "add", "a.txt"); + git(w, "-c", "user.email=a@b", "-c", "user.name=n", "commit", "-q", "-m", "one"); + git(w, "worktree", "add", "-q", join(w.base, "linked")); + await writeFile(join(w.project, ".git", "config.worktree"), "[user]\n\tname = kept\n"); + const record = join(w.project, ".git", "worktrees", "linked"); + const before = await readFile(join(record, "gitdir"), "utf8"); + const lines = run(w, [ + `echo b > b.txt && git add b.txt && git ${GIT_IDENTITY} commit -q -m two && git checkout -q -b x && echo GIT-ok`, + 'echo /tmp/x > .git/worktrees/linked/gitdir 2>/dev/null || echo W-record-denied', + 'echo .. > .git/worktrees/linked/commondir 2>/dev/null || echo W-record-commondir-denied', + 'rm -rf .git/worktrees 2>/dev/null; [ -d .git/worktrees/linked ] && echo RM-worktrees-denied', + 'echo x >> .git/config.worktree 2>/dev/null || echo W-config-worktree-denied' + ].join("; ")); + assert.deepEqual(lines, ["GIT-ok", "W-record-denied", "W-record-commondir-denied", "RM-worktrees-denied", "W-config-worktree-denied"]); + assert.equal(await readFile(join(record, "gitdir"), "utf8"), before); + assert.equal(await readFile(join(w.project, ".git", "config.worktree"), "utf8"), "[user]\n\tname = kept\n"); + assert.equal(git(w, "rev-parse", "--abbrev-ref", "HEAD").trim(), "x"); +}); diff --git a/tests/isolation-hardening.test.mjs b/tests/isolation-hardening.test.mjs new file mode 100644 index 000000000..06ca0aaac --- /dev/null +++ b/tests/isolation-hardening.test.mjs @@ -0,0 +1,333 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { AgentControlService, subagentFolder } from "../src/main/services/AgentControlService.ts"; +import { EnvironmentRegistry } from "../src/main/services/EnvironmentRegistry.ts"; +import { AgentIsolation, SANDBOX_EXEC } from "../src/main/services/isolation/AgentIsolation.ts"; +import { contributedAccountHome, validateAccountHome } from "../src/main/services/isolation/accountHomeIsolation.ts"; +import { bubblewrapArguments } from "../src/main/services/isolation/bubblewrap.ts"; +import { isolationPaths } from "../src/main/services/isolation/isolationPaths.ts"; +import { TerminalManager } from "../src/main/services/TerminalManager.ts"; +import { availableRegistry, fakeSpawner } from "./helpers/terminal.mjs"; + +// Plugin data and gateway sockets together: the account home a launch plugin selected stays open, a CANVASTTY_*_ADDRESS +// that did not come from CanvasTTY's own gateways binds nothing, and a folder inside plugin data is a project only +// where CanvasTTY itself placed the launch. Any plugin ids: the host treats every plugin's data the same way. +const ACCOUNTS = "com.example.accounts"; +const ENVIRONMENTS = "com.example.env"; +const posixHost = { skip: process.platform === "win32" ? "bubblewrap and seatbelt paths are POSIX paths" : false }; +const mac = process.platform === "darwin" && existsSync(SANDBOX_EXEC); +const onMac = { skip: mac ? false : "macOS seatbelt (sandbox-exec) only" }; +const at = { x: 0, y: 0 }; +const BWRAP = "/usr/bin/bwrap"; +// The program an environment wraps the agent in; the spawner is fake, so it never runs. +const WRAPPER = process.execPath; + +/** A folder that holds CanvasTTY's userData (like a project at HOME), with an accounts plugin and an environment plugin. */ +async function world(t) { + // Short: Unix socket paths are limited to ~104 bytes, so /tmp rather than macOS's long per-user folder. + const base = await realpath(await mkdtemp(join(process.platform === "win32" ? tmpdir() : "/tmp", "ctty-hard-"))); + t.after(() => rm(base, { recursive: true, force: true })); + const home = join(base, "h"); + const userData = join(base, "u"); + const pluginData = join(userData, "plugin-data"); + const homes = join(pluginData, ACCOUNTS, "homes"); + const selected = join(homes, "acct-1"); + const sibling = join(homes, "acct-2"); + const worktrees = join(pluginData, ENVIRONMENTS, "worktrees"); + const worktree = join(worktrees, "w1"); + const otherWorktree = join(worktrees, "w2"); + const lifecycle = join(userData, "lifecycle", "runtime"); + const project = join(base, "p"); + const temp = join(base, "t"); + for (const dir of [home, join(home, ".ssh"), selected, sibling, worktree, otherWorktree, lifecycle, project, temp, + join(userData, "agent-control")]) await mkdir(dir, { recursive: true }); + await writeFile(join(selected, "auth.json"), "FAKE-SELECTED-TOKEN\n"); + await writeFile(join(sibling, "auth.json"), "FAKE-SIBLING-TOKEN\n"); + await writeFile(join(otherWorktree, "file.txt"), "other worktree\n"); + await writeFile(join(userData, "agent-control", "token-app"), "FAKE-APP-TOKEN\n"); + const env = { HOME: home, PATH: "/usr/bin:/bin" }; + return { base, home, userData, pluginData, homes, selected, sibling, worktrees, worktree, otherWorktree, lifecycle, project, temp, env }; +} + +const linux = (w) => new AgentIsolation({ userDataPath: w.userData, enabled: () => true, platform: "linux", bubblewrapPath: BWRAP, + bubblewrapProbe: () => null, tempRoot: w.temp, hostEnvironment: w.env }); + +/** The last mount whose target is `path` or one of its folders (a later mount on a folder hides earlier ones under it). */ +function mountFor(args, path) { + let found = null; + for (let i = 0; i < args.length; i++) { + const flag = args[i]; + if (flag === "--") break; + let target = null; + let mode = null; + if (flag === "--bind" || flag === "--dev-bind") { target = args[i + 2]; mode = "bind"; i += 2; } + else if (flag === "--ro-bind") { target = args[i + 2]; mode = "ro"; i += 2; } + else if (flag === "--tmpfs") { target = args[i + 1]; mode = "tmpfs"; i += 1; } + else if (flag === "--proc" || flag === "--chdir") { i += 1; continue; } + else continue; + if (path === target || path.startsWith(`${target.replace(/\/+$/u, "")}/`)) found = { target, mode }; + } + return found; +} + +const waitFor = async (predicate, timeoutMs = 5_000) => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error("Condition was not met in time."); +}; + +/** A TerminalManager whose isolation layer is the real Linux one (bubblewrap arguments, the PTY spawner is fake). */ +function manager(t, w, { runtime, environments } = {}) { + const calls = []; + const terminals = new TerminalManager(() => undefined, availableRegistry(), undefined, runtime, true, fakeSpawner(calls)); + t.after(() => terminals.shutdown()); + const layer = linux(w); + // The launch environment comes from this process; the layer gets the fixture's HOME instead (it may put placeholder + // files into the CLI's folders there), and a CLI home only when a launch plugin set one inside the fixture. + const hermetic = (env) => { + const kept = Object.fromEntries(Object.entries(env).filter(([key]) => !/^(XDG_|CLAUDE_CONFIG_DIR$|GROK_HOME$)/u.test(key))); + return { ...kept, HOME: w.home, CODEX_HOME: env.CODEX_HOME?.startsWith(`${w.base}/`) ? env.CODEX_HOME : join(w.home, ".codex") }; + }; + terminals.configureIsolation({ containment: () => true, decide: (input) => layer.decide(input), + wrap: (launch) => layer.wrap({ ...launch, env: hermetic(launch.env) }) }); + if (environments) terminals.configureEnvironments(environments); + const card = (id) => terminals.list().find((session) => session.id === id); + return { terminals, calls, card }; +} + +test("together: the selected account home stays open, a plugin's CANVASTTY_*_ADDRESS binds nothing, the gateway folder is read-only", posixHost, async (t) => { + const w = await world(t); + const runtimeSocket = join(w.lifecycle, "r.sock"); + // A plugin aims a socket address at the accounts plugin's homes: before, its folder was bound writable after the + // tmpfs over plugin data, which reopened every account's sign-in. + const foreign = join(w.homes, "foo.sock"); + const runtime = { + prepareLaunch: () => ({ args: [], environment: { CANVASTTY_RUNTIME_ADDRESS: runtimeSocket, CANVASTTY_RUNTIME_CAPABILITY: "c" }, cleanup() {} }), + currentStatus: () => null + }; + const { terminals, calls, card } = manager(t, w, { runtime }); + // The launch pipeline already refuses CANVASTTY_* names from plugins; the layer must not depend on that. + terminals.configureLaunchPipeline({ + normalizeOptions: (_provider, options) => options, + unavailable: () => [], + forgetSession: async () => undefined, + prepare: async () => ({ ok: true, env: { CODEX_HOME: w.selected, CANVASTTY_FOO_ADDRESS: foreign }, args: [], secrets: [], + envSources: { CODEX_HOME: "Plugin Accounts", CANVASTTY_FOO_ADDRESS: "Plugin Accounts" }, thirdPartyModel: false, + accountHome: w.selected, cleanup: async () => undefined }) + }); + const created = terminals.create({ provider: "codex", profile: "acceptEdits", cwd: w.project, position: at, + launchOptions: { [ACCOUNTS]: { account: "acct-1" } } }); + await waitFor(() => calls.length === 1 || card(created.id).status === "failed"); + assert.equal(card(created.id).failureDetails, null); + const { command, args, options } = calls[0]; + assert.equal(command, BWRAP); + assert.equal(options.env.CODEX_HOME, w.selected); + assert.equal(options.env.CANVASTTY_FOO_ADDRESS, foreign, "the variable itself still reaches the agent"); + // The selected account home: bound back, writable (it is the CLI's own home). + assert.deepEqual(mountFor(args, join(w.selected, "auth.json")), { target: w.selected, mode: "bind" }); + // The plugin's address bound nothing: the other account and the homes folder stay under the tmpfs over plugin data. + assert.deepEqual(mountFor(args, join(w.sibling, "auth.json")), { target: w.pluginData, mode: "tmpfs" }); + assert.deepEqual(mountFor(args, foreign), { target: w.pluginData, mode: "tmpfs" }); + assert.ok(!args.some((arg, i) => (arg === "--bind" || arg === "--ro-bind") && args[i + 2] === w.homes), "the homes folder is never bound"); + // CanvasTTY's own gateway folder: read-only, never writable. + assert.deepEqual(mountFor(args, runtimeSocket), { target: w.lifecycle, mode: "ro" }); + assert.ok(!args.some((arg, i) => arg === "--bind" && args[i + 2] === w.lifecycle)); +}); + +test("a subagent cannot work in CanvasTTY's private data, even when its project (the home folder) contains it", async (t) => { + const w = await world(t); + const refused = /cannot work in .*: it is CanvasTTY's private data/u; + for (const requested of [w.homes, w.selected, join(w.pluginData, ACCOUNTS), w.pluginData, join(w.userData, "agent-control"), w.worktree]) { + assert.match(subagentFolder(w.base, w.base, requested, w.userData).error ?? "", refused, requested); + } + assert.deepEqual(subagentFolder(w.base, w.base, w.project, w.userData), { cwd: w.project }, "the rest of the project is fine"); + assert.deepEqual(subagentFolder(w.base, w.base, join(w.userData, "lifecycle"), w.userData), { cwd: join(w.userData, "lifecycle") }, + "userData outside its private data is the project's like any folder"); + // A project that is an environment's worktree: its own folders, never a whole plugin's data above it. + const sub = join(w.worktree, "src"); + await mkdir(sub); + assert.deepEqual(subagentFolder(w.worktree, w.worktree, "src", w.userData), { cwd: sub }); + assert.match(subagentFolder(join(w.pluginData, ENVIRONMENTS), w.worktree, w.worktree, w.userData).error ?? "", refused, + "a project that is a whole plugin's data does not open its folders"); + + // Through the service: an orchestrator the person started in the folder above userData. + const { terminals } = manager(t, w); + const control = new AgentControlService(terminals, { containment: () => true, userDataPath: w.userData }); + const parent = terminals.create({ provider: "codex", profile: "normal", cwd: w.base, position: at, role: "orchestrator" }); + assert.throws(() => control.spawn({ parentSessionId: parent.id, provider: "codex", cwd: w.homes }), refused); + const child = await control.spawn({ parentSessionId: parent.id, provider: "codex", cwd: w.project }); + assert.equal(child.cwd, w.project); +}); + +test("the layer opens a project inside plugin data only where CanvasTTY placed the launch", posixHost, async (t) => { + const w = await world(t); + const notPlaced = /inside CanvasTTY's private plugin data, and CanvasTTY did not place this launch there/u; + + // The exploit, past the subagent check: an agent's subagent asked for the account homes as its folder. + const { terminals, calls, card } = manager(t, w); + const parent = terminals.create({ provider: "codex", profile: "normal", cwd: w.base, position: at, role: "orchestrator" }); + const exploit = terminals.create({ provider: "codex", profile: "acceptEdits", cwd: w.homes, position: at, role: "subagent", + parentSessionId: parent.id }); + assert.equal(card(exploit.id).status, "failed"); + assert.match(card(exploit.id).failureDetails, notPlaced); + assert.equal(calls.filter((call) => call.command === BWRAP).length, 0, "nothing ran"); + + // The folder the person chose for a card, and its subagents below it: opened (the person's choice). + const chosen = terminals.create({ provider: "codex", profile: "acceptEdits", cwd: w.worktree, position: at, role: "orchestrator" }); + assert.equal(card(chosen.id).failureDetails, null); + const below = join(w.worktree, "src"); + await mkdir(below); + const nested = terminals.create({ provider: "codex", profile: "acceptEdits", cwd: below, position: at, role: "subagent", parentSessionId: chosen.id }); + assert.equal(card(nested.id).failureDetails, null); + const launched = calls.filter((call) => call.command === BWRAP); + assert.equal(launched.length, 2); + assert.deepEqual(mountFor(launched[0].args, join(w.worktree, "f")), { target: w.worktree, mode: "bind" }); + assert.deepEqual(mountFor(launched[0].args, join(w.otherWorktree, "file.txt")), { target: w.pluginData, mode: "tmpfs" }); + + // The pure check: no placement, a placement elsewhere, or one an environment of another plugin claims: refused. + const base = { provider: "codex", cwd: w.worktree, sessionTemp: join(w.temp, "s"), userDataPath: w.userData, sessionId: "s", env: w.env }; + assert.throws(() => isolationPaths(base), notPlaced); + assert.throws(() => isolationPaths({ ...base, placedProjects: [{ folder: w.base }] }), notPlaced, "a project above plugin data places nothing in it"); + assert.throws(() => isolationPaths({ ...base, placedProjects: [{ folder: w.worktree, pluginId: ACCOUNTS }] }), notPlaced, "another plugin's environment"); + assert.throws(() => isolationPaths({ ...base, placedProjects: [{ folder: w.worktree, pluginId: "../com.example.env" }] }), notPlaced); + assert.throws(() => isolationPaths({ ...base, cwd: w.homes, placedProjects: [{ folder: w.worktree, pluginId: ENVIRONMENTS }] }), notPlaced); + const paths = isolationPaths({ ...base, placedProjects: [{ folder: w.worktree, pluginId: ENVIRONMENTS }] }); + assert.deepEqual(paths.readableAgain.filter((path) => path.startsWith(w.pluginData)), [w.worktree]); +}); + +test("an environment's worktree inside its own plugin data still runs inside the layer; one in another plugin's data does not", posixHost, async (t) => { + const w = await world(t); + const environments = (cwd) => new EnvironmentRegistry({ + providers: () => [{ pluginId: ENVIRONMENTS, pluginName: "Env", serviceId: "env", secrets: false, + kinds: [{ kind: "worktree", label: "Worktree", keeps: { launch: true } }] }], + call: async (_pluginId, _serviceId, method, params) => { + const step = method.replace("canvastty.environment.", ""); + if (step === "prepare") return { ref: { id: "w1" }, label: "worktree w1", cwd }; + if (step === "wrap") return { command: WRAPPER, args: ["run", params.command, ...params.args], cwd: params.cwd }; + if (step === "resume") return { ok: true }; + return {}; + }, + secret: async () => null + }); + const create = (terminals) => terminals.create({ provider: "codex", profile: "acceptEdits", cwd: w.project, position: at, + environment: { pluginId: ENVIRONMENTS, kind: "worktree" } }); + + const own = manager(t, w, { environments: environments(w.worktree) }); + const placed = create(own.terminals); + await waitFor(() => own.calls.length === 1 || own.card(placed.id).status === "failed"); + assert.equal(own.card(placed.id).failureDetails, null); + assert.equal(own.card(placed.id).cwd, w.worktree); + const { command, args } = own.calls[0]; + assert.equal(command, BWRAP); + assert.deepEqual(mountFor(args, join(w.worktree, "new.txt")), { target: w.worktree, mode: "bind" }); + assert.deepEqual(mountFor(args, join(w.otherWorktree, "file.txt")), { target: w.pluginData, mode: "tmpfs" }); + assert.deepEqual(mountFor(args, join(w.selected, "auth.json")), { target: w.pluginData, mode: "tmpfs" }); + + // The environment plugin answers with the accounts plugin's homes: not its own data, so the launch is refused. + const foreign = manager(t, w, { environments: environments(w.homes) }); + const refused = create(foreign.terminals); + await waitFor(() => foreign.card(refused.id).status === "failed"); + assert.match(foreign.card(refused.id).failureDetails, /CanvasTTY did not place this launch there/u); + assert.equal(foreign.calls.length, 0); +}); + +test("account homes and socket folders: a trailing separator names the same home; a socket folder over a writable one is refused", posixHost, async (t) => { + const w = await world(t); + const dataDir = join(w.pluginData, ACCOUNTS); + assert.equal(contributedAccountHome("codex", dataDir, "CODEX_HOME", `${w.selected}/`), w.selected); + assert.equal(contributedAccountHome("codex", dataDir, "CODEX_HOME", `${w.selected}//`), w.selected); + assert.equal(validateAccountHome(w.userData, "codex", w.selected, { CODEX_HOME: `${w.selected}/` }), w.selected); + // Only the CLI's own home variable counts: never HOME or XDG_* (OpenCode's is OPENCODE_CONFIG_DIR). + for (const [provider, key] of [["opencode", "XDG_CONFIG_HOME"], ["opencode", "XDG_DATA_HOME"], ["codex", "HOME"]]) { + assert.equal(contributedAccountHome(provider, dataDir, key, w.selected), undefined, `${provider} ${key}`); + } + assert.equal(contributedAccountHome("opencode", dataDir, "OPENCODE_CONFIG_DIR", w.selected), w.selected); + + // A gateway socket folder that holds the writable project would make it read-only and undo its hooks tmpfs. + const work = join(w.base, "work"); + const project = join(work, "p"); + await mkdir(project, { recursive: true }); + const sessionTemp = join(w.temp, "s"); + const paths = isolationPaths({ provider: "codex", cwd: project, sessionTemp, env: w.env, userDataPath: w.userData, sessionId: "s", + gatewaySockets: [join(work, "gw.sock")] }); + const kinds = new Map([[project, "directory"], [work, "directory"], [sessionTemp, "directory"]]); + assert.throws(() => bubblewrapArguments(paths, { command: "/usr/bin/codex", args: [], cwd: project }, (path) => kinds.get(path) ?? null), + /gateway socket folder .*work contains .*p, which the agent writes/u); +}); + +test("seatbelt, for real: account home open, siblings and a foreign socket closed, gateway folder read-only, homes never a project", onMac, async (t) => { + const w = await world(t); + const runtimeSocket = join(w.lifecycle, "r.sock"); + const foreignFolder = join(w.base, "o"); + await mkdir(foreignFolder); + const foreignSocket = join(foreignFolder, "f.sock"); + const servers = [runtimeSocket, foreignSocket].map((path) => { + const server = createServer((socket) => socket.end()); + server.listen(path); + return server; + }); + t.after(() => servers.forEach((server) => server.close())); + await new Promise((resolve) => setTimeout(resolve, 100)); + const layer = new AgentIsolation({ userDataPath: w.userData, enabled: () => true, tempRoot: w.temp, hostEnvironment: w.env }); + const probe = (path, label) => `/usr/bin/python3 -c "import socket +s=socket.socket(socket.AF_UNIX) +try: + s.connect('${path}'); print('${label}-CONNECT-ok') +except Exception: print('${label}-CONNECT-denied')"`; + const wrapped = layer.wrap({ + sessionId: "together", + provider: "codex", + cwd: w.project, + command: "/bin/sh", + args: ["-c", [ + 'cat "$CODEX_HOME/auth.json" >/dev/null && echo OWN-READ-ok', + 'echo actor > "$CODEX_HOME/actor.txt" && echo OWN-WRITE-ok', + `cat ${JSON.stringify(join(w.sibling, "auth.json"))} >/dev/null 2>&1 || echo SIBLING-READ-denied`, + `ls ${JSON.stringify(w.homes)} >/dev/null 2>&1 || echo HOMES-LIST-denied`, + probe(runtimeSocket, "GATEWAY"), + probe(foreignSocket, "FOREIGN"), + `rm -f ${JSON.stringify(runtimeSocket)} 2>/dev/null; [ -S ${JSON.stringify(runtimeSocket)} ] && echo GATEWAY-RM-denied`, + `touch ${JSON.stringify(join(w.lifecycle, "planted"))} 2>/dev/null || echo GATEWAY-WRITE-denied` + ].join("; ")], + env: { ...w.env, CODEX_HOME: w.selected, CANVASTTY_RUNTIME_ADDRESS: runtimeSocket, CANVASTTY_FOO_ADDRESS: foreignSocket }, + profile: "acceptEdits", + accountHome: w.selected, + gatewaySockets: [runtimeSocket] + }); + try { + const result = spawnSync(wrapped.command, wrapped.args, { cwd: w.project, env: wrapped.env, encoding: "utf8", timeout: 20_000 }); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(result.stdout.split("\n").filter(Boolean), [ + "OWN-READ-ok", "OWN-WRITE-ok", "SIBLING-READ-denied", "HOMES-LIST-denied", + "GATEWAY-CONNECT-ok", "FOREIGN-CONNECT-denied", "GATEWAY-RM-denied", "GATEWAY-WRITE-denied" + ]); + assert.equal(await readFile(join(w.selected, "actor.txt"), "utf8"), "actor\n"); + assert.equal(existsSync(join(w.lifecycle, "planted")), false); + } finally { + wrapped.cleanup(); + } + // The exploit's folder as a project (a subagent's cwd, not placed by CanvasTTY): the layer is never set up. + assert.throws(() => layer.wrap({ sessionId: "exploit", provider: "codex", cwd: w.homes, command: "/bin/sh", args: ["-c", "true"], + env: w.env, profile: "acceptEdits" }), /did not place this launch there/u); + // An environment's worktree placed by its own plugin: writable there, nothing else of plugin data readable. + const placed = layer.wrap({ sessionId: "worktree", provider: "codex", cwd: w.worktree, command: "/bin/sh", args: ["-c", [ + "echo x > new.txt && echo WORKTREE-WRITE-ok", + `cat ${JSON.stringify(join(w.otherWorktree, "file.txt"))} >/dev/null 2>&1 || echo OTHER-WORKTREE-denied`, + `cat ${JSON.stringify(join(w.selected, "auth.json"))} >/dev/null 2>&1 || echo ACCOUNT-denied` + ].join("; ")], env: w.env, profile: "acceptEdits", placedProjects: [{ folder: w.worktree, pluginId: ENVIRONMENTS }] }); + try { + const result = spawnSync(placed.command, placed.args, { cwd: w.worktree, env: placed.env, encoding: "utf8", timeout: 20_000 }); + assert.deepEqual(result.stdout.split("\n").filter(Boolean), ["WORKTREE-WRITE-ok", "OTHER-WORKTREE-denied", "ACCOUNT-denied"]); + } finally { + placed.cleanup(); + } +}); diff --git a/tests/isolation-linux-host.test.mjs b/tests/isolation-linux-host.test.mjs index a9ac1b77b..085e65a46 100644 --- a/tests/isolation-linux-host.test.mjs +++ b/tests/isolation-linux-host.test.mjs @@ -84,6 +84,26 @@ test("bubblewrap: a missing CLI permission file inside its writable folder is pr assert.equal(existsSync(join(w.home, ".claude", "settings.json")), false); }); +test("bubblewrap: a CANVASTTY_*_ADDRESS into HOME binds nothing and un-hides nothing; a core gateway folder over a hidden path is refused", posixHost, async (t) => { + const w = await freshHome(t); + await mkdir(join(w.home, ".ssh")); + await writeFile(join(w.home, ".ssh", "id_test"), "FAKE-PRIVATE-KEY"); + const lifecycle = join(w.userData, "lifecycle", "runtime"); + await mkdir(lifecycle, { recursive: true }); + // The person's or a plugin's variable: in the environment only, not a gateway CanvasTTY set up. + const wrapped = linux(w).wrap({ sessionId: "s1", provider: "codex", cwd: w.project, command: "/bin/sh", args: ["-c", "true"], + env: { ...w.env, CANVASTTY_FOO_ADDRESS: join(w.home, "foo.sock") }, gatewaySockets: [join(lifecycle, "r.sock")] }); + try { + assert.equal(mountFor(wrapped.args, w.home)?.target, "/", "HOME is not bound"); + assert.equal(mountFor(wrapped.args, join(w.home, ".ssh", "id_test"))?.mode, "tmpfs", "its keys stay hidden"); + assert.equal(persistsWrite(wrapped.args, join(w.home, "notes.txt")), false); + assert.deepEqual(mountFor(wrapped.args, join(lifecycle, "r.sock")), { target: lifecycle, mode: "ro" }, "the core gateway folder, read-only"); + } finally { wrapped.cleanup(); } + // A core gateway whose folder holds something hidden is refused rather than mounted over it. + assert.throws(() => linux(w).wrap({ sessionId: "s2", provider: "codex", cwd: w.project, command: "/bin/sh", args: ["-c", "true"], env: w.env, + gatewaySockets: [join(w.home, "gw.sock")] }), /gateway socket folder .* contains .*\.ssh, which the agent may not read/u); +}); + test("bubblewrap: a fresh HOME gets the CLI's own state folders, so its first run can write them", posixHost, async (t) => { const w = await freshHome(t); const wrapped = wrapIn(linux(w), w, "opencode"); diff --git a/tests/isolation-userdata-readonly.test.mjs b/tests/isolation-userdata-readonly.test.mjs new file mode 100644 index 000000000..a250408cd --- /dev/null +++ b/tests/isolation-userdata-readonly.test.mjs @@ -0,0 +1,131 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { AgentIsolation } from "../src/main/services/isolation/AgentIsolation.ts"; +import { isolationPaths } from "../src/main/services/isolation/isolationPaths.ts"; +import { seatbeltProfile } from "../src/main/services/isolation/seatbelt.ts"; +import { bubblewrapArguments } from "../src/main/services/isolation/bubblewrap.ts"; + +const mac = process.platform === "darwin" && existsSync("/usr/bin/sandbox-exec"); +const onMac = { skip: mac ? false : "macOS seatbelt (sandbox-exec) only" }; + +/** A fake HOME that holds CanvasTTY's data folder (its settings, a plugin, an account home, a project the app keeps). */ +async function world(t) { + const base = await realpath(await mkdtemp(join(process.platform === "win32" ? tmpdir() : "/tmp", "ctty-ud-test-"))); + t.after(() => rm(base, { recursive: true, force: true })); + const home = join(base, "h"); + const userData = join(home, ".config", "canvastty"); + const temp = join(base, "t"); + const accountHome = join(userData, "account-homes", "a1"); + const appProject = join(userData, "projects", "p"); + for (const dir of [temp, join(home, "src"), join(userData, "plugins", "x"), accountHome, appProject, join(userData, "agent-control")]) { + await mkdir(dir, { recursive: true }); + } + await writeFile(join(userData, "settings.json"), "{\"agentIsolation\":\"on\"}\n"); + await writeFile(join(userData, "plugins", "x", "index.js"), "// plugin\n"); + await writeFile(join(userData, "agent-control", "token-app"), "APP-TOKEN"); + const env = { HOME: home, PATH: "/usr/bin:/bin:/usr/sbin:/sbin", LANG: "en_US.UTF-8" }; + return { base, home, userData, temp, accountHome, appProject, env }; +} + +function pathsFor(w, cwd, env = {}) { + return isolationPaths({ provider: "codex", cwd, sessionTemp: join(w.temp, "s"), env: { ...w.env, ...env }, userDataPath: w.userData, sessionId: "s" }); +} + +test("the paths: a project that contains CanvasTTY's data folder leaves that folder read-only, its account home writable", async (t) => { + const w = await world(t); + const paths = pathsFor(w, w.home, { CODEX_HOME: w.accountHome }); + assert.ok(paths.writable.includes(w.home), "the project stays writable"); + assert.ok(paths.readOnlyFolders.includes(w.userData), "CanvasTTY's data folder is read-only"); + const profile = seatbeltProfile(paths); + const deny = profile.indexOf(`(deny file-write* (subpath "${w.userData}")`); + assert.ok(deny > profile.indexOf(`(subpath "${w.home}")`), "denied after the project is allowed"); + const again = profile.indexOf(`(allow file-write* (subpath "${w.accountHome}"))`); + assert.ok(again > deny, "the CLI's own account home is writable again"); + // Its parent inside the project cannot be renamed away (and back) or replaced. + assert.deepEqual(paths.fixedFolders, [join(w.home, ".config")]); + assert.ok(profile.includes(`(deny file-write-unlink (literal "${join(w.home, ".config")}"))`)); + // A project the app keeps inside its data folder (Even G2's default), or any other project: nothing changes. + assert.deepEqual(pathsFor(w, w.appProject).readOnlyFolders, []); + assert.deepEqual(pathsFor(w, w.appProject).fixedFolders, []); + assert.ok(pathsFor(w, w.appProject).writable.includes(w.appProject)); + assert.deepEqual(pathsFor(w, join(w.home, "src")).readOnlyFolders, []); + // A read-only (plan) project at HOME has nothing to take back. + assert.deepEqual(isolationPaths({ provider: "codex", cwd: w.home, sessionTemp: join(w.temp, "s"), env: w.env, userDataPath: w.userData, + sessionId: "s", readOnlyProject: true }).readOnlyFolders, []); +}); + +test("bubblewrap: CanvasTTY's data folder is mounted read-only over a project at HOME, its account home bound writable again", async (t) => { + const w = await world(t); + const paths = pathsFor(w, w.home, { CODEX_HOME: w.accountHome }); + const kinds = new Map([[w.home, "directory"], [join(w.home, ".config"), "directory"], [w.userData, "directory"], [w.accountHome, "directory"], [join(w.userData, "agent-control"), "directory"]]); + const args = bubblewrapArguments(paths, { command: "/usr/bin/codex", args: [], cwd: w.home }, + // The CLI's permission settings exist (LinuxHostPaths puts a placeholder where one is missing). + (path) => kinds.get(path) ?? (paths.protectedWrites.includes(path) && !path.endsWith(".lock") ? "file" : null)).join(" "); + const project = args.indexOf(`--bind ${w.home} ${w.home}`); + const readOnly = args.indexOf(`--ro-bind ${w.userData} ${w.userData}`); + assert.ok(project >= 0 && readOnly > project, "read-only over the writable project"); + const parent = args.indexOf(`--bind ${join(w.home, ".config")} ${join(w.home, ".config")}`); + assert.ok(parent > project && parent < readOnly, "its parent is a mount point (cannot be renamed), mounted before it"); + assert.ok(args.indexOf(`--bind ${w.accountHome} ${w.accountHome}`, readOnly) > readOnly, "the account home is writable again"); + assert.ok(args.indexOf(`--tmpfs ${join(w.userData, "agent-control")}`) > readOnly, "private data still hidden"); + // A project inside the data folder is bound writable, nothing over it. + const inside = bubblewrapArguments(pathsFor(w, w.appProject), { command: "/usr/bin/codex", args: [], cwd: w.appProject }, + (path) => path === w.appProject || path === w.userData ? "directory" : null).join(" "); + assert.ok(inside.includes(`--bind ${w.appProject} ${w.appProject}`)); + assert.ok(!inside.includes(`--ro-bind ${w.userData} ${w.userData}`)); +}); + +function run(w, cwd, script, env = {}) { + const wrapped = new AgentIsolation({ userDataPath: w.userData, enabled: () => true, tempRoot: w.temp }) + .wrap({ sessionId: "s1", provider: "codex", cwd, command: "/bin/sh", args: ["-c", script], env: { ...w.env, ...env } }); + try { + const result = spawnSync(wrapped.command, wrapped.args, { cwd, env: wrapped.env, encoding: "utf8", timeout: 20_000 }); + return result.stdout.split("\n").filter(Boolean); + } finally { + wrapped.cleanup(); + } +} + +test("seatbelt, for real: a project at HOME cannot change CanvasTTY's settings or plugins; ordinary work still succeeds", onMac, async (t) => { + const w = await world(t); + const ud = w.userData; + const lines = run(w, w.home, [ + 'echo ok > notes.txt && echo W-project-ok', + 'mkdir -p src/deep && echo ok > src/deep/f && echo W-subfolder-ok', + `echo '{}' > "${ud}/settings.json" 2>/dev/null || echo W-settings-denied`, + `echo x > "${ud}/settings.json.tmp" 2>/dev/null || echo W-settings-tmp-denied`, + `echo x > "${ud}/plugins/x/index.js" 2>/dev/null || echo W-plugin-denied`, + `mkdir "${ud}/plugins/y" 2>/dev/null || echo W-new-plugin-denied`, + `rm -rf "${ud}/plugins" 2>/dev/null; [ -f "${ud}/plugins/x/index.js" ] && echo RM-plugins-denied`, + `mv "${ud}" "$HOME/moved" 2>/dev/null || echo MV-userdata-denied`, + 'mv "$HOME/.config" "$HOME/moved" 2>/dev/null || echo MV-parent-denied', + 'rm -rf "$HOME/.config" 2>/dev/null; [ -d "$HOME/.config" ] && echo RM-parent-denied', + 'mkdir "$HOME/.config/other-app" && echo x > "$HOME/.config/other-app/f" && echo W-parent-entry-ok', + `echo x > "${ud}/projects/p/f" 2>/dev/null || echo W-app-project-denied`, + `cat "${ud}/settings.json" >/dev/null && echo R-settings-ok`, + `cat "${ud}/agent-control/token-app" 2>/dev/null || echo R-token-denied`, + 'echo s > "$CODEX_HOME/auth.json" && echo W-account-home-ok' + ].join("; "), { CODEX_HOME: w.accountHome }); + assert.deepEqual(lines, ["W-project-ok", "W-subfolder-ok", "W-settings-denied", "W-settings-tmp-denied", "W-plugin-denied", "W-new-plugin-denied", + "RM-plugins-denied", "MV-userdata-denied", "MV-parent-denied", "RM-parent-denied", "W-parent-entry-ok", "W-app-project-denied", "R-settings-ok", "R-token-denied", "W-account-home-ok"]); + assert.equal(await readFile(join(ud, "settings.json"), "utf8"), "{\"agentIsolation\":\"on\"}\n"); + assert.equal(await readFile(join(ud, "plugins", "x", "index.js"), "utf8"), "// plugin\n"); + assert.equal(existsSync(join(ud, "plugins", "y")), false); + assert.equal(existsSync(join(w.home, "moved")), false); + assert.equal(await readFile(join(w.home, "notes.txt"), "utf8"), "ok\n"); +}); + +test("seatbelt, for real: a project CanvasTTY keeps inside its data folder stays writable", onMac, async (t) => { + const w = await world(t); + const lines = run(w, w.appProject, [ + 'echo ok > f && mkdir -p sub && echo ok > sub/g && echo W-project-ok', + `echo '{}' > "${w.userData}/settings.json" 2>/dev/null || echo W-settings-denied` + ].join("; ")); + assert.deepEqual(lines, ["W-project-ok", "W-settings-denied"]); + assert.equal(await readFile(join(w.appProject, "sub", "g"), "utf8"), "ok\n"); +}); diff --git a/tests/launch-cleanup-order.test.mjs b/tests/launch-cleanup-order.test.mjs new file mode 100644 index 000000000..f18e5c115 --- /dev/null +++ b/tests/launch-cleanup-order.test.mjs @@ -0,0 +1,211 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { LaunchPipeline } from "../src/main/services/LaunchPipeline.ts"; +import { TerminalManager } from "../src/main/services/TerminalManager.ts"; +import { availableRegistry, fakeSpawner } from "./helpers/terminal.mjs"; + +function deferred() { + let resolve; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} + +function cleanupPipeline(runs, serviceCalls = []) { + return new LaunchPipeline({ + contributors: () => [{ + pluginId: "fixture.cleanup", + pluginName: "Cleanup fixture", + serviceId: "launch", + secrets: false, + launch: { fields: [{ key: "enabled", label: "Enabled", kind: "boolean", default: true }] } + }], + call: async (...args) => { + serviceCalls.push(args); + return { env: { FIXTURE_FILE: "{launchFiles}/run.txt" }, files: [{ relPath: "run.txt", content: "temporary launch data" }] }; + }, + secret: async () => null, + runsRoot: runs, + timeoutMs: 2000 + }); +} + +const createCard = (terminals, cwd) => terminals.create({ + provider: "codex", + profile: "normal", + cwd, + position: { x: 0, y: 0 }, + launchOptions: { "fixture.cleanup": { enabled: true } } +}); + +/** + * One card with a launch plugin. Each run's cleanup is held until the test releases it, and every step of the + * child (run folder) and parent (card folder) removals is recorded in `events`. + */ +async function fixture(t, { rejectChild = false } = {}) { + const root = await mkdtemp(join(tmpdir(), "ctty-cleanup-order-")); + const runs = join(root, "runs"); + const rows = []; + const calls = []; + const events = []; + const spawned = [deferred(), deferred()]; + const forgotten = deferred(); + const pipeline = cleanupPipeline(runs); + const prepare = pipeline.prepare.bind(pipeline); + const forget = pipeline.forgetSession.bind(pipeline); + pipeline.prepare = async (context) => { + const contribution = await prepare(context); + assert.equal(contribution.ok, true); + const cleanup = contribution.cleanup; + const index = rows.length; + const row = { started: deferred(), release: deferred(), calls: 0 }; + rows.push(row); + contribution.cleanup = async () => { + row.calls++; + events.push(`child${index}:start`); + row.started.resolve(); + try { + await row.release.promise; + if (rejectChild) throw new Error("fixture child cleanup failure"); + await cleanup(); + } finally { + events.push(`child${index}:settled`); + } + }; + return contribution; + }; + let forgetCalls = 0; + pipeline.forgetSession = (id, runDirectories) => { + forgetCalls++; + events.push("parent:start"); + const pending = forget(id, runDirectories).then(() => { events.push("parent:done"); }); + forgotten.resolve(pending); + return pending; + }; + const fake = fakeSpawner(calls); + const terminals = new TerminalManager(() => undefined, availableRegistry(), undefined, undefined, true, (...args) => { + const pty = fake(...args); + spawned[calls.length - 1]?.resolve(); + return pty; + }); + terminals.configureLaunchPipeline(pipeline); + t.after(async () => { + for (const row of rows) row.release.resolve(); + terminals.disposeAll(); + await forgotten.promise; + await rm(root, { recursive: true, force: true }); + }); + const session = createCard(terminals, root); + await spawned[0].promise; + return { root, runs, rows, calls, events, spawned, forgotten, terminals, session, forgetCalls: () => forgetCalls }; +} + +for (const exited of [false, true]) { + for (const rejectChild of [false, true]) { + test(`parent launch cleanup waits for ${exited ? "previous exit" : "active disposal"} cleanup (${rejectChild ? "rejection" : "success"})`, { timeout: 5000 }, async (t) => { + const f = await fixture(t, { rejectChild }); + if (exited) f.calls[0].process.emitExit(1); + f.terminals.dispose(f.session.id); + f.terminals.dispose(f.session.id); + await f.rows[0].started.promise; + assert.equal(f.forgetCalls(), 0, "parent removal must not overlap a held child removal"); + assert.deepEqual(f.events, ["child0:start"]); + + f.rows[0].release.resolve(); + await f.forgotten.promise; + assert.deepEqual(f.events, ["child0:start", "child0:settled", "parent:start", "parent:done"]); + assert.equal(f.rows[0].calls, 1); + assert.equal(f.forgetCalls(), 1, "disposal remains idempotent"); + await assert.rejects(stat(join(f.runs, f.session.id)), { code: "ENOENT" }, "parent still cleaned after child failure"); + }); + } +} + +test("restart retains every pending run cleanup and invokes each captured callback once before forgetting the card", { timeout: 5000 }, async (t) => { + const f = await fixture(t); + f.calls[0].process.emitExit(1); + await f.rows[0].started.promise; + f.terminals.restart(f.session.id); + await f.spawned[1].promise; + assert.equal(f.rows.length, 2); + + f.terminals.dispose(f.session.id); + assert.equal(f.rows[1].calls, 0, "the newer cleanup queues behind the old launch callback"); + assert.equal(f.forgetCalls(), 0); + // A late notification from the old PTY must neither consume nor overwrite the new launch cleanup. + f.calls[0].process.emitExit(1); + f.rows[0].release.resolve(); + await f.rows[1].started.promise; + assert.equal(f.forgetCalls(), 0, "parent waits for both run directories"); + + f.rows[1].release.resolve(); + await f.forgotten.promise; + assert.deepEqual(f.events, ["child0:start", "child0:settled", "child1:start", "child1:settled", "parent:start", "parent:done"]); + assert.deepEqual(f.rows.map((row) => row.calls), [1, 1]); + assert.equal(f.forgetCalls(), 1); + await assert.rejects(stat(join(f.runs, f.session.id)), { code: "ENOENT" }); +}); + +test("a launch for a card closed in the same tick never asks its plugins or spawns", { timeout: 5000 }, async (t) => { + const root = await mkdtemp(join(tmpdir(), "ctty-cleanup-order-")); + const serviceCalls = []; + const calls = []; + const pipeline = cleanupPipeline(join(root, "runs"), serviceCalls); + const forgotten = []; + const forget = pipeline.forgetSession.bind(pipeline); + pipeline.forgetSession = (...args) => { + const pending = forget(...args); + forgotten.push(pending); + return pending; + }; + const terminals = new TerminalManager(() => undefined, availableRegistry(), undefined, undefined, true, fakeSpawner(calls)); + terminals.configureLaunchPipeline(pipeline); + t.after(async () => { + terminals.disposeAll(); + await rm(root, { recursive: true, force: true }); + }); + + const session = createCard(terminals, root); + const internal = terminals.sessions.get(session.id); + const tasks = [...internal.launchTasks]; + terminals.dispose(session.id); + await Promise.allSettled(tasks); + await Promise.allSettled(forgotten); + + assert.equal(serviceCalls.length, 0, "no plugin prepares a launch for a card that is already closed"); + assert.equal(calls.length, 0); + await assert.rejects(stat(join(root, "runs", session.id)), { code: "ENOENT" }); +}); + +test("a failed launch-file removal at close is reported with the card id", { timeout: 5000 }, async (t) => { + const root = await mkdtemp(join(tmpdir(), "ctty-cleanup-order-")); + const calls = []; + const pipeline = cleanupPipeline(join(root, "runs")); + const failure = new Error("fixture forget failure"); + const forgotten = deferred(); + pipeline.forgetSession = async () => { + forgotten.resolve(); + throw failure; + }; + const warnings = []; + t.mock.method(console, "warn", (...args) => { warnings.push(args); }); + const terminals = new TerminalManager(() => undefined, availableRegistry(), undefined, undefined, true, fakeSpawner(calls)); + terminals.configureLaunchPipeline(pipeline); + t.after(async () => { + terminals.disposeAll(); + await rm(root, { recursive: true, force: true }); + }); + + const session = createCard(terminals, root); + await Promise.allSettled([...terminals.sessions.get(session.id).launchTasks]); + terminals.dispose(session.id); + await forgotten.promise; + for (let i = 0; i < 20 && !warnings.length; i++) await new Promise((done) => setImmediate(done)); + + const warning = warnings.find(([message]) => String(message).includes(session.id)); + assert.ok(warning, "the close reports which card's launch files stayed behind"); + assert.ok(warning[1] instanceof AggregateError); + assert.deepEqual(warning[1].errors, [failure]); +}); diff --git a/tests/launch-contributors.test.mjs b/tests/launch-contributors.test.mjs index df2491a1e..8e47ab77f 100644 --- a/tests/launch-contributors.test.mjs +++ b/tests/launch-contributors.test.mjs @@ -310,11 +310,11 @@ test("secret env comes from the plugin's own secrets, reaches the child, and is calls[0].print(`env: PROVIDER_API_KEY=${SECRET} PLAIN=visible\n`); const control = new AgentControlService(manager); - const observed = control.observe(created.id).output; + const observed = (await control.observe(created.id)).output; assert.match(observed, /PROVIDER_API_KEY= PLAIN=visible/u); assert.doesNotMatch(observed, new RegExp(SECRET, "u")); calls[0].exit(1); - assert.doesNotMatch(control.result(created.id).output, new RegExp(SECRET, "u")); + assert.doesNotMatch((await control.result(created.id)).output, new RegExp(SECRET, "u")); assert.doesNotMatch(card(manager, created.id).failureDetails ?? "", new RegExp(SECRET, "u")); await manager.shutdown(); @@ -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"); +}); diff --git a/tests/launch-file-lifecycle.test.mjs b/tests/launch-file-lifecycle.test.mjs new file mode 100644 index 000000000..7131f2ff5 --- /dev/null +++ b/tests/launch-file-lifecycle.test.mjs @@ -0,0 +1,282 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readdir, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import test from "node:test"; +import { EnvironmentRegistry } from "../src/main/services/EnvironmentRegistry.ts"; +import { LaunchPipeline } from "../src/main/services/LaunchPipeline.ts"; +import { TerminalManager } from "../src/main/services/TerminalManager.ts"; +import { persistedTerminalSession } from "../src/main/services/TerminalSessionStore.ts"; +import { availableRegistry, fakeSpawner } from "./helpers/terminal.mjs"; + +function deferred() { + let resolve; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} + +async function fixture(t, { spawnFailure = false } = {}) { + const root = await mkdtemp(join(tmpdir(), "ctty-launch-files-")); + const runs = join(root, "runs"); + const calls = []; + const pipelines = []; + const gates = []; + const disposals = []; + const fake = fakeSpawner(calls); + const terminals = new TerminalManager(() => undefined, availableRegistry(), undefined, undefined, true, (...args) => { + if (spawnFailure) throw new Error("fixture PTY spawn failure"); + return fake(...args); + }); + // Closing a card starts its run-file cleanup in the background; the tests await that same promise. + const forget = terminals.forgetLaunchFiles.bind(terminals); + terminals.forgetLaunchFiles = (session) => { + const pending = forget(session); + disposals.push(pending); + return pending; + }; + const gate = () => { + const result = deferred(); + gates.push(result); + return result; + }; + + function pipeline({ holdPrepare = false, holdForget = false, failCleanup = false } = {}) { + const started = deferred(); + const release = gate(); + const forgetStarted = deferred(); + const forgetRelease = gate(); + const events = []; + const rows = []; + if (!holdPrepare) release.resolve(); + if (!holdForget) forgetRelease.resolve(); + const value = new LaunchPipeline({ + contributors: () => [{ + pluginId: "fixture.files", + pluginName: "Files", + serviceId: "launch", + secrets: false, + launch: { fields: [{ key: "enabled", label: "Enabled", kind: "boolean" }] } + }], + call: async () => { + started.resolve(); + await release.promise; + return { env: { FIXTURE_FILE: "{launchFiles}/run.txt" }, files: [{ relPath: "run.txt", content: "temporary" }] }; + }, + secret: async () => null, + runsRoot: runs, + timeoutMs: 4000 + }); + const prepare = value.prepare.bind(value); + const remove = value.forgetSession.bind(value); + value.prepare = async (context) => { + const result = await prepare(context); + assert.equal(result.ok, true); + const row = { file: result.env.FIXTURE_FILE, runDirectory: result.runDirectory, cleanupCalls: 0 }; + const cleanup = result.cleanup; + rows.push(row); + result.cleanup = async () => { + row.cleanupCalls++; + events.push("child"); + if (failCleanup) throw new Error("fixture cleanup failure"); + await cleanup(); + }; + return result; + }; + value.forgetSession = async (id, runDirectories) => { + events.push("parent:start"); + forgetStarted.resolve(); + await forgetRelease.promise; + await remove(id, runDirectories); + events.push("parent:done"); + }; + const result = { value, started, release, forgetStarted, forgetRelease, events, rows }; + pipelines.push(result); + return result; + } + + const create = (extra = {}) => terminals.create({ + provider: "codex", + profile: "normal", + cwd: root, + position: { x: 0, y: 0 }, + launchOptions: { "fixture.files": { enabled: true } }, + ...extra + }); + const drain = async () => { await Promise.allSettled(disposals); }; + t.after(async () => { + for (const item of gates) item.resolve(); + terminals.disposeAll(); + await drain(); + await rm(root, { recursive: true, force: true }); + }); + return { root, runs, calls, terminals, pipeline, create, gate, drain, disposals }; +} + +for (const failCleanup of [false, true]) { + test(`closing during preparation drains its late files before parent deletion (${failCleanup ? "failed" : "successful"} child cleanup)`, { timeout: 5000 }, async (t) => { + const f = await fixture(t); + const p = f.pipeline({ holdPrepare: true, failCleanup }); + f.terminals.configureLaunchPipeline(p.value); + const session = f.create(); + await p.started.promise; + f.terminals.dispose(session.id); + assert.deepEqual(p.events, []); + + p.release.resolve(); + await f.drain(); + assert.deepEqual(p.events, ["child", "parent:start", "parent:done"]); + assert.equal(p.rows[0].cleanupCalls, 1); + assert.equal(f.calls.length, 0); + // A failed child cleanup is retried by the parent step, which still removes only this card's own run folder. + await assert.rejects(stat(join(f.runs, session.id)), { code: "ENOENT" }); + }); +} + +test("restart tracks both pending preparations and sequentially forgets their original pipelines after reconfiguration", { timeout: 5000 }, async (t) => { + const f = await fixture(t); + const a = f.pipeline({ holdPrepare: true, holdForget: true }); + const b = f.pipeline({ holdPrepare: true }); + const c = f.pipeline(); + f.terminals.configureLaunchPipeline(a.value); + const session = f.create(); + await a.started.promise; + // Force a restart while its original plugin response is still in flight, as restoration can do. + const internal = f.terminals.sessions.get(session.id); + internal.metadata.status = "failed"; + internal.metadata.exitCode = 1; + f.terminals.configureLaunchPipeline(b.value); + f.terminals.restart(session.id); + await b.started.promise; + f.terminals.configureLaunchPipeline(c.value); + f.terminals.dispose(session.id); + + a.release.resolve(); + await Promise.resolve(); + assert.deepEqual(a.events, [], "parent cannot precede pending preparation"); + b.release.resolve(); + await a.forgetStarted.promise; + assert.deepEqual(a.events, ["child", "parent:start"]); + assert.deepEqual(b.events, ["child"], "same parent cannot be removed concurrently by another pipeline"); + assert.deepEqual(c.events, [], "current configuration is not the owner"); + + a.forgetRelease.resolve(); + await f.drain(); + assert.deepEqual(b.events, ["child", "parent:start", "parent:done"]); + assert.deepEqual([a.rows[0].cleanupCalls, b.rows[0].cleanupCalls], [1, 1]); + assert.equal(f.calls.length, 0); + await assert.rejects(stat(join(f.runs, session.id)), { code: "ENOENT" }); +}); + +for (const outcome of ["success", "refusal", "throw"]) { + test(`close while environment wrapping ${outcome} releases planned capability and files once`, { timeout: 5000 }, async (t) => { + const f = await fixture(t); + const p = f.pipeline(); + f.terminals.configureLaunchPipeline(p.value); + const wrapping = deferred(); + const release = f.gate(); + let plannedCleanups = 0; + const plan = f.terminals.planSpawn.bind(f.terminals); + f.terminals.planSpawn = (...args) => { + const result = plan(...args); + assert.ok(!("failure" in result)); + const cleanup = result.cleanup; + result.cleanup = () => { + plannedCleanups++; + cleanup(); + }; + return result; + }; + const registry = new EnvironmentRegistry({ + providers: () => [{ + pluginId: "fixture.env", + pluginName: "Environment", + serviceId: "env", + secrets: false, + kinds: [{ kind: "test", label: "Test", fields: [] }] + }], + call: async (_plugin, _service, method) => method.endsWith(".prepare") ? { ref: { id: "one" }, label: "Test" } : {}, + secret: async () => null + }); + registry.wrap = async (_environment, input) => { + wrapping.resolve(); + await release.promise; + if (outcome === "throw") throw new Error("fixture wrapper threw"); + if (outcome === "refusal") return { ok: false, reason: "fixture refused" }; + return { ok: true, ...input.launch, secrets: [] }; + }; + f.terminals.configureEnvironments(registry); + const session = f.create({ environment: { pluginId: "fixture.env", kind: "test" } }); + await wrapping.promise; + await stat(p.rows[0].file); + f.terminals.dispose(session.id); + assert.deepEqual(p.events, []); + + release.resolve(); + await f.drain(); + assert.equal(plannedCleanups, 1); + assert.equal(p.rows[0].cleanupCalls, 1); + assert.equal(f.calls.length, 0); + assert.deepEqual(p.events, ["child", "parent:start", "parent:done"]); + await assert.rejects(stat(join(f.runs, session.id)), { code: "ENOENT" }); + }); +} + +test("spawn refusal releases unadopted files and capabilities before eventual parent cleanup", { timeout: 5000 }, async (t) => { + const f = await fixture(t, { spawnFailure: true }); + const p = f.pipeline(); + f.terminals.configureLaunchPipeline(p.value); + let cleanups = 0; + const plan = f.terminals.planSpawn.bind(f.terminals); + f.terminals.planSpawn = (...args) => { + const result = plan(...args); + const cleanup = result.cleanup; + result.cleanup = () => { + cleanups++; + cleanup(); + }; + return result; + }; + const session = f.create(); + const internal = f.terminals.sessions.get(session.id); + await Promise.all([...internal.launchTasks]); + assert.equal(internal.metadata.status, "failed"); + assert.equal(cleanups, 1); + assert.equal(p.rows[0].cleanupCalls, 1); + assert.deepEqual(p.events, ["child"]); + + f.terminals.dispose(session.id); + await f.drain(); + assert.deepEqual(p.events, ["child", "parent:start", "parent:done"]); + await assert.rejects(stat(join(f.runs, session.id)), { code: "ENOENT" }); +}); + +test("a delayed close keeps the run files of a card brought back under the same id", { timeout: 5000 }, async (t) => { + const f = await fixture(t); + const p = f.pipeline({ holdForget: true }); + f.terminals.configureLaunchPipeline(p.value); + const session = f.create(); + const first = f.terminals.sessions.get(session.id); + await Promise.all([...first.launchTasks]); + assert.equal(f.calls.length, 1); + const record = persistedTerminalSession(first.metadata, first.threadId, first.extras); + const firstRun = p.rows[0].runDirectory; + + // Close the card; its folder removal is held, as a slow disk or a busy Windows file would hold it. + f.terminals.dispose(session.id); + await p.forgetStarted.promise; + + // The same card comes back (restore under its saved id) and prepares a new run before the old removal finishes. + await f.terminals.restoreSessionRecords([{ ...record, lastState: "running" }], "continue"); + const second = f.terminals.sessions.get(session.id); + assert.ok(second && second !== first); + await Promise.all([...(second.launchTasks ?? [])]); + assert.equal(p.rows.length, 2); + const secondRun = p.rows[1].runDirectory; + assert.notEqual(secondRun, firstRun); + + p.forgetRelease.resolve(); + await f.drain(); + await assert.rejects(stat(firstRun), { code: "ENOENT" }, "the closed card's own run folder is removed"); + await stat(p.rows[1].file); + assert.deepEqual(await readdir(join(f.runs, session.id)), [basename(secondRun)], "the newer card's run folder is kept"); +}); diff --git a/tests/launch-readiness.test.mjs b/tests/launch-readiness.test.mjs index 47768047e..70fafc5be 100644 --- a/tests/launch-readiness.test.mjs +++ b/tests/launch-readiness.test.mjs @@ -14,9 +14,9 @@ import { controlRequest } from "../scripts/canvastty-control.mjs"; import { AgentControlService } from "../src/main/services/AgentControlService.ts"; import { LaunchPipeline } from "../src/main/services/LaunchPipeline.ts"; import { PluginSessions } from "../src/main/services/PluginSessions.ts"; -import { TerminalManager } from "../src/main/services/TerminalManager.ts"; +import { INPUT_READY_HOOK_GRACE_MS, TerminalManager } from "../src/main/services/TerminalManager.ts"; import { AgentControlGateway } from "../src/main/services/agent-control/AgentControlGateway.ts"; -import { ScopedOrchestrationHandler } from "../src/main/services/agent-browser/OrchestrationTools.ts"; +import { AGENT_TOOL_DELIVERY_MS, ScopedOrchestrationHandler } from "../src/main/services/agent-browser/OrchestrationTools.ts"; const at = { x: 0, y: 0 }; const cwd = process.cwd(); @@ -252,3 +252,236 @@ test("plugin Codex sends wait beyond PTY creation and bracket multiline tasks an await sessions.handle("p.owner","svc","sessions.send",{sessionId,text:"draft",submit:false},permissions); assert.equal(call.written.at(-1),"\x1b[200~draft\x1b[201~"); }); + +// OpenCode drops every key that arrives while its full-screen view starts, so a prompt typed when its PTY exists is +// lost and the agent sits at an empty prompt. Its prompt goes in only once its input field is up: a lifecycle hook, +// or the control sequence that shows its text cursor on the alternate screen (never its human-readable screen text). + +const ALT_SCREEN_HIDDEN_CURSOR = "\x1b[?1049h\x1b[?2004h\x1b[?25l"; +const CURSOR_SHOWN = "\x1b[1 q\x1b[?25h"; + +/** + * A slow-starting OpenCode stand-in: it draws its start-up for `startupMs` with the cursor hidden and drops every key + * written meanwhile (as OpenCode does), then shows its input field. `accepted` holds what reached that field. + */ +function slowOpenCode({ startupMs = 120, ready = true } = {}) { + const runs = []; + const spawn = () => { + let emit = () => undefined; + const exits = []; + const run = { written: [], accepted: [], dropped: [], ready: false, exit: (code) => exits.forEach((listener) => listener({ exitCode: code })) }; + runs.push(run); + setTimeout(() => emit(ALT_SCREEN_HIDDEN_CURSOR), 5); + setTimeout(() => emit(" Ask anything… \"Fix broken tests\" tab agents ctrl+p commands"), startupMs / 2); + if (ready) setTimeout(() => { run.ready = true; emit(CURSOR_SHOWN); }, startupMs); + return { + pid: 70_000 + runs.length, process: "opencode", resize() {}, kill() {}, pause() {}, resume() {}, + write(text) { run.written.push(text); (run.ready ? run.accepted : run.dropped).push(text); }, + onData(listener) { emit = listener; return { dispose() {} }; }, + onExit(listener) { exits.push(listener); return { dispose() {} }; } + }; + }; + return { runs, spawn }; +} + +function slowOpenCodeOrchestration(t, options) { + const cli = slowOpenCode(options); + let orchestratorStarted = false; + const terminals = new TerminalManager(() => {}, clis(), undefined, undefined, true, (...args) => { + if (!orchestratorStarted) { orchestratorStarted = true; return spawner([], { auto: false })(...args); } + return cli.spawn(...args); + }); + t.after(() => terminals.disposeAll()); + const control = new AgentControlService(terminals); + const handler = new ScopedOrchestrationHandler(control); + const orchestrator = terminals.create({ provider: "codex", profile: "normal", cwd, position: at, role: "orchestrator" }); + return { cli, terminals, control, execute: (tool, args) => handler.execute(orchestrator.id, { tool, arguments: args }) }; +} + +test("spawn_agent: a slow-starting OpenCode gets its first prompt once its input field is up, exactly once", async (t) => { + const f = slowOpenCodeOrchestration(t, { startupMs: 150 }); + const spawned = f.execute("spawn_agent", { provider: "opencode", cwd, prompt: "do part A" }); + await new Promise((resolve) => setTimeout(resolve, 100)); + assert.equal(await state(spawned), "pending", "the PTY exists and the home screen text is drawn, but the field is not up"); + assert.deepEqual(f.cli.runs[0].written, []); + const result = await spawned; + assert.equal(typeof result.sessionId, "string"); + assert.deepEqual(f.cli.runs[0].accepted, ["do part A\r"], "the prompt reached the input field"); + assert.deepEqual(f.cli.runs[0].dropped, [], "nothing was typed while OpenCode started"); + + // send_to_agent to the running card goes in at once. + assert.deepEqual(await f.execute("send_to_agent", { sessionId: result.sessionId, prompt: "now part B" }), { sessionId: result.sessionId, sent: true }); + assert.deepEqual(f.cli.runs[0].accepted, ["do part A\r", "now part B\r"]); +}); + +test("spawn_agent: an OpenCode that exits before its input field is up fails the call with the reason", async (t) => { + const f = slowOpenCodeOrchestration(t, { ready: false }); + const spawned = f.execute("spawn_agent", { provider: "opencode", cwd, prompt: "never typed" }); + await tick(); + f.cli.runs[0].exit(1); + await assert.rejects(spawned, (error) => { + assert.match(error.bridgeError?.message ?? "", /was not delivered: The session/u); + return true; + }); + assert.deepEqual(f.cli.runs[0].written, []); +}); + +test("OpenCode that never shows its input field gets no prompt and a bounded reason", async (t) => { + const cli = slowOpenCode({ ready: false }); + const terminals = new TerminalManager(() => {}, clis(), undefined, undefined, true, cli.spawn); + t.after(() => terminals.disposeAll()); + const session = terminals.create({ provider: "opencode", profile: "normal", cwd, position: at }); + const started = Date.now(); + const result = await terminals.deliverInput(session.id, "never\r", 200); + assert.equal(result.delivered, false); + assert.match(result.reason, /did not show its input field within 200 ms; nothing was typed/u); + assert.ok(Date.now() - started < 1_000); + assert.deepEqual(cli.runs[0].written, [], "its home screen text alone is not readiness"); +}); + +test("OpenCode readiness: a cursor shown before the alternate screen does not count; one cut across chunks does", async (t) => { + const calls = []; + const terminals = new TerminalManager(() => {}, clis(), undefined, undefined, true, (command, args, options) => { + const pty = spawner(calls, { auto: false })(command, args, options); + return { ...pty, onData(listener) { calls.at(-1).emit = listener; return { dispose() {} }; } }; + }); + t.after(() => terminals.disposeAll()); + const session = terminals.create({ provider: "opencode", profile: "normal", cwd, position: at }); + const pending = terminals.deliverInput(session.id, "split\r", 2_000); + calls[0].emit("\x1b[?25h"); + calls[0].emit("\x1b[?104"); + calls[0].emit("9h\x1b[?25l drawing"); + await tick(); + assert.deepEqual(calls[0].written, []); + calls[0].emit("\x1b[?2"); + await tick(); + assert.deepEqual(calls[0].written, []); + calls[0].emit("5h"); + assert.deepEqual(await pending, { delivered: true }); + assert.deepEqual(calls[0].written, ["split\r"]); +}); + +test("OpenCode readiness: a lifecycle hook counts only after a grace, and sends made while it starts arrive in order", async (t) => { + const calls = []; + const terminals = new TerminalManager(() => {}, clis(), undefined, undefined, true, spawner(calls, { auto: false })); + t.after(() => terminals.disposeAll()); + const session = terminals.create({ provider: "opencode", profile: "normal", cwd, position: at }); + const first = terminals.deliverInput(session.id, "first\r", INPUT_READY_HOOK_GRACE_MS + 2_000); + const second = terminals.deliverInput(session.id, "second\r", INPUT_READY_HOOK_GRACE_MS + 2_000); + await tick(); + assert.deepEqual(calls[0].written, []); + const hookAt = Date.now(); + terminals.applyProviderSignal(session.id, { kind: "lifecycle", state: "idle", event: "session.created" }); + await new Promise((resolve) => setTimeout(resolve, 200)); + assert.deepEqual(calls[0].written, [], "a hook alone is not the input field"); + assert.deepEqual([await first, await second], [{ delivered: true }, { delivered: true }]); + assert.ok(Date.now() - hookAt >= INPUT_READY_HOOK_GRACE_MS - 20, "typed only after the grace"); + assert.deepEqual(calls[0].written, ["first\r", "second\r"]); +}); + +test("OpenCode readiness: a resumed card whose plugin reports before its input field is up gets the text on the cursor signal", async (t) => { + const calls = []; + const terminals = new TerminalManager(() => {}, clis(), undefined, undefined, true, (command, args, options) => { + const pty = spawner(calls, { auto: false })(command, args, options); + return { ...pty, onData(listener) { calls.at(-1).emit = listener; return { dispose() {} }; } }; + }); + t.after(() => terminals.disposeAll()); + const session = terminals.create({ provider: "opencode", profile: "normal", cwd, position: at }); + calls[0].emit("\x1b[?1049h\x1b[?25l"); + // The plugin reports idle on session.updated about 0.6 s before typed keys reach the prompt. + terminals.applyProviderSignal(session.id, { kind: "lifecycle", state: "idle", event: "session.updated" }); + const sent = terminals.deliverInput(session.id, "resumed\r", 5_000); + await new Promise((resolve) => setTimeout(resolve, 300)); + assert.deepEqual(calls[0].written, [], "not typed on the hook"); + const shownAt = Date.now(); + calls[0].emit("\x1b[?25h"); + assert.deepEqual(await sent, { delivered: true }); + assert.ok(Date.now() - shownAt < INPUT_READY_HOOK_GRACE_MS / 2, "typed on the cursor signal, not after the grace"); + assert.deepEqual(calls[0].written, ["resumed\r"]); +}); + +test("OpenCode readiness: a send cancelled while it waits behind another settles at once and is never typed", async (t) => { + const calls = []; + const terminals = new TerminalManager(() => {}, clis(), undefined, undefined, true, (command, args, options) => { + const pty = spawner(calls, { auto: false })(command, args, options); + return { ...pty, onData(listener) { calls.at(-1).emit = listener; return { dispose() {} }; } }; + }); + t.after(() => terminals.disposeAll()); + const session = terminals.create({ provider: "opencode", profile: "normal", cwd, position: at }); + const first = terminals.deliverInput(session.id, "first\r", 5_000); + const controller = new AbortController(); + const queued = terminals.deliverInput(session.id, "queued\r", 5_000, controller.signal); + const third = terminals.deliverInput(session.id, "third\r", 5_000); + await tick(); + const abortedAt = Date.now(); + controller.abort(); + assert.deepEqual(await queued, { delivered: false, reason: "The delivery was cancelled." }); + assert.ok(Date.now() - abortedAt < 200, "settled without waiting for the send ahead of it"); + calls[0].emit("\x1b[?1049h\x1b[?25h"); + assert.deepEqual([await first, await third], [{ delivered: true }, { delivered: true }]); + assert.deepEqual(calls[0].written, ["first\r", "third\r"]); +}); + +test("OpenCode readiness is not awaited on Windows, whose pseudo console may not pass the cursor sequences", async (t) => { + const platform = Object.getOwnPropertyDescriptor(process, "platform"); + const calls = []; + const terminals = new TerminalManager(() => {}, clis(), undefined, undefined, true, spawner(calls, { auto: false })); + t.after(() => terminals.disposeAll()); + const session = terminals.create({ provider: "opencode", profile: "normal", cwd, position: at }); + Object.defineProperty(process, "platform", { ...platform, value: "win32" }); + try { + assert.deepEqual(await terminals.deliverInput(session.id, "windows\r", 200), { delivered: true }); + } finally { + Object.defineProperty(process, "platform", platform); + } + assert.deepEqual(calls[0].written, ["windows\r"]); +}); + +test("spawn_agent and send_to_agent wait for delivery within a budget under the MCP client's deadline", async (t) => { + assert.ok(AGENT_TOOL_DELIVERY_MS <= 45_000); + const f = slowOpenCodeOrchestration(t, { ready: false }); + const waits = []; + const { control } = f; + const spawn = control.spawn.bind(control); + control.spawn = (request, signal, promptWaitMs) => { waits.push(["spawn", promptWaitMs]); return spawn(request, signal, 150); }; + const send = control.send.bind(control); + control.send = (id, text, submit, signal, waitMs) => { waits.push(["send", waitMs]); return send(id, text, submit, signal, 150); }; + await assert.rejects(f.execute("spawn_agent", { provider: "opencode", cwd, prompt: "late" }), (error) => { + assert.match(error.bridgeError?.message ?? "", /did not show its input field within 150 ms; nothing was typed/u); + return true; + }); + const [child] = f.terminals.listMetadata().filter((session) => session.role === "subagent"); + await assert.rejects(f.execute("send_to_agent", { sessionId: child.id, prompt: "again" })); + assert.equal(waits[0][0], "spawn"); + assert.ok(waits[0][1] > 0 && waits[0][1] <= AGENT_TOOL_DELIVERY_MS, String(waits[0][1])); + assert.deepEqual(waits[1], ["send", AGENT_TOOL_DELIVERY_MS]); +}); + +test("OpenCode readiness: cancellation and a restart while it starts never deliver the queued text", async (t) => { + const cli = slowOpenCode({ startupMs: 80 }); + const terminals = new TerminalManager(() => {}, clis(), undefined, undefined, true, cli.spawn); + t.after(() => terminals.disposeAll()); + const session = terminals.create({ provider: "opencode", profile: "normal", cwd, position: at }); + const controller = new AbortController(); + const canceled = terminals.deliverInput(session.id, "cancel\r", 2_000, controller.signal); + const replaced = terminals.deliverInput(session.id, "old launch\r", 2_000); + controller.abort(); + assert.deepEqual(await canceled, { delivered: false, reason: "The delivery was cancelled." }); + cli.runs[0].exit(0); + terminals.restart(session.id); + assert.equal((await replaced).delivered, false); + await new Promise((resolve) => setTimeout(resolve, 150)); + assert.ok(cli.runs[1].ready, "the new launch became ready"); + assert.ok(cli.runs.every((run) => run.written.length === 0), "neither launch got the text"); +}); + +test("Claude and Codex still get input as soon as their PTY exists", async (t) => { + const calls = []; + const terminals = new TerminalManager(() => {}, clis(), undefined, undefined, true, spawner(calls, { auto: false })); + t.after(() => terminals.disposeAll()); + for (const provider of ["claude", "codex"]) { + const session = terminals.create({ provider, profile: "normal", cwd, position: at }); + assert.deepEqual(await terminals.deliverInput(session.id, `${provider} task\r`, 200), { delivered: true }); + } + assert.deepEqual(calls.map((call) => call.written), [["claude task\r"], ["codex task\r"]]); +}); diff --git a/tests/material-handoff-service.test.mjs b/tests/material-handoff-service.test.mjs index f4797f486..8d28c0b50 100644 --- a/tests/material-handoff-service.test.mjs +++ b/tests/material-handoff-service.test.mjs @@ -1034,3 +1034,19 @@ test("a narrow ordinary Codex composer still receives a material handoff", async assert.ok(terminal.writes.length > 0); }); }); + +test("a handoff waits for the agent's CLI to take input instead of failing at once", async () => { + await withHandoffs(async ({ handoffs, terminal, draft }) => { + terminal.add({ provider: "claude", id: "s1" }); + terminal.tui = claudeTui(terminal); + const waits = []; + const deliverInput = terminal.deliverInput.bind(terminal); + terminal.deliverInput = async (id, data, waitMs) => { + waits.push(waitMs); + return deliverInput(id, data); + }; + assert.equal((await handoffs.send(draft("s1"))).handoff.delivery.state, "submitted"); + assert.ok(waits.length >= 2, "the paste and its Enter"); + assert.ok(waits.every((waitMs) => waitMs >= 10_000), `waits: ${waits.join(", ")}`); + }); +}); diff --git a/tests/opencode-result.test.mjs b/tests/opencode-result.test.mjs index 1355f842a..a6f77225e 100644 --- a/tests/opencode-result.test.mjs +++ b/tests/opencode-result.test.mjs @@ -7,10 +7,12 @@ import test from "node:test"; import { AGENT_RUNTIME_ENV, CAPTURE_RESULT_ENV, MAX_RESULT_CHARS } from "../src/agent-runtime/runtime-protocol.mjs"; import { finalAnswer } from "../src/agent-runtime/opencode-final-answer.mjs"; import { RuntimeGateway } from "../src/main/services/agent-runtime/RuntimeGateway.ts"; +import { applyRuntimeSignal } from "../src/main/services/agent-runtime/applyRuntimeSignal.ts"; +import { LaunchPipeline } from "../src/main/services/LaunchPipeline.ts"; import { TerminalManager } from "../src/main/services/TerminalManager.ts"; import { AgentControlService } from "../src/main/services/AgentControlService.ts"; import { ScopedOrchestrationHandler } from "../src/main/services/agent-browser/OrchestrationTools.ts"; -import { availableRegistry, fakeSpawner } from "./helpers/terminal.mjs"; +import { availableRegistry, fakeSpawner, inputReadySpawner, openCodeInputReady } from "./helpers/terminal.mjs"; const POSIX = { skip: process.platform === "win32" ? "Unix socket test; the Windows pipe transport has its own suite." : false }; @@ -55,9 +57,9 @@ await hooks.event({ event: { type: "session.status", properties: { sessionID: "s await hooks.event({ event: { type: "session.idle", properties: { sessionID: "ses_root" } } }); `; -function runFakeOpenCode(capability, answer, captureResult) { +function runFakeOpenCode(capability, answer, captureResult, script = FAKE_OPENCODE, extraEnv = {}, onCapture = null) { const env = { - ...process.env, + ...process.env, ...extraEnv, PLUGIN_URL: new URL("../src/agent-runtime/opencode-plugin.mjs", import.meta.url).href, FAKE_ANSWER: JSON.stringify(answer), [AGENT_RUNTIME_ENV.address]: capability.address, @@ -67,35 +69,43 @@ function runFakeOpenCode(capability, answer, captureResult) { }; if (captureResult) env[CAPTURE_RESULT_ENV] = "1"; else delete env[CAPTURE_RESULT_ENV]; - const child = spawn(process.execPath, ["--input-type=module", "-e", FAKE_OPENCODE], { env, stdio: ["ignore", "ignore", "pipe"] }); + const child = spawn(process.execPath, ["--input-type=module", "-e", script], { env, stdio: ["pipe", "pipe", "pipe"] }); + const timeout = setTimeout(() => child.kill(), 5_000); let stderr = ""; child.stderr.on("data", (chunk) => { stderr += chunk; }); - return new Promise((resolve) => child.on("close", (code) => resolve({ code, stderr }))); + return new Promise((resolve, reject) => { + // The script writes to stdout when its SDK read starts and waits on stdin until onCapture has run. + if (onCapture) child.stdout.once("data", () => { + Promise.resolve().then(onCapture).then(() => child.stdin.end("continue"), (error) => { child.kill(); reject(error); }); + }); + child.on("error", (error) => { clearTimeout(timeout); reject(error); }); + child.on("close", (code) => { clearTimeout(timeout); resolve({ code, stderr }); }); + }); } -async function setup(t) { +async function setupTerminals(t) { const root = await realpath(await mkdtemp(join(tmpdir(), "ctty-ocr-"))); t.after(() => rm(root, { recursive: true, force: true })); const calls = []; - const terminals = new TerminalManager(() => undefined, availableRegistry(), undefined, undefined, true, fakeSpawner(calls)); + const terminals = new TerminalManager(() => undefined, availableRegistry(), undefined, undefined, true, inputReadySpawner(calls)); t.after(() => terminals.disposeAll()); - const runtimeDirectory = await mkdtemp(join(tmpdir(), "ctty-rt-")); - t.after(() => rm(runtimeDirectory, { recursive: true, force: true })); - // What main/index.ts does with each runtime signal. - const gateway = new RuntimeGateway({ - runtimeDirectory, - onSignal: (id, signal) => { - terminals.applyProviderSignal(id, { kind: "lifecycle", state: signal.state }); - if (signal.result) terminals.recordAnswer(id, signal.result); - } - }); - await gateway.start(); - t.after(() => gateway.close()); const orchestrator = terminals.create({ provider: "codex", cwd: root, profile: "normal", position: { x: 0, y: 0 }, role: "orchestrator" }); const control = new AgentControlService(terminals, { waitTiming: { checkMs: 5, settleMs: 300, quietMs: 2_000 } }); const handler = new ScopedOrchestrationHandler(control); const child = await handler.execute(orchestrator.id, { id: "1", tool: "spawn_agent", arguments: { provider: "opencode", cwd: root } }); - return { root, calls, terminals, gateway, handler, orchestrator, childId: child.sessionId }; + return { root, calls, terminals, control, handler, orchestrator, childId: child.sessionId }; +} + +async function setup(t) { + const fixture = await setupTerminals(t); + const { terminals } = fixture; + const runtimeDirectory = await mkdtemp(join(tmpdir(), "ctty-rt-")); + t.after(() => rm(runtimeDirectory, { recursive: true, force: true })); + // The same glue main/index.ts runs for each runtime signal. + const gateway = new RuntimeGateway({ runtimeDirectory, onSignal: (id, signal) => applyRuntimeSignal(terminals, id, signal) }); + await gateway.start(); + t.after(() => gateway.close()); + return { ...fixture, gateway }; } test("an OpenCode subagent's final reply reaches wait_for_agent and get_agent_result, masked", POSIX, async (t) => { @@ -147,11 +157,14 @@ test("an ordinary OpenCode card (not a subagent) never keeps an answer", async ( test("wait_for_agent ignores an idle before the prompt's turn and returns that turn's answer", async (t) => { const root = await realpath(await mkdtemp(join(tmpdir(), "ctty-ocr-"))); t.after(() => rm(root, { recursive: true, force: true })); - const terminals = new TerminalManager(() => undefined, availableRegistry(), undefined, undefined, true, fakeSpawner([])); + const calls = []; + const terminals = new TerminalManager(() => undefined, availableRegistry(), undefined, undefined, true, fakeSpawner(calls)); t.after(() => terminals.disposeAll()); const orchestrator = terminals.create({ provider: "codex", cwd: root, profile: "normal", position: { x: 0, y: 0 }, role: "orchestrator" }); const handler = new ScopedOrchestrationHandler(new AgentControlService(terminals, { waitTiming: { checkMs: 5, settleMs: 10, quietMs: 60_000 } })); - const child = await handler.execute(orchestrator.id, { id: "1", tool: "spawn_agent", arguments: { provider: "opencode", cwd: root, prompt: "do part A" } }); + const spawning = handler.execute(orchestrator.id, { id: "1", tool: "spawn_agent", arguments: { provider: "opencode", cwd: root, prompt: "do part A" } }); + await openCodeInputReady(calls, 1); + const child = await spawning; const wait = (timeoutSeconds) => handler.execute(orchestrator.id, { id: "w", tool: "wait_for_agent", arguments: { sessionId: child.sessionId, timeoutSeconds } }); // OpenCode's session.created reports idle before the prompt's turn starts. terminals.applyProviderSignal(child.sessionId, { kind: "lifecycle", state: "idle", event: "session.created" }); @@ -167,3 +180,318 @@ test("wait_for_agent ignores an idle before the prompt's turn and returns that t assert.equal(done.reason, "idle"); assert.deepEqual(done.answer, { text: "part A done", truncated: false }); }); + +test("OpenCode turn correlation rejects preceding answers and continuations across submitted prompts", async (t) => { + const { terminals, childId } = await setupTerminals(t); + const apply = (state, event, turnId, answer) => { + const current = terminals.applyProviderSignal(childId, { kind: "lifecycle", state, event, requestId: turnId }); + if (current && answer) terminals.recordAnswer(childId, { text: answer, truncated: false }, { turnId }); + return current; + }; + assert.equal(apply("working", "session.status:busy", "turn1"), true); + assert.equal(apply("idle", "session.idle", "turn1", "old answer"), true); + assert.equal(terminals.answer(childId).text, "old answer"); + assert.deepEqual(await terminals.deliverInput(childId, "new task\r"), { delivered: true }); + assert.equal(terminals.answer(childId), null, "a submitted prompt drops the previous answer"); + assert.equal(apply("idle", "session.idle", "turn1", "delayed old answer"), false); + assert.equal(apply("working", "permission.replied", "turn1"), false, "an old continuation cannot acknowledge the new prompt"); + assert.equal(terminals.getMetadata(childId).status, "working", "but it still moves the status of the turn that runs"); + terminals.recordAnswer(childId, { text: "uncorrelated old answer", truncated: false }); + assert.equal(terminals.answer(childId), null); + assert.equal(terminals.turnProgress(childId).turnStartedSincePrompt, false); + assert.equal(apply("working", "session.status:busy", "turn2"), true); + assert.equal(apply("idle", "session.idle", "turn1", "late old after new start"), false); + assert.equal(terminals.getMetadata(childId).status, "working", "a late signal of an earlier turn changes nothing"); + assert.equal(apply("needs_approval", "permission.asked", "turn2"), true); + const generation = terminals.sessions.get(childId).inputGeneration ?? 0; + terminals.input(childId, "y\r"); + assert.equal(terminals.sessions.get(childId).inputGeneration ?? 0, generation, "an approval answer is not a new task"); + assert.equal(apply("working", "permission.replied", "turn2"), true); + assert.equal(apply("working", "session.status:retry", "turn2"), true); + assert.equal(apply("idle", "session.idle", "turn2", "new answer"), true); + assert.equal(terminals.answer(childId).text, "new answer"); + apply("working", "session.status:busy", "turn3"); + await terminals.deliverInput(childId, "interrupt current work\r"); + const starts = terminals.sessions.get(childId).turnStarts; + assert.equal(apply("working", "session.status:busy", "turn4"), true); + assert.equal(terminals.sessions.get(childId).turnStarts, starts + 1, "a new turn id is a new turn even while the previous one was working"); + assert.equal(terminals.turnProgress(childId).turnStartedSincePrompt, true); + assert.equal(apply("idle", "session.idle", "turn3", "interrupted answer"), false); + assert.equal(terminals.answer(childId), null); +}); + +test("with status hooks off the plugin reports nothing and the wait says the answer cannot be captured", POSIX, async (t) => { + const { terminals, gateway, handler, orchestrator, childId } = await setup(t); + terminals.setLifecycleHooksEnabled(false); + const signals = []; + const apply = terminals.applyProviderSignal.bind(terminals); + terminals.applyProviderSignal = (id, signal, ...args) => { signals.push(signal); return apply(id, signal, ...args); }; + // A lease that exists for another reason (the decision hook) still carries no lifecycle report and no answer. + const capability = gateway.registerSession(childId, "opencode", true); + const run = await runFakeOpenCode(capability, "never captured", true, FAKE_OPENCODE, { CANVASTTY_LIFECYCLE_HOOKS_ENABLED: "0" }); + assert.equal(run.code, 0, run.stderr); + assert.deepEqual(signals, []); + assert.equal(terminals.getMetadata(childId).status, "unavailable"); + assert.equal(terminals.answer(childId), null); + const waited = await handler.execute(orchestrator.id, { id: "w", tool: "wait_for_agent", arguments: { sessionId: childId, timeoutSeconds: 5 } }); + assert.equal(waited.reason, "quiet"); + assert.equal(waited.answer, undefined); + assert.equal(waited.answerUnavailable.reason, "status_hooks_off"); + assert.match(waited.answerUnavailable.message, /status hooks are off[\s\S]*Read its output/u); + const result = await handler.execute(orchestrator.id, { id: "r", tool: "get_agent_result", arguments: { sessionId: childId } }); + assert.equal(result.answer, undefined); + assert.deepEqual(result.answerUnavailable, waited.answerUnavailable); + // Turning them on again applies to agents started or restarted afterwards, not to this launch. + terminals.setLifecycleHooksEnabled(true); + assert.equal(terminals.applyProviderSignal(childId, { kind: "lifecycle", state: "idle", event: "session.idle", requestId: "t" }), false); + const later = await handler.execute(orchestrator.id, { id: "r2", tool: "get_agent_result", arguments: { sessionId: childId } }); + assert.equal(later.answerUnavailable?.reason, "status_hooks_off"); +}); + +test("OpenCode async final answer keeps its ending turn and drops a read superseded by a new turn", POSIX, async (t) => { + const { terminals, gateway, childId } = await setup(t); + const capability = gateway.registerSession(childId, "opencode", true); + const signals = []; + const original = terminals.applyProviderSignal.bind(terminals); + terminals.applyProviderSignal = (id, signal, ...args) => { signals.push(signal); return original(id, signal, ...args); }; + const script = ` + const { CanvasTTYLifecycle } = await import(process.env.PLUGIN_URL); + let resolveOld, started; const capturing = new Promise((done) => started = done); + let reads = 0; + const client = { session: { messages: async () => { + if (reads++ === 0) { started(); return new Promise((done) => resolveOld = done); } + return { data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "second answer" }] }] }; + } } }; + const hooks = await CanvasTTYLifecycle({ client }); + const event = (type, rest = {}) => hooks.event({ event: { type, properties: { sessionID: "root", ...rest } } }); + await event("session.created", { info: { id: "root" } }); + await event("session.status", { status: { type: "busy" } }); + await event("session.status", { status: { type: "idle" } }); + const old = event("session.idle"); await capturing; + await event("session.status", { status: { type: "busy" } }); + resolveOld({ data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "old delayed answer" }] }] }); + await old; + await event("permission.asked"); await event("permission.replied"); + await event("session.status", { status: { type: "retry" } }); + await event("session.status", { status: { type: "idle" } }); + await event("session.idle"); + `; + const run = await runFakeOpenCode(capability, "unused", true, script); + assert.equal(run.code, 0, run.stderr); + const starts = signals.filter((signal) => signal.event === "session.status:busy"); + assert.equal(starts.length, 2); + assert.notEqual(starts[0].requestId, starts[1].requestId, "each turn has its own id"); + assert.equal(signals.filter((signal) => signal.event === "session.status:idle").length, 0, "no idle before the answer was read"); + const ends = signals.filter((signal) => signal.event === "session.idle"); + assert.equal(ends.length, 1, "a superseded answer read reports neither idle nor an answer"); + assert.equal(ends[0].requestId, starts[1].requestId); + assert.equal(signals.find((signal) => signal.event === "permission.replied").requestId, starts[1].requestId); + assert.equal(signals.find((signal) => signal.event === "session.status:retry").requestId, starts[1].requestId); + assert.equal(terminals.answer(childId).text, "second answer"); +}); + +test("an answer read that finishes after the next prompt was submitted is not that prompt's answer", POSIX, async (t) => { + const { terminals, gateway, handler, orchestrator, childId } = await setup(t); + const capability = gateway.registerSession(childId, "opencode", true); + const script = ` + const { CanvasTTYLifecycle } = await import(process.env.PLUGIN_URL); + const client = { session: { messages: async () => { + process.stdout.write("capturing"); + await new Promise((done) => process.stdin.once("data", done)); + return { data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "late preceding answer" }] }] }; + } } }; + const hooks = await CanvasTTYLifecycle({ client }); + const event = (type, rest = {}) => hooks.event({ event: { type, properties: { sessionID: "root", ...rest } } }); + await event("session.created", { info: { id: "root" } }); + await event("session.status", { status: { type: "busy" } }); + await event("session.idle"); + `; + const run = await runFakeOpenCode(capability, "unused", true, script, {}, () => terminals.deliverInput(childId, "next task\r")); + assert.equal(run.code, 0, run.stderr); + const result = await handler.execute(orchestrator.id, { id: "poll", tool: "get_agent_result", arguments: { sessionId: childId } }); + assert.equal(result.answer, undefined); + assert.equal(terminals.turnProgress(childId).turnStartedSincePrompt, false, "the late idle does not acknowledge the next prompt"); +}); + +test("approval continuations and repeated working events stay in the same turn", async (t) => { + const { terminals, childId } = await setupTerminals(t); + const apply = (state, event, turnId) => terminals.applyProviderSignal(childId, { kind: "lifecycle", state, event, requestId: turnId }); + assert.equal(apply("working", "session.status:busy", "turn1"), true); + const starts = terminals.sessions.get(childId).turnStarts; + assert.equal(apply("working", "session.status:busy", "turn1"), true); + assert.equal(apply("working", "session.status:retry", "turn1"), true); + assert.equal(terminals.sessions.get(childId).turnStarts, starts, "a repeated working or retry is not another start"); + assert.equal(apply("needs_approval", "permission.asked", "turn1"), true); + assert.equal(terminals.getMetadata(childId).status, "needs_approval"); + const generation = terminals.sessions.get(childId).inputGeneration ?? 0; + await terminals.deliverInput(childId, "y\r"); + assert.equal(terminals.sessions.get(childId).inputGeneration ?? 0, generation, "an approval answer keeps the current turn"); + assert.equal(apply("working", "permission.replied", "turn1"), true); + assert.equal(terminals.turnProgress(childId).turnStartedSincePrompt, true, "the continuation acknowledges the approval"); + const continued = terminals.sessions.get(childId).turnStarts; + assert.equal(apply("working", "session.status:retry", "turn1"), true); + assert.equal(terminals.sessions.get(childId).turnStarts, continued); + assert.equal(apply("idle", "session.idle", "turn1"), true); + terminals.recordAnswer(childId, { text: "answer after approval", truncated: false }, { turnId: "turn1" }); + assert.equal(terminals.answer(childId).text, "answer after approval"); + assert.equal(terminals.getMetadata(childId).status, "idle"); + terminals.input(childId, "next task\r"); + assert.equal(terminals.sessions.get(childId).inputGeneration, generation + 1); + assert.equal(apply("working", "permission.replied", "turn1"), false); +}); + +test("a result-capturing OpenCode does not report idle while its final answer is still being read", POSIX, async (t) => { + const { terminals, gateway, handler, orchestrator, childId } = await setup(t); + const capability = gateway.registerSession(childId, "opencode", true); + const script = ` + const { CanvasTTYLifecycle } = await import(process.env.PLUGIN_URL); + const client = { session: { messages: async () => { + process.stdout.write("capturing"); await new Promise((done) => process.stdin.once("data", done)); + return { data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "captured after delay" }] }] }; + } } }; + const hooks = await CanvasTTYLifecycle({ client }); + const event = (type, rest = {}) => hooks.event({ event: { type, properties: { sessionID: "root", ...rest } } }); + await event("session.created", { info: { id: "root" } }); + await event("session.status", { status: { type: "busy" } }); + await event("session.status", { status: { type: "idle" } }); + await event("session.idle"); + `; + const run = await runFakeOpenCode(capability, "unused", true, script, {}, () => { + assert.equal(terminals.getMetadata(childId).status, "working"); + assert.equal(terminals.answer(childId), null); + }); + assert.equal(run.code, 0, run.stderr); + const waited = await handler.execute(orchestrator.id, { id: "w", tool: "wait_for_agent", arguments: { sessionId: childId, timeoutSeconds: 5 } }); + assert.equal(waited.reason, "idle"); + assert.equal(waited.answer.text, "captured after delay"); +}); + +for (const failure of ["error", "timeout"]) test(`a captured OpenCode turn still ends, without an answer, after an SDK read ${failure}`, POSIX, async (t) => { + const { terminals, gateway, childId } = await setup(t); + const accepted = []; + const apply = terminals.applyProviderSignal.bind(terminals); + terminals.applyProviderSignal = (id, signal, ...args) => { accepted.push(signal); return apply(id, signal, ...args); }; + const capability = gateway.registerSession(childId, "opencode", true); + const script = ` + const { CanvasTTYLifecycle } = await import(process.env.PLUGIN_URL); + const client = { session: { messages: async () => { ${failure === "error" ? 'throw new Error("fixture failed");' : "return new Promise(() => {});"} } } }; + const hooks = await CanvasTTYLifecycle({ client }); + const event = (type, rest = {}) => hooks.event({ event: { type, properties: { sessionID: "root", ...rest } } }); + await event("session.created", { info: { id: "root" } }); await event("session.status", { status: { type: "busy" } }); + await event("session.status", { status: { type: "idle" } }); await event("session.idle"); + `; + const run = await runFakeOpenCode(capability, "unused", true, script); + assert.equal(run.code, 0, run.stderr); + assert.equal(accepted.filter((signal) => signal.event === "session.status:idle").length, 0); + assert.equal(accepted.filter((signal) => signal.event === "session.idle").length, 1, "the turn end is still reported"); + assert.equal(terminals.answer(childId), null); + assert.equal(terminals.getMetadata(childId).status, "idle"); +}); + +/** Feeds one runtime signal through the glue main uses, as the gateway would hand it over. */ +const signalFrom = (terminals, id) => (state, event, turnId, answer) => applyRuntimeSignal(terminals, id, + { state, event, turnId, ...(answer ? { result: { text: answer, truncated: false } } : {}) }); + +test("a person's Enter in the middle of a turn still lets that turn end the wait, without an answer", async (t) => { + const { terminals, handler, orchestrator, childId } = await setupTerminals(t); + const signal = signalFrom(terminals, childId); + assert.deepEqual(await terminals.deliverInput(childId, "orchestrator task\r"), { delivered: true }); + signal("working", "session.status:busy", "turn1"); + // The person presses Enter in the subagent's card while it works: the turn's answer is no longer for that prompt. + terminals.input(childId, "\r"); + const waiting = handler.execute(orchestrator.id, { id: "w", tool: "wait_for_agent", arguments: { sessionId: childId, timeoutSeconds: 5 } }); + setTimeout(() => signal("idle", "session.idle", "turn1", "answer to the earlier prompt"), 20); + const waited = await waiting; + assert.equal(waited.reason, "idle", "the wait does not hang until its timeout"); + assert.equal(waited.status, "idle"); + assert.equal(waited.answer, undefined); + assert.equal(waited.answerUnavailable, undefined, "status hooks are on: nothing says the answer cannot be captured"); +}); + +test("an answer that arrives as the agent exits is kept when it belongs to the current turn", async (t) => { + const { calls, terminals, handler, orchestrator, childId } = await setupTerminals(t); + const signal = signalFrom(terminals, childId); + await terminals.deliverInput(childId, "last task\r"); + signal("working", "session.status:busy", "turn1"); + calls.at(-1).process.emitExit(0); + assert.equal(terminals.getMetadata(childId).status, "done"); + signal("idle", "session.idle", "turn1", "final words"); + assert.equal(terminals.getMetadata(childId).status, "done", "the exit stays"); + const result = await handler.execute(orchestrator.id, { id: "r", tool: "get_agent_result", arguments: { sessionId: childId } }); + assert.equal(result.state, "done"); + assert.equal(result.answer?.text, "final words"); + // A late answer of an earlier turn still does not count after the exit. + signal("idle", "session.idle", "turn0", "stale"); + assert.equal(terminals.answer(childId)?.text, "final words"); +}); + +test("an Enter with nothing typed in an idle card keeps the stored answer", async (t) => { + const { terminals, childId } = await setupTerminals(t); + const signal = signalFrom(terminals, childId); + await terminals.deliverInput(childId, "task\r"); + signal("working", "session.status:busy", "turn1"); + signal("idle", "session.idle", "turn1", "kept answer"); + const generation = terminals.sessions.get(childId).inputGeneration; + terminals.input(childId, "\r"); + assert.equal(terminals.answer(childId)?.text, "kept answer", "an empty Enter submits nothing"); + assert.equal(terminals.sessions.get(childId).inputGeneration, generation); + // Typed key by key, as a person types: the Enter after it submits a new task. + for (const key of ["n", "e", "x", "t"]) terminals.input(childId, key); + terminals.input(childId, "\r"); + assert.equal(terminals.answer(childId), null); + assert.equal(terminals.sessions.get(childId).inputGeneration, generation + 1); +}); + +test("every input write goes through writeInput, which keeps the turn and answer bookkeeping", async (t) => { + const { terminals, childId } = await setupTerminals(t); + const signal = signalFrom(terminals, childId); + const writes = []; + const writeInput = terminals.writeInput; + assert.equal(typeof writeInput, "function", "one write path for person and agent input"); + terminals.writeInput = function (...args) { writes.push(args[3]); return writeInput.apply(this, args); }; + await terminals.deliverInput(childId, "task\r"); + terminals.input(childId, "x"); + assert.deepEqual(writes, ["task\r", "x"], "input() and deliverInput write through it"); + terminals.writeInput = writeInput; + signal("working", "session.status:busy", "turn1"); + signal("idle", "session.idle", "turn1", "kept answer"); + // A writer that held input and replays it later (outside input()) uses the same method: its Enter submits a task. + const session = terminals.sessions.get(childId); + const generation = session.inputGeneration; + for (const key of ["n", "e", "x", "t", "\r"]) assert.equal(terminals.writeInput(childId, session, session.process, key), true); + assert.equal(terminals.answer(childId), null, "the replayed prompt drops the earlier answer"); + assert.equal(session.inputGeneration, generation + 1); + // Never into a process the card no longer runs. + assert.equal(terminals.writeInput(childId, session, { write: () => assert.fail("stale process") }, "late\r"), false); + assert.equal(session.inputGeneration, generation + 1); +}); + +for (const [atCreate, beforeSpawn] of [[false, true], [true, false]]) { + test(`a deferred launch takes status hooks as they are when it is planned (${atCreate ? "on" : "off"} at create, ${beforeSpawn ? "on" : "off"} at launch)`, async (t) => { + const root = await realpath(await mkdtemp(join(tmpdir(), "ctty-ocr-deferred-"))); + t.after(() => rm(root, { recursive: true, force: true })); + let release; + const prepared = new Promise((resolve) => { release = resolve; }); + const pipeline = new LaunchPipeline({ + contributors: () => [{ pluginId: "fixture.hold", pluginName: "Hold", serviceId: "launch", secrets: false, + launch: { fields: [{ key: "on", label: "On", kind: "boolean", default: true }] } }], + call: async () => { await prepared; return {}; }, + secret: async () => null, + runsRoot: join(root, "runs"), + timeoutMs: 4_000 + }); + const calls = []; + const terminals = new TerminalManager(() => undefined, availableRegistry(), undefined, undefined, atCreate, fakeSpawner(calls)); + t.after(() => { release(); terminals.disposeAll(); }); + terminals.configureLaunchPipeline(pipeline); + const card = terminals.create({ provider: "opencode", cwd: root, profile: "normal", position: { x: 0, y: 0 }, + launchOptions: { "fixture.hold": { on: true } } }, { captureResult: true }); + terminals.setLifecycleHooksEnabled(beforeSpawn); + release(); + const deadline = Date.now() + 4_000; + while (calls.length === 0 && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 5)); + assert.equal(calls.length, 1, "the launch spawned"); + assert.equal(terminals.answerUnavailable(card.id), beforeSpawn ? null : "status_hooks_off"); + assert.equal(terminals.applyProviderSignal(card.id, { kind: "lifecycle", state: "working", event: "session.status:busy", requestId: "t1" }), beforeSpawn); + }); +} diff --git a/tests/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"); +}); diff --git a/tests/orchestration-gateway.test.mjs b/tests/orchestration-gateway.test.mjs index e2f0a524a..ace1c16c2 100644 --- a/tests/orchestration-gateway.test.mjs +++ b/tests/orchestration-gateway.test.mjs @@ -370,6 +370,7 @@ test("a spawn_agent canceled while it was starting closes the agent it created", const control = { status: () => ({ role: "orchestrator", provider: "codex" }), profileFor: () => ({ profile: "normal", inherited: true }), + subagentLaunchOptions: (_parent, _provider, launchOptions) => ({ launchOptions, sources: {} }), spawn: async () => { controller.abort(); return { id: "child-1", provider: "codex", status: "running", title: "worker" }; diff --git a/tests/orchestration-providers-wait.test.mjs b/tests/orchestration-providers-wait.test.mjs index ebe189141..dbeb0c662 100644 --- a/tests/orchestration-providers-wait.test.mjs +++ b/tests/orchestration-providers-wait.test.mjs @@ -2,6 +2,8 @@ import assert from "node:assert/strict"; import test from "node:test"; import { AGENT_PROVIDER_IDS, + CLIENT_SAFE_AGENT_WAIT_SECONDS, + DEFAULT_AGENT_WAIT_SECONDS, MAX_AGENT_WAIT_SECONDS, ORCHESTRATION_TOOL_DEFINITIONS, ORCHESTRATION_TOOL_NAMES, @@ -22,10 +24,14 @@ test("the catalog lists list_providers first and wait_for_agent with a bounded t assert.deepEqual(definition("list_providers").inputSchema, { type: "object", properties: {}, required: [], additionalProperties: false }); const wait = definition("wait_for_agent").inputSchema; assert.deepEqual(wait.required, ["sessionId"]); - assert.equal(wait.properties.timeoutSeconds.maximum, 600); - assert.equal(MAX_AGENT_WAIT_SECONDS, 600); - assert.equal(validateOrchestrationArguments("wait_for_agent", { sessionId: "a", timeoutSeconds: 600 }).ok, true); - assert.match(validateOrchestrationArguments("wait_for_agent", { sessionId: "a", timeoutSeconds: 601 }).error, /above the maximum/u); + assert.equal(wait.properties.timeoutSeconds.maximum, 100); + assert.equal(MAX_AGENT_WAIT_SECONDS, 100); + assert.ok(DEFAULT_AGENT_WAIT_SECONDS <= CLIENT_SAFE_AGENT_WAIT_SECONDS && CLIENT_SAFE_AGENT_WAIT_SECONDS < 60, + "a call without timeoutSeconds, and every call from a 60-second MCP client, ends before that client gives up"); + assert.equal(validateOrchestrationArguments("wait_for_agent", { sessionId: "a", timeoutSeconds: 100 }).ok, true); + assert.match(validateOrchestrationArguments("wait_for_agent", { sessionId: "a", timeoutSeconds: 101 }).error, /above the maximum/u); + assert.match(definition("wait_for_agent").description, /at most 100; unless this session is Claude Code, a call returns after at most 50 seconds/u); + assert.match(definition("wait_for_agent").description, /A timeout means it is still running: read output as its progress and call wait_for_agent again/u); assert.equal(validateOrchestrationArguments("list_providers", {}).ok, true); assert.match(validateOrchestrationArguments("list_providers", { x: 1 }).error, /Unexpected argument/u); assert.match(definition("list_providers").description, /Never search the filesystem/u); @@ -217,7 +223,7 @@ test("wait_for_agent stops at once when the call is canceled", async () => { const controller = new AbortController(); const started = Date.now(); setTimeout(() => controller.abort(), 20); - await assert.rejects(call(handler, "wait_for_agent", { sessionId: "child", timeoutSeconds: 600 }, controller.signal), + await assert.rejects(call(handler, "wait_for_agent", { sessionId: "child", timeoutSeconds: 100 }, controller.signal), (error) => error.bridgeError?.code === "CANCELED"); assert.ok(Date.now() - started < 1_000); const aborted = new AbortController(); @@ -261,7 +267,7 @@ test("after a prompt, an idle before the turn starts does not end wait_for_agent const { tmpdir } = await import("node:os"); const { join } = await import("node:path"); const { TerminalManager } = await import("../src/main/services/TerminalManager.ts"); - const { availableRegistry, fakeSpawner } = await import("./helpers/terminal.mjs"); + const { availableRegistry, fakeSpawner, openCodeInputReady } = await import("./helpers/terminal.mjs"); const root = await realpath(await mkdtemp(join(tmpdir(), "ctty-turn-"))); t.after(() => rm(root, { recursive: true, force: true })); const calls = []; @@ -277,7 +283,9 @@ test("after a prompt, an idle before the turn starts does not end wait_for_agent terminals.applyProviderSignal(idleChild.sessionId, { kind: "lifecycle", state: "idle" }); assert.equal((await handler.execute(orchestrator.id, { id: "i", tool: "wait_for_agent", arguments: { sessionId: idleChild.sessionId } })).reason, "idle"); - const child = await handler.execute(orchestrator.id, { id: "1", tool: "spawn_agent", arguments: { provider: "opencode", cwd: root, prompt: "do part A" } }); + const spawning = handler.execute(orchestrator.id, { id: "1", tool: "spawn_agent", arguments: { provider: "opencode", cwd: root, prompt: "do part A" } }); + await openCodeInputReady(calls, 2); + const child = await spawning; // The CLI's startup idle arrives after the prompt was typed but before its turn starts. terminals.applyProviderSignal(child.sessionId, { kind: "lifecycle", state: "idle" }); const early = await wait(1); @@ -303,3 +311,188 @@ test("the skill tells the orchestrator to pass a model the person names", async const skill = await readFile(new URL("../agent/orchestrator/SKILL.md", import.meta.url), "utf8"); assert.match(skill, /If the person names a model, pass it as `model`/u); }); + +test("wait_for_agent returns before the orchestrator's MCP client gives up, as a timeout that says the agent still runs", async () => { + for (const [provider, requested, expectedMs] of [ + ["opencode", 100, 50_000], ["codex", 100, 50_000], ["opencode", undefined, 50_000], ["opencode", 20, 20_000], ["claude", 100, 100_000] + ]) { + const { terminals, control, handler } = setup(); + terminals.sessions.get("orch").provider = provider; + let received; + const real = control.waitFor.bind(control); + control.waitFor = (id, request) => { received = request.timeoutMs; return real(id, { ...request, timeoutMs: 30 }); }; + terminals.sessions.get("child").buffer = "step 3 of 5"; + const result = await call(handler, "wait_for_agent", { sessionId: "child", ...(requested ? { timeoutSeconds: requested } : {}) }); + assert.equal(received, expectedMs, `${provider} asking for ${requested ?? "the default"}`); + assert.deepEqual([result.reason, result.status, result.output], ["timeout", "working", "step 3 of 5"]); + assert.match(result.message, /Still running\. Read output for its progress and call wait_for_agent again\./u); + } + const { terminals, handler } = setup(); + terminals.sessions.get("child").status = "idle"; + assert.equal((await call(handler, "wait_for_agent", { sessionId: "child" })).message, undefined, "only a timeout says to call again"); +}); + +/** A recorded OpenCode-style session: alternate screen, absolute cursor moves, colors, a spinner and in-place redraws. */ +function openCodeRedraws() { + const e = "\u001b"; + const at = (row, column) => `${e}[${row};${column}H`; + const parts = [ + `${e}[?1049h${e}[?25l${e}[?2004h${e}]0;opencode${e}\\${e}[2J${e}[H`, + `${at(1, 1)}${e}[48;2;24;24;37m${e}[38;2;205;214;244m┃ opencode build gpt-5.5${e}[K${e}[0m`, + `${at(3, 3)}${e}[1mYou${e}[22m`, + `${at(4, 3)}${e}[38;5;252mFix the failing Button test${e}[39m`, + `${at(6, 3)}${e}[38;5;245mThinking${e}[0m` + ]; + // The spinner turns in place many times while the model works. + for (let turn = 0; turn < 120; turn++) parts.push(`${at(6, 12)}${"⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"[turn % 10]}${at(40, 1)}${e}[7m working ${turn}s ${e}[27m${e}[K`); + parts.push( + `${at(6, 1)}${e}[2K${at(6, 3)}${e}[38;2;166;227;161mThe Button test passes now.${e}[0m`, + `${at(7, 3)}Used key sk-secret-value for the request.`, + `${at(8, 3)}${e}[38;5;111m│${e}[0m src/Button.tsx ${e}[32m+3${e}[0m ${e}[31m-1${e}[0m`, + `${at(40, 1)}${e}[2K${e}[7m ctrl+p commands ${e}[27m` + ); + return parts.join(""); +} + +test("wait_for_agent and get_agent_result return a TUI's screen as plain text, not its escape sequences", async () => { + const { terminals, handler } = setup(); + const child = terminals.sessions.get("child"); + setTimeout(() => { + child.buffer = openCodeRedraws(); + child.outputOffset = child.buffer.length; + child.status = "idle"; + }, 20); + const waited = await call(handler, "wait_for_agent", { sessionId: "child", timeoutSeconds: 5 }); + const result = await call(handler, "get_agent_result", { sessionId: "child" }); + for (const output of [waited.output, result.output]) { + assert.equal(/[\u0000-\u0008\u000b-\u001f\u007f]/u.test(output), false, "no control characters or escape sequences"); + assert.deepEqual(output.split("\n"), [ + "┃ opencode build gpt-5.5", + "", + " You", + " Fix the failing Button test", + "", + " The Button test passes now.", + " Used key [redacted] for the request.", + " │ src/Button.tsx +3 -1", + ...Array.from({ length: 31 }, () => ""), + " ctrl+p commands" + ]); + assert.ok(!output.includes("sk-secret") && !output.includes("Thinking") && !output.includes("working"), "masked; only what the screen shows now"); + } +}); + +test("the plain-text output stays bounded and masked for a long line-based transcript", async () => { + const { terminals, control } = setup(); + const child = terminals.sessions.get("child"); + child.buffer = Array.from({ length: 4_000 }, (_value, index) => `\u001b[32mline ${index}\u001b[0m with key sk-secret-value\r\n`).join(""); + child.outputOffset = child.buffer.length; + child.status = "idle"; + const result = await control.result("child"); + assert.ok(result.output.length <= 8_192); + assert.ok(result.output.endsWith("line 3999 with key [redacted]"), result.output.slice(-80)); + assert.ok(!result.output.includes("\u001b") && !result.output.includes("sk-secret")); +}); + +/** Invisible SGR resets that pad a stream to an exact length: any length from 6 up is a sum of 3s and 4s. */ +function sgrPadding(length) { + const e = "\u001b"; + let fours = 0; + while ((length - 4 * fours) % 3 !== 0) fours++; + return `${e}[0m`.repeat(fours) + `${e}[m`.repeat((length - 4 * fours) / 3); +} + +test("a scrollback window that starts inside an escape sequence leaves no fragment of it on the screen", async () => { + const { terminals, handler } = setup(); + const child = terminals.sessions.get("child"); + const e = "\u001b"; + const at = (row, column) => `${e}[${row};${column}H`; + const start = "18;9Hof specialists who had internalized the machine."; + const redraws = []; + for (let turn = 0; turn < 900; turn++) redraws.push(`${at(40, 1)}${e}[7m working ${turn}s ${e}[27m${e}[K`); + const end = `${at(3, 3)}The first response was the assembler.`; + const body = start + redraws.join("") + end; + // The last 65,536 characters (the window the screen is rendered from) begin right after the ESC [ of a cursor move. + child.buffer = `${"earlier output\r\n".repeat(50)}${e}[` + body + sgrPadding(65_536 - body.length); + child.outputOffset = child.buffer.length; + child.status = "idle"; + const waited = await call(handler, "wait_for_agent", { sessionId: "child", timeoutSeconds: 5 }); + const result = await call(handler, "get_agent_result", { sessionId: "child" }); + for (const output of [waited.output, result.output]) { + assert.ok(!output.includes("18;9H"), output.slice(0, 80)); + assert.ok(output.includes(" The first response was the assembler."), output); + } +}); + +test("exitLines are the exited TUI's screen as plain text, not its redraws joined together", async () => { + const { terminals, handler } = setup(); + const child = terminals.sessions.get("child"); + const e = "\u001b"; + const at = (row, column) => `${e}[${row};${column}H`; + // Drawn once at the start and never again: still on the screen when the CLI exits, more than 16 KiB later. + const early = `${at(3, 3)}${e}[2mThinking${e}[0m${at(8, 3)}${e}[38;5;111m▣ Build · glm-5.3-flash${e}[0m`; + const turns = 1_300; + const progress = []; + for (let turn = 0; turn < turns; turn++) progress.push(`${at(2, 3)}${e}[38;2;166;227;161m${"■".repeat(turn % 7)}${"⬝".repeat(7 - (turn % 7))}${e}[0m`); + // A TUI redraws only the cells that changed: here a line written once, then its typos fixed in place. + const body = early + progress.join("") + + `${at(4, 3)}As explicit memoy address, and if a single instructon was` + + `${at(4, 19)}ry address, and if a single instruction was` + + `${at(6, 3)}${e}[31mError: the model stopped answering.${e}[0m`; + assert.ok(body.length > 16_384 && body.length < 65_000, String(body.length)); + // The rendered window (the last 65,536 characters) begins with the `m` of a color sequence whose start was cut off. + child.buffer = `${"x".repeat(100)}${e}[38;2;1;2;3` + "m" + body + sgrPadding(65_536 - 1 - body.length); + child.outputOffset = child.buffer.length; + child.exitCode = 1; + child.status = "failed"; + const waited = await call(handler, "wait_for_agent", { sessionId: "child", timeoutSeconds: 5 }); + const observed = await call(handler, "observe_agent", { sessionId: "child" }); + const result = await call(handler, "get_agent_result", { sessionId: "child" }); + assert.equal(waited.reason, "failed"); + const last = (turns - 1) % 7; + assert.deepEqual(waited.exitLines.split("\n"), [ + ` ${"■".repeat(last)}${"⬝".repeat(7 - last)}`, + " Thinking", + " As explicit memory address, and if a single instruction was", + " Error: the model stopped answering.", + " ▣ Build · glm-5.3-flash" + ]); + assert.equal(observed.exitLines, waited.exitLines); + assert.equal(result.exitLines, waited.exitLines); +}); + +test("observe_agent returns the screen as plain text too, within its maxChars", async () => { + const { terminals, handler } = setup(); + const child = terminals.sessions.get("child"); + child.buffer = openCodeRedraws(); + child.outputOffset = child.buffer.length; + child.status = "working"; + const observed = await call(handler, "observe_agent", { sessionId: "child" }); + assert.equal(/[\u0000-\u0008\u000b-\u001f\u007f]/u.test(observed.output), false, "no ESC or other control bytes"); + assert.ok(observed.output.includes(" The Button test passes now.") && observed.output.includes("[redacted]")); + assert.ok(!observed.output.includes("sk-secret") && !observed.output.includes("working")); + const short = await call(handler, "observe_agent", { sessionId: "child", maxChars: 256 }); + assert.ok(short.output.length <= 256 && short.output.endsWith(" ctrl+p commands"), short.output); +}); + +test("a TUI that left its full screen as it exited keeps its last screen in output and exitLines", async () => { + const { terminals, handler } = setup(); + const child = terminals.sessions.get("child"); + const e = "\u001b"; + const at = (row, column) => `${e}[${row};${column}H`; + // A full-screen CLI shows why it stops on its own screen, then leaves the alternate screen as every TUI does on exit. + child.buffer = `${e}[?1049h${e}[2J${at(1, 1)}┃ opencode build glm-5.3-flash${at(6, 3)}${e}[31mError: Unexpected server error${e}[0m` + + `${e}[?25h${e}[?1049l` + "Session ended.\r\n"; + child.outputOffset = child.buffer.length; + child.exitCode = 1; + child.status = "failed"; + const waited = await call(handler, "wait_for_agent", { sessionId: "child", timeoutSeconds: 5 }); + const result = await call(handler, "get_agent_result", { sessionId: "child" }); + for (const answer of [waited, result]) { + assert.match(answer.exitLines, /Error: Unexpected server error/u); + assert.match(answer.exitLines, /Session ended\./u); + assert.ok(answer.exitLines.indexOf("Error: Unexpected") < answer.exitLines.indexOf("Session ended."), answer.exitLines); + assert.match(answer.output, /Error: Unexpected server error/u); + assert.equal(/[\u0000-\u0008\u000b-\u001f\u007f]/u.test(answer.output), false); + } +}); 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`); + } +}); diff --git a/tests/permission-gate-fail-closed.test.mjs b/tests/permission-gate-fail-closed.test.mjs index a786939de..d4d57c576 100644 --- a/tests/permission-gate-fail-closed.test.mjs +++ b/tests/permission-gate-fail-closed.test.mjs @@ -14,7 +14,7 @@ import { join } from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; import { - AGENT_RUNTIME_ENV, DECISION_FAIL_CLOSED_ENV, OPENCODE_DECISIONS_ENV, RUNTIME_PROTOCOL_VERSION, permissionGateTimings + AGENT_RUNTIME_ENV, DECISION_FAIL_CLOSED_ENV, OPENCODE_DECISIONS_ENV, OPENCODE_DECISION_READS_ENV, RUNTIME_PROTOCOL_VERSION, permissionGateTimings } from "../src/agent-runtime/runtime-protocol.mjs"; import { FAIL_CLOSED_MESSAGE, parseDecision } from "../src/agent-runtime/permission-gate.mjs"; import { createOpenCodeDecisions } from "../src/agent-runtime/opencode-decisions.mjs"; @@ -262,6 +262,18 @@ test("launch: the decision hook carries the fail-closed flag; a launch without i assert.equal(launch.decisions, false); assert.equal(JSON.stringify(launch.args).includes(DECISION_FAIL_CLOSED_ENV), false); assert.equal(JSON.stringify(launch.args).includes("permission-gate"), false); + // Base protection off, a decision plugin applies: the fail-closed hook covers shells and writes, never reads. + const pluginOnly = new DecisionHooks({ baseProtection: () => false, services: () => [{ pluginId: "p", pluginName: "P", serviceId: "s", mayAllow: false }], + call: async () => null, session: () => null }); + const pluginBridge = new AgentRuntimeBridge(gateway, { ...options, coreHooksEnabled: true, wantsDecisions: (provider) => pluginOnly.wanted(provider), + decisionReads: () => pluginOnly.checksReads() }); + const pluginLaunch = pluginBridge.prepareLaunch({ terminalSessionId: "b", provider: "claude", cwd: root }); + assert.equal(pluginLaunch.decisions, true); + assert.equal(JSON.parse(pluginLaunch.args[1]).hooks.PreToolUse[0].matcher, "Bash|Write|Edit|MultiEdit|NotebookEdit"); + const on = new DecisionHooks({ baseProtection: () => true, services: () => [], call: async () => null, session: () => null }); + assert.equal(on.checksReads(), true); + assert.equal(new DecisionHooks({ baseProtection: () => { throw new Error("settings"); }, services: () => [], call: async () => null, session: () => null }).checksReads(), true, + "a settings read that fails counts as on"); }); test("OpenCode: with decisions on, a check that cannot be made fails the tool call; ordinary answers are unchanged", async () => { @@ -282,7 +294,12 @@ test("OpenCode: with decisions on, a check that cannot be made fails the tool ca await createOpenCodeDecisions({ env, send: async () => ({ behavior, message: "", unavailable: false }) }).guard(...call); } // Tools that are not checked are never sent and never fail. - await createOpenCodeDecisions({ env, send: async () => null }).guard({ tool: "read", callID: "c2" }, { args: { filePath: "/etc/hosts" } }); + await createOpenCodeDecisions({ env, send: async () => null }).guard({ tool: "webfetch", callID: "c2" }, { args: { url: "https://example.com" } }); + // Base protection off at launch: a read-only file tool is not checked, so it never fails closed. + await createOpenCodeDecisions({ env, send: async () => null }).guard({ tool: "read", callID: "c3" }, { args: { filePath: "/etc/hosts" } }); + // Base protection on at launch: it is checked (for CanvasTTY's private data), so it fails closed like the others. + await assert.rejects(createOpenCodeDecisions({ env: { ...env, [OPENCODE_DECISION_READS_ENV]: "1" }, send: async () => null }) + .guard({ tool: "read", callID: "c4" }, { args: { filePath: "/etc/hosts" } }), (error) => error.message === FAIL_CLOSED_MESSAGE); }); test("the gate script stays importable without side effects (no socket, no stdin read)", async () => { diff --git a/tests/plugin-launch-choices.test.mjs b/tests/plugin-launch-choices.test.mjs index 884ea5bb6..1f024e90b 100644 --- a/tests/plugin-launch-choices.test.mjs +++ b/tests/plugin-launch-choices.test.mjs @@ -158,7 +158,9 @@ test("spawn_agent takes plugin launch options and hands them to the launch", () get: (id) => (id === "orch" ? parent : undefined), getMetadata: (id) => (id === "orch" ? parent : null), listMetadata: () => [parent], - create: (request) => { created.push(request); return { id: "child", ...request, status: "starting", title: "c" }; } + create: (request) => { created.push(request); return { id: "child", ...request, status: "starting", title: "c" }; }, + launchOptionsOf: () => undefined, + modelSetByOf: () => [] }; const control = new AgentControlService(terminals); control.spawn({ parentSessionId: "orch", provider: "codex", cwd: process.cwd(), launchOptions: { "canvastty-accounts": { account: "ollama" } } }); diff --git a/tests/plugin-service-notice.test.mjs b/tests/plugin-service-notice.test.mjs new file mode 100644 index 000000000..f5adc3872 --- /dev/null +++ b/tests/plugin-service-notice.test.mjs @@ -0,0 +1,206 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { build } from "esbuild"; +import { PluginManager } from "../src/main/services/PluginManager.ts"; +import { PluginServiceSupervisor } from "../src/main/services/PluginServiceSupervisor.ts"; +import { + PLUGIN_NATIVE_CODE_OFF_ERROR, + PLUGIN_SERVICE_MODULE_OFF_ERROR, + pluginServiceNoticeKind, + pluginServiceNoticeSection, + pluginServiceNoticeText +} from "../src/shared/pluginServiceNotice.ts"; + +// A plugin page whose service cannot run because the person has not turned its native code (or its module) on gets +// an actionable notice instead of "Plugin service is not running."; a trusted service that crashed keeps the generic +// text, now with its last error. +const root = fileURLToPath(new URL("..", import.meta.url)); +const example = new URL("../examples/plugins/service-echo/", import.meta.url); +const exampleManifest = JSON.parse(await readFile(new URL("canvastty.plugin.json", example), "utf8")); +const sha256 = (content) => createHash("sha256").update(content).digest("hex"); +const source = (path) => readFile(new URL(`../${path}`, import.meta.url), "utf8"); + +function supervisor(manager, options = {}) { + return new PluginServiceSupervisor({ + command: process.execPath, + hostVersion: "9.9.9", + locale: () => "en", + requestTimeoutMs: 2_000, + stopGraceMs: 300, + restartDelaysMs: [30], + host: { + storageGet: (pluginId, key) => manager.storageGet(pluginId, key), + storageSet: (pluginId, key, value) => manager.storageSet(pluginId, key, value), + emit: () => undefined + }, + ...options + }); +} + +/** What registerIpc's requestPluginService does: the manager's check, then the supervisor. */ +async function pageRequest(manager, instance, pluginId, serviceId, method, params) { + manager.assertService(pluginId, serviceId); + return instance.request(pluginId, serviceId, method, params); +} + +test("native code off: the page's request names the switch, and the notice is localized with a way to Agents", async (t) => { + const userData = await mkdtemp(join(tmpdir(), "canvastty-service-notice-")); + const manager = new PluginManager(userData, async (_url, destination) => { await cp(example, destination, { recursive: true }); }); + const instance = supervisor(manager); + manager.setServiceObserver((specs) => instance.sync(specs)); + t.after(async () => { + await instance.dispose(); + await manager.dispose(); + await rm(userData, { recursive: true, force: true }); + }); + await manager.load(); + const { manifest } = await manager.install((await manager.previewInstall("https://github.com/example/service-echo")).token); + + await assert.rejects(pageRequest(manager, instance, manifest.id, "echo", "echo", { text: "x" }), (error) => { + assert.equal(error.message, PLUGIN_NATIVE_CODE_OFF_ERROR); + return true; + }); + const kind = pluginServiceNoticeKind(PLUGIN_NATIVE_CODE_OFF_ERROR); + assert.equal(kind, "native-code-off"); + assert.equal(pluginServiceNoticeSection(kind), "agents"); + assert.equal( + pluginServiceNoticeText(kind, "ru"), + "Нативный код этого плагина выключен — включите его в Настройки → Агенты → Нативный код расширений." + ); + assert.match(pluginServiceNoticeText(kind, "en"), /native code is turned off — turn it on in Settings → Agents → Extension native code/u); + assert.equal(pluginServiceNoticeText("open", "ru"), "Открыть настройки"); + assert.equal(pluginServiceNoticeText("open", "en"), "Open settings"); + // The renderer recognizes its own localized text again (the page reports it, the host shows the notice). + assert.equal(pluginServiceNoticeKind(pluginServiceNoticeText(kind, "ru")), "native-code-off"); + assert.equal(pluginServiceNoticeKind(pluginServiceNoticeText(kind, "en")), "native-code-off"); + + // Trusted: the same request reaches the service. + await manager.setNativeCodeTrusted(manifest.id, true); + assert.equal((await pageRequest(manager, instance, manifest.id, "echo", "echo", { text: "x" })).count, 1); + // Disabling revokes the trust; the disabled plugin itself is refused as before. + await manager.setEnabled(manifest.id, false); + await manager.setEnabled(manifest.id, true); + assert.throws(() => manager.assertService(manifest.id, "echo"), (error) => error.message === PLUGIN_NATIVE_CODE_OFF_ERROR); + assert.throws(() => manager.assertService(manifest.id, "other"), /does not exist/u); +}); + +test("a service whose module is off says so and points to Plugins", async (t) => { + const userData = await mkdtemp(join(tmpdir(), "canvastty-service-notice-modules-")); + const files = { + "apps/echo.html": await readFile(new URL("apps/echo.html", example)), + "services/echo.mjs": await readFile(new URL("services/echo.mjs", example)), + "services/extra.mjs": Buffer.from("process.stdin.resume();\n") + }; + const asset = (path) => ({ path, bytes: files[path].length, sha256: sha256(files[path]) }); + const manifest = { + ...exampleManifest, + coreFiles: [asset("apps/echo.html"), asset("services/echo.mjs")], + modules: [{ id: "extra", title: "Extra", defaultSelected: false, permissions: [], files: [asset("services/extra.mjs")] }], + services: [...exampleManifest.services, { id: "extra", title: "Extra", entry: "services/extra.mjs", module: "extra" }] + }; + const manager = new PluginManager( + userData, + async (_url, destination) => { + await mkdir(destination, { recursive: true }); + await writeFile(join(destination, "canvastty.plugin.json"), JSON.stringify(manifest)); + }, + async (_url, destination, requested) => { + for (const file of requested) { + await mkdir(join(destination, file.path, ".."), { recursive: true }); + await writeFile(join(destination, file.path), files[file.path]); + } + } + ); + t.after(async () => { + await manager.dispose(); + await rm(userData, { recursive: true, force: true }); + }); + await manager.load(); + const installed = await manager.install((await manager.previewInstall("https://github.com/example/service-echo")).token, []); + await manager.setNativeCodeTrusted(installed.manifest.id, true); + manager.assertService(installed.manifest.id, "echo"); + assert.throws(() => manager.assertService(installed.manifest.id, "extra"), (error) => error.message === PLUGIN_SERVICE_MODULE_OFF_ERROR); + const kind = pluginServiceNoticeKind(PLUGIN_SERVICE_MODULE_OFF_ERROR); + assert.equal(kind, "module-off"); + assert.equal(pluginServiceNoticeSection(kind), "plugins"); + assert.match(pluginServiceNoticeText(kind, "ru"), /Модуль этого плагина с нативным кодом выключен — включите его в Настройки → Плагины/u); +}); + +test("a trusted service that crashed keeps the generic text, with its last error, and no settings notice", async (t) => { + const dir = await mkdtemp(join(tmpdir(), "canvastty-service-notice-crash-")); + const instance = supervisor({ storageGet: async () => null, storageSet: async () => undefined }, { maxRestarts: 0 }); + t.after(async () => { await instance.dispose(); await rm(dir, { recursive: true, force: true }); }); + const entry = [ + 'import { createInterface } from "node:readline";', + 'createInterface({ input: process.stdin }).on("line", (line) => {', + ' const m = JSON.parse(line);', + ' if (m.method === "crash") process.exit(3);', + ' if (m.method === "ping") process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: m.id, result: "pong" }) + "\\n");', + "});" + ].join("\n"); + const entryPath = join(dir, "probe.mjs"); + await writeFile(entryPath, entry); + await instance.sync([{ pluginId: "com.example.a", serviceId: "probe", root: dir, entryPath, sha256: sha256(entry), dataDir: join(dir, "data"), permissions: [] }]); + assert.equal(await instance.request("com.example.a", "probe", "ping", null), "pong"); + await assert.rejects(instance.request("com.example.a", "probe", "crash", null), /stopped/u); + assert.equal(instance.report("com.example.a").services[0].state, "failed"); + await assert.rejects(instance.request("com.example.a", "probe", "ping", null), (error) => { + assert.match(error.message, /^Plugin service is not running\. Last error: The service stopped unexpectedly \(exit code 3\) too often/u); + assert.equal(pluginServiceNoticeKind(error.message), null, "a real crash is not a settings notice"); + return true; + }); + // A service the supervisor never ran (no record) keeps the plain text. + await assert.rejects(instance.request("com.example.b", "probe", "ping", null), (error) => error.message === "Plugin service is not running."); +}); + +test("the toast shows the notice's button only when it has one", async (t) => { + const outdir = await mkdtemp(join(tmpdir(), "canvastty-toast-")); + t.after(() => rm(outdir, { recursive: true, force: true })); + await build({ + stdin: { + contents: [ + 'export { Toast } from "./src/renderer/src/components/Toast";', + 'export { createElement } from "react";', + 'export { renderToStaticMarkup } from "react-dom/server";' + ].join("\n"), + resolveDir: root, + loader: "ts" + }, + jsx: "automatic", bundle: true, platform: "node", format: "cjs", outfile: join(outdir, "toast.cjs"), logLevel: "silent", + define: { "process.env.NODE_ENV": '"production"' } + }); + const { Toast, createElement, renderToStaticMarkup } = createRequire(import.meta.url)(join(outdir, "toast.cjs")); + const plain = renderToStaticMarkup(createElement(Toast, { message: "Saved" })); + assert.match(plain, /class="toast toast--visible"/u); + assert.doesNotMatch(plain, /