diff --git a/README.md b/README.md index 5efdb879..9fc3b7ac 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Launch a shell or agent in a project directory, move and resize its live termina Agents start in **Auto** by default: the CLI's own auto mode (Claude Code, Codex, Grok), CanvasTTY's per-run rules for OpenCode, or, for a CLI without one, its approval bypass only inside agent isolation. Manual, Accept edits, Plan and Bypass are offered only where the CLI has them; Bypass is acknowledged once per CLI and never handed to a subagent. Around every mode sit layers the person controls in Settings → Agents: base protection (hooks that refuse elevation, `curl | sh`, disk commands and writes outside the project before the tool call runs), delegation rules (a subagent never gets more than its orchestrator and stays in its project; 2 levels and 8 live subagents by default), and an OS isolation layer (macOS `sandbox-exec`, Linux bubblewrap; none on Windows yet, where subagents run in Manual). When an isolated session ends, CanvasTTY checks the repositories it touched for git settings that would run programs outside isolation and offers to neutralize them. -An **Orchestrator** session gets the `canvastty_agents` tools: `list_providers` (installed agents, sign-in state, models, efforts, profiles), `spawn_agent` with optional `model`, `effort` and `profile`, `wait_for_agent` (up to 600 s) and `get_agent_result`, which returns a Codex or OpenCode subagent's final reply as `answer` (up to 4,096 characters, masked). See [agent orchestration and isolation](docs/agent-orchestration.md) and [protection layers](docs/installing-and-security.md#agent-protection-layers). +An **Orchestrator** session gets the `canvastty_agents` tools: `list_providers` (installed agents, sign-in state, models, efforts, profiles), `spawn_agent` with optional `model`, `effort` and `profile`, `wait_for_agent` (up to 100 s per call, capped at 50 s for OpenCode) and `get_agent_result`, which returns a Codex or OpenCode subagent's final reply as `answer` (up to 4,096 characters, masked). See [agent orchestration and isolation](docs/agent-orchestration.md) and [protection layers](docs/installing-and-security.md#agent-protection-layers). ## Windows shells and provider CLIs diff --git a/agent/orchestrator/SKILL.md b/agent/orchestrator/SKILL.md index 1b68bdb2..6b7e117b 100644 --- a/agent/orchestrator/SKILL.md +++ b/agent/orchestrator/SKILL.md @@ -11,7 +11,7 @@ A session started from CanvasTTY's launcher with the **Orchestrator** role has t 1. `list_providers`: which agents CanvasTTY can launch here. Each entry's `id` is the exact `spawn_agent.provider` value; prefer entries that are `available` with `signIn: "ok"` (`unknown` only means CanvasTTY has not checked yet). When plugins offer launch options (for example an accounts plugin's `list_routes` tool), the answer names that tool. 2. `spawn_agent` once per part of the task: a known `provider` id, an absolute `cwd` and a self-contained prompt. An unknown provider is refused with a pointer back to `list_providers`. **If the person names a model, pass it as `model`** in the format `list_providers` gives for that provider (`model.format`; OpenCode takes `provider/model`, for example `zai-coding-plan/glm-5.3-flash`, and `model.known` lists what `opencode models` reported); without `model` the CLI uses its own default. `effort` sets the reasoning effort where the CLI has one (`efforts`). Without `profile` the subagent gets your own launch profile, or the next lower one its CLI has (never more than yours, never YOLO); `list_providers` shows each provider's `profiles`. `cwd` must be this project's folder or a folder inside it. A refusal says why (a folder outside the project, a profile above yours, a limit the person set): change the request, do not retry it. -3. `wait_for_agent` for each subagent (`timeoutSeconds` up to 600; call it again after `timeout`) instead of polling. `needs_approval` means the person must answer a prompt in that card: never answer it yourself. +3. `wait_for_agent` for each subagent (`timeoutSeconds` up to 100; OpenCode calls return within 50 seconds; call it again after `timeout`) instead of polling. `needs_approval` means the person must answer a prompt in that card: never answer it yourself. 4. `get_agent_result` to read what the subagent produced; check the actual files before reporting success. Do not explore the filesystem, `PATH` or config folders (for example `~/.local/bin` or `~/.config/`) looking for agents or their configuration: `list_providers` is what CanvasTTY can launch, and delegation goes through `spawn_agent`. diff --git a/docs/agent-orchestration.md b/docs/agent-orchestration.md index 0e8b6e3f..bed8bec6 100644 --- a/docs/agent-orchestration.md +++ b/docs/agent-orchestration.md @@ -10,8 +10,8 @@ A session launched from the desktop with the **Orchestrator** role gets the `can | --- | --- | | `list_providers` | The agent providers CanvasTTY can launch as subagents: `id` (the exact `spawn_agent.provider` value), `name`, `installed`/`available` (from the provider CLI registry resolved at startup or on a recheck), `signIn` (`ok`, `signed_out`, `expired` or `unknown`, from the last usage-limits read; it never starts a read and never reads credentials), `subagent` and `orchestrator` support, `model` (whether the CLI takes one, its format, and for OpenCode the models a cached `opencode models` listing reported: run in the background with a 5 s timeout, refreshed after 10 minutes, never awaited) and `efforts`, and plugin launch options (`launchOptions`, plus `launchOptionTools` such as an accounts plugin's `list_routes`). Fast: cached state only, no network calls. | | `spawn_agent` | Launches a subagent (profile: see below). `provider` must be a `list_providers` id (the schema lists them); an unknown one is refused with `INVALID_REQUEST` naming `list_providers`. Optional `model` goes to the CLI's own `--model` for that run (OpenCode `provider/model`; Codex, Claude, Qwen, Kimi alias, Grok, OMP, Pi, Cursor; Hermes, MiniMax, Devin and Antigravity take none) and optional `effort` to its reasoning effort (Codex `-c model_reasoning_effort`, Claude `--effort`, Grok `--reasoning-effort`). Both are checked per provider, kept on restart and restore, and never written to the CLI's configuration. If the person names a model, the orchestrator passes it as `model`. 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. | +| `wait_for_agent` | `{ sessionId, timeoutSeconds ≤ 100 }` (default 55; OpenCode caps each call at 50 seconds). A timeout returns progress while the agent continues; call again to keep waiting. Pending reviews do not extend this deadline. 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. An unfinished review returns `review.status: "pending"` immediately; call `get_agent_result` later for its outcome. `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. | ### Subagent profiles diff --git a/docs/canvastty-plugin.schema.json b/docs/canvastty-plugin.schema.json index 72f2e886..7a8464d5 100644 --- a/docs/canvastty-plugin.schema.json +++ b/docs/canvastty-plugin.schema.json @@ -149,7 +149,7 @@ }, "cardActions": { "description": "Card actions (needs cards:decorate): items in the options menu of matching cards; the host calls canvastty.cards.invoke and shows the answer's message as a toast.", - "type": "array", "minItems": 1, "maxItems": 8, + "type": "array", "minItems": 1, "maxItems": 16, "items": { "$ref": "#/$defs/cardAction" } }, "browserEngine": { "$ref": "#/$defs/browserEngine" } diff --git a/docs/installing-and-security.md b/docs/installing-and-security.md index c8148025..b7962548 100644 --- a/docs/installing-and-security.md +++ b/docs/installing-and-security.md @@ -89,10 +89,18 @@ Agents work in their own mode; CanvasTTY's layers sit outside it as a quiet safe | 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. | | 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. | +| 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. The layer blocks foreign processes, apps, Apple events, preference writes and launchd jobs. macOS blocks foreign Unix sockets; Linux hides the user runtime folder. Strict network modes also use Landlock to deny external Unix sockets except the exact runtime, orchestration and authorized proxy endpoints; local CLI sockets created inside the sandbox remain usable. A validated plugin worktree hides its private `plugin-data` parent and sibling worktrees while keeping its own files editable. The original task checkout remains readable and unwritable. Shared Git metadata stays read-only under isolation so an agent cannot change shared objects, references or index data; `git add` and `git commit` are unavailable inside the sandbox. The host environment collects file edits into a review for the user to accept. Plan has no Git metadata writes. Fails closed. | Network is unrestricted in open mode. Strict Linux network modes require the packaged native helper and Landlock ABI 9 or newer, enabled by the kernel. Unsupported kernels refuse strict launches with a reason; open mode retains the existing bubblewrap behavior. The CLI's own sandbox cannot run inside the layer on macOS, so it is switched off there (approvals unchanged). 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. | +| Network policy | Per project: open, allowed domains (optionally provider APIs and package registries) or offline, applied when an agent starts and shown on its card. The policy lives in CanvasTTY's private data, so a project file or plugin cannot widen it; an extra HTTPS API domain is allowed only by the person in Settings. | It applies from the next agent start. Strict modes need the macOS sandbox, or on Linux the packaged helper and Landlock ABI 9; elsewhere a strict launch is refused. Plain SSH sessions are not covered. | +| Provider keys on request | An agent asks for one configured provider key with `request_secret`; the person allows it for 10 minutes, one turn or the session, and `run_secret_request` sends typed HTTPS requests with it. The key itself is never given to the agent, and a grant can be revoked in the card's details. | What the provider's API does with the requests the person approved. | + +One-turn provider-key approval is available only when the current launch installed reliable turn-completion hooks and the host has observed its active turn. It is unavailable with lifecycle hooks disabled and for providers without a per-turn completion event (including the current Hermes integration). A new submitted task, hook disable, launch replacement or turn completion revokes the turn grant; enabling hooks later requires a new launch. Ten-minute and session approvals keep their separate lifetimes. + +Allowed network domains include the root and dot-separated subdomains (`example.com` permits `api.example.com`, not `notexample.com` or `example.com.attacker.test`). An explicit `*.example.com` permits descendants only. DNS resolution must still produce a public address before the proxy connects. 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`. +For the existing Accounts plugin, the host derives one transient exception from its own plugin data directory and the exact selected account ID. Only that provider's `plugin-data/canvastty-accounts/homes/-` subtree is reopened. The final sandbox wrapper rechecks every directory below userData for symlinks and canonical containment; the plugin cannot return an exception list. Parent data, sibling accounts and worktrees remain hidden, and the CLI's approval configuration remains write-protected. + OpenCode Auto inspects only regular local config files of at most 1 MiB. An existing source that cannot be inspected adds no Auto allowances. A launch contributor's uninspectable config is refused; only absent optional directory candidates are skipped. JSONC comments and trailing commas are parsed without changing quoted strings. **Git audit.** When an isolated agent session ends, is closed or is restored after a quit, CanvasTTY checks the repositories under its folder whose git folder changed. If git would now run something outside isolation (a `core.hooksPath`, a filter or diff driver, `fsmonitor`, a hook file, `info/attributes`), a notice lists exactly what changed; **Neutralize** removes those keys and disables those files, **Keep as is** leaves them. It does not undo other file changes inside the project. diff --git a/docs/installing-and-security.ru.md b/docs/installing-and-security.ru.md index 0532080d..009d30be 100644 --- a/docs/installing-and-security.ru.md +++ b/docs/installing-and-security.ru.md @@ -85,7 +85,9 @@ gh variable set SPARKLE_PUBLIC_ED_KEY --repo howdeploy/CanvasTTY \ - **Режимы запуска**: Авто (по умолчанию), Вручную, Правки, План, Обход — только там, где их поддерживает CLI. Обход выбирает человек для каждого CLI, он проверяется в main-процессе, никогда не даётся субагенту и всё равно остаётся внутри слоёв ниже. - **Базовая защита** (хуки) запрещает повышение прав, `curl | sh`, дисковые команды и запись вне проекта у агентов с хуками; это проверка команд, а не песочница. - **Правила делегирования**: субагент получает не больше оркестратора (никогда YOLO), работает только в папке проекта, в пределах глубины и числа, заданных человеком; агенты не могут менять настройки, защиту, профили и изоляцию. -- **Изоляция агентов** (macOS — sandbox-exec, Linux — bubblewrap): запись только в проект, свою временную папку и папки своего CLI; ключи и токены недоступны; другие процессы, приложения и демоны недоступны; при сбое запуск отклоняется. Сеть не ограничивается; на Windows слоя пока нет, и субагенты там работают вручную. Подробности — в [agent-orchestration.md](agent-orchestration.md). +- **Изоляция агентов** (macOS — sandbox-exec, Linux — bubblewrap): запись только в проект, свою временную папку и папки своего CLI; ключи и токены недоступны; другие процессы, приложения и демоны недоступны; при сбое запуск отклоняется. Без сетевой политики сеть не ограничивается; на Windows слоя пока нет, и субагенты там работают вручную. Подробности — в [agent-orchestration.md](agent-orchestration.md). +- **Сетевая политика** проекта: открыта, разрешённые домены (по выбору — API провайдеров и реестры пакетов) или без сети; применяется при следующем запуске агента и видна на карточке. Политика хранится в приватных данных CanvasTTY, проектный файл или плагин не могут её расширить. Строгим режимам нужна песочница macOS или, на Linux, встроенный помощник и Landlock ABI 9; иначе строгий запуск отклоняется. Обычные SSH-сессии не покрываются. +- **Ключи по запросу**: агент просит один настроенный ключ провайдера через `request_secret`, человек разрешает его на 10 минут, ход или сессию, а `run_secret_request` отправляет с ним типизированные HTTPS-запросы. Сам ключ агенту не передаётся; разрешение можно отозвать в подробностях карточки. **Аудит git.** Когда изолированная сессия агента заканчивается, закрывается или восстанавливается после выхода, CanvasTTY проверяет репозитории в её папке, у которых изменилась папка git. Если git теперь запустит что-то вне изоляции (`core.hooksPath`, фильтр или diff-драйвер, `fsmonitor`, файл хука, `info/attributes`), появляется уведомление со списком изменений; **Обезвредить** удаляет эти ключи и отключает файлы, **Оставить как есть** ничего не трогает. Другие изменения файлов в проекте аудит не откатывает. diff --git a/docs/installing-and-security.zh-CN.md b/docs/installing-and-security.zh-CN.md index 605deda0..3cf3cd37 100644 --- a/docs/installing-and-security.zh-CN.md +++ b/docs/installing-and-security.zh-CN.md @@ -85,7 +85,9 @@ gh variable set SPARKLE_PUBLIC_ED_KEY --repo howdeploy/CanvasTTY \ - **启动模式**:自动(默认)、手动、接受编辑、计划、绕过,仅在 CLI 支持时提供。绕过由用户按 CLI 确认,在主进程中检查,绝不交给子智能体,并且仍处于下列各层之内。 - **基础保护**(hooks)为有 hooks 的智能体拒绝提权、`curl | sh`、磁盘命令以及项目外的写入;它检查命令,不是沙箱。 - **委派规则**:子智能体的权限不超过其编排者(绝不为 YOLO),只在项目文件夹内工作,并受用户设定的深度和数量限制;智能体无法更改设置、防护、配置档或隔离。 -- **智能体隔离**(macOS 使用 sandbox-exec,Linux 使用 bubblewrap):只能写入项目、自身临时目录和其 CLI 的目录;密钥和令牌不可读;其他进程、应用和守护进程不可达;无法建立时拒绝启动。网络不受限制;Windows 暂无此层,子智能体在那里以手动模式运行。详见 [agent-orchestration.md](agent-orchestration.md)。 +- **智能体隔离**(macOS 使用 sandbox-exec,Linux 使用 bubblewrap):只能写入项目、自身临时目录和其 CLI 的目录;密钥和令牌不可读;其他进程、应用和守护进程不可达;无法建立时拒绝启动。未设置网络策略时网络不受限制;Windows 暂无此层,子智能体在那里以手动模式运行。详见 [agent-orchestration.md](agent-orchestration.md)。 +- **网络策略**按项目设置:开放、仅允许的域名(可选包括提供方 API 和软件包仓库)或离线;在下次启动智能体时生效,并显示在卡片上。策略保存在 CanvasTTY 的私有数据中,项目文件或插件无法放宽它。严格模式需要 macOS 沙箱,或在 Linux 上需要随附的 helper 和 Landlock ABI 9;否则严格启动会被拒绝。普通 SSH 会话不在此范围内。 +- **按需使用密钥**:智能体通过 `request_secret` 请求一个已配置的提供方密钥,用户授权 10 分钟、一轮或整个会话,随后 `run_secret_request` 用它发送类型化 HTTPS 请求。密钥本身不会交给智能体;授权可在卡片详情中撤销。 **Git 审计。** 隔离的智能体会话结束、被关闭或在退出后恢复时,CanvasTTY 会检查其文件夹下 git 目录发生变化的仓库。如果 git 现在会在隔离外运行某些东西(`core.hooksPath`、filter 或 diff 驱动、`fsmonitor`、hook 文件、`info/attributes`),会出现一条列出具体变化的通知;**Neutralize** 删除这些键并停用这些文件,**Keep as is** 保持不变。它不会撤销项目内的其他文件改动。 diff --git a/docs/plugins.md b/docs/plugins.md index 0047ad17..cbf86aab 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -255,6 +255,10 @@ An environment is where a card runs: a git worktree, a container, a remote host. `keeps` declares what of CanvasTTY's protection reaches the agent there: `launch` (the launch's arguments and environment reach the agent unchanged, so CanvasTTY's hooks and the profile's per-run settings work), `isolated` (the agent does not run on this computer's files: a container or a remote host) and `confines` (the environment itself confines the agent to the project). Undeclared means no: any profile but normal is refused without `launch`, and the card says that base protection does not reach the agent there. An `isolated` environment is not wrapped in CanvasTTY's agent isolation again (the card names the environment's own boundary); any other runs inside it, with the environment's folder as the project. +Checkpoint restoration also refuses repositories when the current index or saved checkpoint contains submodules: parent-repository snapshots do not preserve dirty or untracked data inside nested repositories. Safeguard and restore that state with Git separately; CanvasTTY does not recursively delete or reset submodules. + +Checkpoint restoration cannot suspend an isolated agent by pausing its local SSH/container wrapper. CanvasTTY refuses restoration for related isolated launches, including after the wrapper exits; that exit does not establish remote workload termination. The host remembers the isolation declaration and project scope used by successful launches in the saved host-only card record, independently of later plugin changes or app restarts. Legacy placed cards without this evidence, and malformed evidence, are treated as unknown isolation scope and refuse restoration. These guards cover tracked host sessions, not untracked external workloads. + CanvasTTY keeps the card, the PTY, the saved record and the restore order; the service answers five host-only requests (surfaces cannot send them): | Request | Params | Answer | Budget | @@ -338,6 +342,12 @@ A service may offer up to 16 `tools` to agents. They appear in the `canvastty_ag ### Session events and plugin-owned cards (`sessions:*`) +`canvastty.activity` is a separate host-only integration for the enabled, native-code-trusted Assistant service installed from a canonical Assistant repository. `sessions:events` does not grant activity access. Tool equality fingerprints sent to that service are host-secret HMAC values scoped to the card and fingerprint kind; they are not plain hashes of tool input or output. Install provenance is checked on each delivery. +OpenCode tool outcomes use its direct `tool.execute.after` hook and terminal `message.part.updated` tool states, correlated with a root-turn `tool.execute.before` call. Duplicate, child-session and obsolete completions are ignored. Shell nonzero exit codes are errors; missing exit status remains unknown. Separately trusted native `after-tool` hooks keep their provider-payload permission: the direct callback supplies `{ input, output }`, and a tool-part completion supplies its provider event. This does not grant `sessions:events` subscribers access to those payloads. +Kimi core hooks report `PostToolUse` and `PostToolUseFailure`; Hermes reports `post_tool_call`, independently of native plugin hooks. Kimi's `tool_output` is an opaque `str(ret)` that can also represent a tool error: its class stays `unknown`, while an explicit failure event reports `error`. It supplies no verified changed-path evidence. Hermes shell-hook `extra` carries the result, explicit outcome status and stable turn ID; result text is never interpreted as a status. + + + A service with `sessions:events` calls `sessions.subscribe` `{ ownedOnly? }` (again after every start). The answer lists the open cards; after that the host sends `canvastty.sessions.event` notifications: ```ts diff --git a/electron.vite.config.ts b/electron.vite.config.ts index eefc3536..4ecc1383 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -14,7 +14,11 @@ export default defineConfig({ plugins: [externalizeDepsPlugin()], build: { rollupOptions: { - input: resolve("src/main/index.ts") + input: { + index: resolve("src/main/index.ts"), + TerminalOutputHistoryWorker: resolve("src/main/services/TerminalOutputHistoryWorker.ts"), + SessionTimelineIndexWorker: resolve("src/main/services/SessionTimelineIndexWorker.ts") + } } } }, diff --git a/integrations/even-g2/src/main.mjs b/integrations/even-g2/src/main.mjs index eefa1be5..4a345c13 100644 --- a/integrations/even-g2/src/main.mjs +++ b/integrations/even-g2/src/main.mjs @@ -1017,6 +1017,17 @@ async function event(e) { paint(); } } +let attentionNotice = ""; +let attentionId = ""; +function showAttention(data) { + if(data.attention && data.attention.id!==attentionId) { + attentionId=data.attention.id; + attentionNotice=`${data.attention.title}: ${data.attention.kind}`; + notice=attentionNotice; + } else if(!data.attention && notice===attentionNotice) { + notice="";attentionNotice="";attentionId=""; + } +} async function poll() { if (!alive) return; try { @@ -1033,6 +1044,7 @@ async function poll() { "/g2/api/terminal?id=" + encodeURIComponent(selected), ); model.terminal(data); + showAttention(data); } if (native) { const state = { diff --git a/native/canvastty-helper/catalog.json b/native/canvastty-helper/catalog.json index d3ec39a2..6ccf4750 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); omit model to keep the selected account or provider default. effort sets the reasoning effort where that CLI has one (list_providers shows its efforts). An unsupported model or effort is refused with the reason. profile: without it the subagent gets this session's launch profile, or the next lower one its CLI has; a subagent never gets more than this session (plan < normal < acceptEdits < auto) and never YOLO. \"auto\" lets it work without asking the person for each edit or command inside the project; CanvasTTY's outer layers (agent isolation, base protection) still stop writes outside the project, secrets and system changes. The answer says the profile it got. cwd must be this project's folder or a folder inside it. isolate: \"worktree\" asks the installed environments plugin for a separate worktree. review: true asks CanvasTTY to run a read-only review after this agent finishes; reviewModel optionally selects a different known model for the reviewer. 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. Without a model account in launchOptions the subagent runs on this session's own model account when this session has one; {\"canvastty-accounts\":{\"account\":\"none\"}} explicitly runs it on the CLI's own sign-in and default model. The answer's servedBy says which provider, account and model serve it. Refusals say why (a limit the person set, a folder outside this session's project, a profile above this session's): change the request instead of retrying it. Then call wait_for_agent and get_agent_result.", "inputSchema": { "type": "object", "properties": { @@ -1028,6 +1028,20 @@ "max" ] }, + "isolate": { + "type": "string", + "enum": [ + "worktree" + ] + }, + "review": { + "type": "boolean" + }, + "reviewModel": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, "launchOptions": { "type": "object", "maxProperties": 16, @@ -1095,7 +1109,7 @@ }, { "name": "wait_for_agent", - "description": "Wait until one of this session's subagents stops working, instead of polling observe_agent or get_agent_result; nothing is sent to it while it waits. Returns reason \"idle\" (the turn that answers your latest prompt ended and it waits for input; an idle before that turn started does not count), \"needs_approval\" (its card shows a prompt only the person may answer; never answer it yourself), \"done\" or \"failed\" (its process exited), \"quiet\" (it reports no status or no turn start and its screen stopped changing, so judge from output), \"closed\" (its card was closed) or \"timeout\" after timeoutSeconds (default 55, at most 600), with status, exitCode, waitedMs, the masked terminal tail as output, and answer (the final reply of the turn that ended, for Codex and OpenCode subagents); when the subagent's process exited (for example at once, on a model its CLI does not know), exitLines holds the last lines of its screen as plain text, which say why. After a timeout, call it again. Then read get_agent_result.", + "description": "Wait until one of this session's subagents stops working, instead of polling observe_agent or get_agent_result; nothing is sent to it while it waits. Returns reason \"idle\" (the turn that answers your latest prompt ended and it waits for input; an idle before that turn started does not count), \"needs_approval\" (its card shows a prompt only the person may answer; never answer it yourself), \"done\" or \"failed\" (its process exited), \"quiet\" (it reports no status or no turn start and its screen stopped changing, so judge from output), \"closed\" (its card was closed) or \"timeout\" after timeoutSeconds (default 55, at most 100), 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. OpenCode calls return within 50 seconds to fit its client's deadline. A timeout means it is still running: use output as progress and call wait_for_agent again. Pending reviews also return within this deadline. Then read get_agent_result.", "inputSchema": { "type": "object", "properties": { @@ -1107,7 +1121,7 @@ "timeoutSeconds": { "type": "integer", "minimum": 1, - "maximum": 600 + "maximum": 100 } }, "required": [ @@ -1118,7 +1132,7 @@ }, { "name": "get_agent_result", - "description": "Get one of this session's subagents' result: answer (its last turn's final reply as the agent reported it, for Codex and OpenCode subagents; truncated:true when only the end was kept), status (idle once its turn ended), the exit state (running | done | failed; an interactive CLI stays running after a task) and the masked terminal tail as output. Prefer answer; the terminal tail is raw screen output.", + "description": "Get one of this session's subagents' result: answer (its last turn's final reply as the agent reported it, for Codex and OpenCode subagents; truncated:true when only the end was kept), status (idle once its turn ended), the exit state (running | done | failed; an interactive CLI stays running after a task) and the masked terminal tail as output. Prefer answer; the terminal tail is raw screen output. An unfinished review returns status pending immediately; call get_agent_result later for its outcome.", "inputSchema": { "type": "object", "properties": { @@ -1159,6 +1173,277 @@ "required": [], "additionalProperties": false } + }, + { + "name": "retry_agent", + "description": "Replace one failed or quiet subagent launch with a fresh conversation in the same card, stopping its previous process first. Retains task ownership, history, launch profile, model and folder; sends the original prompt plus a short masked failure tail. Only one retry may run at a time, with at most two successful retries per original agent. A failed retry keeps the stopped card for inspection; it cannot restore a stopped process.", + "inputSchema": { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "reason": { + "type": "string", + "maxLength": 500 + } + }, + "required": [ + "sessionId" + ], + "additionalProperties": false + } + }, + { + "name": "list_tasks", + "description": "List the shared task board for this orchestration. Subagents see only tasks belonging to their own root orchestrator.", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + } + }, + { + "name": "claim_task", + "description": "Atomically claim one open task whose dependencies are all complete. If another agent owns it, the current owner is returned.", + "inputSchema": { + "type": "object", + "properties": { + "taskId": { + "type": "string", + "minLength": 1, + "maxLength": 160 + } + }, + "required": [ + "taskId" + ], + "additionalProperties": false + } + }, + { + "name": "update_task", + "description": "Update the task you own. Only the root orchestrator can reassign tasks or change dependencies; complete work with complete_task.", + "inputSchema": { + "type": "object", + "properties": { + "taskId": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 240 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 8000 + }, + "progress": { + "type": "string", + "minLength": 1, + "maxLength": 4000 + }, + "status": { + "type": "string", + "enum": [ + "open", + "claimed" + ] + }, + "ownerSessionId": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "ownerName": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "dependencies": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "maxItems": 64 + } + }, + "required": [ + "taskId" + ], + "additionalProperties": false + } + }, + { + "name": "complete_task", + "description": "Mark a task complete and record its result. Only its owner or the root orchestrator can complete it, and dependencies must be complete.", + "inputSchema": { + "type": "object", + "properties": { + "taskId": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "result": { + "type": "string", + "minLength": 1, + "maxLength": 8000 + } + }, + "required": [ + "taskId", + "result" + ], + "additionalProperties": false + } + }, + { + "name": "get_task_budget", + "description": "Read the budget for this orchestration tree. Unknown provider token or cost usage is returned as no data; only a person can change or remove limits.", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + } + }, + { + "name": "list_orchestration_templates", + "description": "List built-in and project orchestration flows from .canvastty/flows. Invalid files are returned as line-numbered errors.", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + } + }, + { + "name": "request_secret", + "description": "Ask the person to approve temporary use of one configured provider secret for typed provider API requests. This only creates a pending UI request; never ask the person to paste a key into chat or tool arguments. Approval is scoped to this session and lasts 10 minutes, until this turn ends, or until the session ends.", + "inputSchema": { + "type": "object", + "properties": { + "secretId": { + "type": "string", + "enum": [ + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "XAI_API_KEY", + "GOOGLE_API_KEY", + "ZAI_API_KEY", + "MINIMAX_API_KEY", + "OPENROUTER_API_KEY", + "DEEPSEEK_API_KEY", + "DEVIN_API_KEY", + "CURSOR_API_KEY" + ] + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 1000 + } + }, + "required": [ + "secretId", + "reason" + ], + "additionalProperties": false + } + }, + { + "name": "run_secret_request", + "description": "Send a typed HTTPS API request using a configured provider secret for which this session has current human approval. secretId selects the approved key; apiProfileId optionally selects a human-saved profile using that same key. The host chooses the profile base URL and protocol authentication. Supply only a relative API path, method and optional JSON body; custom origins, headers, redirects and executable commands are unavailable. The response body is masked and capped, and the host may refuse if it cannot enforce isolation.", + "inputSchema": { + "type": "object", + "properties": { + "secretId": { + "type": "string", + "enum": [ + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "XAI_API_KEY", + "GOOGLE_API_KEY", + "ZAI_API_KEY", + "MINIMAX_API_KEY", + "OPENROUTER_API_KEY", + "DEEPSEEK_API_KEY", + "DEVIN_API_KEY", + "CURSOR_API_KEY" + ] + }, + "apiProfileId": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "method": { + "type": "string", + "enum": [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE" + ] + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "body": { + "type": "object", + "maxProperties": 1024, + "additionalProperties": true + }, + "timeoutMs": { + "type": "integer", + "minimum": 1000, + "maximum": 300000 + } + }, + "required": [ + "secretId", + "method", + "path" + ], + "additionalProperties": false + } + }, + { + "name": "apply_orchestration_template", + "description": "Get the roles, expected number of subagents and final-step instructions for a flow, ready to apply to a task.", + "inputSchema": { + "type": "object", + "properties": { + "templateId": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "task": { + "type": "string", + "minLength": 1, + "maxLength": 65536 + } + }, + "required": [ + "templateId", + "task" + ], + "additionalProperties": false + } } ], "providerIds": [ diff --git a/native/canvastty-helper/hook.go b/native/canvastty-helper/hook.go index 1e122f91..72ef706c 100644 --- a/native/canvastty-helper/hook.go +++ b/native/canvastty-helper/hook.go @@ -5,9 +5,12 @@ package main // always exits 0 with nothing on stdout. import ( + "crypto/sha256" + "encoding/hex" "io" "math" "os" + "strings" "time" ) @@ -31,7 +34,12 @@ func runHook(args []string) int { input = parsed } } - turnID := firstString(field(input, "turn_id"), field(input, "turnId"), field(input, "prompt_id"), field(input, "promptId")) + provider, _ := env(envRuntimeProvider) + var providerTurn any = undefinedValue{} + if provider == "hermes" { + providerTurn = field(field(input, "extra"), "turn_id") + } + turnID := firstString(field(input, "turn_id"), field(input, "turnId"), field(input, "prompt_id"), field(input, "promptId"), providerTurn) threadID := firstString(field(input, "session_id"), field(input, "sessionId"), field(input, "thread_id"), field(input, "threadId"), field(input, "conversation_id"), field(input, "conversationId")) var finalAnswer *string @@ -50,7 +58,8 @@ func runHook(args []string) int { text := boundedText(*finalAnswer, maxAnswerChars) lastAssistantMessage = &text } - reportLifecycle(state, event, turnID, threadID, result, lastAssistantMessage) + toolOutcome := nativeToolOutcome(provider, event, input) + reportLifecycle(state, event, turnID, threadID, result, lastAssistantMessage, toolOutcome) return 0 } @@ -81,7 +90,7 @@ func firstString(values ...any) any { return nil } -func reportLifecycle(state, event string, turnID, threadID any, result *jsObject, lastAssistantMessage *string) bool { +func reportLifecycle(state, event string, turnID, threadID any, result *jsObject, lastAssistantMessage *string, toolOutcome *jsObject) bool { if !isRuntimeState(state) || event == "" || jsLength(event) > 80 { return false } @@ -112,6 +121,9 @@ func reportLifecycle(state, event string, turnID, threadID any, result *jsObject if result != nil { message.set("result", result) } + if toolOutcome != nil { + message.set("toolOutcome", toolOutcome) + } expiresAt := envNumber(envCaptureAnswerExpiresAt) shouldCheck := envEquals(envCaptureAnswer, "1") && isFinite(expiresAt) && expiresAt > nowMs() && provider == "codex" && event == "Stop" && state == "idle" && lastAssistantMessage != nil @@ -127,6 +139,139 @@ func reportLifecycle(state, event string, turnID, threadID any, result *jsObject }) } +func nativeToolOutcome(provider, event string, input any) *jsObject { + if event != "PostToolUse" && !((provider == "claude" || provider == "kimi") && event == "PostToolUseFailure") && !(provider == "hermes" && event == "post_tool_call") { + return nil + } + rawToolName, _ := field(input, "tool_name").(string) + toolName := boundedText(stripToolNameControls(strings.TrimSpace(rawToolName)), 80) + if toolName == "" { + toolName = "unknown" + } + response := field(input, "tool_response") + legacyResponse := !isUndefined(response) + var extra any = undefinedValue{} + if provider == "hermes" && isPlainObject(field(input, "extra")) { + extra = field(input, "extra") + } + if !legacyResponse { + if provider == "kimi" { + if output, ok := field(input, "tool_output").(string); ok { + response = output + } + } else if provider == "hermes" { + response = field(extra, "result") + } + } + resultClass := "unknown" + if provider == "claude" && event == "PostToolUse" { + resultClass = "success" + } else if provider == "claude" && event == "PostToolUseFailure" { + if field(input, "is_interrupt") != true { + resultClass = "error" + } + } else if provider == "kimi" && event == "PostToolUseFailure" { + resultClass = "error" + } else if status, ok := field(extra, "status").(string); provider == "hermes" && !legacyResponse && ok { + switch status { + case "ok": + resultClass = "success" + case "blocked": + resultClass = "denied" + case "error", "timeout": + resultClass = "error" + } + } else { + resultClass = explicitNativeToolResultClass(response) + } + outcome := obj("toolName", toolName, "resultClass", resultClass) + toolInput := field(input, "tool_input") + if !isUndefined(toolInput) { + action := strings.ToLower(toolName) + "\n" + jsonStringify(toolInput) + outcome.set("normalizedActionHash", hashToolOutcome(action)) + } + errorText := firstToolOutcomeString(field(input, "error"), field(input, "tool_error"), field(input, "toolError"), + field(response, "error"), field(response, "tool_error"), field(extra, "error_message")) + if resultClass == "error" && errorText != "" { + outcome.set("errorHash", hashToolOutcome(normalizeToolOutcomeText(boundedText(errorText, 8192)))) + } + // Parity with runtime-protocol.mjs: a hash of the tool's output for non-error results; the text never leaves. + if resultClass != "error" && !isUndefined(response) && response != nil { + text, ok := response.(string) + if !ok { + text = jsonStringify(response) + } + outcome.set("outputHash", hashToolOutcome(normalizeToolOutcomeText(boundedText(text, 16384)))) + } + paths := []any{} + if resultClass == "success" && (strings.EqualFold(strings.TrimSpace(rawToolName), "Edit") || strings.EqualFold(strings.TrimSpace(rawToolName), "Write")) { + if path, ok := field(response, "filePath").(string); ok { + path = strings.ReplaceAll(strings.TrimSpace(path), "\\", "/") + if path != "" { + paths = append(paths, hashToolOutcome(path)) + } + } + } + outcome.set("changedPathHashes", paths) + return outcome +} + +func explicitNativeToolResultClass(response any) string { + if !isPlainObject(response) { + return "unknown" + } + if field(response, "denied") == true || field(response, "status") == "denied" || field(response, "resultClass") == "denied" { + return "denied" + } + if field(response, "isError") == true || field(response, "is_error") == true || field(response, "success") == false || + field(response, "status") == "error" || field(response, "status") == "failed" || field(response, "resultClass") == "error" { + return "error" + } + if field(response, "success") == true || field(response, "status") == "success" || field(response, "status") == "completed" || field(response, "resultClass") == "success" { + return "success" + } + exitCode := field(response, "exit_code") + if isUndefined(exitCode) { + exitCode = field(response, "exitCode") + } + if code, ok := exitCode.(float64); ok && !math.IsNaN(code) && !math.IsInf(code, 0) && math.Trunc(code) == code { + if code == 0 { + return "success" + } + return "error" + } + return "unknown" +} + +func firstToolOutcomeString(values ...any) string { + for _, value := range values { + if text, ok := value.(string); ok && text != "" { + return text + } + } + return "" +} + +func stripToolNameControls(value string) string { + var b strings.Builder + for _, r := range value { + if r < 0x20 || r == 0x7f { + continue + } + b.WriteRune(r) + } + return b.String() +} + +func normalizeToolOutcomeText(value string) string { + return strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(value, "\r\n", "\n"), "\r", "\n")) +} + +func hashToolOutcome(value string) string { + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:]) +} + func answerCaptureIsActive(address, terminalSessionID, provider, capability string) bool { request := obj( "v", float64(runtimeProtocolVersion), diff --git a/native/canvastty-helper/landlock_linux.go b/native/canvastty-helper/landlock_linux.go new file mode 100644 index 00000000..79da5b04 --- /dev/null +++ b/native/canvastty-helper/landlock_linux.go @@ -0,0 +1,163 @@ +//go:build linux + +package main + +import ( + "fmt" + "runtime" + "syscall" + "unsafe" +) + +const ( + closeRangeSyscall = 436 + landlockCreateRulesetSyscall = 444 + landlockAddRuleSyscall = 445 + landlockRestrictSelfSyscall = 446 + + closeRangeCloexec = 1 << 2 + landlockCreateRulesetVersion = 1 + landlockRulePathBeneath = 1 + landlockRestrictSelfTSYNC = 1 << 3 + + landlockAccessFSRefer = 1 << 13 + landlockAccessFSResolveUnix = 1 << 16 + landlockScopeAbstractUnix = 1 << 0 + + prSetNoNewPrivs = 38 + oPath = 0x200000 +) + +type landlockRulesetAttr struct { + handledAccessFS uint64 + handledAccessNet uint64 + scoped uint64 +} + +// The kernel's landlock_path_beneath_attr is packed (12 bytes). Go's natural struct padding is harmless here: the +// kernel copies the packed 12-byte UAPI prefix, and the pointer remains 8-byte aligned. +type landlockPathBeneathAttr struct { + allowedAccess uint64 + parentFD int32 +} + +type landlockSocketIdentity struct { + device uint64 + inode uint64 +} + +type landlockSocketRule struct { + fd int + path string +} + +// installUnixSocketGuard permits externally created pathname sockets only when their exact socket inode was granted. +// Landlock's ABI 9 RESOLVE_UNIX right leaves sockets created inside this domain available to same-domain servers. +func installUnixSocketGuard(allowedSocketPaths []string) error { + abi, err := landlockABIVersion() + if err != nil { + return err + } + if abi < 9 { + return fmt.Errorf("Landlock ABI %d is too old; ABI 9 is required", abi) + } + + openFDs := make([]int, 0, len(allowedSocketPaths)+1) + defer func() { + for _, fd := range openFDs { + _ = syscall.Close(fd) + } + }() + + rootFD, err := openLandlockPath("/") + if err != nil { + return fmt.Errorf("open / for the cross-directory rename rule: %w", err) + } + openFDs = append(openFDs, rootFD) + + socketRules := make([]landlockSocketRule, 0, len(allowedSocketPaths)) + identities := make(map[landlockSocketIdentity]struct{}, len(allowedSocketPaths)) + for _, path := range allowedSocketPaths { + fd, openErr := openLandlockPath(path) + if openErr != nil { + return fmt.Errorf("open allowed Unix socket %q: %w", path, openErr) + } + openFDs = append(openFDs, fd) + + var stat syscall.Stat_t + if statErr := syscall.Fstat(fd, &stat); statErr != nil { + return fmt.Errorf("inspect allowed Unix socket %q: %w", path, statErr) + } + if stat.Mode&syscall.S_IFMT != syscall.S_IFSOCK { + return fmt.Errorf("allowed Unix socket %q is not a socket", path) + } + identity := landlockSocketIdentity{device: uint64(stat.Dev), inode: stat.Ino} + if _, exists := identities[identity]; exists { + continue + } + identities[identity] = struct{}{} + socketRules = append(socketRules, landlockSocketRule{fd: fd, path: path}) + } + + attr := landlockRulesetAttr{ + handledAccessFS: landlockAccessFSRefer | landlockAccessFSResolveUnix, + scoped: landlockScopeAbstractUnix, + } + rulesetFD, _, errno := syscall.RawSyscall(landlockCreateRulesetSyscall, + uintptr(unsafe.Pointer(&attr)), unsafe.Sizeof(attr), 0) + runtime.KeepAlive(&attr) + if errno != 0 { + return fmt.Errorf("create Landlock ruleset: %w", errno) + } + defer syscall.Close(int(rulesetFD)) + + if err := addLandlockPathRule(int(rulesetFD), rootFD, landlockAccessFSRefer); err != nil { + return fmt.Errorf("allow cross-directory rename and link operations: %w", err) + } + for _, rule := range socketRules { + if err := addLandlockPathRule(int(rulesetFD), rule.fd, landlockAccessFSResolveUnix); err != nil { + return fmt.Errorf("allow Unix socket %q: %w", rule.path, err) + } + } + // Keep inherited descriptors available to this helper and the Go runtime, but never pass them through exec to + // the agent. close_range(CLOSE_RANGE_CLOEXEC) makes preopened non-CLOEXEC descriptors safe without closing them. + if _, _, errno := syscall.RawSyscall(closeRangeSyscall, 3, uintptr(^uint32(0)), closeRangeCloexec); errno != 0 { + return fmt.Errorf("mark inherited descriptors close-on-exec: %w", errno) + } + + // no_new_privs is per-thread. Keep this goroutine on the same OS thread through restrict_self(TSYNC), which + // applies the Landlock domain to every Go runtime thread before any application goroutine or child is started. + runtime.LockOSThread() + defer runtime.UnlockOSThread() + if _, _, errno := syscall.RawSyscall(syscall.SYS_PRCTL, prSetNoNewPrivs, 1, 0); errno != 0 { + return fmt.Errorf("set no_new_privs: %w", errno) + } + if _, _, errno := syscall.RawSyscall(landlockRestrictSelfSyscall, + rulesetFD, landlockRestrictSelfTSYNC, 0); errno != 0 { + return fmt.Errorf("restrict all process threads with Landlock: %w", errno) + } + return nil +} + +func landlockABIVersion() (int, error) { + abi, _, errno := syscall.RawSyscall(landlockCreateRulesetSyscall, 0, 0, landlockCreateRulesetVersion) + if errno != 0 { + return 0, fmt.Errorf("query Landlock ABI: %w", errno) + } + return int(abi), nil +} + +func openLandlockPath(path string) (int, error) { + return syscall.Open(path, oPath|syscall.O_CLOEXEC, 0) +} + +func addLandlockPathRule(rulesetFD, parentFD int, allowedAccess uint64) error { + rule := landlockPathBeneathAttr{allowedAccess: allowedAccess, parentFD: int32(parentFD)} + _, _, errno := syscall.RawSyscall6(landlockAddRuleSyscall, + uintptr(rulesetFD), landlockRulePathBeneath, uintptr(unsafe.Pointer(&rule)), 0, 0, 0) + runtime.KeepAlive(&rule) + if errno != 0 { + return errno + } + return nil +} diff --git a/native/canvastty-helper/main.go b/native/canvastty-helper/main.go index f612db59..da17cfce 100644 --- a/native/canvastty-helper/main.go +++ b/native/canvastty-helper/main.go @@ -19,7 +19,7 @@ const helperVersion = "canvastty-helper 1" func main() { if len(os.Args) < 2 { - os.Stderr.WriteString("usage: canvastty-helper mcp-browser | mcp-orchestration | permission-gate pretool | hook | version\n") + os.Stderr.WriteString("usage: canvastty-helper mcp-browser | mcp-orchestration | permission-gate pretool | hook | network-bridge | version\n") os.Exit(2) } args := os.Args[2:] @@ -32,6 +32,8 @@ func main() { os.Exit(runPermissionGate(args)) case "hook": os.Exit(runHook(args)) + case "network-bridge": + os.Exit(runNetworkBridge(args)) case "version": os.Stdout.WriteString(helperVersion + "\n") os.Exit(0) diff --git a/native/canvastty-helper/network_bridge_linux.go b/native/canvastty-helper/network_bridge_linux.go new file mode 100644 index 00000000..f44cab9a --- /dev/null +++ b/native/canvastty-helper/network_bridge_linux.go @@ -0,0 +1,236 @@ +//go:build linux + +package main + +import ( + "errors" + "flag" + "fmt" + "io" + "net" + "os" + "os/exec" + "os/signal" + "regexp" + "strings" + "syscall" + "time" + "unsafe" +) + +var networkTokenPattern = regexp.MustCompile(`^[a-f0-9]{64}$`) + +type repeatedStrings []string + +func (values *repeatedStrings) String() string { return strings.Join(*values, ",") } +func (values *repeatedStrings) Set(value string) error { + *values = append(*values, value) + return nil +} + +// runNetworkBridge enforces the Unix-socket boundary before creating any listener, goroutine, or child. The strict +// network modes share the same ABI-9 Landlock guard; offline skips the TCP proxy entirely. +func runNetworkBridge(arguments []string) int { + if len(arguments) == 1 && arguments[0] == "--probe" { + if err := installUnixSocketGuard(nil); err != nil { + fmt.Fprintf(os.Stderr, "canvastty-helper network-bridge: Unix socket guard is unavailable: %v\n", err) + return 1 + } + return 0 + } + + separator := -1 + for index, argument := range arguments { + if argument == "--" { + separator = index + break + } + } + if separator < 0 { + fmt.Fprintln(os.Stderr, "canvastty-helper network-bridge: expected --offline or --socket PATH --token TOKEN, followed by [--allow-socket PATH]* -- COMMAND [ARGUMENTS]") + return 2 + } + + flags := flag.NewFlagSet("network-bridge", flag.ContinueOnError) + flags.SetOutput(os.Stderr) + var socket, token string + var offline bool + var allowedSockets repeatedStrings + flags.StringVar(&socket, "socket", "", "host allowlist proxy Unix socket") + flags.StringVar(&token, "token", "", "single-launch proxy capability") + flags.BoolVar(&offline, "offline", false, "disable the TCP proxy") + flags.Var(&allowedSockets, "allow-socket", "exact host-selected Unix socket path (repeatable)") + if err := flags.Parse(arguments[:separator]); err != nil { + return 2 + } + if flags.NArg() != 0 { + fmt.Fprintln(os.Stderr, "canvastty-helper network-bridge: options must precede -- COMMAND") + return 2 + } + command := arguments[separator+1:] + if len(command) == 0 { + fmt.Fprintln(os.Stderr, "canvastty-helper network-bridge: expected a command after --") + return 2 + } + + if offline { + if socket != "" || token != "" { + fmt.Fprintln(os.Stderr, "canvastty-helper network-bridge: --offline cannot be combined with --socket or --token") + return 2 + } + } else if socket == "" || !networkTokenPattern.MatchString(token) || !strings.HasPrefix(socket, "/") { + fmt.Fprintln(os.Stderr, "canvastty-helper network-bridge: allowed-domains mode requires --socket PATH --token TOKEN") + return 2 + } + for _, path := range allowedSockets { + if path == "" || !strings.HasPrefix(path, "/") { + fmt.Fprintln(os.Stderr, "canvastty-helper network-bridge: every --allow-socket path must be absolute") + return 2 + } + } + grants := append([]string(nil), allowedSockets...) + if !offline { + grants = append(grants, socket) + } + if err := installUnixSocketGuard(grants); err != nil { + fmt.Fprintf(os.Stderr, "canvastty-helper network-bridge: Unix socket guard is unavailable: %v\n", err) + return 1 + } + + if offline { + environment := withoutProxy(os.Environ()) + environment = append(environment, "CANVASTTY_NETWORK_MODE=offline") + return runNetworkBridgeChild(command, environment) + } + + if err := bringLoopbackUp(); err != nil { + fmt.Fprintf(os.Stderr, "canvastty-helper network-bridge: private loopback is unavailable: %v\n", err) + return 1 + } + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + fmt.Fprintf(os.Stderr, "canvastty-helper network-bridge: private proxy listener could not start: %v\n", err) + return 1 + } + defer listener.Close() + go acceptProxyClients(listener, socket) + + proxy := "http://canvastty:" + token + "@" + listener.Addr().String() + environment := withoutProxy(os.Environ()) + environment = append(environment, + "HTTP_PROXY="+proxy, "http_proxy="+proxy, + "HTTPS_PROXY="+proxy, "https_proxy="+proxy, + "ALL_PROXY="+proxy, "all_proxy="+proxy, + "NO_PROXY=", "no_proxy=", "CANVASTTY_NETWORK_MODE=allowed-domains") + return runNetworkBridgeChild(command, environment) +} + +func runNetworkBridgeChild(command, environment []string) int { + child := exec.Command(command[0], command[1:]...) + child.Env = environment + child.Stdin, child.Stdout, child.Stderr = os.Stdin, os.Stdout, os.Stderr + if err := child.Start(); err != nil { + fmt.Fprintf(os.Stderr, "canvastty-helper network-bridge: agent could not start: %v\n", err) + return 127 + } + signals := make(chan os.Signal, 2) + signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) + done := make(chan struct{}) + go func() { + select { + case received := <-signals: + _ = child.Process.Signal(received) + case <-done: + } + }() + err := child.Wait() + close(done) + signal.Stop(signals) + if err == nil { + return 0 + } + var exitError *exec.ExitError + if errors.As(err, &exitError) { + if status, ok := exitError.Sys().(syscall.WaitStatus); ok && status.Exited() { + return status.ExitStatus() + } + if status, ok := exitError.Sys().(syscall.WaitStatus); ok && status.Signaled() { + return 128 + int(status.Signal()) + } + } + fmt.Fprintf(os.Stderr, "canvastty-helper network-bridge: agent failed: %v\n", err) + return 1 +} + +func acceptProxyClients(listener net.Listener, socket string) { + for { + client, err := listener.Accept() + if err != nil { + return + } + go bridgeConnection(client, socket) + } +} + +func bridgeConnection(client net.Conn, socket string) { + defer client.Close() + upstream, err := net.DialTimeout("unix", socket, 5*time.Second) + if err != nil { + return + } + defer upstream.Close() + finished := make(chan struct{}, 2) + go func() { + _, _ = io.Copy(upstream, client) + if closer, ok := upstream.(*net.UnixConn); ok { + _ = closer.CloseWrite() + } + finished <- struct{}{} + }() + go func() { + _, _ = io.Copy(client, upstream) + if closer, ok := client.(*net.TCPConn); ok { + _ = closer.CloseWrite() + } + finished <- struct{}{} + }() + <-finished + <-finished +} + +func withoutProxy(environment []string) []string { + kept := make([]string, 0, len(environment)+9) + for _, item := range environment { + key, _, found := strings.Cut(item, "=") + if found && (strings.EqualFold(key, "http_proxy") || strings.EqualFold(key, "https_proxy") || strings.EqualFold(key, "all_proxy") || strings.EqualFold(key, "no_proxy")) { + continue + } + kept = append(kept, item) + } + return kept +} + +// bubblewrap's fresh network namespace starts with loopback down. Bring only that interface up; no physical or +// host interface is joined. If the namespace lacks CAP_NET_ADMIN, strict mode refuses before starting the agent. +func bringLoopbackUp() error { + fd, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_DGRAM|syscall.SOCK_CLOEXEC, 0) + if err != nil { + return err + } + defer syscall.Close(fd) + var request [40]byte // Linux ifreq: 16-byte name followed by a 24-byte union. + copy(request[:16], "lo") + if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), uintptr(0x8913), uintptr(unsafe.Pointer(&request[0]))); errno != 0 { // SIOCGIFFLAGS + return errno + } + flags := *(*uint16)(unsafe.Pointer(&request[16])) + if flags&0x1 != 0 { // IFF_UP + return nil + } + flags |= 0x1 + *(*uint16)(unsafe.Pointer(&request[16])) = flags + if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), uintptr(0x8914), uintptr(unsafe.Pointer(&request[0]))); errno != 0 { // SIOCSIFFLAGS + return errno + } + return nil +} diff --git a/native/canvastty-helper/network_bridge_linux_test.go b/native/canvastty-helper/network_bridge_linux_test.go new file mode 100644 index 00000000..9be6f7a7 --- /dev/null +++ b/native/canvastty-helper/network_bridge_linux_test.go @@ -0,0 +1,361 @@ +//go:build linux + +package main + +import ( + "bufio" + "context" + "encoding/base64" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "testing" + "time" +) + +const ( + networkBridgeTestRoleEnv = "CANVASTTY_NETWORK_BRIDGE_TEST_ROLE" + networkBridgeTestModeEnv = "CANVASTTY_NETWORK_BRIDGE_TEST_MODE" + networkBridgeTestGatewayEnv = "CANVASTTY_NETWORK_BRIDGE_TEST_GATEWAY" + networkBridgeTestDeniedEnv = "CANVASTTY_NETWORK_BRIDGE_TEST_DENIED" + networkBridgeTestAliasEnv = "CANVASTTY_NETWORK_BRIDGE_TEST_ALIAS" + networkBridgeTestAbstractEnv = "CANVASTTY_NETWORK_BRIDGE_TEST_ABSTRACT" + networkBridgeTestProxySocketEnv = "CANVASTTY_NETWORK_BRIDGE_TEST_PROXY_SOCKET" + networkBridgeTestFDTargetEnv = "CANVASTTY_NETWORK_BRIDGE_TEST_FD_TARGET" + networkBridgeTestProxyToken = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +) + +// One subprocess macro exercises the public network-bridge path without restricting the go test process itself. +// The parent owns fake external sockets; the helper receives a connected ExtraFiles descriptor, applies the guard, +// and execs this test binary as a small agent client. No app credentials or services are involved. +func TestNetworkBridgeLandlockIntegration(t *testing.T) { + switch os.Getenv(networkBridgeTestRoleEnv) { + case "helper": + os.Exit(runNetworkBridgeTestHelper()) + case "client": + if err := runNetworkBridgeTestClient(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + os.Exit(0) + } + + abi, err := landlockABIVersion() + if err != nil { + t.Fatalf("Landlock ABI query failed: %v", err) + } + if abi < 9 { + t.Skipf("Landlock ABI %d is too old for pathname Unix-socket rules (need ABI 9)", abi) + } + + socketDir := t.TempDir() + approvedSocket := filepath.Join(socketDir, "gateway.sock") + deniedSocket := filepath.Join(socketDir, "sibling.sock") + aliasSocket := filepath.Join(socketDir, "sibling-alias.sock") + proxySocket := filepath.Join(socketDir, "proxy.sock") + approvedListener := startLandlockTestEcho(t, approvedSocket) + defer approvedListener.Close() + deniedListener := startLandlockTestEcho(t, deniedSocket) + defer deniedListener.Close() + if err := os.Symlink(deniedSocket, aliasSocket); err != nil { + t.Fatal(err) + } + + abstractSocket := fmt.Sprintf("@canvastty-landlock-%d-%d", os.Getpid(), time.Now().UnixNano()) + abstractListener, err := net.Listen("unix", abstractSocket) + if err != nil { + abstractSocket = "" // Abstract sockets are an optional assertion; pathname checks remain mandatory. + } else { + defer abstractListener.Close() + } + + expectedAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte("canvastty:"+networkBridgeTestProxyToken)) + proxyListener, err := net.Listen("unix", proxySocket) + if err != nil { + t.Fatal(err) + } + defer proxyListener.Close() + go serveLandlockTestProxy(proxyListener, expectedAuth) + + connectedFDs, err := syscall.Socketpair(syscall.AF_UNIX, syscall.SOCK_STREAM|syscall.SOCK_CLOEXEC, 0) + if err != nil { + t.Fatal(err) + } + connectedFD := os.NewFile(uintptr(connectedFDs[0]), "inherited-connected-unix-socket") + defer connectedFD.Close() + defer syscall.Close(connectedFDs[1]) + fdTarget, err := os.Readlink(fmt.Sprintf("/proc/self/fd/%d", connectedFD.Fd())) + if err != nil || !strings.HasPrefix(fdTarget, "socket:[") { + t.Fatalf("could not identify connected socketpair descriptor: target=%q err=%v", fdTarget, err) + } + + for _, mode := range []string{"offline", "allowed-domains"} { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + child := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestNetworkBridgeLandlockIntegration$") + child.ExtraFiles = []*os.File{connectedFD} + child.Env = os.Environ() + child.Env = setLandlockTestEnv(child.Env, networkBridgeTestRoleEnv, "helper") + child.Env = setLandlockTestEnv(child.Env, networkBridgeTestModeEnv, mode) + child.Env = setLandlockTestEnv(child.Env, networkBridgeTestGatewayEnv, approvedSocket) + child.Env = setLandlockTestEnv(child.Env, networkBridgeTestDeniedEnv, deniedSocket) + child.Env = setLandlockTestEnv(child.Env, networkBridgeTestAliasEnv, aliasSocket) + child.Env = setLandlockTestEnv(child.Env, networkBridgeTestAbstractEnv, abstractSocket) + child.Env = setLandlockTestEnv(child.Env, networkBridgeTestProxySocketEnv, proxySocket) + child.Env = setLandlockTestEnv(child.Env, networkBridgeTestFDTargetEnv, fdTarget) + output, err := child.CombinedOutput() + cancel() + if err != nil { + t.Fatalf("%s network-bridge integration failed: %v\n%s", mode, err, strings.TrimSpace(string(output))) + } + if len(output) != 0 { + t.Fatalf("%s network-bridge integration wrote unexpected output: %q", mode, string(output)) + } + } +} + +func runNetworkBridgeTestHelper() int { + fdTarget, err := os.Readlink("/proc/self/fd/3") + if err != nil || fdTarget != os.Getenv(networkBridgeTestFDTargetEnv) { + fmt.Fprintf(os.Stderr, "test helper did not receive the connected external socket on fd 3: target=%q err=%v\n", fdTarget, err) + return 1 + } + _ = os.Setenv(networkBridgeTestRoleEnv, "client") + args := make([]string, 0, 10) + if os.Getenv(networkBridgeTestModeEnv) == "offline" { + args = append(args, "--offline") + } else { + args = append(args, "--socket", os.Getenv(networkBridgeTestProxySocketEnv), "--token", networkBridgeTestProxyToken) + } + args = append(args, + "--allow-socket", os.Getenv(networkBridgeTestGatewayEnv), + "--", os.Args[0], "-test.run=^TestNetworkBridgeLandlockIntegration$") + return runNetworkBridge(args) +} + +func runNetworkBridgeTestClient() error { + fdTarget, err := os.Readlink("/proc/self/fd/3") + if err == nil && fdTarget == os.Getenv(networkBridgeTestFDTargetEnv) { + return fmt.Errorf("the inherited connected Unix socket reached the agent child") + } + if err != nil && !errors.Is(err, syscall.ENOENT) { + return fmt.Errorf("could not verify inherited socket closure: %w", err) + } + + mode := os.Getenv(networkBridgeTestModeEnv) + if mode == "offline" { + for _, key := range []string{"HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy", "NO_PROXY", "no_proxy"} { + if _, exists := os.LookupEnv(key); exists { + return fmt.Errorf("offline mode retained proxy variable %s", key) + } + } + if os.Getenv("CANVASTTY_NETWORK_MODE") != "offline" { + return fmt.Errorf("offline mode environment marker was not set") + } + } else { + proxy, parseErr := url.Parse(os.Getenv("HTTPS_PROXY")) + if parseErr != nil || proxy.Scheme != "http" || proxy.Hostname() != "127.0.0.1" || proxy.Port() == "" { + return fmt.Errorf("allowed-domains HTTPS_PROXY does not point to the helper's localhost listener: %q", os.Getenv("HTTPS_PROXY")) + } + if proxy.User == nil { + return fmt.Errorf("allowed-domains proxy URL has no launch credentials") + } + user := proxy.User.Username() + password, hasPassword := proxy.User.Password() + if user != "canvastty" || !hasPassword || password != networkBridgeTestProxyToken || + os.Getenv("HTTP_PROXY") != os.Getenv("HTTPS_PROXY") || os.Getenv("https_proxy") != os.Getenv("HTTPS_PROXY") { + return fmt.Errorf("allowed-domains proxy credentials or scheme variables do not match the launch token") + } + if os.Getenv("CANVASTTY_NETWORK_MODE") != "allowed-domains" { + return fmt.Errorf("allowed-domains mode environment marker was not set") + } + } + + if err := roundTripLandlockTestSocket(os.Getenv(networkBridgeTestGatewayEnv)); err != nil { + return fmt.Errorf("exact approved gateway socket did not work: %w", err) + } + for _, path := range []string{os.Getenv(networkBridgeTestDeniedEnv), os.Getenv(networkBridgeTestAliasEnv)} { + conn, dialErr := net.DialTimeout("unix", path, time.Second) + if dialErr == nil { + _ = conn.Close() + return fmt.Errorf("ungranted socket path %q unexpectedly connected", path) + } + if !errors.Is(dialErr, syscall.EACCES) { + return fmt.Errorf("ungranted socket path %q failed with %v, expected EACCES", path, dialErr) + } + } + if abstractSocket := os.Getenv(networkBridgeTestAbstractEnv); abstractSocket != "" { + conn, dialErr := net.DialTimeout("unix", abstractSocket, time.Second) + if dialErr == nil { + _ = conn.Close() + return fmt.Errorf("external abstract Unix socket unexpectedly connected") + } + if !errors.Is(dialErr, syscall.EPERM) && !errors.Is(dialErr, syscall.EACCES) { + return fmt.Errorf("external abstract Unix socket failed with %v, expected EPERM or EACCES", dialErr) + } + } + + if mode == "allowed-domains" { + proxyURL, err := url.Parse(os.Getenv("HTTP_PROXY")) + if err != nil { + return fmt.Errorf("parse helper proxy URL: %w", err) + } + transport := &http.Transport{Proxy: http.ProxyURL(proxyURL)} + client := &http.Client{Transport: transport, Timeout: 5 * time.Second} + response, requestErr := client.Get("http://landlock-test.invalid/relay") + if requestErr != nil { + return fmt.Errorf("localhost proxy could not reach the exact allowed Unix proxy socket: %w", requestErr) + } + body, readErr := io.ReadAll(response.Body) + closeErr := response.Body.Close() + transport.CloseIdleConnections() + if readErr != nil || closeErr != nil || response.StatusCode != http.StatusOK || string(body) != "proxy-ok" { + return fmt.Errorf("fake Unix proxy returned status=%d body=%q readErr=%v closeErr=%v", response.StatusCode, body, readErr, closeErr) + } + } + + return exerciseLandlockTestLocalIPCAndRefer() +} + +func roundTripLandlockTestSocket(path string) error { + conn, err := net.DialTimeout("unix", path, time.Second) + if err != nil { + return err + } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(time.Second)) + if _, err := conn.Write([]byte("ping")); err != nil { + return err + } + reply := make([]byte, 4) + if _, err := io.ReadFull(conn, reply); err != nil { + return err + } + if string(reply) != "ping" { + return fmt.Errorf("unexpected reply %q", reply) + } + return nil +} + +func exerciseLandlockTestLocalIPCAndRefer() error { + workDir, err := os.MkdirTemp("", "canvastty-landlock-client-") + if err != nil { + return err + } + defer os.RemoveAll(workDir) + + localSocket := filepath.Join(workDir, "local.sock") + listener, err := net.Listen("unix", localSocket) + if err != nil { + return fmt.Errorf("create same-domain Unix server: %w", err) + } + accepted := make(chan error, 1) + go func() { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + accepted <- acceptErr + return + } + defer conn.Close() + buf := make([]byte, 4) + if _, acceptErr = io.ReadFull(conn, buf); acceptErr == nil { + _, acceptErr = conn.Write(buf) + } + accepted <- acceptErr + }() + if err := roundTripLandlockTestSocket(localSocket); err != nil { + _ = listener.Close() + return fmt.Errorf("connect to same-domain Unix server: %w", err) + } + if err := <-accepted; err != nil { + _ = listener.Close() + return fmt.Errorf("same-domain Unix server failed: %w", err) + } + if err := listener.Close(); err != nil { + return err + } + + left, right := filepath.Join(workDir, "left"), filepath.Join(workDir, "right") + if err := os.Mkdir(left, 0o700); err != nil { + return err + } + if err := os.Mkdir(right, 0o700); err != nil { + return err + } + from := filepath.Join(left, "move-me") + renamed := filepath.Join(right, "renamed") + linked := filepath.Join(left, "linked") + if err := os.WriteFile(from, []byte("refer-ok"), 0o600); err != nil { + return err + } + if err := os.Rename(from, renamed); err != nil { + return fmt.Errorf("cross-directory rename failed: %w", err) + } + if err := os.Link(renamed, linked); err != nil { + return fmt.Errorf("cross-directory hard link failed: %w", err) + } + contents, err := os.ReadFile(linked) + if err != nil || string(contents) != "refer-ok" { + return fmt.Errorf("cross-directory rename/link produced contents %q: %v", contents, err) + } + return nil +} + +func startLandlockTestEcho(t *testing.T, path string) net.Listener { + t.Helper() + listener, err := net.Listen("unix", path) + if err != nil { + t.Fatal(err) + } + go func() { + for { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + go func() { + defer conn.Close() + buf := make([]byte, 4) + if _, readErr := io.ReadFull(conn, buf); readErr == nil { + _, _ = conn.Write(buf) + } + }() + } + }() + return listener +} + +func serveLandlockTestProxy(listener net.Listener, expectedAuth string) { + conn, err := listener.Accept() + if err != nil { + return + } + defer conn.Close() + request, err := http.ReadRequest(bufio.NewReader(conn)) + if err != nil { + return + } + if request.Header.Get("Proxy-Authorization") != expectedAuth { + _, _ = io.WriteString(conn, "HTTP/1.1 407 Proxy Authentication Required\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + return + } + _, _ = io.WriteString(conn, "HTTP/1.1 200 OK\r\nContent-Length: 8\r\nConnection: close\r\n\r\nproxy-ok") +} + +func setLandlockTestEnv(environment []string, key, value string) []string { + prefix := key + "=" + result := make([]string, 0, len(environment)+1) + for _, item := range environment { + if !strings.HasPrefix(item, prefix) { + result = append(result, item) + } + } + return append(result, prefix+value) +} diff --git a/native/canvastty-helper/network_bridge_other.go b/native/canvastty-helper/network_bridge_other.go new file mode 100644 index 00000000..3286d1a8 --- /dev/null +++ b/native/canvastty-helper/network_bridge_other.go @@ -0,0 +1,13 @@ +//go:build !linux + +package main + +import ( + "fmt" + "os" +) + +func runNetworkBridge(_ []string) int { + fmt.Fprintln(os.Stderr, "canvastty-helper network-bridge is available on Linux only") + return 2 +} diff --git a/package-lock.json b/package-lock.json index 301d8211..a66afa8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,7 @@ "@xterm/addon-web-links": "0.12.0", "@xterm/addon-webgl": "0.19.0", "@xterm/xterm": "6.0.0", - "electron": "43.2.0", + "electron": "43.7.7", "electron-builder": "26.15.3", "electron-vite": "5.0.0", "node-gyp": "12.4.0", @@ -1068,9 +1068,9 @@ "license": "MIT" }, "node_modules/@electron/asar/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -1270,9 +1270,9 @@ "license": "MIT" }, "node_modules/@electron/universal/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "dev": true, "license": "MIT", "dependencies": { @@ -3397,32 +3397,6 @@ "win32" ] }, - "node_modules/@sindresorhus/is": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", - "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" - } - }, - "node_modules/@szmarczak/http-timer": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", - "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", - "dev": true, - "license": "MIT", - "dependencies": { - "defer-to-connect": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -3468,19 +3442,6 @@ "@babel/types": "^7.28.2" } }, - "node_modules/@types/cacheable-request": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", - "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/http-cache-semantics": "*", - "@types/keyv": "^3.1.4", - "@types/node": "*", - "@types/responselike": "^1.0.0" - } - }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -3508,23 +3469,6 @@ "@types/node": "*" } }, - "node_modules/@types/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/keyv": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", - "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -3562,16 +3506,6 @@ "@types/react": "^19.2.0" } }, - "node_modules/@types/responselike": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", - "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@typescript/typescript-aix-ppc64": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", @@ -4110,53 +4044,6 @@ "electron-builder-squirrel-windows": "26.15.3" } }, - "node_modules/app-builder-lib/node_modules/@electron/get": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@electron/get/-/get-3.1.0.tgz", - "integrity": "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.1.1", - "env-paths": "^2.2.0", - "fs-extra": "^8.1.0", - "got": "^11.8.5", - "progress": "^2.0.3", - "semver": "^6.2.0", - "sumchecker": "^3.0.1" - }, - "engines": { - "node": ">=14" - }, - "optionalDependencies": { - "global-agent": "^3.0.0" - } - }, - "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/app-builder-lib/node_modules/ci-info": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", @@ -4173,16 +4060,6 @@ "node": ">=8" } }, - "node_modules/app-builder-lib/node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/app-builder-lib/node_modules/isexe": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", @@ -4193,16 +4070,6 @@ "node": ">=18" } }, - "node_modules/app-builder-lib/node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, - "license": "MIT", - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, "node_modules/app-builder-lib/node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -4216,16 +4083,6 @@ "node": ">=10" } }, - "node_modules/app-builder-lib/node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/app-builder-lib/node_modules/which": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", @@ -4360,19 +4217,10 @@ "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", "license": "MIT" }, - "node_modules/boolean": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", - "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", - "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4498,35 +4346,6 @@ "node": ">=8" } }, - "node_modules/cacheable-lookup": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", - "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.6.0" - } - }, - "node_modules/cacheable-request": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", - "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", - "dev": true, - "license": "MIT", - "dependencies": { - "clone-response": "^1.0.2", - "get-stream": "^5.1.0", - "http-cache-semantics": "^4.0.0", - "keyv": "^4.0.0", - "lowercase-keys": "^2.0.0", - "normalize-url": "^6.0.1", - "responselike": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -4648,19 +4467,6 @@ "node": ">=12" } }, - "node_modules/clone-response": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", - "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-response": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -4797,35 +4603,6 @@ } } }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/decompress-response/node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/default-browser": { "version": "5.5.1", "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.1.tgz", @@ -4856,35 +4633,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/defer-to-connect": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", - "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/define-lazy-prop": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", @@ -4898,25 +4646,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -4937,14 +4666,6 @@ "node": ">=8" } }, - "node_modules/detect-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", - "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/dir-compare": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", @@ -4964,9 +4685,9 @@ "license": "MIT" }, "node_modules/dir-compare/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -5070,9 +4791,9 @@ } }, "node_modules/electron": { - "version": "43.2.0", - "resolved": "https://registry.npmjs.org/electron/-/electron-43.2.0.tgz", - "integrity": "sha512-80zvrgG7ZRXD+tD0IyLvrnN9n+veSxadMRsMaC9wKKP3iUbtC7rGM8+dVuCmOb0Rrwwv8ESW4awnUZh9Hbp1fA==", + "version": "43.7.7", + "resolved": "https://registry.npmjs.org/electron/-/electron-43.7.7.tgz", + "integrity": "sha512-y0pg+KhWLAkW2Ts2njkFFNFl3KtB1FmbUp9Te9i+R2Og2v1fcI/I6MADJ7jvnzB5UKdt+U+0EUoCnVL79xMPMA==", "dev": true, "license": "MIT", "dependencies": { @@ -5300,16 +5021,6 @@ "integrity": "sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw==", "license": "MIT" }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, "node_modules/env-paths": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", @@ -5379,14 +5090,6 @@ "node": ">= 0.4" } }, - "node_modules/es6-error": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", - "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -5439,20 +5142,6 @@ "node": ">=6" } }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", @@ -5547,9 +5236,9 @@ "license": "MIT" }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "dev": true, "license": "MIT", "dependencies": { @@ -5691,22 +5380,6 @@ "node": ">= 0.4" } }, - "node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -5737,9 +5410,9 @@ "license": "MIT" }, "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -5760,57 +5433,6 @@ "node": "*" } }, - "node_modules/global-agent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", - "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "dependencies": { - "boolean": "^3.0.1", - "es6-error": "^4.1.1", - "matcher": "^3.0.0", - "roarr": "^2.15.3", - "semver": "^7.3.2", - "serialize-error": "^7.0.1" - }, - "engines": { - "node": ">=10.0" - } - }, - "node_modules/global-agent/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "optional": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/globalthis": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", - "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "define-properties": "^1.2.1", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -5824,32 +5446,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/got": { - "version": "11.8.6", - "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", - "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sindresorhus/is": "^4.0.0", - "@szmarczak/http-timer": "^4.0.5", - "@types/cacheable-request": "^6.0.1", - "@types/responselike": "^1.0.0", - "cacheable-lookup": "^5.0.3", - "cacheable-request": "^7.0.2", - "decompress-response": "^6.0.0", - "http2-wrapper": "^1.0.0-beta.5.2", - "lowercase-keys": "^2.0.0", - "p-cancelable": "^2.0.0", - "responselike": "^2.0.0" - }, - "engines": { - "node": ">=10.19.0" - }, - "funding": { - "url": "https://github.com/sindresorhus/got?sponsor=1" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -5866,20 +5462,6 @@ "node": ">=8" } }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -5961,13 +5543,6 @@ "dev": true, "license": "ISC" }, - "node_modules/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", - "dev": true, - "license": "BSD-2-Clause" - }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -5982,20 +5557,6 @@ "node": ">= 14" } }, - "node_modules/http2-wrapper": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", - "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "quick-lru": "^5.1.1", - "resolve-alpn": "^1.0.0" - }, - "engines": { - "node": ">=10.19.0" - } - }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -6248,13 +5809,6 @@ "node": ">=6" } }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -6262,14 +5816,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "dev": true, - "license": "ISC", - "optional": true - }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -6295,16 +5841,6 @@ "graceful-fs": "^4.1.6" } }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, "node_modules/lazy-val": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", @@ -6592,16 +6128,6 @@ "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", "license": "MIT" }, - "node_modules/lowercase-keys": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", - "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -6622,20 +6148,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/matcher": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", - "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "escape-string-regexp": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -6682,16 +6194,6 @@ "node": ">= 0.6" } }, - "node_modules/mimic-response": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", - "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/minimatch": { "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", @@ -6904,9 +6406,9 @@ } }, "node_modules/node-gyp/node_modules/undici": { - "version": "6.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", - "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", + "version": "6.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.0.tgz", + "integrity": "sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==", "dev": true, "license": "MIT", "engines": { @@ -6971,30 +6473,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/normalize-url": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", - "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -7026,16 +6504,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-cancelable": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", - "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -7283,17 +6751,6 @@ "signal-exit": "^3.0.2" } }, - "node_modules/pump": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", - "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, "node_modules/pvtsutils": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", @@ -7330,19 +6787,6 @@ "qrcode-terminal": "bin/qrcode-terminal.js" } }, - "node_modules/quick-lru": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", - "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", @@ -7442,26 +6886,6 @@ "url": "https://github.com/sponsors/jet2jet" } }, - "node_modules/resolve-alpn": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", - "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", - "dev": true, - "license": "MIT" - }, - "node_modules/responselike": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", - "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", - "dev": true, - "license": "MIT", - "dependencies": { - "lowercase-keys": "^2.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -7487,25 +6911,6 @@ "rimraf": "bin.js" } }, - "node_modules/roarr": { - "version": "2.15.4", - "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", - "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "dependencies": { - "boolean": "^3.0.1", - "detect-node": "^2.0.4", - "globalthis": "^1.0.1", - "json-stringify-safe": "^5.0.1", - "semver-compare": "^1.0.0", - "sprintf-js": "^1.1.2" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/rolldown": { "version": "1.2.9", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz", @@ -7690,31 +7095,6 @@ "semver": "bin/semver.js" } }, - "node_modules/semver-compare": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", - "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/serialize-error": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", - "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "type-fest": "^0.13.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -7802,14 +7182,6 @@ "source-map": "^0.6.0" } }, - "node_modules/sprintf-js": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true - }, "node_modules/stat-mode": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", @@ -8016,20 +7388,6 @@ "dev": true, "license": "0BSD" }, - "node_modules/type-fest": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", - "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "optional": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/typescript": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", @@ -8066,9 +7424,9 @@ } }, "node_modules/undici": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", - "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "version": "7.30.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", + "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", "dev": true, "license": "MIT", "optional": true, diff --git a/package.json b/package.json index 29a2321b..12563b4b 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "package:mac": "npm run build:mac-updater && npm run build && electron-builder --mac dmg zip --arm64 --publish never", "build:mac-updater": "node scripts/build-mac-updater.mjs", "smoke:browser": "node scripts/smoke-browser-electron.mjs", + "smoke:github-auth": "node scripts/smoke-github-auth-electron.mjs", "smoke:browser:providers": "node scripts/smoke-browser-providers.mjs --deterministic", "smoke:browser:provider-helper": "node scripts/smoke-browser-providers.mjs --direct", "smoke:browser:providers:live": "node scripts/smoke-browser-providers.mjs --all", @@ -34,7 +35,9 @@ "audit:secrets": "node scripts/audit-secrets.mjs", "build:windows-pipe-host": "node scripts/build-windows-agent-pipe-host.mjs", "test:windows-pipe-host": "node scripts/build-windows-agent-pipe-host.mjs --require-windows --self-test", - "test": "node --test --test-concurrency=1 --test-reporter=tap tests/*.test.mjs", + "test": "node --test --test-concurrency=4 --test-reporter=tap tests/*.test.mjs", + "test:fast": "node --test --test-concurrency=8 --test-reporter=tap tests/*.test.mjs", + "test:serial": "node --test --test-concurrency=1 --test-reporter=tap tests/*.test.mjs", "typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json", "postinstall": "install-electron && electron-rebuild -f -w node-pty && node scripts/build-windows-agent-pipe-host.mjs", "build:even": "npm run build --workspace integrations/even-g2", @@ -46,6 +49,7 @@ "build:helpers": "node scripts/build-native-helpers.mjs", "pack:even": "npm run pack --workspace integrations/even-g2", "smoke:terminal-hidden": "node scripts/smoke-hidden-terminal-electron.mjs", + "smoke:plugin-review": "node scripts/smoke-plugin-review-electron.mjs", "bench:terminal-hidden-renderer": "node scripts/smoke-hidden-terminal-electron.mjs --benchmark" }, "dependencies": { @@ -67,7 +71,7 @@ "@xterm/addon-web-links": "0.12.0", "@xterm/addon-webgl": "0.19.0", "@xterm/xterm": "6.0.0", - "electron": "43.2.0", + "electron": "43.7.7", "electron-builder": "26.15.3", "electron-vite": "5.0.0", "node-gyp": "12.4.0", @@ -77,7 +81,8 @@ "vite": "7.3.6" }, "overrides": { - "js-yaml": "4.3.2" + "js-yaml": "4.3.2", + "@electron/get": "5.1.0" }, "allowScripts": { "esbuild@0.25.12": true, diff --git a/scripts/audit-secrets.mjs b/scripts/audit-secrets.mjs index 8e0bbe04..df3412e9 100644 --- a/scripts/audit-secrets.mjs +++ b/scripts/audit-secrets.mjs @@ -20,6 +20,9 @@ const IGNORED_ENTRY_NAMES = new Set([ // objects, which is build-environment noise rather than publishable content. // `build/` itself is not ignored: it also holds tracked icons and resources. const IGNORED_RELATIVE_PATHS = new Set([ + // Private local context is source-side only. An accidental out/.context copy + // remains subject to the artifact audit. + ".context", "build/windows-agent-pipe-host", "native/windows-agent-pipe-host/build" ]); diff --git a/scripts/bench-redaction-optimization.mjs b/scripts/bench-redaction-optimization.mjs new file mode 100644 index 00000000..194eeebf --- /dev/null +++ b/scripts/bench-redaction-optimization.mjs @@ -0,0 +1,65 @@ +#!/usr/bin/env node +// Deterministic synthetic terminal output; never reads application state or real secrets. +// Node 24+: node scripts/bench-redaction-optimization.mjs [--module path] [--runs 3] +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const args = process.argv.slice(2); +const option = (name, fallback) => { + const index = args.indexOf(name); + return index < 0 ? fallback : args[index + 1]; +}; +const modulePath = resolve(option('--module', 'src/main/services/safety/SecretRedaction.ts')); +const digest = value => createHash('sha256').update(value).digest('hex'); +const median = values => [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)]; + +if (args.includes('--child')) { + const { SecretRedactionRegistry } = await import(pathToFileURL(modulePath).href); + const ordinaryLine = 'Compilation finished; waiting.\n'; + const padding = ordinaryLine.repeat(1_500); + const secrets = Array.from({ length: 64 }, (_, i) => `fixture${String(i).padStart(3, '0')}Z${'abCD42'.repeat(2_700)}`); + const registry = new SecretRedactionRegistry(); + registry.add('benchmark', secrets); + const chunks = secrets.map(secret => padding + secret.slice(0, 8_000) + '\n│ ' + secret.slice(8_000) + '\n'); + const input = chunks.join(''); + const expected = secrets.map(() => padding + '\n').join(''); + assert.ok(input.length <= 16_000_000, 'fixture must fit the history limit'); + const started = performance.now(); + const output = registry.redact(input); + const redactionMs = performance.now() - started; + assert.equal(output, expected, 'whole output including all wrapped held values must remain exact'); + const ordinary = ordinaryLine.repeat(100_000); + const ordinaryStarted = performance.now(); + assert.equal(registry.redact(ordinary), ordinary); + const ordinaryMs = performance.now() - ordinaryStarted; + process.stdout.write(JSON.stringify({ redaction_ms: redactionMs, ordinary_ms: ordinaryMs, + correctness: true, input_chars: input.length, known_values: secrets.length, + output_sha256: digest(output), max_rss_kib: process.resourceUsage().maxRSS, + array_buffers_bytes: process.memoryUsage().arrayBuffers })); +} else { + const runs = Number(option('--runs', '3')); + assert.ok(Number.isSafeInteger(runs) && runs > 0 && runs <= 10); + const samples = []; + for (let run = 0; run < runs; run++) { + const child = spawnSync(process.execPath, ['--max-old-space-size=128', '--max-semi-space-size=2', + fileURLToPath(import.meta.url), '--child', '--module', modulePath], { + encoding: 'utf8', timeout: 60_000, maxBuffer: 1_000_000, + env: { PATH: process.env.PATH, SystemRoot: process.env.SystemRoot } + }); + assert.equal(child.status, 0, child.stderr || String(child.error)); + samples.push(JSON.parse(child.stdout)); + process.stderr.write(`redaction run ${run + 1}/${runs}: ${samples.at(-1).redaction_ms.toFixed(1)} ms\n`); + } + assert.ok(samples.every(sample => sample.correctness && sample.output_sha256 === samples[0].output_sha256)); + process.stdout.write(JSON.stringify({ node: process.version, platform: process.platform, + source_sha256: digest(readFileSync(modulePath)), runs, correctness: true, + redaction_ms: median(samples.map(sample => sample.redaction_ms)), + ordinary_ms: median(samples.map(sample => sample.ordinary_ms)), + max_rss_kib: median(samples.map(sample => sample.max_rss_kib)), + array_buffers_bytes: median(samples.map(sample => sample.array_buffers_bytes)), + input_chars: samples[0].input_chars, output_sha256: samples[0].output_sha256, samples }, null, 2) + '\n'); +} diff --git a/scripts/bench-renderer-graph.mjs b/scripts/bench-renderer-graph.mjs new file mode 100644 index 00000000..5037491f --- /dev/null +++ b/scripts/bench-renderer-graph.mjs @@ -0,0 +1,31 @@ +#!/usr/bin/env node +// Measure the actual built HTML's static JavaScript graph, including shared chunks. +// esbuild only parses each emitted file here; it does not rebundle or estimate sizes. +import assert from 'node:assert/strict'; +import { readFile, readdir, stat } from 'node:fs/promises'; +import { dirname, join, relative, resolve } from 'node:path'; +import { build } from 'esbuild'; +const directory = resolve(process.argv[2] ?? 'out/renderer'); +const html = await readFile(join(directory, 'index.html'), 'utf8'); +const entries = [...html.matchAll(/]*\bsrc="([^"]+)"[^>]*>/gu)].map(match => resolve(directory, match[1])); +assert.ok(entries.length > 0, 'built HTML must name an entry script'); +const files = new Map(); +async function visit(path) { + if (files.has(path)) return; + assert.ok(relative(directory, path) && !relative(directory, path).startsWith('..'), 'static imports must stay inside built renderer'); + const source = await readFile(path); + files.set(path, source.length); + const parsed = await build({entryPoints:[path], bundle:false, write:false, metafile:true, format:'esm', platform:'browser', logLevel:'silent'}); + for (const output of Object.values(parsed.metafile.outputs)) for (const imported of output.imports) { + if (imported.kind === 'import-statement') { + assert.ok(imported.path.startsWith('.'), `unexpected external renderer import: ${imported.path}`); + await visit(resolve(dirname(path), imported.path)); + } + } +} +for (const entry of entries) await visit(entry); +const assets = join(directory, 'assets'); +const jsFiles = (await readdir(assets)).filter(name => name.endsWith('.js')); +const totalBytes = (await Promise.all(jsFiles.map(name => stat(join(assets, name))))).reduce((sum, file) => sum + file.size, 0); +process.stdout.write(JSON.stringify({correctness:true, eager_js_bytes:[...files.values()].reduce((sum,size)=>sum+size,0), total_js_bytes:totalBytes, + static_files:[...files].map(([path,bytes])=>({path:relative(directory,path),bytes})), all_chunks:jsFiles.length}, null, 2) + '\n'); diff --git a/scripts/bench-runtime/app/boot.cjs b/scripts/bench-runtime/app/boot.cjs index 881ba38b..f602a3bd 100644 --- a/scripts/bench-runtime/app/boot.cjs +++ b/scripts/bench-runtime/app/boot.cjs @@ -137,10 +137,19 @@ async function scenarios(win) { const ids = []; for (let i = 0; i < terminals; i++) { const position = { x: 80 + (i % 4) * 760, y: 1400 + Math.floor(i / 4) * 520 }; - const created = await js(`window.canvasTTY.terminal.create({ provider: "terminal", profile: "normal", cwd: ${JSON.stringify(env.BENCH_WORK)}, position: ${JSON.stringify(position)} }).then((s) => s.id)`); + const taskRelation = i === 1 && ids[0] + ? `, role: "subagent", parentSessionId: ${JSON.stringify(ids[0])}` : ""; + const created = await js(`window.canvasTTY.terminal.create({ provider: "terminal", profile: "normal", cwd: ${JSON.stringify(env.BENCH_WORK)}, position: ${JSON.stringify(position)}${taskRelation} }).then((s) => s.id)`); ids.push(created); } await wait(panOnly ? 3000 : 8000); + const edgeDeadline = Date.now() + 15_000; + let taskEdgeCount = 0; + while (terminals >= 2 && taskEdgeCount === 0 && Date.now() < edgeDeadline) { + taskEdgeCount = Number(await js("document.querySelectorAll('.workspace__task-edge').length")); + if (taskEdgeCount === 0) await wait(50); + } + if (terminals >= 2 && taskEdgeCount === 0) throw new Error("the parent-child task edge did not render"); if (!panOnly) { report.scenarios.terminals = await sample(10); @@ -176,6 +185,9 @@ async function scenarios(win) { commits: countsAfter.commits - countsBefore.commits, componentRendersPerMove: Math.round((countsAfter.rendered - countsBefore.rendered) / PAN_MOVES * 10) / 10, terminalCardRendersPerMove: Math.round((countsAfter.terminalCards - countsBefore.terminalCards) / PAN_MOVES * 10) / 10, + taskEdgeCount, + taskEdgeHostUpdatesDuringPan: countsAfter.taskEdgeHosts - countsBefore.taskEdgeHosts, + taskEdgeHostUpdatesPerMove: Math.round((countsAfter.taskEdgeHosts - countsBefore.taskEdgeHosts) / PAN_MOVES * 100) / 100, rendererCpu: pan.cpu.renderer, // Renderer main-thread milliseconds per move: JavaScript, style, layout, all tasks. rendererMsPerMove: perMove(timesAfter, timesBefore, PAN_MOVES), diff --git a/scripts/bench-runtime/app/render-counter.cjs b/scripts/bench-runtime/app/render-counter.cjs index b837a7d6..5991f33d 100644 --- a/scripts/bench-runtime/app/render-counter.cjs +++ b/scripts/bench-runtime/app/render-counter.cjs @@ -5,8 +5,9 @@ const { contextBridge } = require("electron"); contextBridge.executeInMainWorld({ func: () => { - const counts = { commits: 0, rendered: 0, terminalCards: 0, byName: {} }; + const counts = { commits: 0, rendered: 0, terminalCards: 0, taskEdgeHosts: 0, byName: {} }; const seen = new WeakSet(); + const seenTaskEdges = new WeakSet(); const renderers = new Map(); const walk = (root) => { const stack = [root]; @@ -25,6 +26,15 @@ contextBridge.executeInMainWorld({ if ("focusChangeSource" in props && "session" in props) counts.terminalCards++; } } + // Workspace task edges are inline host divs rather than their own component. Count committed + // edge prop objects separately so a pan run can measure their React reconciliation directly. + if (fiber.tag === 5) { + const props = fiber.memoizedProps; + if (props?.className === "workspace__task-edge" && !seenTaskEdges.has(props)) { + seenTaskEdges.add(props); + counts.taskEdgeHosts++; + } + } if (fiber.sibling) stack.push(fiber.sibling); if (fiber.child) stack.push(fiber.child); } diff --git a/scripts/bench-startup-optimization.mjs b/scripts/bench-startup-optimization.mjs new file mode 100644 index 00000000..5f5c2c28 --- /dev/null +++ b/scripts/bench-startup-optimization.mjs @@ -0,0 +1,109 @@ +#!/usr/bin/env node +// Fresh homes and hidden real Electron windows; no real accounts, keychain, plugins or paid agents. +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +const repo = resolve(fileURLToPath(new URL('..', import.meta.url))); +const self = fileURLToPath(import.meta.url); +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); +const metricBoundary = 'built main entry import to first stable frame'; +assert.equal(process.platform, 'darwin', 'The startup optimization benchmark is macOS-only.'); +const temporaryRoot = '/private/tmp'; + +if (!process.versions.electron) { + const electron = createRequire(import.meta.url)('electron'); + const samples = []; + for (let run = 0; run < 3; run++) { + const fixture = await mkdtemp(join(temporaryRoot, 'ctty-boot-')); + try { + await mkdir(join(fixture, 'home')); await mkdir(join(fixture, 'project')); await mkdir(join(fixture, 'empty-bin')); + // Electron treats a directory as an app, so main points only at this controlled harness. + await writeFile(join(fixture, 'package.json'), JSON.stringify({type:'module', main:self})); + const env = {HOME:join(fixture,'home'), USERPROFILE:join(fixture,'home'), PATH:join(fixture,'empty-bin'), + TMPDIR:temporaryRoot, LANG:'en_US.UTF-8', SHELL:'/bin/sh', CTTY_BOOT_FIXTURE:fixture, + CANVASTTY_USER_DATA_DIR:join(fixture,'data')}; + const log = await new Promise((resolveOutput, reject) => { + const child = spawn(electron, [fixture], {cwd:repo, env, stdio:['ignore','pipe','pipe']}); + let output = ''; + let timedOut = false; + let spawnError = null; + const timer = setTimeout(() => {timedOut = true; child.kill('SIGKILL');}, 35_000); + child.stdout.on('data', part => { output += part; }); child.stderr.on('data', part => { output += part; }); + child.once('error', error => {spawnError = error;}); + child.once('close', code => { + clearTimeout(timer); + if (timedOut) reject(Error('startup fixture timed out')); + else if (spawnError) reject(spawnError); + else if (code === 0) resolveOutput(output); + else reject(Error(output)); + }); + }); + const match = /^CTTY_BOOT_RESULT (.+)$/mu.exec(log); + assert.ok(match, log); + samples.push(JSON.parse(match[1])); + process.stderr.write(`startup run ${run+1}/3: ${samples.at(-1).first_stable_frame_ms} ms\n`); + } finally { await rm(fixture, {recursive:true, force:true}); } + } + const median = key => samples.map(row=>row[key]).sort((a,b)=>a-b)[1]; + process.stdout.write(JSON.stringify({correctness:true, metric_boundary:metricBoundary, + first_stable_frame_ms:median('first_stable_frame_ms'), + terminal_ready_ms:median('terminal_ready_ms'), samples},null,2)+'\n'); +} else { + const electron = await import('electron'); + const { registerHooks } = await import('node:module'); + const fixture = process.env.CTTY_BOOT_FIXTURE; + assert.ok(fixture && fixture.startsWith(temporaryRoot + '/')); + const main = pathToFileURL(join(repo,'out/main/')).href; + const shim = pathToFileURL(join(repo,'scripts/bench-runtime/app/hidden-electron.mjs')).href; + const noKeychain = pathToFileURL(join(repo,'scripts/bench-runtime/app/no-keychain.mjs')).href; + registerHooks({resolve(specifier,context,next){ + if(context.parentURL?.startsWith(main)) { + if(specifier==='electron') return {url:shim,format:'module',shortCircuit:true}; + if(specifier==='node:child_process'||specifier==='child_process')return {url:noKeychain,format:'module',shortCircuit:true}; + } + return next(specifier,context); + }}); + electron.app.commandLine.appendSwitch('use-mock-keychain'); electron.app.dock?.hide(); + electron.app.setPath('userData',join(fixture,'data')); + const began = Date.now(); + let started = false; + electron.app.on('browser-window-created',(_event,window)=>{ + if(started)return; started=true; + const errors=[]; + window.webContents.on('console-message',event=>{if(event.level==='error')errors.push(event.message);}); + void (async()=>{ + try { + const read = code => window.webContents.executeJavaScript(code,true).catch(()=>null); + let marks; + for(let retry=0;retry<500;retry++) { + marks=await read('window.__canvasTTYBootMarks'); + if(marks?.some(row=>row.name==='firstStableFrame'))break; + await sleep(20); + } + const first=marks?.find(row=>row.name==='firstStableFrame'); assert.ok(first,'first stable frame was not reached'); + const requestStarted=Date.now(); + const project=join(fixture,'project'); + const session=await read(`window.canvasTTY.terminal.create({provider:'terminal',profile:'normal',cwd:${JSON.stringify(project)},position:{x:0,y:0},size:{width:720,height:400}})`); + assert.ok(session?.id,'fixture shell creation failed'); + await read(`window.canvasTTY.terminal.input(${JSON.stringify(session.id)}, 'echo CTTY_BOOT_READY\\r')`); + let ready=false; + for(let retry=0;retry<500;retry++) { + ready=await read(`Boolean(document.querySelector('[data-session-id="${session.id}"] .xterm-screen')) && window.canvasTTY.terminal.readBuffer(${JSON.stringify(session.id)}).then(row=>row.buffer.includes('CTTY_BOOT_READY'))`); + if(ready)break; + await sleep(20); + } + assert.ok(ready,'terminal chunk or fixture shell did not become ready'); + assert.equal(window.isVisible(),false); assert.equal(window.isFocused(),false); + assert.deepEqual(errors,[],'renderer console errors'); + await read(`window.canvasTTY.terminal.dispose(${JSON.stringify(session.id)})`); + console.log('CTTY_BOOT_RESULT '+JSON.stringify({metric_boundary:metricBoundary, + first_stable_frame_ms:first.epochMs-began,terminal_ready_ms:Date.now()-requestStarted,marks})); + electron.app.quit(); + } catch(error) { console.error(error.stack ?? error);electron.app.exit(1); } + })(); + }); + await import(pathToFileURL(join(repo,'out/main/index.js'))); +} diff --git a/scripts/bench-usage-optimization.mjs b/scripts/bench-usage-optimization.mjs new file mode 100644 index 00000000..46bc222c --- /dev/null +++ b/scripts/bench-usage-optimization.mjs @@ -0,0 +1,57 @@ +#!/usr/bin/env node +// Fresh synthetic journal, public load/usage APIs, no application data or private homes. +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +const root = resolve(process.argv[2] ?? '.'); +const { SessionTimelineService } = await import(pathToFileURL(join(root, 'src/main/services/SessionTimelineService.ts'))); +const directory = await mkdtemp(join(tmpdir(), 'canvastty-usage-bench-')); +try { + const journal = join(directory, 'session-timeline'); + await mkdir(journal); + const sessions = 1_000, counters = 8; + const events = []; + for (let session = 0; session < sessions; session++) for (let counter = 0; counter < counters; counter++) { + const sessionId = `session-${session}`; + events.push(JSON.stringify({id: `event-${session}-${counter}`, sessionId, at: 1_000, type: 'usage', summary: 'Fixture usage', + detail: JSON.stringify({input:100 + counter, output:10, total:110 + counter, cost:counter / 1_000, + source:'fixture-provider', cumulative:true, counterId:`counter-${counter}`})})); + } + await writeFile(join(journal, '0000000000001000-fixture.ndjson'), events.join('\n') + '\n'); + const service = new SessionTimelineService(directory, text => text); + await service.load(); + const sampleUsage = service.usage(['session-0']); + const sampleRows = service.usageCounters('session-0'); + assert.deepEqual(sampleUsage.tokens, {input:828, output:80, total:908}); + assert.equal(sampleUsage.source, 'fixture-provider'); + assert.equal(sampleRows.length, counters); + assert.deepEqual(sampleRows.map(row => row.legacyId), Array.from({length:counters}, (_, i) => `counter-${i}`)); + assert.equal(sampleRows.reduce((sum, row) => sum + row.tokens, 0), sampleUsage.tokens.total); + assert.equal(sampleRows.reduce((sum, row) => sum + row.costUsd, 0), sampleUsage.cost); + + const refresh = () => { + let tokenSum = 0, counterCount = 0; + for (let session = 0; session < sessions; session++) { + const id = `session-${session}`; + const usage = service.usage([id]); + const rows = service.usageCounters(id); + tokenSum += usage.tokens.total; counterCount += rows.length; + } + return {tokenSum, counterCount}; + }; + const assertTotals = ({tokenSum, counterCount}) => { + assert.equal(tokenSum, 908_000); assert.equal(counterCount, 8_000); + }; + assertTotals(refresh()); // Identical warm-up before timing every sample. + const samples = []; + for (let run = 0; run < 3; run++) { + const started = performance.now(); + const totals = refresh(); + samples.push(performance.now() - started); + assertTotals(totals); + } + process.stdout.write(JSON.stringify({node:process.version, sessions, counters, correctness:true, + refresh_ms:[...samples].sort((a,b)=>a-b)[1], samples, heap_used_bytes:process.memoryUsage().heapUsed}, null, 2) + '\n'); +} finally { await rm(directory, {recursive:true, force:true}); } diff --git a/scripts/bench/app/boot.cjs b/scripts/bench/app/boot.cjs index 59b3db93..5728d35a 100644 --- a/scripts/bench/app/boot.cjs +++ b/scripts/bench/app/boot.cjs @@ -102,6 +102,7 @@ function kindOf(command) { if (helper) return /^\/bin\/sh -c/u.test(command) ? "wrapper" : helper[1]; const native = /canvastty-helper (mcp-browser|mcp-orchestration|permission-gate|hook)\b/u.exec(command); if (native) return /^\/bin\/sh -c/u.test(command) ? "wrapper" : `native:${native[1]}`; + if (command.includes("TerminalOutputHistoryWorker")) return "history-worker"; if (command.includes("flood.mjs")) return "flood"; if (/(^|\/)-?(zsh|bash|sh)(\s|$)/u.test(command)) return "shell"; return `other:${command.split(/\s+/u)[0].split("/").at(-1)}`; diff --git a/scripts/lib/run-electron-smoke.mjs b/scripts/lib/run-electron-smoke.mjs new file mode 100644 index 00000000..e42c3c8f --- /dev/null +++ b/scripts/lib/run-electron-smoke.mjs @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; + +export async function runElectronSmoke(executable, script, environment, marker) { + const child = spawn(executable, [script, "--disable-gpu"], { + env: environment, stdio: ["ignore", "pipe", "pipe"] + }); + let output = "", errorOutput = ""; + child.stdout.on("data", chunk => { output += chunk; process.stdout.write(chunk); }); + child.stderr.on("data", chunk => { errorOutput += chunk; process.stderr.write(chunk); }); + const timeout = setTimeout(() => child.kill("SIGKILL"), 90_000); + try { + const result = await new Promise((resolve, reject) => { + child.once("error", reject); + // Wait for the pipes to drain, including a marker written just before exit. + child.once("close", (code, signal) => resolve({ code, signal })); + }); + assert.equal(result.signal, null, `${marker}: timed out or crashed\n${errorOutput}`); + assert.equal(result.code, 0, `${marker}: failed\n${errorOutput}`); + assert.ok(output.includes(marker), `${marker}: did not finish`); + return { output, errorOutput }; + } finally { + clearTimeout(timeout); + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + } +} diff --git a/scripts/smoke-browser-electron.mjs b/scripts/smoke-browser-electron.mjs index 38dd7c5a..bb1b9878 100644 --- a/scripts/smoke-browser-electron.mjs +++ b/scripts/smoke-browser-electron.mjs @@ -1,6 +1,6 @@ import { spawn } from "node:child_process"; import { createServer } from "node:http"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -10,6 +10,7 @@ const PROJECT_ROOT = resolve(fileURLToPath(new URL("..", import.meta.url))); const READY_MARKER = "CANVASTTY_BROWSER_SMOKE_READY"; const TIMEOUT_MS = 60_000; const MAX_OUTPUT_BYTES = 128 * 1024; +const layeringOnly = process.argv.includes("--layering-only"); const fixture = await startFixture(); // Keep the macOS Unix-domain-socket endpoint below its 104-byte kernel limit. @@ -17,6 +18,8 @@ const fixture = await startFixture(); // private random test directory just like the Linux CI path. const smokeTempRoot = process.platform === "win32" ? tmpdir() : "/tmp"; const userDataPath = await mkdtemp(join(smokeTempRoot, "ctb-")); +const fakeHomePath = join(userDataPath, "home"); +await mkdir(fakeHomePath, { recursive: true, mode: 0o700 }); let child; let output = ""; @@ -26,11 +29,31 @@ try { // root ownership and mode 4755. Product WebContents security is asserted by // unit tests; only this isolated CI process disables the outer Chromium sandbox. if (process.platform === "linux" && process.env.CI === "true") electronArgs.push("--no-sandbox"); + const smokeEnv = { + ...process.env, + HOME: fakeHomePath, + CODEX_HOME: join(fakeHomePath, ".codex"), + XDG_CONFIG_HOME: join(fakeHomePath, ".config"), + XDG_DATA_HOME: join(fakeHomePath, ".local", "share"), + XDG_STATE_HOME: join(fakeHomePath, ".local", "state"), + XDG_CACHE_HOME: join(fakeHomePath, ".cache"), + CANVASTTY_USER_DATA_DIR: userDataPath, + CANVASTTY_BROWSER_SMOKE_URL: fixture.origin, + CANVASTTY_BROWSER_SMOKE_LAYERING_ONLY: layeringOnly ? "1" : "0" + }; + if (process.platform === "win32") { + smokeEnv.USERPROFILE = fakeHomePath; + smokeEnv.APPDATA = join(fakeHomePath, "AppData", "Roaming"); + smokeEnv.LOCALAPPDATA = join(fakeHomePath, "AppData", "Local"); + } + // This smoke opens only a loopback fixture and must not inherit a usable provider account. + for (const name of Object.keys(smokeEnv)) { + if (/(?:^|_)(?:API_KEY|ACCESS_TOKEN|AUTH_TOKEN|OAUTH_TOKEN|CLIENT_SECRET|SECRET_ACCESS_KEY|APPLICATION_CREDENTIALS|GITHUB_TOKEN|TOKEN)$/i.test(name)) { + delete smokeEnv[name]; + } + } child = spawn(electronPath, electronArgs, { - env: { - ...process.env, - CANVASTTY_BROWSER_SMOKE_URL: fixture.origin - }, + env: smokeEnv, stdio: ["ignore", "pipe", "pipe"] }); await new Promise((resolveReady, reject) => { @@ -79,7 +102,9 @@ try { await waitForExit(child, 2_000).catch(() => child.kill("SIGKILL")); } await fixture.close(); - await rm(userDataPath, { recursive: true, force: true }); + // Chromium's helper processes can still be flushing the partition when the main process exits; retry the + // removal so a cleanup race cannot replace the smoke's real result with ENOTEMPTY. + await rm(userDataPath, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }); } async function startFixture() { diff --git a/scripts/smoke-github-auth-electron.mjs b/scripts/smoke-github-auth-electron.mjs new file mode 100644 index 00000000..3f64a4b1 --- /dev/null +++ b/scripts/smoke-github-auth-electron.mjs @@ -0,0 +1,340 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { createRequire } from "node:module"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; + +const self = fileURLToPath(import.meta.url); +const electron = createRequire(import.meta.url)("electron"); +const marker = "CANVASTTY_GITHUB_AUTH_SMOKE_OK"; +const timeoutMs = 60_000; +const authChannels = new Set([ + "github-auth:start", + "github-auth:status", + "github-auth:cancel", + "github-auth:open-url" +]); + +if (typeof electron === "string") { + await launchElectronFixture(electron); +} else { + await runElectronFixture(electron); +} + +async function launchElectronFixture(electronPath) { + const root = await mkdtemp(join(process.platform === "win32" ? tmpdir() : "/tmp", "ctgh-")); + const userData = join(root, "data"); + const fixtureHome = join(root, "home"); + const fixtureBin = join(root, "bin"); + await Promise.all([mkdir(userData), mkdir(fixtureHome), mkdir(fixtureBin)]); + await writeFile(join(userData, "settings.json"), JSON.stringify({ locale: "en", sessionRestoreMode: "continue" })); + await writeFile(join(userData, "terminal-sessions.json"), JSON.stringify({ version: 2, sessions: [] })); + await writeFile(join(root, "untrusted-renderer.html"), "Untrusted auth IPC fixture"); + + const environment = { + ...process.env, + PATH: fixtureBin, + HOME: fixtureHome, + CODEX_HOME: join(fixtureHome, ".codex"), + QWEN_HOME: join(fixtureHome, ".qwen"), + XDG_CONFIG_HOME: join(fixtureHome, ".config"), + XDG_DATA_HOME: join(fixtureHome, ".local", "share"), + XDG_STATE_HOME: join(fixtureHome, ".local", "state"), + XDG_CACHE_HOME: join(fixtureHome, ".cache"), + CANVASTTY_USER_DATA_DIR: userData, + CANVASTTY_GITHUB_AUTH_FIXTURE_DIR: root + }; + delete environment.ELECTRON_RUN_AS_NODE; + delete environment.GITHUB_OAUTH_CLIENT_ID; + delete environment.CANVASTTY_GITHUB_CLIENT_ID; + for (const name of Object.keys(environment)) { + if (/(?:^|_)(?:API_KEY|ACCESS_TOKEN|AUTH_TOKEN|OAUTH_TOKEN|CLIENT_SECRET|SECRET_ACCESS_KEY|APPLICATION_CREDENTIALS|GITHUB_TOKEN|TOKEN)$/i.test(name)) { + delete environment[name]; + } + } + if (process.platform === "win32") { + environment.USERPROFILE = fixtureHome; + environment.APPDATA = join(fixtureHome, "AppData", "Roaming"); + environment.LOCALAPPDATA = join(fixtureHome, "AppData", "Local"); + } + + const args = [self, "--disable-gpu"]; + if (process.platform === "linux" && process.env.CI === "true") args.push("--no-sandbox"); + const child = spawn(electronPath, args, { env: environment, stdio: ["ignore", "pipe", "pipe"] }); + let output = ""; + let outputLength = 0; + const collect = (chunk) => { + const text = chunk.toString("utf8"); + outputLength += text.length; + output = `${output}${text}`.slice(-32 * 1024); + }; + child.stdout.on("data", collect); + child.stderr.on("data", collect); + let timeout; + try { + const result = await new Promise((resolveExit, reject) => { + timeout = setTimeout(() => { + child.kill("SIGKILL"); + reject(new Error(`GitHub auth smoke exceeded its deadline.${capturedOutput(output, outputLength)}`)); + }, timeoutMs); + child.once("error", reject); + // close includes drained stdout/stderr; exit can precede the final marker. + child.once("close", (code, signal) => resolveExit({ code, signal })); + }); + if (result.signal !== null) { + assert.fail(`GitHub auth smoke timed out or crashed (signal ${result.signal}).${capturedOutput(output, outputLength)}`); + } + if (result.code !== 0) { + assert.fail(`GitHub auth smoke failed (exit ${result.code}).${capturedOutput(output, outputLength)}`); + } + const summary = output.split("\n").find((line) => line.startsWith(`${marker} `)); + assert.ok(summary, `GitHub auth smoke did not finish.${capturedOutput(output, outputLength)}`); + process.stdout.write(`${summary}\n`); + } finally { + clearTimeout(timeout); + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + await rm(root, { recursive: true, force: true }); + } +} + +function capturedOutput(output, totalLength) { + const maxLength = 12 * 1024; + let tail = output.slice(-maxLength); + tail = tail.replace( + /(["']?(?:access[_-]?token|refresh[_-]?token|id[_-]?token|user[_-]?code|device[_-]?code|oauth[_-]?code|client[_-]?secret|authorization)\b["']?\s*[:=]\s*)["']?[^"'\s,}]+["']?/gi, + "$1[REDACTED]" + ); + tail = tail.replace(/\bBearer\s+[^\s"',}]+/gi, "Bearer [REDACTED]"); + const truncated = totalLength > tail.length ? "\n[child output truncated to a bounded tail]" : ""; + return `\n--- captured child output ---\n${tail || "(empty)"}${truncated}\n--- end captured child output ---`; +} + +async function runElectronFixture(electronApi) { + const fixture = { + status: authStatus("idle"), + starts: 0, + statusReads: 0, + cancels: 0, + openUrlCalls: 0, + completeOnCancel: false, + untrustedStatusCalls: 0, + untrustedStatusRejected: 0 + }; + let primaryWindow = null; + let ran = false; + const originalHandle = electronApi.ipcMain.handle.bind(electronApi.ipcMain); + Object.defineProperty(electronApi.ipcMain, "handle", { + configurable: true, + value(channel, handler) { + if (!authChannels.has(channel)) return originalHandle(channel, handler); + return originalHandle(channel, async (event, ...args) => { + if (!isPrimaryRenderer(event, primaryWindow)) { + // The real status handler is safe to invoke and proves that its trusted-renderer check + // rejects an actual second WebContents. Other unexpected senders fail closed here. + if (channel === "github-auth:status") { + fixture.untrustedStatusCalls += 1; + try { + return await handler(event, ...args); + } catch (error) { + fixture.untrustedStatusRejected += 1; + throw error; + } + } + throw new Error("Untrusted caller rejected by the auth smoke fixture."); + } + + if (channel === "github-auth:status") { + fixture.statusReads += 1; + return { ...fixture.status }; + } + if (channel === "github-auth:start") { + fixture.starts += 1; + fixture.status = authStatus("pending"); + // Empty by design: the renderer follows its normal sign-in path, while its clipboard + // IPC receives empty text and the production main handler leaves the user clipboard alone. + return { + userCode: "", + verificationUri: "https://github.com/login/device", + interval: 0.05, + expiresAt: Date.now() + 60_000 + }; + } + if (channel === "github-auth:cancel") { + fixture.cancels += 1; + if (fixture.completeOnCancel) { + fixture.completeOnCancel = false; + fixture.status = { + ...authStatus("idle"), + authorized: true, + login: "fixture-user" + }; + } else if (fixture.status.deviceFlowState === "pending") { + fixture.status = authStatus("cancelled"); + } + return undefined; + } + if (channel === "github-auth:open-url") { + fixture.openUrlCalls += 1; + assert.equal(args[0], "https://github.com/login/device", "fixture should use the device-flow URL"); + // Do not call the original handler: it would open a real browser. + return undefined; + } + throw new Error("Unexpected GitHub auth channel."); + }); + } + }); + + electronApi.app.on("browser-window-created", (_event, window) => { + if (primaryWindow) return; + primaryWindow = window; + window.webContents.once("did-finish-load", () => { + if (ran) return; + ran = true; + void exerciseAuthUi(window, fixture, electronApi) + .then((summary) => { + console.log(`${marker} ${JSON.stringify(summary)}`); + electronApi.app.quit(); + }) + .catch((error) => { + console.error("GitHub auth Electron smoke failed:", error instanceof Error ? error.message : "unknown failure"); + electronApi.app.exit(1); + }); + }); + }); + + void import(new URL("../out/main/index.js", import.meta.url).href); + + async function exerciseAuthUi(window, authFixture, electron) { + const evidence = []; + await untilRenderer(window, "Boolean(window.canvasTTY) && Boolean(document.querySelector('.settings-button'))", "production renderer and preload"); + await evaluate(window, "document.querySelector('.settings-button').click(); true"); + await untilRenderer(window, "Boolean(document.querySelector('#settings-tab-plugins'))", "Settings navigation"); + await evaluate(window, "document.querySelector('#settings-tab-plugins').click(); true"); + await untilRenderer(window, "Boolean(document.querySelector('.plugin-github-group'))", "GitHub auth settings"); + await untilRenderer(window, "Boolean(document.querySelector('.plugin-github-signin-secondary'))", "configured sign-in actions"); + + await assertUntrustedStatusRejected(window, authFixture, electron); + evidence.push("untrusted secondary renderer status IPC rejected by the production handler"); + + await startFlow(window, authFixture, 1); + evidence.push(await expectTerminalNotice(window, authFixture, "denied", "denied", "denial notice, code clearing, and retry")); + await startFlow(window, authFixture, 2); + evidence.push(await expectTerminalNotice(window, authFixture, "expired", "expired", "expiry notice, code clearing, and retry")); + await startFlow(window, authFixture, 3); + evidence.push(await expectTerminalNotice(window, authFixture, "failed", "could not be completed", "failure notice, code clearing, and retry")); + + await startFlow(window, authFixture, 4); + await evaluate(window, "document.querySelector('.plugin-github-cancel').click(); true"); + await untilRenderer(window, "(() => { const notice = document.querySelector('.plugin-github-flow-notice'); return !document.querySelector('.plugin-github-code') && notice?.textContent.includes('was cancelled') && document.querySelectorAll('.plugin-github-signin-actions button').length === 2; })()", "explicit cancel clears the code and restores retry actions"); + assert.equal(authFixture.cancels, 1); + evidence.push("explicit cancellation notice, code clearing, and retry"); + + await startFlow(window, authFixture, 5); + authFixture.completeOnCancel = true; + await evaluate(window, "document.querySelector('.plugin-github-cancel').click(); true"); + await untilRenderer(window, "(() => { const signedIn = document.querySelector('.plugin-github-status strong'); return signedIn?.textContent === '@fixture-user' && !document.querySelector('.plugin-github-code') && !document.querySelector('.plugin-github-flow-notice'); })()", "authorization success wins a raced cancel"); + assert.equal(authFixture.status.authorized, true); + assert.equal(authFixture.status.deviceFlowState, "idle"); + assert.equal(authFixture.cancels, 2); + assert.equal(authFixture.starts, 5); + assert.equal(authFixture.openUrlCalls, 5); + evidence.push("canonical authorized status wins a raced cancel without a cancelled notice"); + + await assertUntrustedStatusRejected(window, authFixture, electron); + assert.equal(authFixture.untrustedStatusCalls, 2); + assert.equal(authFixture.untrustedStatusRejected, 2); + evidence.push("untrusted status IPC remains rejected after the fixture flow"); + + return { + scope: "production renderer and preload with synthetic GitHub auth IPC responses", + checks: evidence.length, + evidence, + authCalls: { + starts: authFixture.starts, + statusReads: authFixture.statusReads, + cancels: authFixture.cancels, + openUrl: authFixture.openUrlCalls + }, + externalBrowserOpened: false, + liveDeviceFlowStarted: false, + providerSessionsRestored: 0 + }; + } +} + +async function startFlow(window, fixture, expectedStartCount) { + await evaluate(window, "document.querySelector('.plugin-github-signin-secondary').click(); true"); + await untilRenderer(window, "Boolean(document.querySelector('.plugin-github-code strong'))", "synthetic authorization code UI"); + await untilRenderer(window, "document.querySelector('.plugin-github-code strong')?.textContent === ''", "empty synthetic code fixture"); + assert.equal(fixture.starts, expectedStartCount); + assert.equal(fixture.openUrlCalls, expectedStartCount); +} + +async function expectTerminalNotice(window, fixture, state, text, label) { + fixture.status = authStatus(state); + await untilRenderer(window, `(() => { + const notice = document.querySelector('.plugin-github-flow-notice'); + return !document.querySelector('.plugin-github-code') && notice?.textContent.includes(${JSON.stringify(text)}) + && document.querySelectorAll('.plugin-github-signin-actions button').length === 2; + })()`, label); + return label; +} + +async function assertUntrustedStatusRejected(primaryWindow, fixture, electronApi) { + const preload = fileURLToPath(new URL("../out/preload/index.cjs", import.meta.url)); + const root = process.env.CANVASTTY_GITHUB_AUTH_FIXTURE_DIR; + assert.ok(root, "fixture directory should be set"); + const secondary = new electronApi.BrowserWindow({ + show: false, + webPreferences: { preload, contextIsolation: true, nodeIntegration: false } + }); + try { + await secondary.loadFile(join(root, "untrusted-renderer.html")); + const exposed = await secondary.webContents.executeJavaScript("typeof window.canvasTTY", true); + assert.equal(exposed, "object", "secondary window must use the production preload"); + const rejected = await secondary.webContents.executeJavaScript( + "window.canvasTTY.githubAuth.status().then(() => false, () => true)", + true + ); + assert.equal(rejected, true, "the production GitHub status handler must reject the untrusted sender"); + } finally { + if (!secondary.isDestroyed()) secondary.close(); + } +} + +function isPrimaryRenderer(event, window) { + return Boolean(window && !window.isDestroyed() + && event.sender === window.webContents + && event.senderFrame === window.webContents.mainFrame); +} + +function authStatus(deviceFlowState) { + return { + configured: true, + authorized: false, + login: null, + tokenExpiresAt: null, + deviceFlowState + }; +} + +async function evaluate(window, expression) { + return window.webContents.executeJavaScript(expression, true); +} + +async function untilRenderer(window, expression, name, timeout = 12_000, interval = 40) { + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + if (window.isDestroyed() || window.webContents.isDestroyed()) throw new Error("Main renderer was destroyed."); + const result = await evaluate(window, expression); + if (result) return result; + await delay(interval); + } + throw new Error(`Timed out waiting for ${name}.`); +} + +function delay(ms) { + return new Promise((resolveDelay) => setTimeout(resolveDelay, ms)); +} diff --git a/scripts/smoke-plugin-review-electron.mjs b/scripts/smoke-plugin-review-electron.mjs new file mode 100644 index 00000000..dce5ba5b --- /dev/null +++ b/scripts/smoke-plugin-review-electron.mjs @@ -0,0 +1,351 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { execFileSync } from "node:child_process"; +import { copyFile, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { runElectronSmoke } from "./lib/run-electron-smoke.mjs"; + +const repo = resolve(fileURLToPath(new URL("..", import.meta.url))); +const self = fileURLToPath(import.meta.url); +const electron = createRequire(import.meta.url)("electron"); +const marker = "CANVASTTY_PLUGIN_REVIEW_ELECTRON_OK"; + +if (typeof electron === "string") { + const root = await mkdtemp("/private/tmp/ctv-review-"); + const userData = join(root, "data"); + const home = join(root, "home"); + const project = join(root, "project"); + const pluginWorktrees = join(userData, "plugin-data", "canvastty-environments", "worktrees"); + await Promise.all([mkdir(home), mkdir(userData, { recursive: true }), mkdir(project, { recursive: true }), mkdir(pluginWorktrees, { recursive: true, mode: 0o700 })]); + const fixture = await createFixture({ root, userData, project, pluginWorktrees }); + const environment = { + HOME: home, PATH: [dirname(process.execPath), "/usr/bin", "/bin", "/usr/sbin", "/sbin"].join(":"), + TMPDIR: process.env.TMPDIR || "/private/tmp", LANG: "en_US.UTF-8", LC_ALL: "en_US.UTF-8", + GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1", + SHELL: fixture.shell, CANVASTTY_USER_DATA_DIR: userData, + CANVASTTY_PLUGIN_REVIEW_FIXTURE: JSON.stringify(fixture) + }; + try { + const { errorOutput } = await runElectronSmoke(electron, self, environment, marker); + assert.doesNotMatch(errorOutput, /Terminal output history is (?:shutting down|closed)/u, + "ordinary application shutdown does not warn for canceled background history updates"); + await verifyCleanup(fixture); + } finally { + await rm(root, { recursive: true, force: true }); + } +} else { + let ran = false; + electron.app.on("browser-window-created", (_event, window) => { + window.webContents.once("did-finish-load", async () => { + if (ran) return; + ran = true; + try { + const result = await window.webContents.executeJavaScript( + `(${probe.toString()})(${process.env.CANVASTTY_PLUGIN_REVIEW_FIXTURE})`, true + ); + console.log(marker + " " + JSON.stringify(result)); + electron.app.quit(); + } catch (error) { + console.error("Plugin review smoke failed:", error); + electron.app.exit(1); + } + }); + }); + // Keep the actual main, preload, plugin supervisor, IPC validation and renderer in this test. + void import(pathToFileURL(join(repo, "out/main/index.js")).href); +} + +async function createFixture({ root, userData, project, pluginWorktrees }) { + const pluginSource = resolve(repo, "../canvastty-work/canvastty-plugin-environments"); + const sourceManifestPath = join(pluginSource, "canvastty.plugin.json"); + const manifest = JSON.parse(await readFile(sourceManifestPath, "utf8")); + const resultsModule = manifest.modules.find(module => module.id === "results"); + const worktreeModule = manifest.modules.find(module => module.id === "worktree"); + const resultsService = manifest.services.find(service => service.id === "results"); + const worktreeService = manifest.services.find(service => service.id === "worktree"); + assert.ok(resultsModule && worktreeModule && resultsService && worktreeService, "the environments plugin declares its results and worktree modules and services"); + const serviceAsset = resultsModule.files.find(file => file.path === resultsService.entry); + assert.ok(serviceAsset, "the results service entry is a declared module asset"); + assert.ok(worktreeModule.files.some(file => file.path === worktreeService.entry), "the worktree service entry is a declared module asset"); + + const installedPlugin = join(userData, "plugins", manifest.id); + const metadataDir = join(installedPlugin, "metadata"); + await mkdir(metadataDir, { recursive: true }); + await copyFile(sourceManifestPath, join(metadataDir, "canvastty.plugin.json")); + const selectedModules = [resultsModule.id, worktreeModule.id]; + const selectedAssets = [...(manifest.coreFiles ?? []), ...resultsModule.files, ...worktreeModule.files]; + for (const asset of selectedAssets) { + const source = join(pluginSource, ...asset.path.split("/")); + const destination = join(installedPlugin, ...asset.path.split("/")); + await mkdir(dirname(destination), { recursive: true }); + await copyFile(source, destination); + const bytes = await readFile(destination); + assert.equal(bytes.length, asset.bytes, `fixture plugin asset length: ${asset.path}`); + assert.equal(createHash("sha256").update(bytes).digest("hex"), asset.sha256, `fixture plugin asset hash: ${asset.path}`); + } + const serviceHash = async service => createHash("sha256").update(await readFile(join(installedPlugin, ...service.entry.split("/")))).digest("hex"); + await writeFile(join(userData, "plugins.json"), JSON.stringify({ + [manifest.id]: { + sourceUrl: "https://github.com/BIackFIame/canvastty-plugin-environments.git", + enabled: true, + installedAt: Date.now(), + selectedModules, + enabledHooks: [], + // This disposable fixture trusts only the local worktree and results services; no router, SSH, or hooks run. + trustedServices: { + [resultsService.id]: await serviceHash(resultsService), + [worktreeService.id]: await serviceHash(worktreeService) + } + } + }, null, 2)); + + await writeFile(join(project, "shared.txt"), "SHARED=BASE\n"); + await writeFile(join(project, "kept.txt"), "BASE=KEEP\n"); + execFileSync("git", ["init", "-q", "-b", "main", project]); + await git(project, "config", "user.name", "CanvasTTY Fixture"); + await git(project, "config", "user.email", "fixture@example.invalid"); + await git(project, "add", "shared.txt", "kept.txt"); + await commit(project, "fixture base"); + const base = (await git(project, "rev-parse", "HEAD")).trim(); + + const oneDir = join(pluginWorktrees, "project-review-agent-one"); + const twoDir = join(pluginWorktrees, "project-review-agent-two"); + await git(project, "worktree", "add", "--quiet", "-b", "fixture-agent-one", oneDir, base); + await git(project, "worktree", "add", "--quiet", "-b", "fixture-agent-two", twoDir, base); + await Promise.all([ + writeFile(join(oneDir, "shared.txt"), "SHARED=AGENT_ONE\n"), + writeFile(join(oneDir, "unique-one.txt"), "UNIQUE=AGENT_ONE\n"), + writeFile(join(oneDir, "unselected-one.txt"), "UNSELECTED=AGENT_ONE\n"), + writeFile(join(twoDir, "shared.txt"), "SHARED=AGENT_TWO\n"), + writeFile(join(twoDir, "unique-two.txt"), "UNIQUE=AGENT_TWO\n") + ]); + for(let index=0;index<150;index++)await writeFile(join(oneDir,`large-${String(index).padStart(3,'0')}.txt`), + Array.from({length:index===0 ? 5002 : 40},(_,line)=>`LARGE_PROOF_${index}_${line}`).join('\n')+'\n'); + const shell=join(root,'review-echo-shell'); + const shellQuote=value=>`'${value.replaceAll("'","'\\''")}'`; + await writeFile(shell,`#!/bin/sh\nprintf 'REVIEW_REFRESH_READY\\n'\nwhile IFS= read -r line; do\n if [ "$line" = 'REFRESH_PROOF' ]; then\n printf 'REFRESH_PROOF\\n' > ${shellQuote(join(oneDir,'refresh-one.txt'))}\n exit 0\n fi\ndone\n`,{mode:0o700}); + await writeFile(join(project, "shared.txt"), "SHARED=CURRENT\n"); + await git(project, "add", "shared.txt"); + await commit(project, "fixture current version"); + + const baseSession = { + provider: "codex", profile: "normal", titleCustomized: true, cwd: project, + size: { width: 600, height: 420 }, lastState: "exited", exitCode: 0, restore: true + }; + const worktreeRef = (dir, branch) => ({ + repo: project, dir, branch, createdBranch: true, sub: "", base, baseBranch: "main" + }); + await writeFile(join(userData, "terminal-sessions.json"), JSON.stringify({ version: 2, sessions: [ + { ...baseSession, id: "review-parent", title: "Review fixture", role: "orchestrator", position: { x: 80, y: 60 } }, + { ...baseSession, id: "review-agent-one", title: "Agent one", role: "subagent", parentSessionId: "review-parent", + position: { x: 720, y: 60 }, environment: { pluginId: manifest.id, kind: "worktree", label: "Fixture worktree", ref: worktreeRef(oneDir, "fixture-agent-one") } }, + { ...baseSession, id: "review-agent-two", title: "Agent two", role: "subagent", parentSessionId: "review-parent", + position: { x: 720, y: 520 }, environment: { pluginId: manifest.id, kind: "worktree", label: "Fixture worktree", ref: worktreeRef(twoDir, "fixture-agent-two") } } + ] })); + await writeFile(join(userData, "settings.json"), JSON.stringify({ locale: "en", sessionRestoreMode: "continue" })); + return { + shell, + project, + parentId: "review-parent", + agentOneId: "review-agent-one", + agentTwoId: "review-agent-two", + worktreeDirs: [oneDir, twoDir] + }; +} + +async function verifyCleanup(fixture) { + assert.equal(await readFile(join(fixture.project, "unique-one.txt"), "utf8"), "UNIQUE=AGENT_ONE\n", + "the selected clean file was accepted into the generated project"); + assert.equal(await readFile(join(fixture.project, "shared.txt"), "utf8"), "SHARED=AGENT_ONE\n", + "the explicitly selected conflict side was accepted into the generated project"); + for (const rejectedPath of ["unselected-one.txt", "unique-two.txt", "refresh-one.txt", "large-000.txt"]) { + await assert.rejects(readFile(join(fixture.project, rejectedPath)), { code: "ENOENT" }, `${rejectedPath} was not accepted`); + } + const worktrees = git(fixture.project, "worktree", "list", "--porcelain"); + for (const dir of fixture.worktreeDirs) { + await assert.rejects(stat(dir), { code: "ENOENT" }, `finished source worktree removed: ${dir}`); + assert.ok(!worktrees.includes(`worktree ${dir}\n`), `git no longer registers source worktree ${dir}`); + } + assert.equal(git(fixture.project, "branch", "--list", "fixture-agent*").trim(), "", "full task rejection deletes both source branches"); + assert.equal(git(fixture.project, "branch", "--list", "canvastty/*").trim(), "", "full task rejection deletes every collected branch"); + assert.equal(git(fixture.project, "status", "--porcelain").trim(), "", "review and reject leave the project checkout clean"); +} + +function git(cwd, ...args) { + return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); +} + +function commit(cwd, message) { + return execFileSync("git", ["-c", "user.name=CanvasTTY Fixture", "-c", "user.email=fixture@example.invalid", + "-c", "core.hooksPath=/dev/null", "commit", "-qm", message], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); +} + +async function probe(fixture) { + const checks = []; + const check = (condition, name) => { if (!condition) throw new Error(name); checks.push(name); }; + const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); + const until = async (condition, name, timeoutMs = 15_000) => { + const end = Date.now() + timeoutMs; + while (Date.now() < end) { + const value = await condition(); + if (value) return value; + await delay(50); + } + throw new Error(`Timed out: ${name}; review state: ${document.querySelector('.plugin-changes-review')?.textContent?.slice(-4000) ?? 'no dialog'}`); + }; + const api = await until(() => window.canvasTTY, "preload API"); + await until(async () => (await api.terminal.list()).some(row => row.id === fixture.parentId), "restored orchestrator card"); + const decorations = await until(async () => { + const value = await api.plugins.cardDecorations(); + return value.actions.some(action => action.pluginId === "canvastty-environments" && action.actionId === "review-task-changes") ? value : null; + }, "trusted environments plugin card actions"); + check(decorations.actions.some(action => action.actionId === "reject-task-changes"), "actual installed results service publishes task review and reject actions"); + + const openReview = async () => { + // Lazy terminal loading renders a card placeholder before its real controls are available. + const options = await until(() => document.querySelector(`[data-session-id="${fixture.parentId}"] .terminal-card__action--options`), "orchestrator options menu"); + options.focus(); + options.click(); + const item = await until(() => [...document.querySelectorAll(".terminal-card__menu-action")] + .find(button => button.textContent?.trim() === "Review task changes"), "review task card action"); + item.click(); + return until(() => document.querySelector('.plugin-changes-review[role="dialog"]'), "task review dialog"); + }; + + let dialog = await openReview(); + check(dialog.getAttribute("aria-modal") === "true", "review opens as a modal dialog through the card UI"); + const picker = await until(() => dialog.querySelector(".plugin-changes-review__group-picker select"), "two-agent group picker"); + check(picker.options.length === 2, "core review groups both finished subagents"); + const initialAgentOne = [...picker.options].find(option => option.value === fixture.agentOneId); + if (!initialAgentOne) throw new Error("Agent one is absent from the core task review"); + picker.value = fixture.agentOneId; + picker.dispatchEvent(new Event("change", { bubbles: true })); + const names = () => [...dialog.querySelectorAll(".plugin-changes-review__files code")].map(node => node.textContent.trim()); + const findFileRow=async(path,currentDialog)=>{ + const list=currentDialog.querySelector('.plugin-changes-review__files'); + if(!list)throw new Error('No virtual file list'); + for(let top=0;top<=list.scrollHeight;top+=Math.max(56,list.clientHeight)){ + list.scrollTop=top;list.dispatchEvent(new Event('scroll',{bubbles:true})); + await delay(20); + const row=[...list.querySelectorAll('li[data-file-path]')].find(node=>node.dataset.filePath===path); + if(row)return row; + } + throw new Error(`Missing reviewed file: ${path}`); + }; + await until(()=>dialog.querySelector('.plugin-changes-review__files')?.dataset.totalFiles==='153','large agent-one review'); + check(names().length<40,'more than 5,000 diff lines use a virtual file list with bounded DOM rows'); + check([...picker.options].some(option => option.textContent.includes("Agent two")), "agent group selector names both subagents"); + const largeRow=await findFileRow('large-000.txt',dialog); + (await until(()=>{const button=largeRow.querySelector('.plugin-changes-review__file-open');return button&&!button.disabled?button:null;},'large file navigation ready')).click(); + await until(()=>dialog.querySelector('.plugin-changes-review__diff')?.textContent.includes('LARGE_PROOF_0_0'),'large diff first page'); + check(!dialog.querySelector('.plugin-changes-review__diff').textContent.includes('LARGE_PROOF_0_5001'),'5,002-line diff loads only a bounded first page'); + const sharedRow=await findFileRow('shared.txt',dialog); + (await until(()=>{const button=sharedRow.querySelector('.plugin-changes-review__file-open');return button&&!button.disabled?button:null;},'conflict file navigation ready')).click(); + const conflictSides = await until(()=>{ + const sides=[...dialog.querySelectorAll('.plugin-changes-review__version pre')].map(node=>node.textContent); + return sides.some(value=>value.includes('SHARED=AGENT_ONE'))&&sides.some(value=>value.includes('SHARED=AGENT_TWO')) ? sides : null; + },'shared-path conflict sides'); + check(conflictSides.some(value => value.includes("SHARED=AGENT_ONE")) && conflictSides.some(value => value.includes("SHARED=AGENT_TWO")), + "review shows current and agent sides for the actual cross-agent conflict"); + const acceptAllTask = [...dialog.querySelectorAll(".plugin-changes-review__actions button")].find(button => button.textContent.includes("Accept the full task result")); + check(Boolean(acceptAllTask?.disabled), "task-wide accept is disabled while the reviewed agents have conflicts"); + const trigger=await api.terminal.create({provider:'terminal',profile:'normal',cwd:fixture.project,role:'subagent',parentSessionId:fixture.parentId,title:'Refresh trigger',position:{x:1400,y:60}}); + await until(async()=> (await api.terminal.readBuffer(trigger.id)).buffer.includes('REVIEW_REFRESH_READY'),'live refresh child ready'); + api.terminal.input(trigger.id,'REFRESH_PROOF\r'); + await until(()=>dialog.querySelector('.plugin-changes-review__files')?.dataset.totalFiles==='154','open review refreshes after child completion'); + check(true,'already-open review recollects a newly changed file when a real child PTY completes'); + await api.terminal.dispose(trigger.id); + + await until(()=>!dialog.querySelector('.plugin-changes-review__busy'),'completion refresh settled'); + const conflictPreview=await api.plugins.invokeCardAction('canvastty-environments','review-task-changes',fixture.parentId, + {agentSessionId:fixture.agentOneId,file:'shared.txt',page:0}); + check(Boolean(conflictPreview.review?.groups.some(group=>group.files.some(file=>file.path==='shared.txt'&&file.page===0))), + 'actual plugin records the conflict diff page before mutation attempts'); + const noSuchFile = await api.plugins.invokeCardAction("canvastty-environments", "accept-task-changes", fixture.parentId, + { agentSessionId: fixture.agentOneId, files: ["never-reviewed.txt"] }); + check(noSuchFile.tone === "error" && /Review and open the selected file diffs/u.test(noSuchFile.message ?? ""), `core RPC refuses a file absent from the current review: ${JSON.stringify(noSuchFile)}`); + const unresolved = await api.plugins.invokeCardAction("canvastty-environments", "accept-task-changes", fixture.parentId, + { agentSessionId: fixture.agentOneId, files: ["shared.txt"] }); + check(unresolved.tone === "error" && /Choose the current or agent version/u.test(unresolved.message ?? ""), "core RPC refuses an unresolved conflict"); + + const selectOnly = async (selectedPath, currentDialog) => { + const clear=await until(()=>[...currentDialog.querySelectorAll('.plugin-changes-review__file-selection button')].find(node=>node.textContent==='Clear selection'&&!node.disabled),'clear file selection'); + clear.click(); + const row=await findFileRow(selectedPath,currentDialog); + row.querySelector('input[type="checkbox"]').click(); + row.querySelector('.plugin-changes-review__file-open').click(); + }; + const dialogFocus = await until(() => dialog.contains(document.activeElement) ? document.activeElement : null, "focus enters review dialog"); + check(Boolean(dialogFocus), "opening review moves keyboard focus into the modal"); + const focusables = () => [...dialog.querySelectorAll("button:not([disabled]), input:not([disabled]), select:not([disabled])")] + .filter(element => !element.hidden && element.getClientRects().length > 0); + const first = focusables()[0], last = focusables().at(-1); + last.focus(); + last.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true })); + check(document.activeElement === first, "Tab from the last review control wraps to the first control"); + first.focus(); + first.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true, cancelable: true })); + check(document.activeElement === last, "Shift+Tab from the first review control wraps to the last control"); + last.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true })); + await until(() => !document.querySelector(".plugin-changes-review"), "Escape closes review"); + await until(() => document.querySelector(`[data-session-id="${fixture.parentId}"]`)?.contains(document.activeElement), "focus returns to the originating card"); + check(true, "Escape closes the dialog and restores focus to the orchestrator card"); + + dialog = await openReview(); + await until(()=>dialog.querySelector('.plugin-changes-review__files')?.dataset.totalFiles==='154','reopened review files'); + await selectOnly("unique-one.txt", dialog); + const acceptSelected = [...dialog.querySelectorAll(".plugin-changes-review__actions button")] + .find(button => button.textContent.includes("Accept selected files")); + await until(() => acceptSelected && !acceptSelected.disabled, "one clean file becomes selectable for acceptance"); + check(true, "per-file accept becomes available after selecting one clean file"); + acceptSelected.click(); + const confirm = await until(() => dialog.querySelector('.plugin-changes-review__confirm[role="alertdialog"]'), "per-file confirmation dialog"); + check(confirm.textContent.includes("Accept the selected files"), "per-file accept requires explicit confirmation"); + confirm.querySelector("button")?.click(); + await until(() => !document.querySelector(".plugin-changes-review"), "selected file acceptance completes"); + const acceptedToast = await until(() => [...document.querySelectorAll(".terminal-card__toast")] + .find(node => node.textContent.includes("Accepted 1 selected file")), "per-file acceptance result"); + check(Boolean(acceptedToast), "renderer reports that the selected file was accepted"); + + dialog = await openReview(); + const groupPicker = await until(() => dialog.querySelector(".plugin-changes-review__group-picker select"), "reopened group picker"); + const oneOption = [...groupPicker.options].find(option => option.value === fixture.agentOneId); + if (oneOption) groupPicker.value = fixture.agentOneId; + groupPicker.dispatchEvent(new Event("change", { bubbles: true })); + await until(()=>dialog.querySelector('.plugin-changes-review__files'),'agent-one conflict file'); + await selectOnly("shared.txt", dialog); + const agentChoice = await until(() => [...dialog.querySelectorAll('.plugin-changes-review__version input[type="radio"]')] + .find(input => input.value === "agent"), "agent conflict resolution choice"); + agentChoice.click(); + const acceptConflict = [...dialog.querySelectorAll(".plugin-changes-review__actions button")] + .find(button => button.textContent.includes("Accept selected files")); + await until(() => acceptConflict && !acceptConflict.disabled, "selected conflict becomes acceptable after resolution"); + check(true, "choosing the agent conflict side enables per-file accept"); + acceptConflict.click(); + await until(() => dialog.querySelector(".plugin-changes-review__confirm"), "conflict accept confirmation"); + dialog.querySelector(".plugin-changes-review__confirm button")?.click(); + await until(() => !document.querySelector(".plugin-changes-review"), "chosen conflict acceptance completes"); + await until(() => [...document.querySelectorAll(".terminal-card__toast")] + .some(node => node.textContent.includes("Accepted 1 selected file")), "chosen conflict acceptance result"); + check(true, "renderer reports completion of the explicitly chosen conflict side"); + + dialog = await openReview(); + const rejectTask = await until(() => [...dialog.querySelectorAll(".plugin-changes-review__actions button")] + .find(button => button.textContent.includes("Reject the full task result") && !button.disabled), "task-wide reject action"); + rejectTask.click(); + const rejectConfirmation = await until(() => dialog.querySelector('.plugin-changes-review__confirm[role="alertdialog"]'), "task reject confirmation"); + check(rejectConfirmation.textContent.includes("finished agent") && rejectConfirmation.textContent.includes("worktree"), "full rejection explains finished worktree cleanup"); + rejectConfirmation.querySelector("button")?.click(); + await until(() => !document.querySelector(".plugin-changes-review"), "full task rejection completes"); + await until(() => [...document.querySelectorAll(".terminal-card__toast")] + .some(node => node.textContent.includes("Rejected changes from 2 agents")), "full task rejection result"); + check(true, "renderer completes full task rejection and reports both finished agents"); + return { + checks: checks.length, + evidence: checks, + fixture: { project: fixture.project, worktreeDirs: fixture.worktreeDirs }, + scope: "Actual Electron main/preload/renderer and trusted environments results service; generated local Git repo/worktrees only; one inert local shell completion trigger; no paid models, SSH, or customer data." + }; +} diff --git a/scripts/smoke-terminal-loading-electron.mjs b/scripts/smoke-terminal-loading-electron.mjs new file mode 100644 index 00000000..6b59f435 --- /dev/null +++ b/scripts/smoke-terminal-loading-electron.mjs @@ -0,0 +1,248 @@ +#!/usr/bin/env node +// A fresh-home Electron smoke for restored terminals, lazy chunk failure/retry, +// and keystrokes forwarded by the terminal's loading shell through the real PTY. +// Prepare with `electron-vite build --mode terminal-smoke`; restore a normal build afterward. +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { runElectronSmoke } from "./lib/run-electron-smoke.mjs"; + +const repo = resolve(fileURLToPath(new URL("..", import.meta.url))); +const self = fileURLToPath(import.meta.url); +const electron = createRequire(import.meta.url)("electron"); +const marker = "CANVASTTY_TERMINAL_LOADING_ELECTRON_OK"; +const shellReadyMarker = "CTTY_LAZY_SHELL_READY"; +const typedOutputMarker = "CTTY_LAZY_INPUT_OK"; +const typedCommand = `echo ${typedOutputMarker}`; +const sessionId = "lazy-terminal-fixture"; +const wait = (milliseconds) => new Promise((resolveWait) => setTimeout(resolveWait, milliseconds)); + +if (typeof electron === "string") { + const root = await mkdtemp("/private/tmp/ctty-terminal-load-"); + try { + const fixture = await createFixture(root); + const environment = { + HOME: fixture.home, + USERPROFILE: fixture.home, + XDG_CONFIG_HOME: join(fixture.home, ".config"), + XDG_DATA_HOME: join(fixture.home, ".local", "share"), + CANVASTTY_USER_DATA_DIR: fixture.userData, + SHELL: fixture.shell, + PATH: [dirname(process.execPath), "/usr/bin", "/bin", "/usr/sbin", "/sbin"].join(":"), + TMPDIR: root, + LANG: "en_US.UTF-8", + LC_ALL: "en_US.UTF-8" + }; + await runElectronSmoke(electron, self, environment, marker); + } finally { + await rm(root, { recursive: true, force: true }); + } +} else { + const { app } = await import("electron"); + const userData = process.env.CANVASTTY_USER_DATA_DIR; + assert.ok(userData, "fresh user data directory was not configured"); + app.setPath("userData", userData); + + let ran = false; + app.on("browser-window-created", (_event, window) => { + if (ran) return; + ran = true; + void runProbe(window, app).catch((error) => { + console.error("Terminal loading Electron smoke failed:", error); + app.exit(1); + }); + }); + void import(pathToFileURL(join(repo, "out/main/index.js")).href); +} + +async function createFixture(root) { + const home = join(root, "home"); + const userData = join(root, "data"); + const project = join(root, "project"); + await Promise.all([mkdir(home), mkdir(userData, { recursive: true }), mkdir(project)]); + + // The fixture accepts one exact command and prints one marker. It never invokes + // arbitrary command text, so this checks a real PTY without running user tools. + const shell = join(root, "inert-echo-shell"); + await writeFile(shell, `#!/bin/sh +printf '${shellReadyMarker}\\n' +while IFS= read -r line; do + case "$line" in + '${typedCommand}') printf '${typedOutputMarker}\\n' ;; + *) printf 'FIXTURE_COMMAND_REJECTED\\n' ;; + esac +done +`, { mode: 0o700 }); + + await writeFile(join(userData, "settings.json"), JSON.stringify({ + locale: "en", sessionRestoreMode: "continue", agentIsolation: "off", agentControlEnabled: false, + agentLifecycleHooksEnabled: false, browserAgentAccess: false, browserRestoreTabs: false + })); + await writeFile(join(userData, "terminal-sessions.json"), JSON.stringify({ version: 2, sessions: [{ + id: sessionId, provider: "terminal", profile: "normal", role: "agent", title: "Lazy terminal fixture", + titleCustomized: true, cwd: project, position: { x: 80, y: 60 }, size: { width: 620, height: 420 }, + lastState: "running", restore: true + }] })); + return { home, userData, shell }; +} + +async function runProbe(window, app) { + const contents = window.webContents; + const protocol = contents.debugger; + const errors = []; + let finishedLoads = 0; + let probeFinished = false; + let loadsAtTerminalFailure = null; + const failProbe = (error) => { + if (probeFinished) return; + probeFinished = true; + console.error(error?.stack ?? error); + appExit(1); + }; + + contents.on("did-finish-load", () => { finishedLoads += 1; }); + contents.on("render-process-gone", (_event, details) => failProbe(new Error(`renderer exited: ${JSON.stringify(details)}`))); + contents.on("console-message", (event) => { + if (event.level === "error") errors.push(event.message); + }); + try { + protocol.attach("1.3"); + console.log("terminal loading smoke: CDP attached"); + await waitFor(() => contents.getURL().includes("index.html"), "application page to start", contents); + await waitFor(async () => await evaluate(contents, + "window.__canvasttyTerminalCardLoadState?.attempts === 1"), "controlled first import failure", contents); + await waitFor(async () => await evaluate(contents, `Boolean(document.querySelector(".workspace"))`), "workspace render", contents); + + await waitFor(async () => await evaluate(contents, `Boolean( + document.querySelector('.terminal-card--load-error[data-session-id="${sessionId}"] [role=alert]') + && document.querySelector(".terminal-card--load-error button") + && document.querySelector(".workspace") + && !document.querySelector(".renderer-recovery") + )`), "terminal-only error panel with retry", contents); + loadsAtTerminalFailure = finishedLoads; + assert.equal(await evaluate(contents, `document.querySelector(".terminal-card--load-error [role=alert]")?.textContent.includes("Could not load this terminal")`), true, + "the terminal load error is localized inside its own card"); + assert.equal(await evaluate(contents, `document.querySelector(".terminal-card--load-error button")?.textContent.trim()`), "Retry", + "the failed terminal exposes a retry action"); + assert.equal(await evaluate(contents, "window.__canvasttyTerminalCardLoadState.attempts"), 1, + "the CDP fixture rejected the initial lazy import"); + const startupOutput = await waitForValue(async () => { + const snapshot = await evaluate(contents, `window.canvasTTY.terminal.readBuffer("${sessionId}")`); + return snapshot?.buffer?.includes(shellReadyMarker) ? snapshot : null; + }, "the restored inert shell to start", contents); + assert.ok(startupOutput.buffer.includes(shellReadyMarker), "the restored terminal has a live PTY before retry"); + + await evaluate(contents, `document.querySelector(".terminal-card--load-error button")?.click()`); + await waitFor(async () => await evaluate(contents, "window.__canvasttyTerminalCardLoadState.attempts === 2"), + "the retry import to pause in the CDP fixture", contents); + assert.equal(await evaluate(contents, `Boolean(document.querySelector('.terminal-card--loading[data-session-id="${sessionId}"] textarea'))`), true, + "the retried terminal stays on its loading shell while the chunk is held"); + + window.show(); + window.focus(); + await wait(80); + const textareaFocused = await evaluate(contents, `(() => { + const field = document.querySelector('.terminal-card--loading[data-session-id="${sessionId}"] textarea'); + if (!field) return false; + field.focus(); + return document.activeElement === field; + })()`); + assert.equal(textareaFocused, true, "the loading shell provides a focused native input proxy"); + await contents.debugger.sendCommand("Input.insertText", { text: typedCommand }); + await contents.debugger.sendCommand("Input.dispatchKeyEvent", { + type: "rawKeyDown", key: "Enter", code: "Enter", windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 + }); + await contents.debugger.sendCommand("Input.dispatchKeyEvent", { + type: "keyUp", key: "Enter", code: "Enter", windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 + }); + + const preRenderBuffer = await waitForValue(async () => { + const snapshot = await evaluate(contents, `window.canvasTTY.terminal.readBuffer("${sessionId}")`); + return snapshot?.buffer && new RegExp(`(?:^|\\r?\\n)${typedOutputMarker}(?:\\r?\\n|$)`, "u").test(snapshot.buffer) + ? snapshot : null; + }, "real PTY output while the renderer chunk is still paused", contents); + assert.ok(preRenderBuffer.buffer.includes(typedCommand), + "the text entered while loading reached the restored terminal PTY"); + assert.match(preRenderBuffer.buffer, new RegExp(`(?:^|\\r?\\n)${typedOutputMarker}(?:\\r?\\n|$)`, "u"), + "the inert shell accepted the typed command and printed its output"); + assert.equal(await evaluate(contents, `Boolean(document.querySelector('.terminal-card--loading[data-session-id="${sessionId}"] .xterm-screen'))`), false, + "the terminal has not mounted before the delayed chunk is released"); + + await protocol.sendCommand("Runtime.evaluate", { + expression: "window.__canvasttyTerminalCardLoadState.release()", + awaitPromise: true, + returnByValue: true + }); + await waitFor(async () => await evaluate(contents, `Boolean( + document.querySelector('.terminal-card[data-session-id="${sessionId}"] .xterm-screen') + && Array.from(document.querySelectorAll('.terminal-card[data-session-id="${sessionId}"] .xterm-rows > div')) + .some((row) => (row.textContent || "").includes("CTTY_LAZY_INPUT_OK") && !(row.textContent || "").includes("echo")) + )`), "restored terminal xterm to render the preserved PTY output", contents); + assert.equal(await evaluate(contents, "window.__canvasttyTerminalCardLoadState.attempts"), 2, + "the retry action invoked the terminal chunk loader again"); + assert.equal(finishedLoads, loadsAtTerminalFailure, + "terminal chunk failure did not reload the whole application window"); + assert.equal(errors.filter((message) => message.includes("CanvasTTY could not load the terminal panel")).length, 1, + "the only logged renderer error is the controlled terminal chunk failure"); + assert.equal(await evaluate(contents, `Boolean(document.querySelector('.workspace') && !document.querySelector('.renderer-recovery'))`), true, + "the workspace remains available after the terminal-only import failure"); + console.log(`${marker} ${JSON.stringify({ + attempts: await evaluate(contents, "window.__canvasttyTerminalCardLoadState.attempts"), + output_before_xterm_render: preRenderBuffer.buffer.includes("CTTY_LAZY_INPUT_OK"), + restored_terminal_rendered: true, + main_window_loads: finishedLoads, + renderer_console_errors: errors.length + })}`); + probeFinished = true; + appExit(0); + } catch (error) { + const diagnostic = await evaluate(contents, `(async () => { + const [settings, sessions] = await Promise.all([ + window.canvasTTY.settings.get().catch(() => null), + window.canvasTTY.terminal.list().catch(() => null) + ]); + return { + url: location.href, + body: document.body.innerText.slice(0, 1200), + settingsMode: settings?.sessionRestoreMode, + sessions: sessions?.map(({ id, provider, status, cwd, exitCode, failureDetails, startedAt }) => ({ + id, provider, status, cwd, exitCode, failureDetails, startedAt + })), + terminalCards: [...document.querySelectorAll(".terminal-card")].map((card) => ({ + id: card.getAttribute("data-session-id"), className: card.className, text: card.textContent?.slice(0, 120) + })), + fixture: window.__canvasttyTerminalCardLoadState + ? { attempts: window.__canvasttyTerminalCardLoadState.attempts } : null + }; + })()`); + console.error("Terminal loading smoke startup diagnostics:", JSON.stringify(diagnostic)); + failProbe(error); + } + + function appExit(code) { + if (code === 0) app.quit(); + else app.exit(code); + } +} + +async function evaluate(contents, expression) { + try { return await contents.executeJavaScript(expression, true); } + catch { return null; } +} + +async function waitFor(predicate, description, contents, timeoutMs = 20_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const value = await predicate(); + if (value) return value; + if (contents.isDestroyed()) throw new Error(`renderer closed while waiting for ${description}`); + await wait(25); + } + throw new Error(`timed out waiting for ${description}`); +} + +async function waitForValue(probe, description, contents, timeoutMs = 10_000) { + return waitFor(probe, description, contents, timeoutMs); +} diff --git a/src/agent-browser/orchestration-catalog.mjs b/src/agent-browser/orchestration-catalog.mjs index ad7b53d2..67552450 100644 --- a/src/agent-browser/orchestration-catalog.mjs +++ b/src/agent-browser/orchestration-catalog.mjs @@ -2,10 +2,12 @@ import { canonicalStringify } from "./tool-catalog.mjs"; export const ORCHESTRATION_MCP_SERVER_NAME = "canvastty_agents"; export const MAX_ORCHESTRATION_PAYLOAD_BYTES = 128 * 1024; +const MAX_SECRET_API_BODY_BYTES = 64 * 1024; const string = (options = {}) => ({ type: "string", ...options }); const boolean = () => ({ type: "boolean" }); const integer = (options = {}) => ({ type: "integer", ...options }); +const array = (items, options = {}) => ({ type: "array", items, ...options }); const object = (properties, required = []) => ({ type: "object", properties, @@ -30,10 +32,15 @@ export const AGENT_PROVIDER_IDS = Object.freeze([ ]); /** 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 MAX_AGENT_WAIT_SECONDS = 100; export const DEFAULT_AGENT_WAIT_SECONDS = 55; /** 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"]); +/** Keep in sync with ProviderSecretId; the main-process service verifies again against ProviderSecretsService. */ +export const PROVIDER_SECRET_IDS = Object.freeze([ + "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "XAI_API_KEY", "GOOGLE_API_KEY", "ZAI_API_KEY", + "MINIMAX_API_KEY", "OPENROUTER_API_KEY", "DEEPSEEK_API_KEY", "DEVIN_API_KEY", "CURSOR_API_KEY" +]); const provider = string({ minLength: 1, maxLength: 32, enum: [...AGENT_PROVIDER_IDS] }); /** The refusal for a provider id CanvasTTY does not know; names list_providers. */ @@ -58,7 +65,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); omit model to keep the selected account or provider default. effort sets the reasoning effort where that CLI has one (list_providers shows its efforts). An unsupported model or effort is refused with the reason. profile: without it the subagent gets this session's launch profile, or the next lower one its CLI has; a subagent never gets more than this session (plan < normal < acceptEdits < auto) and never YOLO. "auto" lets it work without asking the person for each edit or command inside the project; CanvasTTY's outer layers (agent isolation, base protection) still stop writes outside the project, secrets and system changes. The answer says the profile it got. cwd must be this project's folder or a folder inside it. isolate: "worktree" asks the installed environments plugin for a separate worktree. review: true asks CanvasTTY to run a read-only review after this agent finishes; reviewModel optionally selects a different known model for the reviewer. 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. Without a model account in launchOptions the subagent runs on this session's own model account when this session has one; {"canvastty-accounts":{"account":"none"}} explicitly runs it on the CLI's own sign-in and default model. The answer's servedBy says which provider, account and model serve it. Refusals say why (a limit the person set, a folder outside this session's 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 }), @@ -67,6 +74,9 @@ export const ORCHESTRATION_TOOL_DEFINITIONS = Object.freeze([ profile: string({ enum: ["auto", "normal", "acceptEdits", "plan"] }), model: string({ minLength: 1, maxLength: 200 }), effort: string({ enum: [...REASONING_EFFORT_IDS] }), + isolate: string({ enum: ["worktree"] }), + review: boolean(), + reviewModel: string({ minLength: 1, maxLength: 200 }), launchOptions }, ["provider", "cwd"] @@ -85,13 +95,13 @@ export const ORCHESTRATION_TOOL_DEFINITIONS = Object.freeze([ ), tool( "wait_for_agent", - `Wait until one of this session's subagents stops working, instead of polling observe_agent or get_agent_result; nothing is sent to it while it waits. Returns reason "idle" (the turn that answers your latest prompt ended and it waits for input; an idle before that turn started does not count), "needs_approval" (its card shows a prompt only the person may answer; never answer it yourself), "done" or "failed" (its process exited), "quiet" (it reports no status or no turn start and its screen stopped changing, so judge from output), "closed" (its card was closed) or "timeout" after timeoutSeconds (default ${DEFAULT_AGENT_WAIT_SECONDS}, at most ${MAX_AGENT_WAIT_SECONDS}), with status, exitCode, waitedMs, the masked terminal tail as output, and answer (the final reply of the turn that ended, for Codex and OpenCode subagents); when the subagent's process exited (for example at once, on a model its CLI does not know), exitLines holds the last lines of its screen as plain text, which say why. After a timeout, call it again. Then read get_agent_result.`, + `Wait until one of this session's subagents stops working, instead of polling observe_agent or get_agent_result; nothing is sent to it while it waits. Returns reason "idle" (the turn that answers your latest prompt ended and it waits for input; an idle before that turn started does not count), "needs_approval" (its card shows a prompt only the person may answer; never answer it yourself), "done" or "failed" (its process exited), "quiet" (it reports no status or no turn start and its screen stopped changing, so judge from output), "closed" (its card was closed) or "timeout" after timeoutSeconds (default ${DEFAULT_AGENT_WAIT_SECONDS}, at most ${MAX_AGENT_WAIT_SECONDS}), 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. OpenCode calls return within 50 seconds to fit its client's deadline. A timeout means it is still running: use output as progress and call wait_for_agent again. Pending reviews also return within this deadline. Then read get_agent_result.`, { sessionId, timeoutSeconds: integer({ minimum: 1, maximum: MAX_AGENT_WAIT_SECONDS }) }, ["sessionId"] ), tool( "get_agent_result", - "Get one of this session's subagents' result: answer (its last turn's final reply as the agent reported it, for Codex and OpenCode subagents; truncated:true when only the end was kept), status (idle once its turn ended), the exit state (running | done | failed; an interactive CLI stays running after a task) and the masked terminal tail as output. Prefer answer; the terminal tail is raw screen output.", + "Get one of this session's subagents' result: answer (its last turn's final reply as the agent reported it, for Codex and OpenCode subagents; truncated:true when only the end was kept), status (idle once its turn ended), the exit state (running | done | failed; an interactive CLI stays running after a task) and the masked terminal tail as output. Prefer answer; the terminal tail is raw screen output. An unfinished review returns status pending immediately; call get_agent_result later for its outcome.", { sessionId }, ["sessionId"] ), @@ -103,6 +113,82 @@ export const ORCHESTRATION_TOOL_DEFINITIONS = Object.freeze([ tool( "list_agents", "List this session's subagents with provider, status, and title." + ), + tool( + "retry_agent", + "Replace one failed or quiet subagent launch with a fresh conversation in the same card, stopping its previous process first. Retains task ownership, history, launch profile, model and folder; sends the original prompt plus a short masked failure tail. Only one retry may run at a time, with at most two successful retries per original agent. A failed retry keeps the stopped card for inspection; it cannot restore a stopped process.", + { sessionId, reason: string({ maxLength: 500 }) }, + ["sessionId"] + ), + tool( + "list_tasks", + "List the shared task board for this orchestration. Subagents see only tasks belonging to their own root orchestrator.", + {}, + [] + ), + tool( + "claim_task", + "Atomically claim one open task whose dependencies are all complete. If another agent owns it, the current owner is returned.", + { taskId: string({ minLength: 1, maxLength: 160 }) }, + ["taskId"] + ), + tool( + "update_task", + "Update the task you own. Only the root orchestrator can reassign tasks or change dependencies; complete work with complete_task.", + { + taskId: string({ minLength: 1, maxLength: 160 }), + title: string({ minLength: 1, maxLength: 240 }), + description: string({ minLength: 1, maxLength: 8_000 }), + progress: string({ minLength: 1, maxLength: 4_000 }), + status: string({ enum: ["open", "claimed"] }), + ownerSessionId: string({ minLength: 1, maxLength: 160 }), + ownerName: string({ minLength: 1, maxLength: 160 }), + dependencies: array(string({ minLength: 1, maxLength: 80 }), { maxItems: 64 }) + }, + ["taskId"] + ), + tool( + "complete_task", + "Mark a task complete and record its result. Only its owner or the root orchestrator can complete it, and dependencies must be complete.", + { taskId: string({ minLength: 1, maxLength: 160 }), result: string({ minLength: 1, maxLength: 8_000 }) }, + ["taskId", "result"] + ), + tool( + "get_task_budget", + "Read the budget for this orchestration tree. Unknown provider token or cost usage is returned as no data; only a person can change or remove limits.", + {}, + [] + ), + tool( + "list_orchestration_templates", + "List built-in and project orchestration flows from .canvastty/flows. Invalid files are returned as line-numbered errors.", + {}, + [] + ), + tool( + "request_secret", + "Ask the person to approve temporary use of one configured provider secret for typed provider API requests. This only creates a pending UI request; never ask the person to paste a key into chat or tool arguments. Approval is scoped to this session and lasts 10 minutes, until this turn ends, or until the session ends.", + { secretId: string({ enum: [...PROVIDER_SECRET_IDS] }), reason: string({ minLength: 1, maxLength: 1_000 }) }, + ["secretId", "reason"] + ), + tool( + "run_secret_request", + "Send a typed HTTPS API request using a configured provider secret for which this session has current human approval. secretId selects the approved key; apiProfileId optionally selects a human-saved profile using that same key. The host chooses the profile base URL and protocol authentication. Supply only a relative API path, method and optional JSON body; custom origins, headers, redirects and executable commands are unavailable. The response body is masked and capped, and the host may refuse if it cannot enforce isolation.", + { + secretId: string({ enum: [...PROVIDER_SECRET_IDS] }), + apiProfileId: string({ minLength: 1, maxLength: 64 }), + method: string({ enum: ["GET", "POST", "PUT", "PATCH", "DELETE"] }), + path: string({ minLength: 1, maxLength: 2_048 }), + body: { type: "object", maxProperties: 1_024, additionalProperties: true }, + timeoutMs: integer({ minimum: 1_000, maximum: 300_000 }) + }, + ["secretId", "method", "path"] + ), + tool( + "apply_orchestration_template", + "Get the roles, expected number of subagents and final-step instructions for a flow, ready to apply to a task.", + { templateId: string({ minLength: 1, maxLength: 64 }), task: prompt }, + ["templateId", "task"] ) ]); @@ -173,6 +259,37 @@ export function validateOrchestrationArguments(toolName, args) { else if (property.minimum !== undefined && candidate < property.minimum) errors.push(`${key} is below the minimum.`); else if (property.maximum !== undefined && candidate > property.maximum) errors.push(`${key} is above the maximum.`); else value[key] = candidate; + } else if (property.type === "object") { + if (candidate === null || typeof candidate !== "object" || Array.isArray(candidate)) { + errors.push(`${key} must be an object.`); + continue; + } + if (property.maxProperties !== undefined && Object.keys(candidate).length > property.maxProperties) { + errors.push(`${key} has too many properties.`); + continue; + } + let encoded; + try { encoded = canonicalStringify(candidate); } catch { errors.push(`${key} must contain JSON values.`); continue; } + if (Buffer.byteLength(encoded, "utf8") > MAX_SECRET_API_BODY_BYTES) { + errors.push(`${key} exceeds 64 KB.`); + continue; + } + value[key] = JSON.parse(encoded); + } else if (property.type === "array") { + if (!Array.isArray(candidate) || candidate.length > (property.maxItems ?? Number.MAX_SAFE_INTEGER)) { + errors.push(`${key} must be an array of at most ${property.maxItems ?? "the allowed number of"} items.`); + } else { + const items = []; + for (let index = 0; index < candidate.length; index += 1) { + const item = candidate[index]; + if (property.items?.type === "string") { + if (typeof item !== "string") errors.push(`${key}[${index}] must be a string.`); + else if (item.length < (property.items.minLength ?? 0) || item.length > (property.items.maxLength ?? Number.MAX_SAFE_INTEGER)) errors.push(`${key}[${index}] has an invalid length.`); + else items.push(item); + } else errors.push(`${key} has an unsupported item type.`); + } + value[key] = items; + } } } for (const key of Object.keys(args)) { diff --git a/src/agent-runtime/hook-helper.mjs b/src/agent-runtime/hook-helper.mjs index 024a1376..48be44bf 100644 --- a/src/agent-runtime/hook-helper.mjs +++ b/src/agent-runtime/hook-helper.mjs @@ -1,12 +1,14 @@ #!/usr/bin/env node import { + AGENT_RUNTIME_ENV, CAPTURE_ANSWER_ENV, CAPTURE_ANSWER_EXPIRES_AT_ENV, CAPTURE_RESULT_ENV, MAX_ANSWER_CHARS, MAX_HOOK_INPUT_BYTES, MAX_RESULT_CHARS, - RUNTIME_STATES + RUNTIME_STATES, + toolOutcomeFromHook } from "./runtime-protocol.mjs"; import { reportLifecycle } from "./runtime-client.mjs"; @@ -34,11 +36,13 @@ try { } catch { input = null; } +const provider = process.env[AGENT_RUNTIME_ENV.provider]; const turnId = firstString( input?.turn_id, input?.turnId, input?.prompt_id, - input?.promptId + input?.promptId, + provider === "hermes" ? input?.extra?.turn_id : undefined ); // The provider's own conversation id; runtime-client keeps it only in a shape that provider issues. const threadId = firstString( @@ -60,13 +64,15 @@ if (captureResult && finalAnswer !== null) { const lastAssistantMessage = captureAnswer && finalAnswer !== null ? boundedText(finalAnswer, MAX_ANSWER_CHARS) : undefined; +const toolOutcome = toolOutcomeFromHook(provider, event, input); await reportLifecycle({ state, event, turnId, ...(threadId ? { threadId } : {}), ...(result === undefined ? {} : { result }), - ...(lastAssistantMessage === undefined ? {} : { lastAssistantMessage }) + ...(lastAssistantMessage === undefined ? {} : { lastAssistantMessage }), + ...(toolOutcome === undefined ? {} : { toolOutcome }) }); /** Cuts at the limit without leaving a dangling high surrogate. */ diff --git a/src/agent-runtime/opencode-plugin.mjs b/src/agent-runtime/opencode-plugin.mjs index bd8151b8..b582e035 100644 --- a/src/agent-runtime/opencode-plugin.mjs +++ b/src/agent-runtime/opencode-plugin.mjs @@ -1,12 +1,17 @@ import { reportLifecycle } from "./runtime-client.mjs"; -import { CAPTURE_RESULT_ENV } from "./runtime-protocol.mjs"; +import { CAPTURE_RESULT_ENV, toolOutcomeFromOpenCode, normalizedActionHashFromHook } from "./runtime-protocol.mjs"; import { finalAnswer } from "./opencode-final-answer.mjs"; -import { createOpenCodeDecisions } from "./opencode-decisions.mjs"; +import { createOpenCodeDecisions, guardedCall } 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; +let rootTurnId = null; +// Retain only correlation, never arguments/output. Calls omitted at the bound cannot produce an uncorrelated outcome. +const toolCalls = new Map(); +const MAX_TRACKED_TOOL_CALLS = 256; 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 ?? ""; @@ -16,6 +21,7 @@ const pluginHooks = parsePluginHooks(process.env.CANVASTTY_PLUGIN_HOOK_SESSION); // Set only for a session whose orchestrator reads its final answer (a subagent): the last reply is then sent with // the turn's end, at most MAX_RESULT_CHARS. const captureResult = process.env[CAPTURE_RESULT_ENV] === "1"; +const trackTools = lifecycleEnabled || captureResult || pluginHooks.some(hook => hook.events.includes("after-tool")); // OpenCode (1.18+) runs EVERY exported function of a plugin module as a plugin and reads its hooks; a helper exported // here broke the whole OpenCode start ("plugin config hook failed", then "n.provider"). Export nothing but the plugin. @@ -24,7 +30,22 @@ export const CanvasTTYLifecycle = async (input) => { // fails the tool call before OpenCode asks anyone. const decisions = createOpenCodeDecisions({ client: input && typeof input === "object" ? input.client : undefined }); return { - ...(decisions.enabled ? { "tool.execute.before": (hookInput, output) => decisions.guard(hookInput, output) } : {}), + ...((decisions.enabled || trackTools) ? { + "tool.execute.before": async (hookInput, output) => { + const sessionId = rootSessionId, turnId = rootTurnId; + if (decisions.enabled) await decisions.guard(hookInput, output); + if (!trackTools || !rootWorking || !turnId || sessionId !== rootSessionId + || turnId !== rootTurnId || hookInput?.sessionID !== sessionId || typeof hookInput.callID !== "string" + || !hookInput.callID || hookInput.callID.length > 256 || typeof hookInput.tool !== "string" + || !hookInput.tool || hookInput.tool.length > 80) return; + if (toolCalls.size < MAX_TRACKED_TOOL_CALLS && !toolCalls.has(hookInput.callID)) { + toolCalls.set(hookInput.callID, { sessionId, turnId, tool: hookInput.tool }); + } + } + } : {}), + ...(trackTools ? { + "tool.execute.after": (hookInput, output) => completeTool(hookInput, output, "tool.execute.after") + } : {}), event: async ({ event }) => lifecycleEvent(event, decisions, input && typeof input === "object" ? input.client : undefined) }; }; @@ -45,8 +66,10 @@ async function lifecycleEvent(event, decisions, client) { if (session.parentID || session.parentId) return; rootSessionId = stringField(session.id, sessionId); rootWorking = false; + rootTurnId = null; + toolCalls.clear(); if (!rootSessionId) return; - if (lifecycleEnabled) { + if (lifecycleEnabled || captureResult) { await reportLifecycle({ state: "idle", event: event.type, threadId: rootSessionId }); } runPluginHooks("session-start", event.type, event); @@ -63,50 +86,84 @@ async function lifecycleEvent(event, decisions, client) { ? statusValue.type : null; if (status === "busy" || status === "retry") { - if (lifecycleEnabled) { - await reportLifecycle({ state: "working", event: `session.status:${status}`, turnId: rootSessionId }); + const startsTurn = status === "busy" && !rootWorking; + if (startsTurn || !rootTurnId) { rootTurnId = randomUUID(); toolCalls.clear(); } + rootWorking = true; + if (lifecycleEnabled || captureResult) { + 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 }); + toolCalls.clear(); + // A result-capturing turn is not complete until its bounded session.idle SDK read settles. Publishing idle + // here lets an orchestrator review before the final answer has reached the host. + 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 } : {}) }); + toolCalls.clear(); + const endingSessionId = rootSessionId, endingTurnId = rootTurnId; + const result = captureResult && endingTurnId ? await finalAnswer(client, endingSessionId) : undefined; + // SDK reads are asynchronous: the next turn may already have started while the old answer was being read. + if (endingSessionId !== rootSessionId || endingTurnId !== rootTurnId || rootWorking) return; + if (lifecycleEnabled || captureResult) { + await reportLifecycle({ state: "idle", event: event.type, turnId: rootTurnId, ...(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 || captureResult) 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 || captureResult) 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 || captureResult) 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 || captureResult) 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 }); + toolCalls.clear(); + if (lifecycleEnabled || captureResult) 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; - } else if (event.type === "tool.execute.after") { - runPluginHooks("after-tool", event.type, event); + rootTurnId = null; + toolCalls.clear(); + } else if (event.type === "message.part.updated") { + const part = properties.part; + if (part?.type !== "tool" || !["completed", "error"].includes(part.state?.status)) return; + await completeTool({ sessionID: part.sessionID ?? sessionId, callID: part.callID, tool: part.tool, + args: part.state.input }, part.state, event.type, part.state.status, event); } } +async function completeTool(input, output, event, status = "completed", hookPayload = { input, output }) { + const call = toolCalls.get(input?.callID); + if (!call || !rootWorking || call.sessionId !== rootSessionId || call.turnId !== rootTurnId + || input.sessionID !== call.sessionId || input.tool !== call.tool) return; + const toolOutcome = toolOutcomeFromOpenCode(call.tool, input.args, output, status); + if (!toolOutcome) return; + // The permission hook normalizes file/shell inputs before hashing; use the identical shape for equality. + const action = guardedCall(call.tool, input.args); + if (action) toolOutcome.normalizedActionHash = normalizedActionHashFromHook(action.toolName, action.toolInput); + // Delete before awaiting transport: the direct hook and PartUpdated may race for the same completion. + toolCalls.delete(input.callID); + if (lifecycleEnabled || captureResult) await reportLifecycle({ state: "working", event, turnId: call.turnId, toolOutcome }); + // Explicitly trusted native hooks retain their provider payload contract. This is separate from service events. + runPluginHooks("after-tool", event, hookPayload); +} + + function stringField(...values) { return values.find((value) => typeof value === "string" && value.length > 0) ?? null; } diff --git a/src/agent-runtime/runtime-client.mjs b/src/agent-runtime/runtime-client.mjs index 4a4d2a71..a4d0f8d1 100644 --- a/src/agent-runtime/runtime-client.mjs +++ b/src/agent-runtime/runtime-client.mjs @@ -8,12 +8,13 @@ import { MAX_RUNTIME_MESSAGE_BYTES, normalizeThreadId, RUNTIME_PROTOCOL_VERSION, - RUNTIME_STATES + RUNTIME_STATES, + sanitizeToolOutcome } from "./runtime-protocol.mjs"; const CONNECT_TIMEOUT_MS = 1_000; -export async function reportLifecycle({ state, event, turnId = null, threadId, result, lastAssistantMessage }) { +export async function reportLifecycle({ state, event, turnId = null, threadId, result, lastAssistantMessage, toolOutcome }) { if (!RUNTIME_STATES.includes(state)) return false; if (typeof event !== "string" || event.length === 0 || event.length > 80) return false; const address = process.env[AGENT_RUNTIME_ENV.address]; @@ -23,6 +24,7 @@ export async function reportLifecycle({ state, event, turnId = null, threadId, r if (!address || !terminalSessionId || !provider || !capabilityToken) return false; const validThreadId = normalizeThreadId(provider, threadId); + const safeToolOutcome = sanitizeToolOutcome(toolOutcome); const message = { v: RUNTIME_PROTOCOL_VERSION, @@ -34,7 +36,8 @@ export async function reportLifecycle({ state, event, turnId = null, threadId, r event, turnId: normalizedId(turnId), ...(validThreadId !== undefined ? { threadId: validThreadId } : {}), - ...(result === undefined ? {} : { result }) + ...(result === undefined ? {} : { result }), + ...(safeToolOutcome === undefined ? {} : { toolOutcome: safeToolOutcome }) }; const answerCaptureExpiresAt = Number(process.env[CAPTURE_ANSWER_EXPIRES_AT_ENV]); const shouldCheckAnswerGrant = process.env[CAPTURE_ANSWER_ENV] === "1" diff --git a/src/agent-runtime/runtime-protocol.d.mts b/src/agent-runtime/runtime-protocol.d.mts index cdf16b46..87294b4b 100644 --- a/src/agent-runtime/runtime-protocol.d.mts +++ b/src/agent-runtime/runtime-protocol.d.mts @@ -6,6 +6,9 @@ export const CAPTURE_ANSWER_ENV: string; export const CAPTURE_ANSWER_EXPIRES_AT_ENV: string; export const MAX_ANSWER_CHARS: number; export const MAX_HOOK_INPUT_BYTES: number; +export const MAX_TOOL_OUTCOME_NAME_CHARS: number; +export const MAX_TOOL_OUTCOME_ERROR_CHARS: number; +export const MAX_TOOL_OUTCOME_PATHS: number; export const AGENT_RUNTIME_ENV: Readonly<{ address: "CANVASTTY_RUNTIME_ADDRESS"; terminalSessionId: "CANVASTTY_RUNTIME_TERMINAL_SESSION_ID"; @@ -32,6 +35,16 @@ export const MIN_DECIDE_TIMEOUT_MS: number; export const MAX_DECIDE_TIMEOUT_MS: number; export function permissionGateTimings(budgetMs?: number): { budgetMs: number; gatewayMs: number; helperMs: number; hookSeconds: number }; export function helperDeadlineMs(env: Record | undefined): number; +export function toolOutcomeFromHook(provider: string, event: string, input: unknown): { + toolName: string; + resultClass: "success" | "error" | "denied" | "unknown"; + normalizedActionHash?: string; + errorHash?: string; + outputHash?: string; + changedPathHashes: string[]; +} | undefined; +export function normalizedActionHashFromHook(toolName: unknown, toolInput: unknown): string | undefined; +export function sanitizeToolOutcome(value: unknown): ReturnType; export const CLAUDE_HTTP_HOOK: Readonly<{ pathPrefix: string; sessionHeader: string; diff --git a/src/agent-runtime/runtime-protocol.mjs b/src/agent-runtime/runtime-protocol.mjs index 4aad8753..9f0c2881 100644 --- a/src/agent-runtime/runtime-protocol.mjs +++ b/src/agent-runtime/runtime-protocol.mjs @@ -1,3 +1,5 @@ +import { createHash } from "node:crypto"; + export const RUNTIME_PROTOCOL_VERSION = 1; export const MAX_RUNTIME_MESSAGE_BYTES = 64 * 1024; export const CAPTURE_RESULT_ENV = "CANVASTTY_RUNTIME_CAPTURE_RESULT"; @@ -10,6 +12,11 @@ export const MAX_ANSWER_CHARS = 4000; // of the wire cap: a large Stop payload must still yield its turn id and the // truncated answer instead of being dropped. export const MAX_HOOK_INPUT_BYTES = 512 * 1024; +export const MAX_TOOL_OUTCOME_NAME_CHARS = 80; +export const MAX_TOOL_OUTCOME_ERROR_CHARS = 8 * 1024; +export const MAX_TOOL_OUTCOME_PATHS = 16; +export const MAX_TOOL_OUTCOME_OUTPUT_CHARS = 16 * 1024; +const TOOL_RESULT_CLASSES = new Set(["success", "error", "denied", "unknown"]); export const AGENT_RUNTIME_ENV = Object.freeze({ address: "CANVASTTY_RUNTIME_ADDRESS", @@ -86,6 +93,189 @@ export function helperDeadlineMs(env) { return permissionGateTimings(typeof raw === "string" && /^\d{1,6}$/.test(raw) ? Number(raw) : undefined).helperMs; } +/** + * Derives the small, non-text summary CanvasTTY may share with trusted plugin services after a real tool + * completion hook. Permission requests and denials are deliberately outside this path. Tool inputs, errors and + * paths are used only as hash inputs and are never returned or persisted. + */ +export function toolOutcomeFromHook(provider, event, input) { + if (!isCompletedToolEvent(provider, event)) return undefined; + const record = isRecord(input) ? input : {}; + const rawToolName = typeof record.tool_name === "string" ? record.tool_name.trim() : ""; + const toolName = boundedOutcomeText(rawToolName.replace(/[\u0000-\u001f\u007f]/gu, ""), MAX_TOOL_OUTCOME_NAME_CHARS) || "unknown"; + const extra = provider === "hermes" && isRecord(record.extra) ? record.extra : {}; + // Provider shell-hook envelopes differ; keep legacy tool_response authoritative when present. + const response = record.tool_response !== undefined ? record.tool_response + : provider === "kimi" && typeof record.tool_output === "string" ? record.tool_output + : provider === "hermes" ? extra.result : undefined; + const error = firstHookString(record.error, record.tool_error, record.toolError, + isRecord(response) ? response.error : undefined, + isRecord(response) ? response.tool_error : undefined, extra.error_message); + let resultClass = "unknown"; + if (provider === "claude" && event === "PostToolUse") resultClass = "success"; + else if (provider === "claude" && event === "PostToolUseFailure") { + resultClass = record.is_interrupt === true ? "unknown" : "error"; + } else if (provider === "kimi" && event === "PostToolUseFailure") resultClass = "error"; + else if (provider === "hermes" && record.tool_response === undefined && typeof extra.status === "string") { + // These are authoritative observer statuses, not words parsed from arbitrary result text. + resultClass = extra.status === "ok" ? "success" : extra.status === "blocked" ? "denied" + : extra.status === "error" || extra.status === "timeout" ? "error" : "unknown"; + } else { + resultClass = explicitToolResultClass(response); + } + + const normalizedActionHash = normalizedActionHashFromHook(rawToolName, record.tool_input); + const errorHash = resultClass === "error" && error !== null + ? sha256(normalizeHashText(error.slice(0, MAX_TOOL_OUTCOME_ERROR_CHARS))) + : undefined; + + // A hash of what the tool printed lets a loop detector tell a shell loop that keeps producing the same output from + // one that is still learning something. The response text is never returned or stored. + const outputHash = resultClass !== "error" ? outputHashFromResponse(response) : undefined; + + const changedPathHashes = []; + if (resultClass === "success" && isFileWriteTool(rawToolName) && isRecord(response)) { + const path = typeof response.filePath === "string" ? normalizePathForHash(response.filePath) : ""; + if (path) changedPathHashes.push(sha256(path)); + } + return { + toolName, + resultClass, + ...(normalizedActionHash ? { normalizedActionHash } : {}), + ...(errorHash ? { errorHash } : {}), + ...(outputHash ? { outputHash } : {}), + changedPathHashes + }; +} + +/** OpenCode's direct after hook and terminal ToolPart states, never the generic event wrapper. */ +export function toolOutcomeFromOpenCode(tool, args, value, status = "completed") { + if (!isRecord(value)) return undefined; // Some failed task calls invoke after(undefined) before their error part. + const metadata = isRecord(value.metadata) ? value.metadata : {}; + let resultClass = status === "error" || value.isError === true ? "error" : "unknown"; + if (resultClass !== "error") { + if (Object.hasOwn(metadata, "exit")) { + resultClass = Number.isInteger(metadata.exit) ? (metadata.exit === 0 ? "success" : "error") : "unknown"; + } else if (!/^(?:bash|shell)$/iu.test(String(tool)) + && (typeof value.output === "string" || Array.isArray(value.content))) resultClass = "success"; + } + let output = typeof value.output === "string" ? value.output.slice(0, MAX_TOOL_OUTCOME_OUTPUT_CHARS) : undefined; + if (output === undefined && Array.isArray(value.content)) { + // MCP after hooks carry content instead of native output. Ignore non-text blocks, and bound work as well as bytes. + output = value.content.slice(0, 16).filter(part => isRecord(part) && part.type === "text" && typeof part.text === "string") + .map(part => part.text.slice(0, MAX_TOOL_OUTCOME_OUTPUT_CHARS)).join("\n").slice(0, MAX_TOOL_OUTCOME_OUTPUT_CHARS); + } + // Native write/edit metadata is authoritative; input paths alone do not prove a file was changed. + const filePath = typeof metadata.filepath === "string" ? metadata.filepath + : isRecord(metadata.filediff) && typeof metadata.filediff.file === "string" ? metadata.filediff.file : undefined; + return toolOutcomeFromHook("opencode", "tool.execute.after", { + tool_name: tool, tool_input: args, + error: typeof value.error === "string" ? value.error : resultClass === "error" ? output : undefined, + tool_response: { resultClass, ...(output !== undefined ? { output: output.slice(0, MAX_TOOL_OUTCOME_OUTPUT_CHARS) } : {}), + ...(filePath ? { filePath } : {}) } + }); +} + +function outputHashFromResponse(response) { + if (response === undefined || response === null) return undefined; + let text; + if (typeof response === "string") text = response; + else { try { text = JSON.stringify(response); } catch { return undefined; } } + if (typeof text !== "string") return undefined; + let bounded = text.slice(0, MAX_TOOL_OUTCOME_OUTPUT_CHARS); + // Never hash half of a surrogate pair (the native helper's boundedText drops it the same way). + if (/[\uD800-\uDBFF]$/u.test(bounded)) bounded = bounded.slice(0, -1); + return sha256(normalizeHashText(bounded)); +} + +/** Hashes the same hook action shape for both the pretool activity and its completed-tool outcome. */ +export function normalizedActionHashFromHook(rawToolName, toolInput) { + if (toolInput === undefined) return undefined; + const toolName = typeof rawToolName === "string" + ? boundedOutcomeText(rawToolName.trim().replace(/[\u0000-\u001f\u007f]/gu, ""), MAX_TOOL_OUTCOME_NAME_CHARS) || "unknown" + : "unknown"; + let serialized; + try { serialized = JSON.stringify(toolInput); } catch { return undefined; } + if (serialized === undefined) return undefined; + return sha256(`${toolName.toLowerCase()}\n${serialized}`); +} + +/** Strips unexpected fields before the compact summary is allowed onto the authenticated lifecycle wire. */ +export function sanitizeToolOutcome(value) { + if (!isRecord(value)) return undefined; + const resultClass = value.resultClass; + if (!TOOL_RESULT_CLASSES.has(resultClass)) return undefined; + const toolName = typeof value.toolName === "string" + ? boundedOutcomeText(value.toolName.replace(/[\u0000-\u001f\u007f]/gu, ""), MAX_TOOL_OUTCOME_NAME_CHARS) + : "unknown"; + const normalizedActionHash = typeof value.normalizedActionHash === "string" && /^[a-f0-9]{64}$/u.test(value.normalizedActionHash) + ? value.normalizedActionHash : undefined; + const errorHash = resultClass === "error" && typeof value.errorHash === "string" && /^[a-f0-9]{64}$/u.test(value.errorHash) + ? value.errorHash : undefined; + const outputHash = resultClass !== "error" && typeof value.outputHash === "string" && /^[a-f0-9]{64}$/u.test(value.outputHash) + ? value.outputHash : undefined; + const changedPathHashes = Array.isArray(value.changedPathHashes) + ? [...new Set(value.changedPathHashes.filter((hash) => typeof hash === "string" && /^[a-f0-9]{64}$/u.test(hash)))].slice(0, MAX_TOOL_OUTCOME_PATHS) + : []; + return { + toolName: toolName || "unknown", + resultClass, + ...(normalizedActionHash ? { normalizedActionHash } : {}), + ...(errorHash ? { errorHash } : {}), + ...(outputHash ? { outputHash } : {}), + changedPathHashes + }; +} + +function isCompletedToolEvent(provider, event) { + if (event === "PostToolUse") return true; + if ((provider === "claude" || provider === "kimi") && event === "PostToolUseFailure") return true; + if (provider === "opencode" && (event === "tool.execute.after" || event === "message.part.updated")) return true; + return provider === "hermes" && event === "post_tool_call"; +} + +function explicitToolResultClass(response) { + if (!isRecord(response)) return "unknown"; + if (response.denied === true || response.status === "denied" || response.resultClass === "denied") return "denied"; + if (response.isError === true || response.is_error === true || response.success === false + || response.status === "error" || response.status === "failed" || response.resultClass === "error") return "error"; + if (response.success === true || response.status === "success" || response.status === "completed" + || response.resultClass === "success") return "success"; + const exitCode = typeof response.exit_code === "number" ? response.exit_code + : typeof response.exitCode === "number" ? response.exitCode : undefined; + if (exitCode !== undefined && Number.isInteger(exitCode)) return exitCode === 0 ? "success" : "error"; + return "unknown"; +} + +function isFileWriteTool(value) { + return typeof value === "string" && /^(?:Edit|Write)$/iu.test(value.trim()); +} + +function normalizePathForHash(value) { + return value.trim().replace(/\\/gu, "/"); +} + +function normalizeHashText(value) { + return value.replace(/\r\n?/gu, "\n").trim(); +} + +function sha256(value) { + return createHash("sha256").update(value, "utf8").digest("hex"); +} + +function firstHookString(...values) { + return values.find((value) => typeof value === "string" && value.length > 0) ?? null; +} + +function boundedOutcomeText(value, limit) { + const text = value.slice(0, limit); + return /[\uD800-\uDBFF]$/u.test(text) ? text.slice(0, -1) : text; +} + +function isRecord(value) { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + // Claude Code's own HTTP hooks (`type: "http"`, measured with 2.1.281) carry the lifecycle events straight to the // gateway's loopback listener: no process per event. Claude fills both headers from the session's environment // (`allowedEnvVars`), so the capability never appears in its argv or in a file. Decision hooks (PreToolUse) stay on diff --git a/src/main/editContextMenu.ts b/src/main/editContextMenu.ts new file mode 100644 index 00000000..ad553d44 --- /dev/null +++ b/src/main/editContextMenu.ts @@ -0,0 +1,36 @@ +import type { ContextMenuParams, MenuItemConstructorOptions, WebContents } from "electron"; +import type { LocaleId } from "../shared/contracts.ts"; + +type EditParams = Pick; + +/** + * The right-click menu for text: Cut/Copy/Paste/Select All in an editable field (an app field or a field in a plugin + * page), Copy for selected page text, nothing elsewhere. A page that handles its own right click (the canvas, a + * terminal) cancels the DOM event, and Electron then never asks for this menu. + */ +export function editContextMenuTemplate(params: EditParams, locale: LocaleId): MenuItemConstructorOptions[] { + const ru = locale === "ru"; + const label = { cut: ru ? "Вырезать" : "Cut", copy: ru ? "Скопировать" : "Copy", paste: ru ? "Вставить" : "Paste", selectAll: ru ? "Выбрать все" : "Select All" }; + if (params.isEditable) { + return [ + { role: "cut", label: label.cut, enabled: params.editFlags.canCut }, + { role: "copy", label: label.copy, enabled: params.editFlags.canCopy }, + { role: "paste", label: label.paste, enabled: params.editFlags.canPaste }, + { type: "separator" }, + { role: "selectAll", label: label.selectAll, enabled: params.editFlags.canSelectAll } + ]; + } + if (params.selectionText.length > 0) return [{ role: "copy", label: label.copy, enabled: params.editFlags.canCopy }]; + return []; +} + +export function attachEditContextMenu( + contents: WebContents, + locale: () => LocaleId, + popup: (template: MenuItemConstructorOptions[], contents: WebContents) => void +): void { + contents.on("context-menu", (_event, params) => { + const template = editContextMenuTemplate(params, locale()); + if (template.length) popup(template, contents); + }); +} diff --git a/src/main/index.ts b/src/main/index.ts index e2fb63e2..9c9d07b7 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,8 +1,34 @@ +import { WorkspaceArchive } from "./services/WorkspaceArchive"; +import { configuredApiDomains, apiProfileDomains } from "./services/isolation/configuredApiDomains"; +import { registerNetworkPolicyIpc } from "./ipc/registerNetworkPolicyIpc"; +import { NetworkPolicyManager } from "./services/isolation/networkPolicy"; +import { SecretGrantService } from "./services/SecretGrantService"; +import { secretApiRequestExecutor } from "./services/SecretCommandExecutor"; +import { runShutdownSteps } from "./services/shutdownSteps"; +import { BACKLOG_TERMINAL_IPC } from "../shared/backlog"; +import { AttentionService } from "./services/AttentionService"; +import { SessionReports } from "./services/SessionReports"; +import { collectTerminalHistoryRecoverySnapshots, TerminalOutputHistory } from "./services/TerminalOutputHistory"; +import { actionFromHook } from "./services/safety/baseProtection"; +import { normalizedActionHashFromHook } from "../agent-runtime/runtime-protocol.mjs"; +import { acceptLoopSignal } from "./services/AssistantLoopSignal"; +import { UsagePrices } from "./services/UsagePrices"; +import { ProviderUsageSource } from "./services/ProviderUsageSource"; +import { SessionTimelineService } from "./services/SessionTimelineService"; +import { configuredModel } from "./services/configuredModel"; +import { subagentWorktreeResolver } from "./services/SubagentWorktreeResolver"; +import { GitCheckpoints } from "./services/GitCheckpoints"; +import { OrchestrationBudgetService } from "./services/OrchestrationBudgetService"; +import { OrchestrationTaskBoard } from "./services/OrchestrationTaskBoard"; +import { OrchestrationTemplateService } from "./services/OrchestrationTemplateService"; +import { resolveAgentHistoryPaths } from "./services/agent-history/historyPaths"; +import { refreshOrchestrationUsageBatch, type OrchestrationUsageScope } from "./services/OrchestrationUsageSync"; +import { registerBacklogIpc } from "./ipc/registerBacklogIpc"; import "./stdio"; import appIcon from "../../build/icon.png?asset"; import appManifest from "../../package.json"; import { ipcMain } from "electron"; -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { dirname, isAbsolute } from "node:path"; import { EvenG2Controller } from "./services/companion/EvenG2Controller"; import { join } from "node:path"; @@ -84,7 +110,7 @@ import { RuntimeGateway } from "./services/agent-runtime"; import type { RuntimeHookHelperLaunch } from "./services/agent-runtime/ProviderRuntimeLaunch"; -import { agentHelperLaunches } from "./services/agentHelpers"; +import { agentHelperLaunches, nativeHelperPath } from "./services/agentHelpers"; import { recoverHermesConfigurationOnStartup, resolveHermesHomeDirectory @@ -92,6 +118,7 @@ import { import { startupPageUrl } from "./startupPage"; import { mainWindowChromeOptions } from "./windowChrome"; import { markMainBoot, mainBootMarks } from "./bootMarks"; +import { attachEditContextMenu } from "./editContextMenu"; import { macApplicationMenuTemplate } from "./macApplicationMenu"; if (process.env.CANVASTTY_USER_DATA_DIR) { @@ -164,6 +191,13 @@ async function showCompanionBrowser():Promise<{title:string;url:string}> { const state=browserService.getState(), tab=state.tabs.find(tab=>tab.id===state.activeTabId); return {title:tab?.title||"Browser",url:tab?.url||""}; } +let disposeBudgetObservers:(()=>void)|null=null; +let flushBudgets:(()=>Promise)|null=null; +let sessionTimeline:SessionTimelineService|null=null; +let networkPolicies: NetworkPolicyManager | null = null; +let secretGrants: SecretGrantService | null = null; +let sessionReports:SessionReports|null=null; +let terminalOutputHistory:TerminalOutputHistory|null=null; let terminalManager: TerminalManager | null = null; let agentControl: AgentControlGateway | null = null; let limitsService: LimitsService | null = null; @@ -194,6 +228,8 @@ let appSurfaceReady = false; let pendingMenuUpdateCheck = false; let checkUpdatesFromMenu: (() => void) | null = null; let installMacMenu: (() => void) | null = null; +/** The locale of the text right-click menu; startApplication points it at the settings once they are loaded. */ +let editMenuLocale: () => LocaleId = () => (app.getLocale().toLowerCase().startsWith("ru") ? "ru" : "en"); let updateTimer: ReturnType | null = null; let updateInterval: ReturnType | null = null; let startupRunning = false; @@ -256,6 +292,8 @@ function createWindow(): BrowserWindow { preventMouseBindings: false, captureMacEditShortcuts: process.platform === "darwin" }); + attachEditContextMenu(window.webContents, () => editMenuLocale(), + (template, contents) => Menu.buildFromTemplate(template).popup({ window: BrowserWindow.fromWebContents(contents) ?? undefined })); // Crash recovery: a dead renderer must never leave the user staring at a // blank window. The application surface is reloaded in place — the same entry // startup loads — so services, sessions and their scrollback stay untouched @@ -327,6 +365,7 @@ async function initializeServices(ipc: IpcRegistrar): Promise { recoverKimiConfigurationOnStartup(kimiHomeDirectory); const userDataPath = app.getPath("userData"); const settings = new SettingsStore(userDataPath, app.getLocale(), process.platform, providerCliAvailability(providerClis)); + editMenuLocale = () => settings.get().locale; const terminalBorderSkins = new SkinRegistry(userDataPath); const pixelSkinPacks = new PixelSkinPackRegistry(userDataPath); pluginManager = new PluginManager(userDataPath); @@ -352,6 +391,78 @@ async function initializeServices(ipc: IpcRegistrar): Promise { markMainBoot("criticalServicesReady"); // Secrets this app knows are masked in every text one agent reads from another (EP-8). const redaction = new SecretRedactionRegistry(); + const outputHistory=new TerminalOutputHistory(()=>redaction.snapshotForWorker(), () => + collectTerminalHistoryRecoverySnapshots((terminalManager?.listMetadata() ?? []).map(row => row.id), + (id, maxChars) => terminalManager!.readBuffer(id, maxChars)), + // Encrypted crash-recovery copy of the worker's history; its key lives only in this process. + {spillDirectory:join(userDataPath,"terminal-history")}); + terminalOutputHistory=outputHistory; + const outputHistorySeeded=new Set(); + const outputHistoryError = (error: unknown): void => { + if (!shutdownRunning) console.warn(error); + }; + const timeline = new SessionTimelineService(userDataPath,text => redaction.redact(text)); + await timeline.load(); + sessionTimeline=timeline; + const reports=new SessionReports(userDataPath,id=>timeline.report(id),(id,at)=>terminalManager?.setSessionReport(id,at)); + sessionReports=reports; + const reviewUsageSessions=new Map(); + const usagePrices=new UsagePrices(join(userDataPath,"usage-prices.json")); + await usagePrices.load(); + const usageSources=new Map(); + const usageSourceFor=(home:string):ProviderUsageSource=>{ + let source=usageSources.get(home); + if(!source){source=new ProviderUsageSource(home);usageSources.set(home,source);} + return source; + }; + const attention=new AttentionService(join(userDataPath,"attention-preferences.json"),text=>redaction.redact(text)); + await attention.load(); + const notifyAttention=(id:string,kind:string)=>{ + const row=terminalManager?.getMetadata(id) ?? terminalManager?.listMetadata().find(row=>!row.parentSessionId && row.taskScope?.id===id);if(!row)return; + const event=attention.publish(row.id,row.title || row.provider,kind); + if(!event || !attention.allows("desktop",event) || !settings.get().attentionNotifications || !Notification.isSupported())return; + const labels:Record=settings.get().locale==="ru" ? {response:"Ждёт ответа",approval:"Нужно разрешение",done:"Закончил",failed:"Ошибка",budget:"Бюджет требует внимания",loop:"Повторяет одно действие"} : {response:"Waiting for your answer",approval:"Approval required",done:"Finished",failed:"Failed",budget:"Task budget needs attention",loop:"Repeating the same action"}; + const notice=new Notification({title:event.title,body:labels[kind]}); + notice.on("click",()=>{mainWindow?.show();mainWindow?.focus();mainWindow?.webContents.send(BACKLOG_TERMINAL_IPC.focusRequested,row.id);});notice.show(); + }; + const taskBoard=new OrchestrationTaskBoard(join(userDataPath,"task-boards")); + const templates=new OrchestrationTemplateService(join(userDataPath,"flow-approvals.json")); + let budgetInputGate:(id:string)=>void=()=>undefined; + let markLoopDetected:(id:string)=>boolean=()=>false; + const applyBudgetEnforcement=(row:ReturnType):void=>{ + if(!terminalManager)return; + const result=terminalManager.setBudgetPaused(row.rootSessionId,row.paused); + const failure=result.failed ? redaction.redact(result.failed) : undefined; + budgets.setEnforcementFailure(row.rootSessionId,failure); + if(failure){row.paused=true;row.reason=`${row.reason ?? "Task budget is paused."} ${failure}`;} + }; + const budgets=new OrchestrationBudgetService(join(userDataPath,"task-budgets.json"),{ + onWarning:row=>{notifyAttention(row.rootSessionId,"budget");void timeline.append(row.rootSessionId,"budget","Task reached 80% of its budget").catch(console.warn);}, + onPause:row=>{applyBudgetEnforcement(row);notifyAttention(row.rootSessionId,"budget");void timeline.append(row.rootSessionId,"budget","Task paused at its budget limit",row.reason).catch(console.warn);}, + onChange:applyBudgetEnforcement + }); + await budgets.load(); + flushBudgets=()=>budgets.flush(); + const checkpoints = new GitCheckpoints(text => redaction.redact(text),50,join(userDataPath,"checkpoints.json")); + const checkpointTurns = new Map;signal?:AbortSignal;current:()=>boolean}>(); + const checkpointBeforeTurn = (id: string, signal?: AbortSignal): Promise => { + const row=terminalManager?.getMetadata(id); + if (!row || row.exitCode !== null || row.profile !== "auto" && row.profile !== "yolo") return Promise.resolve(); + if (!terminalManager!.canCaptureCheckpoint(id)) return Promise.resolve(); + let entry=checkpointTurns.get(id); + if (!entry || !entry.current()) { + const created={pending:Promise.resolve(),signal,current:terminalManager!.providerSignalGuard(id,{kind:"lifecycle",state:"working"})}; + created.pending=checkpoints.capture(id,row.cwd,signal).catch(error => { + // Keep the failed attempt until the turn ends or its input generation changes. + // Retrying at PostToolUse could snapshot edits as if they preceded the turn. + const reason=redaction.redact(String(error)); + console.warn("Rollback point unavailable",reason); + void timeline.append(id,"checkpoint","Rollback point unavailable",reason).catch(console.warn); + }); + checkpointTurns.set(id,created);entry=created; + } + return entry.pending; + }; diagnostics.configureRedaction(text => redaction.redact(text)); diagnosticContext = () => ({ servicesReady, @@ -373,8 +484,26 @@ async function initializeServices(ipc: IpcRegistrar): Promise { await pluginManager!.storageSet(pluginId, key, value); broadcastPluginStorageChange(pluginId, key, value); }, - emit: (pluginId, serviceId, event, data) => broadcastPluginServiceEvent({ pluginId, serviceId, event, data }), + emit: (pluginId, serviceId, event, data) => { + let broadcastData = data; + if (event === "loop.detected") { + // Verified by the install record (source repository, enabled, trusted services), not the manifest id. + const accepted = acceptLoopSignal({ + installRecord: id => pluginManager?.installRecord(id) ?? null, + session: id => terminalManager?.getMetadata(id) ?? undefined, + turnEpoch: id => runtimeGateway?.currentTurnEpoch(id) ?? null, + consumeEvidence: (id, evidence, epoch) => pluginSessions?.consumeLoopEvidence(id, evidence, epoch) ?? false, + markLoopDetected: id => markLoopDetected(id) + }, pluginId, serviceId, data, text => redaction.redact(text)); + if (!accepted) return; + notifyAttention(accepted.sessionId,"loop"); + void timeline.append(accepted.sessionId,"loop",accepted.label,accepted.reason,"canvastty-assistant").catch(console.warn); + broadcastData = accepted.data; + } + broadcastPluginServiceEvent({ pluginId, serviceId, event, data: broadcastData }); + }, registerSecrets: (pluginId, values) => redaction.add(`plugin:${pluginId}`, values), + maskSecrets: text => redaction.redact(text), secretGet: (pluginId, key) => { if (!pluginSecretsService) throw new Error("Plugin secrets are not ready yet."); return pluginSecretsService.get(pluginId, key); @@ -512,16 +641,61 @@ async function initializeServices(ipc: IpcRegistrar): Promise { runtimeGateway = new RuntimeGateway({ runtimeDirectory: lifecycleRuntimeDirectory, windowsHostPath, + lifecycleGuard: (terminalSessionId, signal) => terminalManager?.providerSignalGuard(terminalSessionId, { + kind:"lifecycle",state:signal.state,event:signal.event,...(signal.turnId ? {requestId:signal.turnId} : {}) + }) ?? (()=>false), + beforeLifecycle: async (terminalSessionId, signal, cancellation) => { + const manager=terminalManager; + if (!manager) return false; + const current=manager.providerSignalGuard(terminalSessionId, { + kind: "lifecycle", state: signal.state, event: signal.event, + ...(signal.turnId ? {requestId:signal.turnId} : {}) + }); + if (!current()) return false; + const controller=new AbortController(), abort=():void=>controller.abort(); + cancellation.addEventListener("abort",abort,{once:true}); + if(cancellation.aborted)abort(); + // Only a successful write that changes the captured turn cancels its checkpoint; typing and approval replies do not. + const unobserve=manager.observeInputWrites(terminalSessionId,()=>()=>{if(!current())abort();}); + try { await checkpointBeforeTurn(terminalSessionId,controller.signal);return current(); } + finally {unobserve();cancellation.removeEventListener("abort",abort);} + }, onSignal: (terminalSessionId, signal) => { - terminalManager?.applyProviderSignal(terminalSessionId, { + const accepted = 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 (!accepted) return; + const usageRow=terminalManager?.getMetadata(terminalSessionId); + if(usageRow?.provider==="codex" && signal.threadId && (!terminalManager?.pluginContext(terminalSessionId)?.environment || terminalManager.pluginContext(terminalSessionId)?.environment?.kind==="worktree")) { + const account=terminalManager!.usageAccount(terminalSessionId); + const source=usageSourceFor(account.home ?? resolveAgentHistoryPaths().codex); + void source.codexUsage(signal.threadId).then(usage=>usage===null || !terminalManager?.getMetadata(terminalSessionId) ? undefined : timeline.recordCumulativeUsage(terminalSessionId,usage,"codex-cli conversation counter",signal.threadId,{provider:"codex",accountId:account.id,taskId:agentControlService.taskRoot(terminalSessionId).id,...(usage.model ?? usageRow.model ? {model:usage.model ?? usageRow.model} : {})},{resumed:terminalManager!.resumedConversation(terminalSessionId,signal.threadId!)})).catch(console.warn); + } + if(signal.state !== "working" && signal.state !== "needs_approval")checkpointTurns.delete(terminalSessionId); + if (signal.state === "idle") secretGrants?.turnEnded(terminalSessionId); + else secretGrants?.revalidateTurn(terminalSessionId); + // The answer belongs to the accepted provider turn and its host-submitted input generation. + if (signal.result) terminalManager?.recordAnswer(terminalSessionId, signal.result, { turnId: signal.turnId }); + void timeline.append(terminalSessionId,"lifecycle",signal.event ?? signal.state,undefined,"provider-hook").catch(console.warn); + if (signal.toolOutcome) { + pluginSessions?.activity({ + type: "tool-outcome", + sessionId: terminalSessionId, + at: Date.now(), + turnEpoch: signal.turnEpoch, + ...(signal.turnId ? {turnId:signal.turnId} : {}), + toolName: redaction.redact(signal.toolOutcome.toolName), + resultClass: signal.toolOutcome.resultClass, + ...(signal.toolOutcome.normalizedActionHash ? { normalizedActionHash: signal.toolOutcome.normalizedActionHash } : {}), + ...(signal.toolOutcome.errorHash ? { errorHash: signal.toolOutcome.errorHash } : {}), + ...(signal.toolOutcome.outputHash ? { outputHash: signal.toolOutcome.outputHash } : {}), + changedPathHashes: signal.toolOutcome.changedPathHashes + }); + } agentControl?.onSignal(terminalSessionId, signal); if (signal.lastAssistantMessage !== undefined && signal.answerCaptureGrantExpiresAt !== undefined) { evenG2?.answer( @@ -533,7 +707,29 @@ async function initializeServices(ipc: IpcRegistrar): Promise { } }, onAnswerCaptureRevoked: (terminalSessionId) => evenG2?.clearAnswer(terminalSessionId), - onPermissionRequest: (terminalSessionId, request, signal) => decisionHooks.decide(terminalSessionId, request, signal), + onPermissionRequest: async (terminalSessionId, request, signal) => { + if (terminalManager?.isCheckpointRestoreActive(terminalSessionId)) return {behavior:"deny",message:"Workspace checkpoint restoration is in progress."}; + try {budgetInputGate(terminalSessionId);}catch(error) { + const message=redaction.redact(error instanceof Error ? error.message : "Task budget is paused."); + void timeline.append(terminalSessionId,"budget","Tool blocked by task budget",message).catch(console.warn); + return {behavior:"deny",message}; + } + const current=terminalManager?.providerSignalGuard(terminalSessionId,{kind:"lifecycle",state:"working"}); + await checkpointBeforeTurn(terminalSessionId, signal); + if(!current?.() || terminalManager?.isCheckpointRestoreActive(terminalSessionId))return {behavior:"deny",message:"The agent turn changed while preparing its rollback point."}; + const action=actionFromHook(request.toolName,request.toolInput,request.toolInputPreview); + const detail=redaction.redact(JSON.stringify({kind:action.kind,command:action.command,paths:action.paths})); + void timeline.append(terminalSessionId,action.kind === "shell" ? "command" : action.kind === "edit" ? "file" : "tool",request.toolName,detail,"provider-hook").catch(console.warn); + const decision=await decisionHooks.decide(terminalSessionId,request,signal); + if (decision.behavior !== "none") void timeline.append(terminalSessionId,"decision",decision.behavior,decision.message,"core").catch(console.warn); + const normalizedActionHash=normalizedActionHashFromHook(request.toolName,request.toolInput); + pluginSessions?.activity({type:"pretool",sessionId:terminalSessionId,at:Date.now(),turnEpoch:request.turnEpoch,toolName:redaction.redact(request.toolName), + ...(request.turnId ? {turnId:request.turnId} : {}), + normalizedAction:createHash("sha256").update(`${request.toolName}:${detail}`).digest("hex"), + ...(normalizedActionHash ? {normalizedActionHash} : {}), + ...(decision.behavior!=="none" ? {resultClass:decision.behavior} : {})}); + return decision; + }, // Claude Code's lifecycle hooks go straight to a loopback listener where ClaudeHttpHookPolicy allows it. httpHooks: true }); @@ -548,6 +744,7 @@ async function initializeServices(ipc: IpcRegistrar): Promise { const claudeHttpHookPolicy = new ClaudeHttpHookPolicy(); agentRuntimeBridge = new AgentRuntimeBridge(runtimeGateway, { helper: agentRuntimeHelper, + onTurnAuthorityChanged: id => secretGrants?.revalidateTurn(id), runtimeDirectory: lifecycleRuntimeDirectory, openCodePluginPath, hermesHomeDirectory, @@ -573,6 +770,10 @@ async function initializeServices(ipc: IpcRegistrar): Promise { } // Output batches of every session flushed in one task leave as one IPC message. + const reportedSafety = new Map(); + let forgetOrchestrationSession=(_id:string):void=>undefined; + let scheduleUsageRefresh=():void=>undefined; + const usageMembership=new Map(); const rendererOutbox = new TerminalRendererOutbox((channel, payload) => { if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.webContents.isDestroyed()) mainWindow.webContents.send(channel, payload); }); @@ -581,6 +782,7 @@ async function initializeServices(ipc: IpcRegistrar): Promise { // only, and the replay when it is shown again to the renderer only; the // event says which (TerminalDataEvent.audience). if (reachesObservers(payload)) { + if(!shutdownRunning && channel===IPC.terminalData && "data" in payload)void outputHistory.append(payload.id,payload.data,payload.outputOffset).catch(outputHistoryError); agentControl?.observe(channel, payload); evenG2?.observe(channel, payload); pluginSessions?.observe(channel, payload); @@ -595,33 +797,68 @@ async function initializeServices(ipc: IpcRegistrar): Promise { // re-derived state (a restored session, already on screen) or the outcome // of a launch the user just asked for, which is news either way. if (channel === IPC.terminalSession && "session" in payload) { - const { id, status, title, provider } = payload.session; + const { id, status, provider } = payload.session; + const membership=JSON.stringify([payload.session.parentSessionId,payload.session.taskScope]); + if(usageMembership.get(id)!==membership){usageMembership.set(id,membership);scheduleUsageRefresh();} + if(!outputHistorySeeded.has(id)){ + outputHistorySeeded.add(id); + const snapshot=terminalManager?.readBuffer(id); + if(!shutdownRunning && snapshot?.buffer)void outputHistory.append(id,snapshot.buffer,snapshot.outputOffset).catch(outputHistoryError); + } + if (payload.session.exitCode !== null) secretGrants?.sessionEnded(id); + const oldSafety=reportedSafety.get(id) ?? {}; + const nextSafety={isolation:payload.session.isolation ? JSON.stringify(payload.session.isolation) : undefined,gitRisk:payload.session.gitRisk ? JSON.stringify(payload.session.gitRisk) : undefined}; + if(nextSafety.isolation && nextSafety.isolation!==oldSafety.isolation)void timeline.append(id,"isolation","Effective session protection",nextSafety.isolation,"core").catch(console.warn); + if(nextSafety.gitRisk && nextSafety.gitRisk!==oldSafety.gitRisk)void timeline.append(id,"git-risk","Git settings require attention",nextSafety.gitRisk,"core").catch(console.warn); + reportedSafety.set(id,nextSafety); const previousStatus = notifiedAttentionStatus.get(id); + if (previousStatus !== status) void timeline.append(id,"status",status,undefined,"session-manager").catch(console.warn); if (previousStatus !== status) diagnostics.record(status === "failed" ? "error" : "info", "terminal", "session.state", { id, provider, status, exitCode: payload.session.exitCode }); notifiedAttentionStatus.set(id, status); + if(previousStatus!==status){ + if(status==="working")reports.invalidate(id); + else if(status==="done" || status==="failed" || status==="idle" && previousStatus==="working" && payload.session.turnCompleted)void reports.complete(id).catch(console.warn); + } const failureOrigin = status === "failed" ? terminalManager?.consumeFailureOrigin() ?? null : null; - if ((status === "needs_approval" || status === "failed") - && failureOrigin !== "restore" - && (failureOrigin === "user" || previousStatus !== status) - && settings.get().attentionNotifications - && Notification.isSupported()) { - new Notification({ - title: title || provider, - body: attentionStatusLabel(status, settings.get().locale) - }).show(); + if(failureOrigin!=="restore" && (failureOrigin==="user" || previousStatus!==status)) { + if(status==="needs_approval")notifyAttention(id,"approval"); + else if(status==="idle" && previousStatus==="working")notifyAttention(id,payload.session.turnCompleted ? "done" : "response"); + else if(status==="failed")notifyAttention(id,"failed"); + else if(status==="done" && previousStatus!==undefined)notifyAttention(id,"done"); } } else if (channel === IPC.terminalRemoved && "id" in payload) { + usageMembership.delete(payload.id);scheduleUsageRefresh(); + outputHistorySeeded.delete(payload.id); + if(!shutdownRunning)void outputHistory.remove(payload.id).catch(outputHistoryError); + reports.forget(payload.id); + reviewUsageSessions.delete(payload.id); + for(const [worker,reviewer] of reviewUsageSessions)if(reviewer===payload.id)reviewUsageSessions.delete(worker); diagnostics.record("info", "terminal", "session.closed", { id: payload.id }); notifiedAttentionStatus.delete(payload.id); + reportedSafety.delete(payload.id); + checkpointTurns.delete(payload.id); + secretGrants?.sessionEnded(payload.id); + forgetOrchestrationSession(payload.id); } }, providerClis, agentBrowserBridge ?? undefined, agentRuntimeBridge ?? undefined, settings.get().agentLifecycleHooksEnabled); terminalManager.configureRedaction(redaction); + timeline.configureSessionContext(id=>{ + const row=terminalManager?.getMetadata(id); + return row ? {taskId:terminalManager!.taskScopeFor(id).id,title:row.title || row.provider} : undefined; + }); terminalManager.setKeyboardShortcuts(settings.get().shortcuts); // The operating-system isolation layer (Settings → Agents → Agent isolation) and YOLO only where the person // acknowledged it: both decided here, in the main process, for every launch whoever asks for it. - terminalManager.configureIsolation(new AgentIsolation({ userDataPath, enabled: () => settings.get().agentIsolation !== "off" })); + const configuredDomains=configuredApiDomains({...process.env,HOME:app.getPath("home")}); + networkPolicies = new NetworkPolicyManager({ userDataPath,providerDomains:provider=>[...(configuredDomains[provider] ?? []),...apiProfileDomains(settings.get().apiProfiles)] }); + await networkPolicies.start().catch(error => console.warn("Restricted-network proxy unavailable:", String(error))); + const isolation = new AgentIsolation({ + userDataPath, enabled: () => settings.get().agentIsolation !== "off", networkPolicy: networkPolicies, + networkHelperPath: nativeHelperPath({ packaged: app.isPackaged, resourcesPath: process.resourcesPath, appPath: app.getAppPath(), execPath: process.execPath }) + }); + terminalManager.configureIsolation(isolation); terminalManager.configureYoloAcknowledgement((provider) => settings.get().acknowledgedDangerousProfiles.includes(provider as AgentProviderId)); // OpenCode's auto profile runs shell commands without asking only while base protection guards them. terminalManager.configureBaseProtection(() => settings.get().baseProtectionEnabled); @@ -632,6 +869,7 @@ async function initializeServices(ipc: IpcRegistrar): Promise { // Plugin services see card events and control only the cards they start (EP-4). const sessionsForPlugins = new PluginSessions({ terminals: terminalManager, + installRecord:id=>pluginManager?.installRecord(id) ?? null, notify: (pluginId, serviceId, method, params) => pluginServices!.notify(pluginId, serviceId, method, params) }); pluginSessions = sessionsForPlugins; @@ -659,7 +897,8 @@ async function initializeServices(ipc: IpcRegistrar): Promise { // list_providers: the CLI registry resolved at startup, the last usage read (never started from here) and the // launch options trusted plugins declared. - const providerModels = new ProviderModelCatalog(providerClis); + const providerModels = new ProviderModelCatalog(providerClis,{codexHome:resolveAgentHistoryPaths().codex}); + await providerModels.refresh("codex"); // OpenCode with a model it does not list fails with only "Unexpected server error": refuse it up front. terminalManager.configureModelCheck((provider, model) => provider === "terminal" ? null : providerModels.unknownModelCached(provider, model)); const providerDirectorySources: ProviderDirectorySources = { @@ -678,8 +917,86 @@ 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(), + currentTurnEpoch: id => runtimeGateway?.currentTurnEpoch(id) ?? null, + budget:budgets, + resolveSubagentEnvironment: subagentWorktreeResolver({ + isGitProject: projectRoot => checkpoints.available(projectRoot), + providers: () => pluginManager!.environmentProviders() + }), + workerModel:async worker=>{ + const account=managedTerminals.usageAccount(worker.id); + const actual=worker.provider==="codex" && worker.threadId ? await usageSourceFor(account.home ?? resolveAgentHistoryPaths().codex).codexUsage(worker.threadId) : null; + return actual?.model ?? configuredModel(worker.provider as AgentProviderId,{codex:resolveAgentHistoryPaths().codex,claude:join(app.getPath("home"),".claude"),opencode:join(process.env.XDG_CONFIG_HOME ?? join(app.getPath("home"),".config"),"opencode")}); + }, + reviewCost:id=>timeline.usage([id],usagePrices.get()).cost, + reviewModel:(provider,model)=>model ? providerDirectorySources.models?.(provider)?.models.find(candidate=>candidate!==model) ?? null : null, + onReview:(id,result)=>{if(result.reviewerSessionId)reviewUsageSessions.set(id,result.reviewerSessionId);managedTerminals.setTaskMetadata(id,{reviewRequested:true});void timeline.append(id,"review",result.status,result.notes ?? result.reason,"reviewer").catch(console.warn);} + }); + budgetInputGate=id=>agentControlService.assertInputAllowed(id); + markLoopDetected=id=>agentControlService.markLoopDetected(id); + managedTerminals.configureInputGate(budgetInputGate); + secretGrants = new SecretGrantService({ + getSecret: id => providerSecretsService!.get(id), + getApiProfiles: () => settings.get().apiProfiles, + getTurnIdentity: id => { + const turn = agentRuntimeBridge?.currentTurnIdentity(id); + const generation = managedTerminals.observedTurnGeneration(id); + return turn && generation !== null ? `${turn}:${generation}` : null; + }, + watchTurn: (id, changed) => managedTerminals.observeInputWrites(id, () => { + const generation = managedTerminals.answerCaptureGeneration(id); + return () => { if (generation !== managedTerminals.answerCaptureGeneration(id)) changed(); }; + }), + getSession: id => { + const context = managedTerminals.pluginContext(id); + if (!context || context.environment && context.environment.kind !== "worktree") return null; + budgetInputGate(id); + const row = context.metadata; + return { provider: row.provider, cwd: context.workingDirectory, networkProjectRoot: agentControlService.taskRoot(id).cwd, profile: row.profile, active: row.exitCode === null && row.status !== "failed" && row.status !== "done" }; + }, + execute: secretApiRequestExecutor(isolation), + rememberSecret: value => redaction.add("vault", [value]), redact: text => redaction.redact(text), + onRequest: request => { notifyAttention(request.sessionId, "approval"); void timeline.append(request.sessionId,"secret-request",request.secretId,request.reason,"core").catch(console.warn); }, + onDecision: event => { void timeline.append(event.request.sessionId,"secret-decision",`${event.decision}: ${event.request.secretId}`,event.duration,"human").catch(console.warn); }, + onRevoke: event => { void timeline.append(event.sessionId,"secret-revoked",event.secretId,event.reason,"core").catch(console.warn); } }); + let usageRefreshPending=false; + const refreshUsage=()=>{ + const rows=managedTerminals.listMetadata(),scopes=agentControlService.taskRoots(rows); + const prices=usagePrices.get(); + const roots=new Map(); + const members=new Map(),parents=new Map(); + for(const row of rows){ + if(row.provider==="terminal")continue; + const reviewer=reviewUsageSessions.get(row.id); + managedTerminals.setObservedUsage(row.id,timeline.usage([row.id],prices),reviewer ? timeline.usage([reviewer],prices) : undefined); + const root=scopes.get(row.id);if(!root)continue; + const ids=members.get(root.id) ?? [];ids.push(row.id);members.set(root.id,ids); + if(!row.parentSessionId)parents.set(root.id,row.id); + if(row.exitCode===null)roots.set(root.id,root); + } + const usageScopes:OrchestrationUsageScope[]=[]; + for(const root of roots.values()) { + usageScopes.push({rootSessionId:root.id,rootStartedAt:root.startedAt,memberSessionIds:members.get(root.id) ?? [], + budgetEnabled:budgets.hasLimits(root.id)}); + } + refreshOrchestrationUsageBatch(budgets,timeline,usageScopes,prices,(scope,snapshot)=>{ + const parent=parents.get(scope.rootSessionId); + if(parent)managedTerminals.setTaskBudget(parent,snapshot ? {...snapshot.remaining,paused:snapshot.paused,warning:snapshot.warning, + ...(snapshot.paused && !managedTerminals.processSuspensionSupported() ? {processesKeepRunning:true} : {})} : undefined); + },scope=>{budgets.snapshot(scope.rootSessionId,scope.rootStartedAt);}); + }; + scheduleUsageRefresh=()=>{ + if(usageRefreshPending)return; + usageRefreshPending=true; + queueMicrotask(()=>{usageRefreshPending=false;refreshUsage();}); + }; + const offUsage=timeline.subscribeUsage(scheduleUsageRefresh),offPrices=usagePrices.subscribe(scheduleUsageRefresh),offBudgets=budgets.subscribe(scheduleUsageRefresh); + disposeBudgetObservers=()=>{offUsage();offPrices();offBudgets();budgets.dispose();scheduleUsageRefresh=()=>undefined;}; + scheduleUsageRefresh(); + const orchestrationHandler=new ScopedOrchestrationHandler(agentControlService, pluginTools, providerDirectorySources,{budget:budgets,taskBoard,templates,secretGrants}); + forgetOrchestrationSession=id=>agentControlService.forgetSession(id); orchestrationGateway = new OrchestrationGateway({ runtimeDirectory: join(userDataPath, "orchestration", "runtime"), windowsHostPath: process.platform === "win32" @@ -687,11 +1004,11 @@ async function initializeServices(ipc: IpcRegistrar): Promise { ? join(process.resourcesPath, "agent-browser", WINDOWS_PIPE_HOST_FILENAME) : join(app.getAppPath(), "build", "windows-agent-pipe-host", WINDOWS_PIPE_HOST_FILENAME) : undefined, - handler: new ScopedOrchestrationHandler(agentControlService, pluginTools, providerDirectorySources) + handler: orchestrationHandler }); await orchestrationGateway.start(); terminalManager.configureOrchestration(new OrchestrationBridge(orchestrationGateway)); - terminalManager.configureAgentTools((role, provider) => pluginTools.names(role, provider)); + terminalManager.configureAgentTools((role, provider) => [...(provider!=="terminal" ? ["list_tasks","claim_task","update_task","complete_task","get_task_budget", "request_secret", "run_secret_request"] : []),...pluginTools.names(role, provider)]); const launchPipeline = new LaunchPipeline({ contributors: () => pluginManager!.launchContributors(), @@ -704,13 +1021,19 @@ async function initializeServices(ipc: IpcRegistrar): Promise { terminalManager.configureEnvironments(new EnvironmentRegistry({ providers: () => pluginManager!.environmentProviders(), call: (pluginId, serviceId, method, params, timeoutMs) => pluginServices!.hostCall(pluginId, serviceId, method, params, timeoutMs), - secret: (pluginId, key) => pluginSecretsService!.get(pluginId, key) + secret: (pluginId, key) => pluginSecretsService!.get(pluginId, key), + onRetained:(id,environment,reason)=>{ + const detail=redaction.redact(`${environment.label}: ${reason}`); + void timeline.append(id,"environment-retained","Unreviewed worktree retained",detail,"core").catch(console.warn); + if(settings.get().attentionNotifications && Notification.isSupported())new Notification({title:settings.get().locale==="ru" ? "Worktree сохранён" : "Worktree retained",body:detail}).show(); + } })); // Every host API a service may call exists now (sessions, cards, tools, secrets, launch, environments): services // start, and one that subscribes on initialize does so before the restored cards' events. Restored cards with // launch options or an environment ask their plugin's service, so start services first. pluginServices.hostReady(); await pluginServicesStarted.catch(() => undefined); + for(const budget of budgets.snapshots())if(budget.paused)applyBudgetEnforcement(budget); await terminalManager.restorePersistedSessions(); // The agent-control endpoint follows Settings → Agents → "Agent orchestration // endpoint"; the start flag / env var force it on for one launch (CI smoke) @@ -836,6 +1159,9 @@ async function initializeServices(ipc: IpcRegistrar): Promise { if (active) browserService?.cancelCanvasNavigationGesture(); canvasNavigationInput?.setShortcutCaptureActive(active); }, + setCanvasNavigationTerminalEditFocus: (contents, active) => { + canvasNavigationInput?.setTerminalEditFocus(contents, active); + }, setCanvasNavigationPointerBinding: (input) => { canvasNavigationInput?.updatePointerBinding(input); }, @@ -846,8 +1172,23 @@ async function initializeServices(ipc: IpcRegistrar): Promise { broadcastPluginStorageChange }); markMainBoot("coreServicesReady"); + const workspace = new WorkspaceArchive(userDataPath,{ + descriptors: () => managedTerminals.archiveDescriptors(), + create: request => managedTerminals.create(request), setBounds: (id,bounds) => managedTerminals.setBounds(id,bounds), + available: provider => provider === "terminal" || providerCliAvailability(providerClis!)[provider as AgentProviderId] === true, + bypassAcknowledged: provider => settings.get().acknowledgedDangerousProfiles.includes(provider as AgentProviderId), + redact: text => redaction.redact(text) + }); + registerBacklogIpc(ipc,{outputHistory,reports,secretGrants,usagePrices,board:taskBoard,budgets,flows:templates,taskRoot:id=>agentControlService.taskRoot(id),attention,terminals:managedTerminals,timeline,checkpoints,workspace,getMainWindow:()=>mainWindow}); + registerNetworkPolicyIpc(ipc,{manager:networkPolicies,getMainWindow:()=>mainWindow, + taskRoot:id=>agentControlService.taskRoot(id), + revokeBrowserCapabilities:cwd=>{managedTerminals.revokeBrowserCapabilitiesForStrictNetwork(cwd);}, + audit:(id,policy)=>{if(id)void timeline.append(id,"network-policy","Person changed project network policy",JSON.stringify(policy),"human").catch(console.warn);} + }); // The Even G2 companion is the last group: nothing on the first frame needs it. evenG2 = new EvenG2Controller({ + notifications:(channel,id)=>attention.list(channel,id), + loopWarningActive:id=>agentControlService.hasCurrentLoopWarning(id), userDataPath, terminals: terminalManager, localDiscovery: process.platform === "darwin", defaultWorkspace: join(app.getPath("documents"), "CanvasTTY Projects"), @@ -1143,12 +1484,6 @@ async function showStartupFailure(window: BrowserWindow, error: unknown): Promis } } -/** Notification body for the two statuses that deserve the user's attention. */ -function attentionStatusLabel(status: "needs_approval" | "failed", locale: LocaleId): string { - if (status === "needs_approval") return locale === "ru" ? "Требуется подтверждение" : "Needs approval"; - return locale === "ru" ? "Сессия завершилась с ошибкой" : "Session failed"; -} - if (hasSingleInstanceLock) { void app.whenReady() .then(() => { @@ -1214,6 +1549,7 @@ app.on("child-process-gone", (_event, details) => { void IPC.terminalData; async function shutdownServices(): Promise { + disposeBudgetObservers?.();disposeBudgetObservers=null; diagnostics.record("info", "application", "shutdown.started"); agentChatHistory?.dispose(); if (agentControl) await Promise.allSettled([agentControl.close()]); @@ -1221,8 +1557,12 @@ async function shutdownServices(): Promise { 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(); + await runShutdownSteps([ + {name:"Even G2",run:()=>evenG2?.close()}, + {name:"terminal processes",run:()=>terminalManager?.shutdown()}, + {name:"network proxy",run:()=>networkPolicies?.close()} + ]); + networkPolicies = null; // 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.`); @@ -1230,10 +1570,17 @@ async function shutdownServices(): Promise { limitsService?.dispose(); 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()]); - if (materialService) await Promise.allSettled([materialService.dispose()]); + if(terminalOutputHistory)await terminalOutputHistory.close().catch(console.warn); + terminalOutputHistory=null; + await runShutdownSteps([ + {name:"session reports",run:()=>sessionReports?.flush()}, + {name:"session timeline",run:()=>sessionTimeline?.flush()}, + {name:"budgets",run:async()=>{try{await flushBudgets?.();}finally{flushBudgets=null;}}}, + {name:"browser",run:()=>browserService?.dispose()}, + {name:"plugin services",run:()=>pluginServices?.dispose()}, + {name:"plugins",run:()=>pluginManager?.dispose()}, + {name:"materials",run:()=>materialService?.dispose()} + ]); await ptyExits; diagnostics.record("info", "application", "shutdown.completed"); await diagnostics.flush(); @@ -1264,6 +1611,8 @@ async function openPluginWindow(pluginId: string, contributionId: string): Promi } }); pluginWindows.set(window, pluginId); + attachEditContextMenu(window.webContents, () => editMenuLocale(), + (template, contents) => Menu.buildFromTemplate(template).popup({ window: BrowserWindow.fromWebContents(contents) ?? undefined })); window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); window.webContents.on("will-navigate", (event, url) => { if (!url.startsWith(`canvastty-plugin://${pluginId}/`)) event.preventDefault(); diff --git a/src/main/ipc/registerBacklogIpc.ts b/src/main/ipc/registerBacklogIpc.ts new file mode 100644 index 00000000..6a4bf31a --- /dev/null +++ b/src/main/ipc/registerBacklogIpc.ts @@ -0,0 +1,256 @@ +import type { BrowserWindow, IpcMainInvokeEvent } from "electron"; +import { realpath } from "node:fs/promises"; +import { isAbsolute, relative } from "node:path"; +import { BACKLOG_IPC, BACKLOG_TERMINAL_IPC, BACKLOG_EVENTS, type WorkspacePreset, type NotificationPreferences, type UsagePrice, type SecretGrantDuration } from "../../shared/backlog.ts"; +import { terminalFileDropText } from "../../shared/terminalFileDrop.ts"; +import type { IpcRegistrar } from "./IpcReadinessGate.ts"; +import type { TerminalManager } from "../services/TerminalManager.ts"; +import type { SessionTimelineService } from "../services/SessionTimelineService.ts"; +import type { GitCheckpoints } from "../services/GitCheckpoints.ts"; +import type { WorkspaceArchive } from "../services/WorkspaceArchive.ts"; +import type { TerminalOutputHistory } from "../services/TerminalOutputHistory.ts"; + +import type { AttentionService } from "../services/AttentionService.ts"; + +import type { OrchestrationTaskBoard, NewOrchestrationTask } from "../services/OrchestrationTaskBoard.ts"; +import type { OrchestrationBudgetService, OrchestrationBudgetLimits } from "../services/OrchestrationBudgetService.ts"; +import type { OrchestrationTemplateService } from "../services/OrchestrationTemplateService.ts"; +import type { SecretGrantService } from "../services/SecretGrantService.ts"; + +import type { UsagePrices } from "../services/UsagePrices.ts"; +import type {SessionReports} from "../services/SessionReports.ts"; +interface Dependencies { + reports?:SessionReports; + usagePrices:UsagePrices; + board:OrchestrationTaskBoard; budgets:OrchestrationBudgetService; flows:OrchestrationTemplateService; + taskRoot(id:string):{id:string;cwd:string;startedAt:number}; + attention: AttentionService; + secretGrants?: SecretGrantService; + outputHistory: TerminalOutputHistory; + terminals: TerminalManager; timeline: SessionTimelineService; checkpoints: GitCheckpoints; workspace: WorkspaceArchive; + getMainWindow(): BrowserWindow | null; +} +export function registerBacklogIpc(ipc: IpcRegistrar, deps: Dependencies): void { + const {terminals,timeline,checkpoints,workspace,outputHistory} = deps; + deps.board.subscribe(change=>{ + const window=deps.getMainWindow(); + if(!window || window.isDestroyed() || window.webContents.isDestroyed())return; + window.webContents.send(BACKLOG_EVENTS.taskBoardChanged,{rootSessionId:change.rootSessionId,revision:change.revision}); + }); + const trust = (event: IpcMainInvokeEvent) => { + const window = deps.getMainWindow(); + if (!window || event.sender !== window.webContents || event.senderFrame !== window.webContents.mainFrame) throw new Error("Untrusted backlog caller."); + }; + const session = (id: unknown) => { + if (typeof id !== "string") throw new Error("Session id is required."); + const row = terminals.getMetadata(id); if (!row) throw new Error("Session no longer exists."); return row; + }; + const handle = (channel: string, fn: (...args: any[]) => unknown) => ipc.handle(channel, (event, ...args) => {trust(event);return fn(...args);}); + const root=(id:string)=>{session(id);return deps.taskRoot(id);}; + const masked=(value:unknown):unknown=>typeof value==="string" ? terminals.redactSecrets(value) : Array.isArray(value) ? value.map(masked) : value && typeof value==="object" ? Object.fromEntries(Object.entries(value).map(([key,row])=>[key,masked(row)])) : value; + handle(BACKLOG_IPC.broadcast,(ids:unknown,text:unknown)=>{ + if(!Array.isArray(ids) || ids.length>100 || ids.some(id=>typeof id!=="string") || typeof text!=="string" || !text.trim() || text.length>16_000)throw new Error("Invalid broadcast input."); + const data=terminals.redactSecrets(text).replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g,""); + const delivered:string[]=[],skipped:string[]=[]; + for(const id of new Set(ids)){ + const row=terminals.getMetadata(id); + if(!row || row.exitCode!==null || row.status!=="idle" && row.status!=="working"){skipped.push(id);continue;} + try{terminals.pasteClipboard(id,data,row.startedAt,{submit:true});delivered.push(id);}catch{skipped.push(id);} + } + return {delivered,skipped}; + }); + handle(BACKLOG_IPC.tasks,(id:string)=>{const task=root(id);return deps.board.listTasks(task.cwd,task.id);}); + handle(BACKLOG_IPC.addTask,async(id:string,input:NewOrchestrationTask)=>{ + const task=root(id); + if(input?.ownerSessionId && root(input.ownerSessionId).id!==task.id)throw new Error("Task owner must belong to this orchestration tree."); + const row=await deps.board.addTask(task.cwd,task.id,task.id,masked(input) as NewOrchestrationTask); + await timeline.append(task.id,"task","Person added a board task",row.title);return row; + }); + handle(BACKLOG_IPC.assignTask,async(id:string,taskId:string,ownerId:string|null)=>{ + const task=root(id);if(ownerId!==null && root(ownerId).id!==task.id)throw new Error("Task owner must belong to this orchestration tree."); + const row=await deps.board.assignTask(task.cwd,task.id,taskId,ownerId,ownerId ? terminals.redactSecrets(session(ownerId).title) : null); + await timeline.append(task.id,"task","Person assigned a board task",row.title);return row; + }); + handle(BACKLOG_IPC.closeTask,async(id:string,taskId:string)=>{ + const task=root(id),row=await deps.board.closeTask(task.cwd,task.id,taskId);await timeline.append(task.id,"task","Person closed a board task",row.title);return row; + }); + handle(BACKLOG_IPC.budget,(id:string)=>{const task=root(id);return deps.budgets.snapshot(task.id,task.startedAt);}); + handle(BACKLOG_IPC.setBudget,async(id:string,limits:OrchestrationBudgetLimits)=>{ + const task=root(id),row=await deps.budgets.setLimits(task.id,limits,task.startedAt);await timeline.append(task.id,"budget","Person changed task limits",JSON.stringify(row.limits));return row; + }); + handle(BACKLOG_IPC.clearBudget,async(id:string)=>{ + const task=root(id),row=await deps.budgets.clearLimits(task.id,task.startedAt);await timeline.append(task.id,"budget","Person removed task limits");return row; + }); + handle(BACKLOG_IPC.saveTaskFlow,async(id:string,name:string)=>{ + const parent=session(id);if(parent.role!=="orchestrator")throw new Error("Choose an orchestrator to save its layout as a flow."); + if(typeof name!=="string" || !name.trim() || name.length>120)throw new Error("Flow name must contain 1–120 characters."); + const children=terminals.listMetadata().filter(row=>row.parentSessionId===id); + if(!children.length || children.length>16)throw new Error("A flow needs 1–16 direct subagents."); + const file=await deps.flows.save(root(id).cwd,{ + id:`task-${Date.now()}`,name:terminals.redactSecrets(name.trim()),description:"Saved from an orchestrator's card layout.", + roles:children.map((child,index)=>({id:`worker-${index+1}`,title:terminals.redactSecrets(child.title),count:1, + instruction:terminals.redactSecrets(`Work on the subtask ${child.title}. Report your result to the orchestrator.`), + ...(child.model ? {model:child.model} : {}),...(child.effort ? {effort:child.effort} : {})})), + finalStep:"Gather every worker's result, resolve disagreements, and deliver one final answer." + }); + await timeline.append(id,"flow","Person saved this layout as a project flow",file);return {file}; + }); + handle(BACKLOG_IPC.flows,async(projectRoot:string)=>masked(await deps.flows.list(projectRoot))); + handle(BACKLOG_IPC.previewFlow,async(projectRoot:string,flowId:string)=>masked(await deps.flows.preview(projectRoot,flowId))); + handle(BACKLOG_IPC.approveFlow,async(projectRoot:string,flowId:string,digest:string)=>deps.flows.approve(projectRoot,flowId,digest)); + handle(BACKLOG_IPC.flowInstructions,async(projectRoot:string,flowId:string)=>{ + const listing=await deps.flows.list(projectRoot),flow=listing.templates.find(row=>row.id===flowId);if(!flow)throw new Error("Flow no longer exists."); + return terminals.redactSecrets(deps.flows.instructions(flow)); + }); + handle(BACKLOG_IPC.notificationPreferences, () => deps.attention.get()); + handle(BACKLOG_IPC.setNotificationPreferences, (value:NotificationPreferences) => deps.attention.set(value)); + handle(BACKLOG_IPC.notifications, () => deps.attention.list("desktop")); + handle(BACKLOG_IPC.sendInstructions, async (id:string,text:unknown) => { + session(id);if(typeof text!=="string" || text.length>32_000)throw new Error("Invalid instructions."); + const result=await terminals.deliverInput(id,`${terminals.redactSecrets(text)}\r`);if(!result.delivered)throw new Error(`Agent did not accept the instructions: ${result.reason}`); + }); + handle(BACKLOG_IPC.redactText, (text: unknown) => { + if (typeof text !== "string" || text.length > 1_000_000) throw new Error("Text is invalid or too large."); return terminals.redactSecrets(text); + }); + handle(BACKLOG_IPC.timeline, async (id: string, cursor?: string, limit?: number,filter?:{query?:string;types?:string[];sessionIds?:string[]}) => { + const task=root(id); + if (cursor !== undefined && (typeof cursor !== "string" || cursor.length > 100)) throw new Error("Invalid timeline cursor."); + await timeline.flush(); + const live=terminals.listMetadata().filter(row=>terminals.taskScopeFor(row.id).id===task.id); + const retained=timeline.taskSessions(task.id); + const agents=new Map(retained.map(row=>[row.id,{id:row.id,title:row.title}])); + for(const row of live)agents.set(row.id,{id:row.id,title:terminals.redactSecrets(row.title || row.provider)}); + // A continued root keeps the original task identity, including its older root/budget journal. + const allowed=new Set([task.id,...agents.keys()]); + if(filter) { + if(typeof filter!=="object" || filter.query!==undefined && (typeof filter.query!=="string" || filter.query.length>200) + || filter.types!==undefined && (!Array.isArray(filter.types) || filter.types.length>30 || filter.types.some(type=>typeof type!=="string" || type.length>80)) + || filter.sessionIds!==undefined && (!Array.isArray(filter.sessionIds) || filter.sessionIds.length>100 || filter.sessionIds.some(other=>typeof other!=="string" || !allowed.has(other))))throw new Error("Invalid timeline filter."); + } + const page=await timeline.page(id,cursor,limit,{...filter,sessionIds:filter?.sessionIds ?? [...allowed]}, + {taskId:task.id,legacySessionIds:[task.id,...live.map(row=>row.id)]}); + return {...page,facets:{agents:[...agents.values()],types:[...new Set(retained.flatMap(row=>row.types))].sort()}}; + }); + const subtree = (id?: string) => { + if (!id) return undefined; + const selected = session(id), ids = new Set([id]), rows = terminals.listMetadata(); + const childrenByParent = new Map(); + for (const row of rows) { + if (!row.parentSessionId) continue; + const children = childrenByParent.get(row.parentSessionId) ?? []; + children.push(row.id); + childrenByParent.set(row.parentSessionId, children); + } + const queue = [id]; + for (let cursor = 0; cursor < queue.length; cursor++) { + for (const childId of childrenByParent.get(queue[cursor]!) ?? []) { + if (ids.has(childId)) continue; + ids.add(childId); + queue.push(childId); + } + } + if(!selected.parentSessionId)for(const historicalId of timeline.sessionIds(root(id).id))ids.add(historicalId); + return [...ids]; + }; + const secretScope = (id: string): { task: {id:string;cwd:string;startedAt:number}; sessionIds: string[] } => { + const task = root(id); + const sessionIds = terminals.listMetadata().flatMap((row) => { + try { return deps.taskRoot(row.id).id === task.id ? [row.id] : []; } + catch { return []; } + }); + return { task, sessionIds: sessionIds.length ? sessionIds : [id] }; + }; + const grants = (): SecretGrantService => { + if (!deps.secretGrants) throw new Error("Secret-grant controls are unavailable."); + return deps.secretGrants; + }; + handle(BACKLOG_IPC.secretRequests, (id: string) => { + const scope = secretScope(id); + return grants().pending(scope.sessionIds).map((request) => ({ ...request, reason: terminals.redactSecrets(request.reason) })); + }); + handle(BACKLOG_IPC.secretGrants, (id: string) => grants().listGrants(secretScope(id).sessionIds)); + handle(BACKLOG_IPC.approveSecretRequest, (id: string, requestId: string, duration: SecretGrantDuration) => { + if (typeof requestId !== "string" || requestId.length > 64 || !["10m", "turn", "session"].includes(duration)) throw new Error("Invalid secret approval request."); + const service = grants(), scope = secretScope(id); + if (!service.pending(scope.sessionIds).some((request) => request.id === requestId)) throw new Error("Secret request is outside this task or has expired."); + return service.approve(requestId, duration); + }); + handle(BACKLOG_IPC.denySecretRequest, (id: string, requestId: string) => { + if (typeof requestId !== "string" || requestId.length > 64) throw new Error("Invalid secret request."); + const service = grants(), scope = secretScope(id); + if (!service.pending(scope.sessionIds).some((request) => request.id === requestId)) throw new Error("Secret request is outside this task or has expired."); + service.deny(requestId); + }); + handle(BACKLOG_IPC.revokeSecretGrant, (id: string, grantSessionId: string, secretId: string) => { + const service = grants(), scope = secretScope(id); + if (typeof grantSessionId !== "string" || !scope.sessionIds.includes(grantSessionId)) throw new Error("Secret grant is outside this task."); + return service.revoke(grantSessionId, secretId); + }); + handle(BACKLOG_IPC.usagePrices,()=>deps.usagePrices.get()); + handle(BACKLOG_IPC.setUsagePrices,(rows:UsagePrice[])=>deps.usagePrices.set(rows)); + handle(BACKLOG_IPC.usageBreakdown,(id:string|undefined,period:"all"|"day"|"week")=>{ + if(!["all","day","week"].includes(period))throw new Error("Invalid usage period."); + return timeline.breakdown(period,id ? root(id).id : undefined,deps.usagePrices.get()); + }); + handle(BACKLOG_IPC.usage, (id?: string) => timeline.usage(subtree(id),deps.usagePrices.get())); + handle(BACKLOG_IPC.report, (id: string) => {session(id);return deps.reports?.report(id) ?? timeline.report(id);}); + handle(BACKLOG_IPC.checkpoints, async (id: string) => { + const row=session(id); if (!await checkpoints.available(row.cwd)) throw new Error("Rollback points are unavailable: the card folder must be a Git project root."); + return checkpoints.list(id,row.cwd); + }); + handle(BACKLOG_IPC.previewCheckpoint, (id: string, ref: string) => checkpoints.preview(id,session(id).cwd,ref)); + handle(BACKLOG_IPC.restoreCheckpoint, async (id: string, ref: string) => { + session(id); + const result=await terminals.withCheckpointRestore(id,cwd=>checkpoints.restore(id,cwd,ref)); + await timeline.append(id,"checkpoint","Person restored a checkpoint",ref);return result; + }); + handle(BACKLOG_IPC.exportWorkspace, async () => { + const value=JSON.parse(workspace.export()),roots=new Map(); + const exportedIds=new Set(value.sessions.map((row:{id:string})=>row.id)); + const rows=terminals.listMetadata().filter(row=>exportedIds.has(row.id)),rootCards=new Map(rows.filter(row=>!row.parentSessionId).map(row=>[row.taskScope?.id ?? row.id,row.id])); + for(const row of rows){const task=root(row.id);roots.set(task.id,task);if(!rootCards.has(task.id))rootCards.set(task.id,row.id);} + value.tasks=await Promise.all([...roots.values()].map(async task=>({rootSessionId:rootCards.get(task.id) ?? task.id,tasks:(await deps.board.listTasks(task.cwd,task.id)).tasks}))); + return JSON.stringify(masked(value),null,2); + }); + handle(BACKLOG_IPC.previewImport, (text: string) => workspace.preview(text)); + handle(BACKLOG_IPC.importWorkspace, async (text: string, options: {confirmBypass?:unknown}) => { + // Version/size/session validation runs before any JSON extras are considered. + await workspace.preview(text); + const value=JSON.parse(text),groups=value.tasks; + if(groups!==undefined && (!Array.isArray(groups) || groups.length>100))throw new Error("Invalid workspace task groups."); + const result=await workspace.import(text,options?.confirmBypass === true); + for(const group of groups ?? []) { + if(!group || typeof group.rootSessionId!=="string") {result.warnings.push("An invalid task group was skipped.");continue;} + const restored=Object.hasOwn(result.restoredIds,group.rootSessionId) ? result.restoredIds[group.rootSessionId] : undefined; + if(typeof restored!=="string" || !restored)continue; + try{await deps.board.importGroup(session(restored).cwd,restored,masked(group.tasks),result.restoredIds);} + catch(error){result.warnings.push(`Task board: ${error instanceof Error ? error.message : "could not be restored"}`);} + } + return {warnings:result.warnings,sessions:result.sessions}; + }); + handle(BACKLOG_IPC.workspacePresets, () => workspace.presets()); + handle(BACKLOG_IPC.saveWorkspacePreset, (preset: WorkspacePreset) => workspace.savePreset(preset)); + handle(BACKLOG_IPC.deleteWorkspacePreset, (id: string) => workspace.deletePreset(id)); + handle(BACKLOG_TERMINAL_IPC.describeFileDrop, async (paths: unknown, id: string) => { + if (!Array.isArray(paths) || paths.length > 100 || !paths.every((entry) => typeof entry === "string" && entry.length < 4096)) throw new Error("Invalid dropped files."); + const row=session(id), root=await realpath(terminals.pluginContext(row.id)?.workingDirectory ?? row.cwd); + const canonical=await Promise.all(paths.map((path: string) => realpath(path))); + const outsideProject=canonical.filter((path) => {const rel=relative(root,path);return rel === ".." || rel.startsWith("../") || rel.startsWith("..\\") || isAbsolute(rel);}); + return {text:terminals.redactSecrets(terminalFileDropText(canonical,process.platform)),paths:canonical,outsideProject}; + }); + handle(BACKLOG_TERMINAL_IPC.paste, (id: string, text: unknown) => { + const startedAt=session(id).startedAt; if (typeof text !== "string" || text.length > 16_000) throw new Error("Context paste is limited to 16,000 characters."); + const clean=terminals.redactSecrets(text).replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g,""); + terminals.pasteClipboard(id,clean,startedAt); + }); + handle(BACKLOG_TERMINAL_IPC.searchOutput, async (query: unknown, requested?: unknown) => { + if (typeof query !== "string" || query.length > 200 || !query.trim()) return {matches:[],prunedSessionIds:[]}; + if (requested !== undefined && (!Array.isArray(requested) || requested.length>256 || requested.some((id) => typeof id !== "string"))) throw new Error("Invalid session list."); + return outputHistory.search(query,Array.isArray(requested) ? requested as string[] : undefined); + }); + handle(BACKLOG_TERMINAL_IPC.readOutputContext, async (id: string, offset: unknown) => { + session(id); + if (typeof offset !== "number" || !Number.isSafeInteger(offset) || offset < 0) throw new Error("Invalid output offset."); + return outputHistory.readContext(id,offset); + }); +} diff --git a/src/main/ipc/registerIpc.ts b/src/main/ipc/registerIpc.ts index 72a6c87e..1e88b962 100644 --- a/src/main/ipc/registerIpc.ts +++ b/src/main/ipc/registerIpc.ts @@ -1,6 +1,6 @@ import { realpath } from "node:fs/promises"; import { app, BrowserWindow, clipboard, dialog, shell } from "electron"; -import type { IpcMainEvent, IpcMainInvokeEvent, OpenDialogOptions } from "electron"; +import type { IpcMainEvent, IpcMainInvokeEvent, OpenDialogOptions, WebContents } from "electron"; import type { AppSettings, AgentChatHistoryProviderId, @@ -75,6 +75,7 @@ interface Dependencies { getMainWindow(): BrowserWindow | null; applyBrowserSettings(settings: AppSettings): Promise | void; setCanvasNavigationShortcutCapture(active: boolean): void; + setCanvasNavigationTerminalEditFocus(contents: WebContents, active: boolean): void; setCanvasNavigationPointerBinding(input: CanvasNavigationPointerBindingInput): void; openPluginWindow(pluginId: string, contributionId: string): Promise; closePluginWindows(pluginId: string): void; @@ -211,6 +212,7 @@ export function registerIpc(ipcMain: IpcRegistrar, { getMainWindow, applyBrowserSettings, setCanvasNavigationShortcutCapture, + setCanvasNavigationTerminalEditFocus, setCanvasNavigationPointerBinding, openPluginWindow, closePluginWindows, @@ -261,6 +263,11 @@ export function registerIpc(ipcMain: IpcRegistrar, { if (typeof active !== "boolean") return; setCanvasNavigationShortcutCapture(active); }); + ipcMain.on(IPC.canvasNavigationTerminalEditFocus, (event, active: boolean) => { + assertMainRenderer(event, getMainWindow); + if (typeof active !== "boolean") return; + setCanvasNavigationTerminalEditFocus(event.sender, active); + }); ipcMain.on(IPC.canvasNavigationPointerBinding, (event, input: unknown) => { assertMainRenderer(event, getMainWindow); if (!isCanvasNavigationPointerBindingInput(input)) return; @@ -432,13 +439,13 @@ export function registerIpc(ipcMain: IpcRegistrar, { assertMainRenderer(event, getMainWindow); return pluginCards.decorations(); }); - ipcMain.handle(IPC.pluginsInvokeCardAction, (event, pluginId: unknown, actionId: unknown, sessionId: unknown) => { + ipcMain.handle(IPC.pluginsInvokeCardAction, (event, pluginId: unknown, actionId: unknown, sessionId: unknown, input?: unknown) => { // Only the app window's own card menu invokes actions; plugin surfaces cannot reach this channel. assertMainRenderer(event, getMainWindow); if (typeof pluginId !== "string" || typeof actionId !== "string" || typeof sessionId !== "string") { throw new Error("Card action request is invalid."); } - return pluginCards.invoke(pluginId, actionId, sessionId); + return pluginCards.invoke(pluginId, actionId, sessionId, input); }); ipcMain.handle(IPC.pluginsLaunchFieldOptions, (event, pluginId: unknown, provider: unknown) => { // Only the app's own launcher asks; plugin surfaces cannot reach this channel. @@ -815,6 +822,10 @@ export function registerIpc(ipcMain: IpcRegistrar, { expiresAt: flow.expiresAt }; }); + ipcMain.handle(IPC.githubAuthCancel, (event) => { + assertMainRenderer(event, getMainWindow); + return githubAuth.cancelDeviceFlow(); + }); ipcMain.handle(IPC.githubAuthSignOut, (event) => { assertMainRenderer(event, getMainWindow); return githubAuth.signOut(); @@ -849,6 +860,10 @@ export function registerIpc(ipcMain: IpcRegistrar, { assertMainRenderer(event, getMainWindow); return terminals.restart(id, { resume: options?.resume === true }); }); + ipcMain.handle(IPC.terminalPasteClipboard, (event, id: string, text: string, startedAt: number) => { + assertMainRenderer(event, getMainWindow); + return terminals.pasteClipboard(id, text, startedAt); + }); ipcMain.on(IPC.terminalInput, (event, id: string, data: string) => { // Fire-and-forget: a foreign sender is dropped instead of throwing into the IPC layer. if (!isMainRenderer(event, getMainWindow)) return; diff --git a/src/main/ipc/registerNetworkPolicyIpc.ts b/src/main/ipc/registerNetworkPolicyIpc.ts new file mode 100644 index 00000000..83c8b079 --- /dev/null +++ b/src/main/ipc/registerNetworkPolicyIpc.ts @@ -0,0 +1,31 @@ +import type { BrowserWindow, IpcMainInvokeEvent } from "electron"; +import { BACKLOG_IPC, type AgentNetworkPolicy } from "../../shared/backlog.ts"; +import type { IpcRegistrar } from "./IpcReadinessGate.ts"; +import type { NetworkPolicyManager } from "../services/isolation/networkPolicy.ts"; + +export function registerNetworkPolicyIpc(ipc:IpcRegistrar,deps:{ + manager:NetworkPolicyManager; + getMainWindow():BrowserWindow|null; + taskRoot(id:string):{cwd:string}; + revokeBrowserCapabilities(projectRoot?:string):void; + audit(sessionId:string|undefined,policy:AgentNetworkPolicy):void; +}):void { + const trust=(event:IpcMainInvokeEvent)=>{ + const window=deps.getMainWindow(); + if(!window || event.sender!==window.webContents || event.senderFrame!==window.webContents.mainFrame)throw new Error("Untrusted network policy caller."); + }; + const project=(id:unknown)=>{ + if(id===undefined)return undefined; + if(typeof id!=="string" || !id)throw new Error("Session id is required."); + return deps.taskRoot(id).cwd; + }; + ipc.handle(BACKLOG_IPC.networkPolicy,(event,id)=>{ + trust(event);const cwd=project(id),available=deps.manager.availability(); + return {policy:deps.manager.getPolicy(cwd),...available,...(cwd ? {projectRoot:cwd} : {})}; + }); + ipc.handle(BACKLOG_IPC.setNetworkPolicy,(event,id,policy:AgentNetworkPolicy)=>{ + trust(event);const cwd=project(id),next=deps.manager.setPolicy(policy,cwd); + deps.revokeBrowserCapabilities(cwd); + deps.audit(id,next);return next; + }); +} diff --git a/src/main/services/AgentControlService.ts b/src/main/services/AgentControlService.ts index 3b35fa78..0e5afb06 100644 --- a/src/main/services/AgentControlService.ts +++ b/src/main/services/AgentControlService.ts @@ -2,6 +2,7 @@ import type { AgentProviderId, CreateSessionRequest, LaunchProfileId, + SessionEnvironmentChoice, SessionMetadata, SessionSnapshot } from "../../shared/contracts.ts"; @@ -15,6 +16,9 @@ import { onDiskPath, otherSpellings } from "./onDiskPath.ts"; import { LaunchRefusal } from "./launchRefusal.ts"; import { terminalFailureDetails } from "./terminalFailureDetails.ts"; import { RESULT_CAPTURE_PROVIDERS } from "./resultCapture.ts"; +import { ACCOUNTS_PLUGIN_ID, selectedAccountId } from "./accountHomeIsolation.ts"; +import type { OrchestrationBudgetService } from "./OrchestrationBudgetService.ts"; +import { createDiffOnlyReviewWorkspace, type DiffOnlyReviewWorkspace } from "./DiffOnlyReviewWorkspace.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; @@ -32,6 +36,13 @@ const MAX_OBSERVE_CHARS = 8_192; const MAX_EXIT_WINDOW_CHARS = 16_384; const MAX_EXIT_LINES = 20; const CHILD_POSITION_STEP = { x: 60, y: 60 }; +const MAX_RETRY_COUNT = 2; +const MAX_RETRY_OUTPUT_BYTES = 4 * 1024; +const MAX_RETRY_OUTPUT_WINDOW_CHARS = 16 * 1024; +const MAX_REVIEW_DIFF_BYTES = 64 * 1024; +const MAX_REVIEW_ANSWER_CHARS = 16 * 1024; +const REVIEW_TIMEOUT_MS = 180_000; +const REVIEW_STARTUP_QUIET_MS = 90_000; export interface SpawnAgentRequest { parentSessionId: string; @@ -46,11 +57,18 @@ export interface SpawnAgentRequest { /** The CLI's --model and reasoning effort for this subagent (checked by the launch for its CLI). */ model?: string; effort?: CreateSessionRequest["effort"]; + review?: boolean; + /** Optional known model reserved for this worker's read-only reviewer. */ + reviewModel?: string; + isolate?: "worktree"; + /** Internal-only role: suppresses all agent/plugin tools for a Plan-profile reviewer. */ + readOnlyReview?: boolean; } export interface AgentObservation { sessionId: string; status: SessionSnapshot["status"]; + loopDetected?: boolean; /** Raw terminal tail, capped; capabilities with result \"none\" see nothing. */ output: string; /** Once the process exited: its exit code and the last lines of its screen as plain text, masked. */ @@ -75,6 +93,8 @@ export interface AgentWaitResult { exitLines?: string; /** The final answer of the turn that ended (Codex, OpenCode subagents), masked. */ answer?: AgentAnswer; + /** Present when spawn_agent requested a read-only second-agent review. */ + review?: AgentReviewResult; } export interface AgentWaitTiming { @@ -93,6 +113,30 @@ export interface AgentControlOptions { limits?: () => DelegationLimits; /** CanvasTTY's isolation layer can contain an agent on this computer now (a "contained" auto needs it). */ containment?: () => boolean; + /** Current host-owned provider turn epoch; loop warnings expire as soon as the turn changes. */ + currentTurnEpoch?: (sessionId: string) => number | null; + /** Persistent task budgets. Only usage from a real provider/timeline source is counted. */ + budget?: Pick; + /** Resolves an explicit or automatic subagent environment using the already trusted environments plugin. */ + resolveSubagentEnvironment?: (request: { + provider: AgentProviderId; + parentSessionId: string; + projectRoot: string; + cwd: string; + liveChildren: number; + isolate?: "worktree"; + }) => SessionEnvironmentChoice | null | Promise; + /** Returns a known model different from the worker's model. Null means a safe alternative is unavailable. */ + reviewModel?: (provider: AgentProviderId, workerModel: string | undefined) => string | null; + /** Actual CLI model metadata/configuration, used when the worker kept its CLI default. */ + workerModel?: (session:SessionMetadata) => string | null | Promise; + reviewCost?: (reviewerSessionId:string) => number|null; + /** Trusted host override: the returned diff must already be scoped to this worker and its launch. */ + reviewDiff?: (session: SessionMetadata) => Promise; + reviewTimeoutMs?: number; + /** How long an OpenCode reviewer may take to expose its input prompt before startup is refused. */ + reviewStartupMs?: number; + onReview?: (sessionId: string, review: AgentReviewResult) => void; } /** The longest wait one call may ask for (wait_for_agent's timeoutSeconds maximum). */ @@ -110,6 +154,19 @@ export interface AgentResult { exitLines?: string; /** The last turn's final answer as the agent reported it (Codex, OpenCode subagents), masked; absent otherwise. */ answer?: AgentAnswer; + /** Present when spawn_agent requested a read-only second-agent review. */ + review?: AgentReviewResult; +} + +export interface AgentReviewResult { + status: "pending" | "accepted" | "revise" | "rejected" | "unavailable"; + verdict?: "accept" | "revise" | "reject"; + notes?: string; + reason?: string; + reviewerSessionId?: string; + model?: string; + /** F-15 usage integrations can replace null when the provider reports a separate review cost. */ + costUsd: number | null; } export interface AgentAnswer { @@ -130,8 +187,23 @@ export class PromptNotDeliveredError extends Error { } export class AgentControlService { + private readonly loopWarnings = new Map(); private readonly terminals: TerminalManager; private readonly options: AgentControlOptions; + private readonly launchRequests = new Map(); + private readonly retryOrigins = new Map(); + private readonly retryCounts = new Map(); + private readonly pendingRetries = new Map(); + private readonly retryableQuiet = new Set(); + private readonly reviewRequested = new Set(); + private readonly readOnlyReviewers = new Set(); + private readonly reviews = new Map(); + private readonly reviewGenerations = new Map(); + private readonly reviewInputObservers = new Map void>(); + private readonly reviewPending = new Map>(); + private readonly reviewControllers = new Map(); + private readonly reviewAgents = new Map(); + private readonly reviewWatchers = new Map }>(); constructor(terminals: TerminalManager, options: AgentControlOptions = {}) { this.terminals = terminals; @@ -151,34 +223,67 @@ 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 account = this.subagentAccount(parent.id, request.provider, request.launchOptions); + if (account.launchOptions !== request.launchOptions) request = { ...request, launchOptions: account.launchOptions }; - const children = this.children(parent.id); - if (children.length >= MAX_CHILDREN_PER_PARENT) { - throw new DelegationRefusal(`Session ${parent.id} already has ${MAX_CHILDREN_PER_PARENT} subagent cards; cancel_agent the finished ones first.`); - } - const lineage = this.lineage(parent.id); - const root = lineage.at(-1)!; + const taskScope = this.taskRoot(parent.id); // 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(taskScope.cwd, parent.cwd, request.cwd); if ("error" in cwd) throw new DelegationRefusal(cwd.error); + this.requireBudgetActive(parent.id); const profile = subagentProfile(parent.profile, request.provider, request.profile, this.containment()); if ("error" in profile) throw new DelegationRefusal(profile.error); - const limits = this.limits(); - // The parent is at level lineage.length - 1 below its top-level agent; the new card one further down. - if (lineage.length > limits.maxDepth) { - throw new DelegationRefusal(`Subagents may nest at most ${limits.maxDepth} level${limits.maxDepth === 1 ? "" : "s"} deep below the agent the person started; this one would be level ${lineage.length}. The person sets this limit in Settings → Agents.`); + const { live } = this.assertSpawnCapacity(parent.id); + const resolveEnvironment = this.options.resolveSubagentEnvironment; + if (!resolveEnvironment) { + if (request.isolate === "worktree") throw new DelegationRefusal("The environments plugin does not provide a worktree for this launch."); + return this.createSubagent(parent, request, cwd.cwd, profile.profile, null, signal); } - const live = this.descendants(root.id).filter((session) => session.exitCode === null).length; - if (live >= limits.maxSubagents) { - throw new DelegationRefusal(`This orchestration already runs ${live} live subagent${live === 1 ? "" : "s"}, its limit (Settings → Agents, set by the person). Wait for one to finish or cancel_agent one first.`); + let environment: SessionEnvironmentChoice | null | Promise; + try { + environment = resolveEnvironment({ + provider: request.provider, + parentSessionId: parent.id, + projectRoot: taskScope.cwd, + cwd: cwd.cwd, + liveChildren: live, + ...(request.isolate ? { isolate: request.isolate } : {}) + }); + } catch (error) { + throw new DelegationRefusal(`The environments plugin could not prepare this subagent: ${error instanceof Error ? error.message : "unknown error"}`); + } + if (environment && typeof (environment as Promise).then === "function") { + return Promise.resolve(environment).then( + (choice) => this.createSubagent(parent, request, cwd.cwd, profile.profile, choice, signal), + (error: unknown) => { throw new DelegationRefusal(`The environments plugin could not prepare this subagent: ${error instanceof Error ? error.message : "unknown error"}`); } + ); + } + return this.createSubagent(parent, request, cwd.cwd, profile.profile, environment as SessionEnvironmentChoice | null, signal); + } + + private createSubagent( + parent: SessionMetadata, + request: SpawnAgentRequest, + cwd: string, + profile: LaunchProfile, + environment: SessionEnvironmentChoice | null, + signal?: AbortSignal + ): Promise { + if (request.isolate === "worktree" && !environment) { + throw new DelegationRefusal("The environments plugin does not provide a worktree for this launch."); } // A call cancelled before its agent starts launches nothing. if (signal?.aborted) return Promise.reject(spawnCanceled()); - const cascade = children.length; + // An asynchronous environment lookup may race another spawn or a human budget change. + // Recheck immediately before synchronous card creation, which reserves the live slot. + this.requireSession(parent.id); + this.requireBudgetActive(parent.id); + const { childrenCount } = this.assertSpawnCapacity(parent.id); + const cascade = childrenCount; const created = this.terminals.create({ provider: request.provider, - cwd: cwd.cwd, - profile: profile.profile, + cwd, + profile, position: { x: parent.position.x + CHILD_POSITION_STEP.x * (cascade + 1), y: parent.position.y + CHILD_POSITION_STEP.y * (cascade + 1) @@ -186,13 +291,21 @@ export class AgentControlService { ...(request.title !== undefined ? { title: request.title } : {}), role: "subagent", parentSessionId: parent.id, + ...(environment ? { environment } : {}), ...(request.launchOptions !== undefined ? { launchOptions: request.launchOptions } : {}), ...(request.model !== undefined ? { model: request.model } : {}), ...(request.effort !== undefined ? { effort: request.effort } : {}) - }, { ...(RESULT_CAPTURE_PROVIDERS.has(request.provider) ? { captureResult: true } : {}), origin: "subagent" }); + }, { ...(RESULT_CAPTURE_PROVIDERS.has(request.provider) ? { captureResult: true } : {}), origin: "subagent", captureReviewDiff: request.review === true }); + this.launchRequests.set(created.id, { ...request, cwd, profile }); + this.retryOrigins.set(created.id, created.id); + if (request.review === true) this.trackReview(created.id); + if (request.readOnlyReview === true) this.readOnlyReviewers.add(created.id); if (request.initialPrompt === undefined || request.initialPrompt.length === 0) return Promise.resolve(created); return this.deliver(created.id, `${request.initialPrompt}\r`, "prompt", signal) - .then(() => this.terminals.getMetadata(created.id) ?? created, (error: unknown) => { + .then(() => { + this.scheduleReview(created.id); + return 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; try { this.terminals.dispose(created.id); } catch { /* it already ended */ } @@ -200,6 +313,32 @@ export class AgentControlService { }); } + /** + * Which model account a subagent of this parent runs on. A spawn that names no account inherits the orchestrator's + * own: it never falls back silently to the CLI's default model (for OpenCode that is OpenCode Zen, a third party). + * Naming one is explicit: "none" runs on the CLI's own sign-in, another account needs the plugin's delegable + * declaration (checked by the launch). An orchestrator without an account keeps the request as it is. + */ + subagentAccount(parentSessionId: string, provider: AgentProviderId, requested: SpawnAgentRequest["launchOptions"]): { + launchOptions: SpawnAgentRequest["launchOptions"]; + account: string | null; + source: "inherited" | "explicit" | "none"; + } { + const parent = this.requireSession(parentSessionId); + const named = requested?.[ACCOUNTS_PLUGIN_ID]; + if (named !== undefined) { + const account = selectedAccountId(requested); + return { launchOptions: requested, account: account === "default" ? null : account, source: "explicit" }; + } + const parentAccount = typeof this.terminals.modelAccountOf === "function" ? this.terminals.modelAccountOf(parent.id) : undefined; + if (!parentAccount) return { launchOptions: requested, account: null, source: "none" }; + if (parent.provider !== provider) { + throw new DelegationRefusal(`This orchestrator runs on model account ${parentAccount.slice(0, 40)} for ${parent.provider}; a ${provider} subagent cannot inherit it. ` + + `Pass launchOptions {"${ACCOUNTS_PLUGIN_ID}":{"account":""}} with a ${provider} account (list_accounts / pick_account), or {"account":"none"} to run it on ${provider}'s own sign-in and default model.`); + } + return { launchOptions: { ...(requested ?? {}), [ACCOUNTS_PLUGIN_ID]: { account: parentAccount } }, account: parentAccount, source: "inherited" }; + } + /** 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()); @@ -213,6 +352,7 @@ export class AgentControlService { if (!capabilities.send) throw new Error(`${session.provider} cannot receive prompts.`); if (typeof text !== "string" || text.length === 0) throw new Error("Prompt text is required."); if (session.exitCode !== null) throw new Error("Agent session has already exited."); + this.assertInputAllowed(sessionId); return this.deliver(sessionId, submit ? `${text}\r` : text, "text", signal); } @@ -239,6 +379,63 @@ export class AgentControlService { return chain; } + /** The top-level orchestrator and project folder governing this session's shared task scope. */ + taskRoot(sessionId: string): { id: string; cwd: string; startedAt: number } { + const root = this.lineage(sessionId).at(-1)!; + const taskScope = root.taskScope; + return taskScope ? { ...taskScope } : { id: root.id, cwd: root.cwd, startedAt: root.startedAt }; + } + + /** Resolve every card against one host snapshot, sharing parent traversal and preserving continuation scopes. */ + taskRoots(sessions: readonly SessionMetadata[]): Map { + const byId = new Map(sessions.map((session) => [session.id, session])); + const resolved = new Map(); + const visiting = new Set(); + const resolveTaskRoot = (sessionId: string): { id: string; cwd: string; startedAt: number } | null => { + const cached = resolved.get(sessionId); + if (cached) return cached; + const session = byId.get(sessionId); + if (!session || visiting.has(sessionId)) return null; + visiting.add(sessionId); + const scope = (session.parentSessionId ? resolveTaskRoot(session.parentSessionId) : null) + ?? session.taskScope + ?? { id: session.id, cwd: session.cwd, startedAt: session.startedAt }; + visiting.delete(sessionId); + resolved.set(sessionId, scope); + return scope; + }; + for (const session of sessions) resolveTaskRoot(session.id); + return resolved; + } + + /** Called when TerminalManager removes a card; retries belonging to a live replacement retain their shared count. */ + forgetSession(sessionId: string): void { + this.loopWarnings.delete(sessionId); + this.reviewInputObservers.get(sessionId)?.(); + this.reviewInputObservers.delete(sessionId); + this.invalidateReview(sessionId); + this.reviewGenerations.delete(sessionId); + this.launchRequests.delete(sessionId); + this.retryableQuiet.delete(sessionId); + this.reviewRequested.delete(sessionId); + this.readOnlyReviewers.delete(sessionId); + this.reviews.delete(sessionId); + + const sourceId = this.retryOrigins.get(sessionId) ?? sessionId; + this.retryOrigins.delete(sessionId); + if (!this.pendingRetries.has(sourceId) && ![...this.retryOrigins.values()].includes(sourceId)) this.retryCounts.delete(sourceId); + + for (const [workerId, workerReviewerId] of this.reviewAgents) { + if (workerReviewerId !== sessionId) continue; + this.reviewAgents.delete(workerId); + } + } + + /** Apply the terminal manager's existing secret masks to host-only task context before it leaves the core. */ + maskText(text: string, maxChars: number): string { + return this.redactTail(text, Math.max(0, Math.min(65_536, maxChars))); + } + /** Every card below this one, at any depth. */ descendants(sessionId: string): SessionMetadata[] { const all = this.terminals.listMetadata(); @@ -265,6 +462,24 @@ export class AgentControlService { return DEFAULT_DELEGATION_LIMITS; } + private assertSpawnCapacity(parentSessionId: string, replacingSessionId?: string): { live: number; childrenCount: number } { + const childrenCount = this.children(parentSessionId).filter(session => session.id !== replacingSessionId).length; + if (childrenCount >= MAX_CHILDREN_PER_PARENT) { + throw new DelegationRefusal(`Session ${parentSessionId} already has ${MAX_CHILDREN_PER_PARENT} subagent cards; cancel_agent the finished ones first.`); + } + const lineage = this.lineage(parentSessionId); + const limits = this.limits(); + if (lineage.length > limits.maxDepth) { + throw new DelegationRefusal(`Subagents may nest at most ${limits.maxDepth} level${limits.maxDepth === 1 ? "" : "s"} deep below the agent the person started; this one would be level ${lineage.length}. The person sets this limit in Settings → Agents.`); + } + const live = this.descendants(lineage.at(-1)!.id) + .filter(session => session.id !== replacingSessionId && session.exitCode === null).length; + if (live >= limits.maxSubagents) { + throw new DelegationRefusal(`This orchestration already runs ${live} live subagent${live === 1 ? "" : "s"}, its limit (Settings → Agents, set by the person). Wait for one to finish or cancel_agent one first.`); + } + return { live, childrenCount }; + } + private containment(): boolean { try { return this.options.containment?.() === true; } catch { return false; } } @@ -294,6 +509,7 @@ export class AgentControlService { return { sessionId: session.id, status: session.status, + ...(this.hasCurrentLoopWarning(sessionId) ? {loopDetected:true} : {}), // 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), @@ -326,28 +542,176 @@ export class AgentControlService { }; } + /** Gets a result with its review; tool calls defer unfinished reviews to stay within client deadlines. */ + async resultWithReview(sessionId: string, options: { deferReview?: boolean } = {}): Promise { + const current = this.result(sessionId); + if (!this.reviewRequested.has(sessionId)) return current; + const status = this.resultLifecycleStatus(sessionId) ?? current.status; + if (status !== "idle" && status !== "done" && status !== "failed" && current.exitCode === null) { + return { ...current, review: this.reviewWithCost(this.reviews.get(sessionId) ?? { status: "pending", costUsd: null }) }; + } + if (options.deferReview) { + void this.ensureReview(sessionId); + return { ...current, review: this.reviewWithCost(this.reviews.get(sessionId) ?? { status: "pending", costUsd: null }) }; + } + const generation = this.reviewGeneration(sessionId); + const review = await this.ensureReview(sessionId); + return { ...this.result(sessionId), review: this.reviewWithCost( + this.reviewGenerations.get(sessionId) === generation ? review : supersededReview()) }; + } + + private reviewWithCost(review:AgentReviewResult):AgentReviewResult{ + try{ + const cost=review.reviewerSessionId ? this.options.reviewCost?.(review.reviewerSessionId) : null; + return typeof cost==="number" && Number.isFinite(cost) && cost>=0 ? {...review,costUsd:cost} : review; + }catch{return review;} + } + + /** Host-only signal from the trusted loop detector; it never stops a process itself. */ + markLoopDetected(sessionId:string):boolean { + const session=this.requireSession(sessionId); + if(session.role!=="subagent" || session.exitCode!==null)return false; + const turnEpoch=this.options.currentTurnEpoch?.(sessionId); + if(typeof turnEpoch!=="number" || !Number.isSafeInteger(turnEpoch) || turnEpoch<=0)return false; + this.loopWarnings.set(sessionId,{at:Date.now(),turnEpoch}); + return true; + } + + /** Current host loop warning, shared by retry eligibility and companion attention. */ + hasCurrentLoopWarning(sessionId:string):boolean { + const warning=this.loopWarnings.get(sessionId); + if(!warning)return false; + const age=Date.now()-warning.at; + if(!Number.isFinite(age) || age<0 || age>60_000)return false; + return this.options.currentTurnEpoch?.(sessionId)===warning.turnEpoch; + } + + /** Retry is restricted to a failed, observed quiet or recently looping agent. */ + async retry(sessionId: string, reason?: string, signal?: AbortSignal): 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.`); + const hasWorktreeBadge = session.environment?.kind === "worktree"; + const pluginContext = hasWorktreeBadge ? this.terminals.pluginContext(sessionId) : null; + if (hasWorktreeBadge && pluginContext?.environment?.kind !== "worktree") { + throw new DelegationRefusal("The agent's worktree environment is unavailable; its retry was not started locally."); + } + const failed = (session.exitCode !== null && session.exitCode !== 0) || session.status === "failed"; + if (!failed && !this.retryableQuiet.has(sessionId) && !this.hasCurrentLoopWarning(sessionId)) { + throw new DelegationRefusal("retry_agent works only for a failed or quiet subagent; a running agent must finish or be canceled first."); + } + const sourceId = this.retryOrigins.get(sessionId) ?? sessionId; + if (this.pendingRetries.has(sourceId)) throw new DelegationRefusal("A retry of this agent is already in progress."); + const count = this.retryCounts.get(sourceId) ?? 0; + if (count >= MAX_RETRY_COUNT) throw new DelegationRefusal(`This agent has already used its limit of ${MAX_RETRY_COUNT} retries.`); + const original = this.launchRequests.get(sessionId) ?? this.launchRequests.get(sourceId); + if (!original) throw new DelegationRefusal("CanvasTTY no longer has the original launch request for this agent, so it cannot retry it safely."); + const raw = this.terminals.readBuffer(sessionId).buffer; + const output = utf8Tail(this.redactTail(raw, MAX_RETRY_OUTPUT_WINDOW_CHARS), MAX_RETRY_OUTPUT_BYTES); + const failureReason = (typeof reason === "string" && reason.trim().slice(0, 500)) + || session.failureDetails + || (session.exitCode !== null ? `process exited with code ${session.exitCode}` : "the agent stopped producing output"); + const context = [ + "\n\nCanvasTTY retry context:", + `Failure reason: ${this.redactTail(failureReason, 500)}`, + output ? `Masked output tail (up to ${MAX_RETRY_OUTPUT_BYTES} UTF-8 bytes):\n${output}` : "No terminal output was available." + ].join("\n"); + const retryPrompt = `${original.initialPrompt ?? ""}${context}`; + // Reserve synchronously: concurrent tool calls must share the same finite allowance. + this.retryCounts.set(sourceId, count + 1); + this.pendingRetries.set(sourceId, (this.pendingRetries.get(sourceId) ?? 0) + 1); + try { + if (signal?.aborted) throw spawnCanceled(); + const parentId = session.parentSessionId ?? original.parentSessionId; + const validate = (): void => { + const parent = this.requireSession(parentId); + const taskScope = this.taskRoot(parentId); + const cwd = subagentFolder(taskScope.cwd, parent.cwd, hasWorktreeBadge ? original.cwd : session.cwd); + if ("error" in cwd) throw new DelegationRefusal(cwd.error); + const profile = this.profileFor(parentId, session.provider as AgentProviderId, session.profile); + if ("error" in profile) throw new DelegationRefusal(profile.error); + if (profile.profile !== session.profile) throw new DelegationRefusal("The original launch profile is no longer allowed; choose a new agent with an allowed profile."); + this.requireBudgetActive(parentId); + // This replaces the same card, and the manager waits for its old PTY to exit before starting a successor. + this.assertSpawnCapacity(parentId, sessionId); + }; + validate(); + const retried = await this.terminals.retryAgentLaunch(sessionId, retryPrompt, validate, signal, () => this.invalidateReview(sessionId)); + this.loopWarnings.delete(sessionId); + this.retryableQuiet.delete(sessionId); + this.scheduleReview(sessionId); + this.retryOrigins.set(retried.id, sourceId); + // Each attempt starts from the original request, with only this attempt's masked failure context appended. + this.launchRequests.set(retried.id, original); + if (original.review === true) this.trackReview(retried.id); + return retried; + } catch (error) { + this.retryCounts.set(sourceId, Math.max(0, (this.retryCounts.get(sourceId) ?? 1) - 1)); + throw error; + } finally { + const pending = (this.pendingRetries.get(sourceId) ?? 1) - 1; + if (pending > 0) this.pendingRetries.set(sourceId, pending); + else { + this.pendingRetries.delete(sourceId); + if (![...this.retryOrigins.values()].includes(sourceId)) this.retryCounts.delete(sourceId); + } + } + } + + taskBudget(sessionId: string): ReturnType | null { + if (!this.options.budget) return null; + const root = this.taskRoot(sessionId); + return this.options.budget.snapshot(root.id, root.startedAt); + } + + isReadOnlyReviewer(sessionId: string): boolean { + return this.readOnlyReviewers.has(sessionId); + } + + /** App-level PTY input gate. A hard task pause blocks input without signaling or killing any process. */ + assertInputAllowed(sessionId: string): void { + this.requireSession(sessionId); + this.requireBudgetActive(sessionId); + } + /** * Waits until the agent is at rest (idle, needs_approval, exited, or quiet when it reports no status), its card * closed, or the timeout passed. Only reads metadata and the output offset while it waits; the tail is read and * masked once, as it returns. Rejects with an AbortError once `signal` aborts. */ - async waitFor(sessionId: string, request: { timeoutMs: number; signal?: AbortSignal }): Promise { + async waitFor(sessionId: string, request: { timeoutMs: number; signal?: AbortSignal; quietMs?: number; deferReview?: boolean }): Promise { const { signal } = request; signal?.throwIfAborted(); const first = this.requireSession(sessionId); if (first.provider === "terminal") throw new Error("Plain terminals are not agents."); if (typeof request.timeoutMs !== "number" || !Number.isFinite(request.timeoutMs)) throw new Error("A wait timeout is required."); - const timing = this.options.waitTiming ?? AGENT_WAIT_TIMING; + const base = this.options.waitTiming ?? AGENT_WAIT_TIMING; + const timing = request.quietMs !== undefined ? { ...base, quietMs: Math.max(base.quietMs, request.quietMs) } : base; const timeoutMs = Math.min(MAX_AGENT_WAIT_MS, Math.max(0, request.timeoutMs)); const started = Date.now(); - const answer = (session: SessionMetadata | null, reason: AgentWaitReason): AgentWaitResult => { + const answer = async (session: SessionMetadata | null, reason: AgentWaitReason): Promise => { const waitedMs = Date.now() - started; if (!session) return { sessionId, reason, exitCode: null, waitedMs, output: "" }; + if (reason === "quiet") this.retryableQuiet.add(sessionId); + else if (reason === "idle" || reason === "done" || reason === "failed") this.retryableQuiet.delete(sessionId); let observation: AgentObservation | null = null; try { observation = this.observe(sessionId); } catch { observation = null; } - const answer = reason === "timeout" || reason === "needs_approval" ? null : this.answer(sessionId); + const finalAnswer: AgentAnswer | null = reason === "timeout" || reason === "needs_approval" ? null : this.answer(sessionId); + let review: AgentReviewResult | undefined; + const generation = this.reviewGeneration(sessionId); + if (this.reviewRequested.has(sessionId) && (reason === "idle" || reason === "done" || reason === "failed" || reason === "quiet")) { + if (request.deferReview) { + const readiness = this.reviewReadiness(sessionId, reason === "quiet"); + if (!readiness) void this.ensureReview(sessionId, reason === "quiet"); + review = this.reviewWithCost(readiness ?? this.reviews.get(sessionId) ?? { status: "pending", costUsd: null }); + } else { + const result = await this.ensureReview(sessionId, reason === "quiet"); + review = this.reviewWithCost(this.reviewGenerations.get(sessionId) === generation ? result : supersededReview()); + } + } return { sessionId, reason, status: session.status, exitCode: session.exitCode, waitedMs, output: observation?.output ?? "", - ...(observation?.exitLines ? { exitLines: observation.exitLines } : {}), ...(answer ? { answer } : {}) }; + ...(observation?.exitLines ? { exitLines: observation.exitLines } : {}), ...(finalAnswer ? { answer: finalAnswer } : {}), ...(review ? { review } : {}) }; }; let offset = this.outputOffset(sessionId); let changedAt = started; @@ -359,16 +723,17 @@ 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"); + const status = this.resultLifecycleStatus(sessionId) ?? session.status; + if (status === "needs_approval") return answer(session, "needs_approval"); // 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. const progress = this.turnProgress(sessionId); const awaitingTurn = progress !== null && progress.promptSent && !progress.turnStartedSincePrompt; - if (!awaitingTurn && (session.status === "idle" || session.status === "done" || session.status === "failed") && quietFor >= timing.settleMs) { - return answer(session, session.status); + if (!awaitingTurn && (status === "idle" || status === "done" || status === "failed") && quietFor >= timing.settleMs) { + return answer(session, status); } - if ((session.status === "unavailable" || awaitingTurn) && quietFor >= timing.quietMs) return answer(session, "quiet"); + if ((status === "unavailable" || awaitingTurn) && quietFor >= timing.quietMs) return answer(session, "quiet"); const waited = now - started; if (waited >= timeoutMs) return answer(session, "timeout"); await pause(Math.min(timing.checkMs, timeoutMs - waited), signal); @@ -380,6 +745,266 @@ export class AgentControlService { this.terminals.dispose(sessionId); } + /** Restores review ownership without starting a watcher before the next prompt is actually delivered. */ + private trackReview(sessionId: string): void { + this.reviewRequested.add(sessionId); + if (this.reviewInputObservers.has(sessionId)) return; + this.reviewInputObservers.set(sessionId, this.terminals.observeInputWrites(sessionId, (_data, submitted) => { + this.invalidateReview(sessionId); + const generation = this.reviewGeneration(sessionId); + this.retryableQuiet.delete(sessionId); + if (!submitted) { + this.reviews.set(sessionId, { status: "unavailable", reason: "Input has not been submitted as a new worker task.", costUsd: null }); + return; + } + return () => queueMicrotask(() => { + if (this.reviewRequested.has(sessionId) && this.reviewGenerations.get(sessionId) === generation) this.scheduleReview(sessionId); + }); + })); + } + + private reviewGeneration(sessionId: string): object { + let generation = this.reviewGenerations.get(sessionId); + if (!generation) { generation = {}; this.reviewGenerations.set(sessionId, generation); } + return generation; + } + + private invalidateReview(sessionId: string): void { + const reviewerIds = new Set([this.reviews.get(sessionId)?.reviewerSessionId, this.reviewAgents.get(sessionId)]); + this.reviewGenerations.set(sessionId, {}); + this.reviews.delete(sessionId); + this.reviewWatchers.get(sessionId)?.controller.abort(); + this.reviewWatchers.delete(sessionId); + this.reviewControllers.get(sessionId)?.abort(); + this.reviewControllers.delete(sessionId); + this.reviewPending.delete(sessionId); + this.reviewAgents.delete(sessionId); + for (const reviewerId of reviewerIds) this.disposeReviewer(reviewerId); + } + + private disposeReviewer(reviewerId: string | undefined): void { + if (!reviewerId) return; + this.readOnlyReviewers.delete(reviewerId); + try { this.terminals.dispose(reviewerId); } catch { /* the reviewer may already have ended */ } + } + + private reviewReadiness(sessionId: string, quiet = false): AgentReviewResult | null { + // An old idle/answer can remain visible until the provider acknowledges the submitted input. + const progress = this.turnProgress(sessionId); + if (progress?.promptSent && !progress.turnStartedSincePrompt && !this.answer(sessionId)) { + return quiet || this.terminals.getMetadata(sessionId)?.exitCode !== null + ? { status: "unavailable", reason: "The provider did not report a new completed turn or final answer after the submitted input.", costUsd: null } + : { status: "pending", costUsd: null }; + } + return null; + } + + private async ensureReview(sessionId: string, quiet = false): Promise { + const readiness = this.reviewReadiness(sessionId, quiet); + if (readiness) return readiness; + const generation = this.reviewGeneration(sessionId); + const cached = this.reviews.get(sessionId); + if (cached) return cached; + const active = this.reviewPending.get(sessionId); + if (active) { + const result = await active; + return this.reviewGenerations.get(sessionId) === generation ? result : supersededReview(); + } + const controller = new AbortController(); + this.reviewControllers.set(sessionId, controller); + // Store the guarded promise, so every concurrent caller observes invalidation, not the raw verdict. + const pending = this.performReview(sessionId, controller.signal).catch((error: unknown): AgentReviewResult => ({ + status: "unavailable", + reason: this.redactTail(error instanceof Error ? error.message : "The reviewer failed.", 500), + costUsd: null + })).then(result => { + if (controller.signal.aborted || this.reviewGenerations.get(sessionId) !== generation) { + this.disposeReviewer(result.reviewerSessionId); + return supersededReview(); + } + this.reviews.set(sessionId, result); + try { this.options.onReview?.(sessionId, result); } catch { /* review observers cannot affect the result */ } + return result; + }).finally(() => { + if (this.reviewPending.get(sessionId) === pending) this.reviewPending.delete(sessionId); + if (this.reviewControllers.get(sessionId) === controller) this.reviewControllers.delete(sessionId); + }); + this.reviewPending.set(sessionId, pending); + const result = await pending; + return this.reviewGenerations.get(sessionId) === generation ? result : supersededReview(); + } + + private async performReview(sessionId: string, signal: AbortSignal): Promise { + const worker = this.requireSession(sessionId); + const request = this.launchRequests.get(sessionId); + if (!request) return { status: "unavailable", reason: "CanvasTTY no longer has the worker launch details.", costUsd: null }; + // A worker on a model account (Accounts plugin) is reviewed on that same account unless a review model was named: + // the agent's own sign-in may not exist, and only the account carries the key for this model. + const account = request.reviewModel ? null : selectedModelAccount(request.launchOptions); + if (!account && !request.reviewModel && !this.options.reviewModel) return { status: "unavailable", reason: "No different known reviewer model is available.", costUsd: null }; + let workerModel=worker.model ?? request.model; + if(!workerModel)try{workerModel=await this.options.workerModel?.(worker) ?? undefined;}catch{ /* Unknown remains unknown. */ } + if(signal.aborted)return {status:"unavailable",reason:"The worker session was removed before review.",costUsd:null}; + let model: string | null; + try { model = account ? `account:${account}` : request.reviewModel ?? this.options.reviewModel?.(worker.provider as AgentProviderId, workerModel) ?? null; } + catch { model = null; } + if (!model || (!account && workerModel && model === workerModel)) { + return { status: "unavailable", reason: "No known model different from the worker's model is available.", costUsd: null }; + } + const profile = this.profileFor(this.lineage(sessionId).at(-1)!.id, worker.provider as AgentProviderId, "plan"); + if ("error" in profile) return { status: "unavailable", reason: `A read-only Plan reviewer is unavailable: ${profile.error}`, costUsd: null }; + let diff: string; + try { + diff = this.options.reviewDiff ? await this.options.reviewDiff(worker) : await this.terminals.readReviewDiff(sessionId); + } catch (error) { + return { status: "unavailable", reason: `The worker diff could not be read: ${error instanceof Error ? error.message : "unknown error"}`, costUsd: null }; + } + if (signal.aborted) return { status: "unavailable", reason: "The worker session was removed before review.", costUsd: null }; + const maskedDiff = utf8Tail(this.redactTail(diff, MAX_REVIEW_DIFF_BYTES + 8_192), MAX_REVIEW_DIFF_BYTES); + const answer = this.answer(sessionId); + const maskedAnswer = answer ? this.redactTail(answer.text, MAX_REVIEW_ANSWER_CHARS) : "No final answer was available from this provider."; + const root = this.lineage(sessionId).at(-1)!; + const prompt = [ + "Review only the supplied answer and diff. The temporary review workspace also contains the same patch as review.diff. Do not inspect or modify project files; this is a read-only review.", + "Return one JSON object with exactly these fields: verdict (accept, revise, or reject) and findings (short actionable notes).", + "Use accept when no material issue is visible, revise when the author can address concrete issues, and reject when the result does not satisfy the request.", + "Worker answer:", maskedAnswer, + "Diff:", maskedDiff || "(No reviewable diff was found.)" + ].join("\n\n"); + let reviewer: SessionMetadata | undefined; + let keepReviewer = false; + let reviewerAccount: Awaited> | undefined; + let workspace: DiffOnlyReviewWorkspace; + try { + workspace = createDiffOnlyReviewWorkspace(maskedDiff); + } catch (error) { + return { status: "unavailable", reason: `The diff-only review workspace could not be prepared: ${error instanceof Error ? error.message : "unknown error"}`, costUsd: null, model }; + } + try { + try { + reviewerAccount = account + ? await this.terminals.prepareReviewerAccount({ taskRootSessionId: root.id, provider: worker.provider as AgentProviderId, workspace, + launchOptions: { [MODEL_ACCOUNTS_PLUGIN_ID]: { account } } }) + : undefined; + if (signal.aborted) throw new Error("The worker session was removed before review."); + reviewer = this.terminals.createReadOnlyReviewer({ + taskRootSessionId: root.id, + provider: worker.provider as AgentProviderId, + workspace, + title: `Review: ${worker.title}`.slice(0, 80), + ...(reviewerAccount ? { account: reviewerAccount } : { model }) + }); + // Once creation succeeds, TerminalManager owns the contribution and cleans it up with the session. + reviewerAccount = undefined; + this.readOnlyReviewers.add(reviewer.id); + // OpenCode's fresh home has no conversation until submission; wait for its rendered input prompt or hook. + if (worker.provider === "opencode") { + const readyBy = Date.now() + (this.options.reviewStartupMs ?? REVIEW_STARTUP_QUIET_MS); + const ready = (): boolean => typeof this.terminals.inputReady === "function" + ? this.terminals.inputReady(reviewer!.id) + : (this.resultLifecycleStatus(reviewer!.id) ?? this.terminals.getMetadata(reviewer!.id)?.status) !== "unavailable"; + while (Date.now() < readyBy && !ready()) await pause(250, signal); + if (!ready()) throw new Error("The reviewer CLI did not expose its input prompt before the startup deadline."); + } + await this.deliver(reviewer.id, `${prompt}\r`, "prompt", signal); + } catch (error) { + return { status: "unavailable", reason: `The read-only reviewer could not start: ${error instanceof Error ? error.message : "unknown error"}`, costUsd: null, model }; + } + const reviewerSession = reviewer!; + this.reviewAgents.set(sessionId, reviewerSession.id); + if (signal.aborted) { + return { status: "unavailable", reason: "The worker session was removed before review.", reviewerSessionId: reviewerSession.id, model, costUsd: null }; + } + const waited = await this.waitFor(reviewerSession.id, { + timeoutMs: this.options.reviewTimeoutMs ?? REVIEW_TIMEOUT_MS, + // A reviewer starts its CLI in a fresh, empty home (OpenCode may fetch its provider package first) and reports + // no turn until then: ten silent seconds are not yet "stopped responding". + quietMs: REVIEW_STARTUP_QUIET_MS, + signal + }).catch((error: unknown) => ({ + sessionId: reviewerSession.id, reason: "failed" as const, exitCode: 1, waitedMs: 0, output: "", + exitLines: error instanceof Error ? this.redactTail(error.message, 500) : "review failed" + })); + if (signal.aborted) { + return { status: "unavailable", reason: "The worker session was removed during review.", reviewerSessionId: reviewerSession.id, model, costUsd: null }; + } + if (waited.reason === "timeout" || waited.reason === "quiet" || waited.reason === "failed" || !waited.answer) { + return { + status: "unavailable", + reason: waited.reason === "timeout" ? "The reviewer timed out." : waited.reason === "quiet" ? "The reviewer stopped responding without a readable verdict." : waited.exitLines ? `The reviewer failed before returning a verdict: ${this.redactTail(waited.exitLines, 500)}` : "The reviewer failed before returning a verdict.", + reviewerSessionId: reviewerSession.id, + model, + costUsd: null + }; + } + const parsed = parseReview(waited.answer.text); + if (!parsed) { + return { status: "unavailable", reason: "The reviewer did not return a valid accept, revise, or reject verdict.", reviewerSessionId: reviewerSession.id, model, costUsd: null }; + } + keepReviewer = true; + return { + status: parsed.verdict === "accept" ? "accepted" : parsed.verdict === "revise" ? "revise" : "rejected", + verdict: parsed.verdict, + notes: [account ? "Reviewed on the worker's model account (the same model; name reviewModel for another)." : "", parsed.findings ? this.redactTail(parsed.findings, 8_000) : ""].filter(Boolean).join("\n") || undefined, + reviewerSessionId: reviewerSession.id, + model, + costUsd: null + }; + } finally { + if (reviewerAccount) await reviewerAccount.contribution.cleanup().catch(() => undefined); + if (reviewer && this.reviewAgents.get(sessionId) === reviewer.id) this.reviewAgents.delete(sessionId); + if (!keepReviewer) { + if (reviewer) try { this.terminals.dispose(reviewer.id); } catch { /* the task may already have removed it */ } + workspace.cleanup(); + if (reviewer) this.readOnlyReviewers.delete(reviewer.id); + } + } + } + + + private requireBudgetActive(sessionId: string): void { + if (!this.options.budget) return; + let snapshot: ReturnType; + const root = this.taskRoot(sessionId); + try { snapshot = this.options.budget.snapshot(root.id, root.startedAt); } + catch (error) { throw new DelegationRefusal(`Task budget state is unavailable; new subagent activity is paused. ${error instanceof Error ? error.message : ""}`); } + if (snapshot.paused) throw new DelegationRefusal(snapshot.reason ?? "This task's budget is exhausted. Increase or clear it in CanvasTTY before continuing."); + } + + private scheduleReview(sessionId: string): void { + if (!this.reviewRequested.has(sessionId) || this.reviewWatchers.has(sessionId)) return; + const controller = new AbortController(); + let watcher: { controller: AbortController; promise: Promise }; + const promise = this.monitorReview(sessionId, controller.signal).catch(() => undefined).finally(() => { + if (this.reviewWatchers.get(sessionId) === watcher) this.reviewWatchers.delete(sessionId); + }); + watcher = { controller, promise }; + this.reviewWatchers.set(sessionId, watcher); + } + + /** Waits for the worker's next completed turn and starts its review without requiring a result poll. */ + private async monitorReview(sessionId: string, signal: AbortSignal): Promise { + for (;;) { + if (signal.aborted) return; + const session = this.terminals.getMetadata(sessionId); + if (!session || !this.reviewRequested.has(sessionId)) return; + if (session.exitCode !== null) { + await this.ensureReview(sessionId); + return; + } + const result = await this.waitFor(sessionId, { timeoutMs: MAX_AGENT_WAIT_MS, signal }); + if (signal.aborted) return; + if (result.reason === "idle" || result.reason === "done" || result.reason === "failed" || result.reason === "quiet") { + const review = result.review ?? await this.ensureReview(sessionId, result.reason === "quiet"); + if (signal.aborted) return; + if (review.status !== "pending") return; + } + if (result.reason === "closed") return; + // Approval and quiet states need a later human input or another output sample; avoid spinning on either. + await pause(1_000, signal); + } + } + /** 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); @@ -401,6 +1026,10 @@ export class AgentControlService { return tail(typeof this.terminals.redactSecrets === "function" ? this.terminals.redactSecrets(text) : text, maxChars); } + private resultLifecycleStatus(sessionId: string): "idle" | "working" | "needs_approval" | null { + return typeof this.terminals.resultLifecycleState === "function" ? this.terminals.resultLifecycleState(sessionId) : null; + } + private turnProgress(sessionId: string): { promptSent: boolean; turnStartedSincePrompt: boolean } | null { try { return typeof this.terminals.turnProgress === "function" ? this.terminals.turnProgress(sessionId) : null; } catch { return null; } } @@ -518,6 +1147,38 @@ function tail(text: string, maxChars: number): string { return text.slice(text.length - maxChars); } +function utf8Tail(text: string, maxBytes: number): string { + const bytes = Buffer.from(text, "utf8"); + if (bytes.byteLength <= maxBytes) return text; + let value = bytes.subarray(bytes.byteLength - maxBytes).toString("utf8"); + // A UTF-8 byte window can begin in the middle of a code point; drop that partial character. + if (value.startsWith("\uFFFD")) value = value.slice(1); + return value; +} + +function parseReview(text: string): { verdict: "accept" | "revise" | "reject"; findings: string } | null { + const match = text.match(/\{[\s\S]*\}/u); + if (!match) return null; + let value: unknown; + try { value = JSON.parse(match[0]); } catch { return null; } + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const record = value as Record; + if (record.verdict !== "accept" && record.verdict !== "revise" && record.verdict !== "reject") return null; + const findings = typeof record.findings === "string" ? record.findings : typeof record.notes === "string" ? record.notes : ""; + return { verdict: record.verdict, findings: findings.slice(0, 8_000) }; +} + function spawnCanceled(): Error { return new DOMException("The spawn was canceled before its agent started.", "AbortError"); } + +const MODEL_ACCOUNTS_PLUGIN_ID = "canvastty-accounts"; +/** The model account a worker was launched on through the Accounts plugin, if any. */ +function selectedModelAccount(launchOptions: SpawnAgentRequest["launchOptions"]): string | null { + const account = launchOptions?.[MODEL_ACCOUNTS_PLUGIN_ID]?.account; + return typeof account === "string" && account && account !== "none" ? account : null; +} + +function supersededReview(): AgentReviewResult { + return { status: "unavailable", reason: "The worker prompt changed or the session was removed before this review completed.", costUsd: null }; +} diff --git a/src/main/services/AssistantLoopSignal.ts b/src/main/services/AssistantLoopSignal.ts new file mode 100644 index 00000000..14444bf3 --- /dev/null +++ b/src/main/services/AssistantLoopSignal.ts @@ -0,0 +1,62 @@ +/** + * `loop.detected` from the Assistant plugin marks a subagent as looping, which permits a retry and raises an + * attention notice. The plugin id in a manifest is self-declared, so any GitHub repository could install itself as + * `canvastty-assistant`. The signal is accepted only from the plugin whose install record names the Assistant's own + * repository, that is enabled, and whose services the person trusted as native code. + */ +export const ASSISTANT_PLUGIN_ID = "canvastty-assistant"; +export const ASSISTANT_SERVICE_ID = "assistant"; +/** Canonical install sources (PluginManager.normalizeGithubUrl form) of the first-party Assistant plugin. */ +export const ASSISTANT_PLUGIN_SOURCES: ReadonlySet = new Set([ + "https://github.com/howdeploy/canvastty-plugin-assistant.git", + "https://github.com/BIackFIame/canvastty-plugin-assistant.git" +]); + +export interface PluginInstallRecord { sourceUrl: string; enabled: boolean; nativeCodeTrusted: boolean } +export interface LoopSignalSession { provider: string; exitCode: number | null; status: string; role?: string } +export interface LoopSignalDeps { + installRecord(pluginId: string): PluginInstallRecord | null; + session(sessionId: string): LoopSignalSession | undefined; + turnEpoch(sessionId: string): number | null; + consumeEvidence(sessionId: string, evidenceId: string, turnEpoch: number): boolean; + markLoopDetected(sessionId: string): boolean; +} +export interface AcceptedLoopSignal { + sessionId: string; + label: string; + reason?: string; + /** The event without its single-use host evidence, safe to broadcast to the renderer. */ + data: Record; +} + +export function isInstalledAssistant(record: PluginInstallRecord | null): boolean { + if (!record || !record.enabled || !record.nativeCodeTrusted) return false; + // GitHub owner and repository names are case-insensitive. + const source = record.sourceUrl.toLowerCase(); + return [...ASSISTANT_PLUGIN_SOURCES].some((known) => known.toLowerCase() === source); +} + +/** Returns the accepted signal, or null when it must be ignored. Evidence is consumed only for a verified sender. */ +export function acceptLoopSignal(deps: LoopSignalDeps, pluginId: string, serviceId: string, data: unknown, + redact: (text: string) => string): AcceptedLoopSignal | null { + if (pluginId !== ASSISTANT_PLUGIN_ID || serviceId !== ASSISTANT_SERVICE_ID) return null; + if (!isInstalledAssistant(deps.installRecord(pluginId))) return null; + if (!data || typeof data !== "object" || Array.isArray(data)) return null; + const values = data as Record; + const { sessionId, evidenceId } = values; + if (typeof sessionId !== "string" || typeof evidenceId !== "string") return null; + const session = deps.session(sessionId); + const turnEpoch = deps.turnEpoch(sessionId); + if (!session || session.provider === "terminal" || session.exitCode !== null + || session.status === "done" || session.status === "failed" + || turnEpoch === null || !deps.consumeEvidence(sessionId, evidenceId, turnEpoch)) return null; + if (session.role === "subagent") { + try { if (!deps.markLoopDetected(sessionId)) return null; } catch { return null; } + } + const kind = values.kind; + const label = kind === "repeated-error" ? "Repeated tool errors detected" + : kind === "no-file-progress" || kind === "no-progress" ? "No observed file progress" : "Repeated action detected"; + const reason = typeof values.reason === "string" ? redact(values.reason).replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 160) : undefined; + const { evidenceId: _evidenceId, ...safeData } = values; + return { sessionId, label, ...(reason !== undefined ? { reason } : {}), data: safeData }; +} diff --git a/src/main/services/AttentionService.ts b/src/main/services/AttentionService.ts new file mode 100644 index 00000000..80d831ff --- /dev/null +++ b/src/main/services/AttentionService.ts @@ -0,0 +1,60 @@ +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import { randomUUID } from "node:crypto"; +import type { AttentionEvent, NotificationPreferences, NotificationChannel } from "../../shared/backlog.ts"; + +const defaults = (): NotificationPreferences => ({version:1,channels:{desktop:true,phone:true,glasses:true},quietUntil:null,importantOnly:false,sessionIds:null}); +const important = new Set(["approval","failed","budget","loop"]); +const kinds = new Set(["response","approval","done","failed","budget","loop"]); + +/** Metadata-only attention events. Channel filtering never affects session control or safety decisions. */ +export class AttentionService { + private readonly path: string; + private readonly mask: (text:string)=>string; + private preferences = defaults(); + private readonly events: AttentionEvent[] = []; + private readonly last = new Map(); + private writes: Promise = Promise.resolve(); + constructor(path:string, mask:(text:string)=>string) {this.path=path;this.mask=mask;} + async load(): Promise { + try {const text=await readFile(this.path,"utf8");if(text.length>64_000)throw new Error("Notification preferences are too large.");this.preferences=validate(JSON.parse(text));} + catch(error) {if(!(error && typeof error==="object" && "code" in error && error.code==="ENOENT"))throw error;} + } + get(): NotificationPreferences {return structuredClone(this.preferences);} + async set(value:NotificationPreferences): Promise { + const next=validate(value), serialized=JSON.stringify(next); + const operation=this.writes.catch(()=>undefined).then(async()=>{ + await mkdir(dirname(this.path),{recursive:true,mode:0o700}); + const temporary=`${this.path}.${randomUUID()}.tmp`; + await writeFile(temporary,serialized,{mode:0o600,flag:"wx"}); + await rename(temporary,this.path);this.preferences=next; + }); + this.writes=operation;await operation;return this.get(); + } + publish(sessionId:string,title:string,kind:string,at=Date.now()): AttentionEvent|null { + if(!kinds.has(kind))throw new Error("Unknown attention event."); + const key=`${sessionId}:${kind}`, previous=this.last.get(key); + // Distinct requests for permission must each remain visible to the person. + if(kind!=="approval" && previous!==undefined && at-previous<10_000)return null; + this.last.set(key,at); + if(this.last.size>2000)this.last.delete(this.last.keys().next().value!); + const event:AttentionEvent={id:randomUUID(),sessionId,title:this.mask(title).replace(/[\u0000-\u001f\u007f]/g,"").slice(0,120),kind:kind as AttentionEvent["kind"],at}; + this.events.push(event);if(this.events.length>500)this.events.shift();return event; + } + allows(channel:NotificationChannel,event:AttentionEvent,now=Date.now()):boolean { + const p=this.preferences; + return p.channels[channel] && !(p.quietUntil!==null && p.quietUntil>now) + && (!p.importantOnly || important.has(event.kind)) && (!p.sessionIds || p.sessionIds.includes(event.sessionId)); + } + list(channel:NotificationChannel,sessionId?:string):AttentionEvent[] { + return this.events.filter(event=>(!sessionId || event.sessionId===sessionId) && this.allows(channel,event)).slice(-100).map(event=>({...event})); + } +} +function validate(value:unknown):NotificationPreferences { + if(!value || typeof value!=="object" || Array.isArray(value))throw new Error("Invalid notification preferences."); + const p=value as NotificationPreferences; + if(p.version!==1 || !p.channels || ["desktop","phone","glasses"].some(channel=>typeof p.channels[channel as NotificationChannel]!=="boolean") + || typeof p.importantOnly!=="boolean" || !(p.quietUntil===null || Number.isSafeInteger(p.quietUntil) && p.quietUntil>=0) + || !(p.sessionIds===null || Array.isArray(p.sessionIds) && p.sessionIds.length<=100 && p.sessionIds.every(id=>typeof id==="string" && id.length>0 && id.length<=160)))throw new Error("Invalid notification preferences."); + return {version:1,channels:{desktop:p.channels.desktop,phone:p.channels.phone,glasses:p.channels.glasses},quietUntil:p.quietUntil,importantOnly:p.importantOnly,sessionIds:p.sessionIds ? [...new Set(p.sessionIds)] : null}; +} diff --git a/src/main/services/CanvasNavigationOverride.ts b/src/main/services/CanvasNavigationOverride.ts index 80048276..a8f15c67 100644 --- a/src/main/services/CanvasNavigationOverride.ts +++ b/src/main/services/CanvasNavigationOverride.ts @@ -200,6 +200,8 @@ export class CanvasNavigationInputController { private readonly attachedContents = new Set(); private readonly onActiveChange: (state: CanvasNavigationOverrideState) => void; private menuShortcutContents: WebContents | null = null; + /** Contents whose renderer reported keyboard focus in a terminal surface or shortcut recorder. */ + private readonly terminalEditFocus = new WeakSet(); constructor( bindings: CanvasNavigationBindings, @@ -233,6 +235,19 @@ export class CanvasNavigationInputController { }); } + /** + * The renderer reports whether a terminal surface or shortcut recorder has keyboard focus. Both consume + * Command+C/V/A themselves, so those shortcuts bypass the Edit menu. The terminal-named API covers both; + * ordinary fields and plugin pages keep the menu's native Copy, Paste and Select All. + */ + setTerminalEditFocus(contents: WebContents, focused: boolean): void { + if (focused) this.terminalEditFocus.add(contents); + else { + this.terminalEditFocus.delete(contents); + if (this.menuShortcutContents === contents) this.releaseMenuShortcuts(); + } + } + setBindings(bindings: CanvasNavigationBindings): void { this.releaseMenuShortcuts(); const previous = this.state(); @@ -299,6 +314,7 @@ export class CanvasNavigationInputController { const wheelTransition = this.wheelTracker.update(keyboardInput); const navigationTransition = this.navigationTracker.update(keyboardInput); const macEditShortcut = options.captureMacEditShortcuts === true + && this.terminalEditFocus.has(contents) && topFrameFocused(contents) && input.type === "keyDown" && input.meta && !input.control && !input.alt && !input.shift && (input.code === "KeyA" || input.code === "KeyC" || input.code === "KeyV"); @@ -374,3 +390,10 @@ export class CanvasNavigationInputController { } } + +/** An iframe (a plugin page) with focus is never a terminal surface; contents without the property count as top. */ +function topFrameFocused(contents: WebContents): boolean { + const focused = (contents as { focusedFrame?: unknown }).focusedFrame; + const top = (contents as { mainFrame?: unknown }).mainFrame; + return focused === undefined || focused === null || top === undefined || focused === top; +} diff --git a/src/main/services/DiffOnlyReviewWorkspace.ts b/src/main/services/DiffOnlyReviewWorkspace.ts new file mode 100644 index 00000000..99ac3521 --- /dev/null +++ b/src/main/services/DiffOnlyReviewWorkspace.ts @@ -0,0 +1,66 @@ +import { chmodSync, mkdtempSync, realpathSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const MAX_REVIEW_DIFF_BYTES = 64 * 1024; +const ownedWorkspaces = new WeakSet(); + +/** + * POSIX keeps the exact owner-only modes. Windows has no owner/group/other bits: chmod only toggles the read-only + * attribute, which stat reports as the absence of every write bit, so only that part of the mode is checked there. + */ +function hasReviewMode(mode: number, expected: number, platform: NodeJS.Platform): boolean { + return platform === "win32" ? (mode & 0o222) === 0 : (mode & 0o777) === expected; +} + +/** A private, host-created directory whose only source file is the host-reviewed patch. */ +export interface DiffOnlyReviewWorkspace { + readonly directory: string; + readonly diffPath: string; + cleanup(): void; +} + +export function createDiffOnlyReviewWorkspace(diff: string): DiffOnlyReviewWorkspace { + if (typeof diff !== "string" || Buffer.byteLength(diff, "utf8") > MAX_REVIEW_DIFF_BYTES) { + throw new Error("The reviewed diff exceeds the temporary review workspace limit."); + } + const tempRoot = realpathSync(tmpdir()); + const directory = realpathSync(mkdtempSync(join(tempRoot, "canvastty-review-"))); + chmodSync(directory, 0o700); + const diffPath = join(directory, "review.diff"); + try { + writeFileSync(diffPath, diff, { encoding: "utf8", flag: "wx", mode: 0o400 }); + chmodSync(diffPath, 0o400); + chmodSync(directory, 0o500); + } catch (error) { + chmodSync(directory, 0o700); + rmSync(directory, { recursive: true, force: true }); + throw error; + } + const workspace: DiffOnlyReviewWorkspace = Object.freeze({ + directory, + diffPath, + cleanup() { + try { chmodSync(directory, 0o700); } catch { /* already removed */ } + rmSync(directory, { recursive: true, force: true }); + ownedWorkspaces.delete(workspace); + } + }); + ownedWorkspaces.add(workspace); + return workspace; +} + +/** TerminalManager accepts only the exact object created here, not paths forged through session metadata. */ +export function isHostOwnedDiffOnlyReviewWorkspace(value: unknown, platform: NodeJS.Platform = process.platform): value is DiffOnlyReviewWorkspace { + if (!value || typeof value !== "object" || !ownedWorkspaces.has(value)) return false; + const workspace = value as DiffOnlyReviewWorkspace; + try { + if (realpathSync(workspace.directory) !== workspace.directory || realpathSync(workspace.diffPath) !== workspace.diffPath + || workspace.diffPath !== join(workspace.directory, "review.diff")) return false; + const directory = statSync(workspace.directory); + const diff = statSync(workspace.diffPath); + return directory.isDirectory() && hasReviewMode(directory.mode, 0o500, platform) + && diff.isFile() && hasReviewMode(diff.mode, 0o400, platform) + && readdirSync(workspace.directory).length === 1 && readdirSync(workspace.directory)[0] === "review.diff"; + } catch { return false; } +} diff --git a/src/main/services/EnvironmentRegistry.ts b/src/main/services/EnvironmentRegistry.ts index 7fd3c2f8..b5b7ee1b 100644 --- a/src/main/services/EnvironmentRegistry.ts +++ b/src/main/services/EnvironmentRegistry.ts @@ -30,6 +30,7 @@ export interface EnvironmentRegistryDependencies { secret(pluginId: string, key: string): Promise; timeouts?: Partial>; platform?: NodeJS.Platform; + onRetained?(sessionId:string,environment:PersistedEnvironmentRef,reason:string):void; } /** The core never waits longer and never falls back to a local launch when a step runs out. */ @@ -137,6 +138,7 @@ export class EnvironmentRegistry { provider: ProviderId; cwd: string; choice: SessionEnvironmentChoice; + projectRoot?:string; }): Promise { const found = this.lookup(request.choice.pluginId, request.choice.kind); if (!found) return { ok: false, reason: `Environment ${request.choice.kind} from plugin ${request.choice.pluginId} is not available.` }; @@ -145,6 +147,7 @@ export class EnvironmentRegistry { kind: request.choice.kind, provider: request.provider, cwd: request.cwd, + ...(request.projectRoot ? {projectRoot:request.projectRoot} : {}), options: request.choice.options ?? {} }, undefined, { budgetMs: LATE_ANSWER_BUDGET_MS, @@ -297,6 +300,11 @@ export class EnvironmentRegistry { reason: options.reason }, options.timeoutMs); if (!answer.ok) console.warn(`CanvasTTY environment ${environment.label} could not be released: ${answer.reason}`); + else if(isRecord(answer.value) && answer.value.released===false) { + const reason=plainText(answer.value.reason,500) ?? "Unreviewed work was kept."; + console.warn(`CanvasTTY environment ${environment.label} retained: ${reason}`); + this.dependencies.onRetained?.(sessionId,environment,reason); + } } /** `canvastty.environment.describe`: the card badge text; null when the plugin gave none. */ diff --git a/src/main/services/GitCheckpoints.ts b/src/main/services/GitCheckpoints.ts new file mode 100644 index 00000000..f1393db0 --- /dev/null +++ b/src/main/services/GitCheckpoints.ts @@ -0,0 +1,354 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { realpath, lstat, open, mkdir, mkdtemp, readdir, readFile, writeFile, rename, rm, type FileHandle } from "node:fs/promises"; +import { constants, createReadStream } from "node:fs"; +import { resolve, relative, isAbsolute, sep, dirname, join } from "node:path"; +import { createHash, randomUUID } from "node:crypto"; +const exec = promisify(execFile); + +/** Host-owned packs retain snapshot trees independently of the agent-writable repository refs. */ +export class GitCheckpoints { + private readonly redact: (text:string)=>string; + private readonly retain: number; + private readonly trusted = new Map(); + private loaded = false; + private writes:Promise = Promise.resolve(); + private captures:Promise = Promise.resolve(); + private readonly heldPacks=new Map(); + private readonly storagePath?:string; + constructor(redact: (text: string) => string, retain = 50, storagePath?:string) {this.redact=redact;this.retain=retain;this.storagePath=storagePath;} + private async load():Promise { + if(this.loaded)return; + if(this.storagePath) { + try { + const text=await readFile(this.storagePath,"utf8"); + if(Buffer.byteLength(text)>4*1024*1024)throw new Error("Checkpoint registry is too large."); + const values=JSON.parse(text) as unknown; + if(!Array.isArray(values))throw new Error("Invalid checkpoint registry."); + for(const row of values) { + if(!row || typeof row.id!=="string" || typeof row.cwd!=="string" || typeof row.sessionId!=="string" || !/^[a-f0-9]{40,64}$/.test(row.oid))throw new Error("Invalid checkpoint registry."); + if(row.indexOid!==undefined && !/^[a-f0-9]{40,64}$/u.test(row.indexOid))throw new Error("Invalid checkpoint index object."); + if(row.pack!==undefined && !/^[a-f0-9]{40,64}$/u.test(row.pack))throw new Error("Invalid checkpoint pack."); + this.trusted.set(row.id,{cwd:row.cwd,sessionId:row.sessionId,oid:row.oid,...(row.indexOid ? {indexOid:row.indexOid} : {}),...(row.pack ? {pack:row.pack} : {})}); + } + }catch(error){if((error as NodeJS.ErrnoException).code!=="ENOENT")throw error;} + } + await this.pruneAbandonedPackStaging(); + this.loaded=true; + } + private async pruneAbandonedPackStaging():Promise { + if(!this.storagePath)return; + const root=join(dirname(this.storagePath),"checkpoint-objects"); + const projects=await readdir(root,{withFileTypes:true}).catch(error=>{ + if((error as NodeJS.ErrnoException).code==="ENOENT")return []; + throw error; + }); + for(const project of projects) { + if(!project.isDirectory() || !/^[a-f0-9]{64}$/u.test(project.name))continue; + const directory=join(root,project.name); + for(const entry of await readdir(directory,{withFileTypes:true})) { + const owner=/^\.pending-([1-9][0-9]{0,9})-[A-Za-z0-9]{6}$/u.exec(entry.name); + if(!entry.isDirectory() || !owner)continue; + const pid=Number(owner[1]);if(pid>0x7fffffff)continue; + try {process.kill(pid,0);}catch(error) { + // A reused/live PID or an inaccessible process keeps its staging folder. Only confirmed + // dead owners are removed; no shared packs or another running capture can be swept. + if((error as NodeJS.ErrnoException).code==="ESRCH")await this.removePackStaging(join(directory,entry.name)); + } + } + } + } + private async removePackStaging(path:string):Promise { + await rm(path,{recursive:true,force:true}).catch(error=>{ + // Cleanup failure must not turn an already published valid pack into an unregistered capture. + console.warn("Could not remove unfinished checkpoint files.",(error as NodeJS.ErrnoException).code ?? "unknown error"); + }); + } + private async persist(signal?:AbortSignal):Promise { + if(!this.storagePath)return; + const file=this.storagePath, text=JSON.stringify([...this.trusted].map(([id,row])=>({id,...row}))); + const work=this.writes.catch(()=>undefined).then(async()=>{ + signal?.throwIfAborted(); + await mkdir(dirname(file),{recursive:true,mode:0o700}); + const temp=`${file}.${randomUUID()}.tmp`; + try { + await writeFile(temp,text,{mode:0o600,flag:"wx",signal});signal?.throwIfAborted();await rename(temp,file); + } catch(error) { + await rm(temp,{force:true}).catch(()=>undefined); + throw error; + } + }); + this.writes=work;await work; + } + private prefix(sessionId: string): string { + if (!/^[\w-]{1,100}$/.test(sessionId)) throw new Error("Invalid checkpoint session."); + return `refs/canvastty/${sessionId}/`; + } + private async git(cwd: string, args: string[], signal?:AbortSignal,previewOverflow?:(partial:string)=>string): Promise { + signal?.throwIfAborted(); + const env = { ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_"))), + GIT_OPTIONAL_LOCKS: "0", GIT_TERMINAL_PROMPT: "0" }; + const safe = ["--no-replace-objects", "-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", "-c", "diff.external="]; + // Snapshotting runs outside the agent process; never execute repository-provided clean filters. + const configured = await exec("git", [...safe, "-C", cwd, "config", "--get-regexp", "^filter\\..*\\.(clean|smudge|process|required)$"], {env, signal, timeout: 5000}).catch((error: unknown) => { + if ((error as {code?: unknown}).code === 1) return {stdout: ""}; throw error; + }); + for (const line of configured.stdout.split("\n")) { + const key = line.split(/\s/, 1)[0]; + if (/^filter\..*\.(clean|smudge|process|required)$/.test(key)) safe.push("-c", `${key}=${key.endsWith(".required") ? "false" : ""}`); + } + try {return (await exec("git", [...safe, "-C", cwd, ...args], {env, signal, timeout: 15_000, maxBuffer: 2 * 1024 * 1024})).stdout.trimEnd();} + catch(error) { + const output=error as {code?:unknown;message?:unknown;stdout?:unknown}; + // Only previews can accept a bounded stdout prefix. Timeouts, stderr overflow and real Git errors fail. + if(previewOverflow && output.code==="ERR_CHILD_PROCESS_STDIO_MAXBUFFER" && output.message==="stdout maxBuffer length exceeded" && typeof output.stdout==="string") { + return previewOverflow(output.stdout.slice(0,output.stdout.lastIndexOf("\n")+1)); + } + throw error; + } + } + async available(cwd: string, signal?:AbortSignal): Promise { + try { return await realpath(await this.git(cwd, ["rev-parse", "--show-toplevel"],signal)) === await realpath(cwd); } + catch { return false; } + } + async workingDiff(cwd:string, baselineHead?:string):Promise { + if (baselineHead !== undefined && !/^[a-f0-9]{40,64}$/u.test(baselineHead)) throw new Error("Invalid review baseline commit."); + const parts = [await this.git(cwd,["diff","--no-ext-diff","--no-textconv","--unified=3",baselineHead ?? "HEAD","--"])]; + const root = await realpath(cwd); + const paths = (await this.git(cwd,["ls-files","--others","--exclude-standard","-z"])).split("\0").filter(Boolean); + let bytes = Buffer.byteLength(parts[0]), omitted = 0; + for (const path of paths) { + if (bytes >= 512 * 1024 || /[\u0000-\u001F\u007F]/u.test(path)) { omitted++; continue; } + let file; + try { + const full = resolve(root,path), target = await realpath(full), within = relative(root,target); + const metadata = await lstat(full); + if (isAbsolute(within) || within === ".." || within.startsWith(`..${sep}`) || !metadata.isFile() || metadata.size > 32 * 1024) { omitted++; continue; } + file = await open(full, constants.O_RDONLY | constants.O_NOFOLLOW); + const opened = await file.stat(); + if (!opened.isFile() || opened.ino !== metadata.ino || opened.dev !== metadata.dev || opened.size > 32 * 1024) { omitted++; continue; } + const raw = await readBoundedUntrackedFile(file); + const after = await file.stat(); + if (raw.includes(0) || raw.length > 32 * 1024 || after.size !== opened.size + || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs) { omitted++; continue; } + const text = new TextDecoder("utf8", {fatal:true}).decode(raw), lines = text.endsWith("\n") ? text.slice(0,-1).split("\n") : text.split("\n"); + if (!text) lines.length = 0; + const quoted = JSON.stringify(path); + const hunk = `diff --git ${JSON.stringify(`a/${path}`)} ${JSON.stringify(`b/${path}`)}\nnew file mode ${opened.mode & 0o111 ? "100755" : "100644"}\n--- /dev/null\n+++ ${quoted}\n@@ -0,0 +${lines.length ? 1 : 0},${lines.length} @@\n${lines.map(line=>`+${line}`).join("\n")}\n${text && !text.endsWith("\n") ? "\\ No newline at end of file\n" : ""}`; + parts.push(hunk); bytes += Buffer.byteLength(hunk); + } catch { omitted++; } + finally { await file?.close(); } + } + if (omitted) parts.push(`[${omitted} untracked file(s) omitted: binary, oversized, unsafe, or unavailable.]`); + // Mask the complete collected text before taking the bounded tail consumed by the reviewer. + return this.redact(parts.join("\n")).slice(-512 * 1024); + } + async capture(sessionId: string, cwd: string, signal?:AbortSignal): Promise { + let commit!:()=>void; + const committed=new Promise(resolve=>{commit=resolve;}); + const work=this.captures.catch(()=>undefined).then(()=>this.captureOne(sessionId,cwd,signal,commit)); + this.captures=work; + if(!signal){await work;return;} + // Hook callers stop waiting at the durable commit point (or cancellation), while rollback/pruning stays + // serialized. An aborted queued job must never begin snapshotting a later, already-running turn. + let abort!:()=>void; + const cancelled=new Promise((_resolve,reject)=>{abort=()=>reject(signal.reason ?? new Error("Checkpoint cancelled."));}); + signal.addEventListener("abort",abort,{once:true}); + if(signal.aborted)abort(); + try {await Promise.race([work,committed,cancelled]);} + finally {signal.removeEventListener("abort",abort);} + } + private async captureOne(sessionId:string,cwd:string,signal?:AbortSignal,committed?:()=>void):Promise { + signal?.throwIfAborted(); + await this.load(); + if (!await this.available(cwd,signal)) { signal?.throwIfAborted(); return; } + const stash = await this.git(cwd, ["stash", "create"],signal); + const hash = stash || await this.git(cwd, ["rev-parse", "HEAD"],signal); + // A stash's second parent stores its index state. A clean checkpoint can itself be a merge commit, + // whose second parent is an unrelated branch and must not be used as the saved index. + const indexOid = stash ? await this.git(cwd, ["rev-parse", "--verify", `${hash}^2`],signal) : hash; + const ref = `${this.prefix(sessionId)}${Date.now()}-${randomUUID()}`; + const root=await realpath(cwd),pack=await this.protectObjects(root,hash,indexOid,signal); + try { + signal?.throwIfAborted(); + await this.git(cwd, ["update-ref", ref, hash],signal); + signal?.throwIfAborted(); + } catch(error) { + await this.git(cwd,["update-ref","-d",ref]).catch(()=>undefined); + await this.removePackIfUnreferenced(root,pack); + throw error; + } + + const previous=new Map([...this.trusted].map(([id,row])=>[id,{...row}])); + this.trusted.set(ref,{cwd:root,sessionId,oid:hash,indexOid,...(pack ? {pack} : {})}); + let pruned:Array<{id:string;row:{cwd:string;sessionId:string;oid:string;indexOid?:string;pack?:string}}> = []; + try { + signal?.throwIfAborted(); + const prefix=this.prefix(sessionId); + const snapshots=[...this.trusted].filter(([id,row])=>id.startsWith(prefix) && row.cwd===root && row.sessionId===sessionId) + .map(([id])=>({id,at:Number(id.slice(prefix.length).split("-",1)[0])})).sort((a,b)=>b.at-a.at); + pruned=snapshots.slice(this.retain).map(({id})=>({id,row:this.trusted.get(id)!})); + for (const old of pruned) this.trusted.delete(old.id); + // Commit the registry before discarding older protected packs. If its atomic replacement fails, + // retain the previous registry and packs so a failed capture cannot destroy the last good checkpoint. + await this.persist(signal); + signal?.throwIfAborted(); + } catch(error) { + this.trusted.clear();for(const [id,row] of previous)this.trusted.set(id,row); + // A cancellation can race atomic registry replacement; restore its previous durable contents as well. + try {if(signal?.aborted)await this.persist();} + finally { + await this.git(cwd,["update-ref","-d",ref]).catch(()=>undefined); + await this.removePackIfUnreferenced(root,pack); + } + throw error; + } + committed?.(); + for(const old of pruned) { + await this.git(cwd,["update-ref","-d",old.id]).catch(()=>undefined); + await this.removePackIfUnreferenced(root,old.row.pack); + } + } + private async removePackIfUnreferenced(cwd:string,pack?:string):Promise { + if(!pack || this.heldPacks.has(`${cwd}\0${pack}`) || [...this.trusted.values()].some(row=>row.cwd===cwd && row.pack===pack))return; + const path=this.packPath(cwd,pack); + if(path)await Promise.all([rm(path,{force:true}),rm(path.slice(0,-5)+".idx",{force:true}),rm(path.slice(0,-5)+".rev",{force:true})]); + } + private holdPack(cwd:string,pack?:string):()=>Promise { + if(!pack)return async()=>undefined; + const key=`${cwd}\0${pack}`;this.heldPacks.set(key,(this.heldPacks.get(key)??0)+1); + let released=false; + return async()=>{ + if(released)return;released=true; + const count=this.heldPacks.get(key)??0; + if(count>1)this.heldPacks.set(key,count-1); + else {this.heldPacks.delete(key);await this.removePackIfUnreferenced(cwd,pack);} + }; + } + async list(sessionId: string, cwd: string): Promise> { + await this.load(); + if (!await this.available(cwd)) return []; + const root=await realpath(cwd); + const prefix=this.prefix(sessionId); + // Only the protected host registry decides which snapshots exist; refs are an additional repository convenience. + return [...this.trusted].filter(([id,row])=>id.startsWith(prefix) && row.cwd===root && row.sessionId===sessionId) + .map(([id])=>({id, at:Number(id.slice(prefix.length).split("-",1)[0]), label:"Before agent turn (tracked files)"})) + .sort((a,b)=>b.at-a.at); + } + private async requireRef(sessionId: string, cwd: string, id: string): Promise { + if (!(await this.list(sessionId, cwd)).some((entry) => entry.id === id)) throw new Error("Checkpoint does not belong to this session."); + const row=this.trusted.get(id)!; + // Import from the host pack even if an agent removed refs, reflogs, and every original Git object. + const pack=row.pack && this.packPath(row.cwd,row.pack); + if(pack)await this.importPack(cwd,pack); + let changed=false; + if(!row.indexOid){row.indexOid=await this.snapshotIndexOid(row.cwd,row.oid);changed=true;} + if(!row.pack && this.storagePath) { + // Upgrade a reachable legacy snapshot before it is used. Already deleted legacy objects remain unavailable. + row.pack=await this.protectObjects(row.cwd,row.oid,row.indexOid);changed=true; + } + if(changed)await this.persist(); + return row.oid; + } + private async importPack(cwd:string,pack:string):Promise { + const pending=exec("git",["--no-replace-objects","-c","core.hooksPath=/dev/null","-C",cwd,"index-pack","--stdin"],{timeout:15000,maxBuffer:1024}); + const source=createReadStream(pack);source.on("error",error=>pending.child.stdin?.destroy(error)); + pending.child.stdin?.on("error",()=>undefined);source.pipe(pending.child.stdin!); + try {await pending;}finally {source.destroy();} + } + private packPath(cwd:string,hash:string):string|undefined { + return this.storagePath ? join(dirname(this.storagePath),"checkpoint-objects",createHash("sha256").update(cwd).digest("hex"),`pack-${hash}.pack`) : undefined; + } + private async snapshotIndexOid(cwd:string,oid:string):Promise { + const subject=await this.git(cwd,["show","-s","--format=%s",oid]); + if(!subject.startsWith("WIP on "))return oid; + const parents=(await this.git(cwd,["rev-list","--parents","-n","1",oid])).split(/\s+/u).slice(1); + if(parents.length===2) { + const indexParents=(await this.git(cwd,["rev-list","--parents","-n","1",parents[1]])).split(/\s+/u).slice(1); + // git stash create makes its index commit a child of the same base as the snapshot commit. + if(indexParents.length===1 && indexParents[0]===parents[0])return parents[1]; + } + return oid; + } + private async protectObjects(cwd:string,oid:string,indexOid:string,signal?:AbortSignal):Promise { + signal?.throwIfAborted(); + if(!this.storagePath)return undefined; + const directory=dirname(this.packPath(cwd,"placeholder")!);await mkdir(directory,{recursive:true,mode:0o700}); + // Pack only the snapshot commit and trees used by restore, rather than cloning the project's entire ancestry. + const objects=new Set([oid,...(await this.git(cwd,["rev-list","--objects","--no-object-names",`${oid}^{tree}`],signal)).split("\n")]); + objects.add(indexOid);for(const object of (await this.git(cwd,["rev-list","--objects","--no-object-names",`${indexOid}^{tree}`],signal)).split("\n"))objects.add(object); + if([...objects].some(object=>!/^[a-f0-9]{40,64}$/u.test(object)))throw new Error("Invalid checkpoint object list."); + // Git can leave tmp_pack/partial index files when interrupted. Keep each invocation in its own + // private staging folder so failure cleanup never removes a pack retained by another checkpoint. + const staging=await mkdtemp(join(directory,`.pending-${process.pid}-`)); + try { + const pending=exec("git",["--no-replace-objects","-c","core.hooksPath=/dev/null","-c","pack.threads=1","-C",cwd,"pack-objects","--compression=1",join(staging,"pack")],{signal,timeout:15000,maxBuffer:1024}); + pending.child.stdin?.on("error",()=>undefined); + pending.child.stdin?.end([...objects].join("\n")+"\n"); + const hash=(await pending).stdout.trim();if(!/^[a-f0-9]{40,64}$/u.test(hash))throw new Error("Invalid checkpoint pack hash."); + // Restore streams the pack to index-pack, which creates its own repository index. Retaining + // Git's staging .idx would duplicate unused data; only the complete pack is published atomically. + signal?.throwIfAborted(); + await rename(join(staging,`pack-${hash}.pack`),this.packPath(cwd,hash)!); + return hash; + } finally {await this.removePackStaging(staging);} + } + async preview(sessionId: string, cwd: string, id: string): Promise<{text: string; changedFiles: string[]}> { + const oid=await this.requireRef(sessionId, cwd, id); + let diffTruncated=false,namesTruncated=false; + const text = await this.git(cwd, ["diff", "--no-ext-diff", "--no-textconv", oid, "--", "."],undefined,partial=>{diffTruncated=true;return partial;}); + const names = await this.git(cwd, ["diff", "--name-only", "--no-ext-diff", "--no-textconv", oid, "--", "."],undefined,partial=>{namesTruncated=true;return partial;}); + const notice=diffTruncated || namesTruncated ? "\n[Preview truncated at the output limit. Restoration still uses the complete saved checkpoint."+(namesTruncated ? " The changed-file list is also truncated." : "")+"]" : ""; + return {text: this.redact(text)+notice, changedFiles: this.redact(names).split("\n").filter(Boolean)}; + } + async restore(sessionId: string, cwd: string, id: string): Promise<{ok: boolean; message?: string}> { + const work=this.captures.catch(()=>undefined).then(()=>this.restoreOne(sessionId,cwd,id)); + this.captures=work.then(()=>undefined,()=>undefined); + return work; + } + private async requireRestorableTrees(cwd:string,worktreeOid:string,indexOid:string):Promise { + const entries=await Promise.all([ + this.git(cwd,["ls-files","--stage","-z"]), + this.git(cwd,["ls-tree","-r","-z",worktreeOid]), + this.git(cwd,["ls-tree","-r","-z",indexOid]) + ]); + // Inspect modes only at NUL-delimited record starts: paths can contain newlines, tabs or "160000". + // The parent snapshot does not retain dirty/untracked nested repository data for a safe recursive restore. + if(entries.some(output=>output.split("\0").some(entry=>entry.startsWith("160000 ")))) { + throw new Error("Submodule contents are not captured by checkpoints. Restoration is unavailable when the current index or saved checkpoint contains submodules; safeguard the nested repositories and restore them with Git separately."); + } + } + private async restoreOne(sessionId:string,cwd:string,id:string):Promise<{ok:boolean;message?:string}> { + const initial=this.trusted.get(id); + let release=this.holdPack(initial?.cwd??cwd,initial?.pack); + try { + const target=await this.requireRef(sessionId, cwd, id); + const row=this.trusted.get(id)!; + if(row.pack!==initial?.pack){await release();release=this.holdPack(row.cwd,row.pack);} + const indexOid=row.indexOid!; + await this.requireRestorableTrees(cwd,target,indexOid); + await this.captureOne(sessionId, cwd); + // Capture may prune this checkpoint at a retention limit of one. Re-import its held private pack + // after capture, immediately before restore, in case Git garbage-collected the earlier import. + const pack=row.pack&&this.packPath(row.cwd,row.pack); + if(pack)await this.importPack(cwd,pack); + // Keep the current index until Git removes tracked paths absent from the target worktree. + // Restoring the index first would turn post-checkpoint additions into untouched untracked files. + await this.git(cwd, ["restore", `--source=${target}`, "--worktree", "--", "."]); + await this.git(cwd, ["restore", `--source=${indexOid}`, "--staged", "--", "."]); + return {ok: true, message: "Tracked files restored; the previous work was saved as another checkpoint."}; + } finally {await release();} + } +} + +/** A file can grow after stat; stop reading at the cap before allocating an oversized review input. */ +async function readBoundedUntrackedFile(file: FileHandle): Promise { + const buffer = Buffer.alloc(32 * 1024 + 1); + let length = 0; + while (length < buffer.length) { + const { bytesRead } = await file.read(buffer, length, buffer.length - length, length); + if (bytesRead === 0) break; + length += bytesRead; + } + return buffer.subarray(0, length); +} diff --git a/src/main/services/GithubAuthService.ts b/src/main/services/GithubAuthService.ts index defbdb6b..49fcd24f 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 }; } @@ -150,6 +160,7 @@ export class GithubAuthService { const generation = ++this.generation; const controller = new AbortController(); this.deviceFlow = { generation, controller }; + this.deviceFlowState = "pending"; try { const body = new URLSearchParams({ client_id: this.clientId }); @@ -175,8 +186,11 @@ export class GithubAuthService { const flowLifetimeMs = Math.min(expiresIn * 1000, this.pollTimeoutMs); void this.pollDeviceCode(deviceCode, interval, flowLifetimeMs, generation, controller.signal) - .catch((error) => { - if (!isAbortError(error)) console.warn("CanvasTTY GitHub OAuth device poll failed.", error); + .catch(() => { + if (!controller.signal.aborted) { + this.finishDeviceFlow(generation, "failed"); + console.warn("CanvasTTY GitHub OAuth device poll failed."); + } }) .finally(() => { if (this.deviceFlow?.generation === generation) this.deviceFlow = null; @@ -184,14 +198,31 @@ 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; + this.deviceFlowState = "failed"; + } throw error; } } + cancelDeviceFlow(): void { + const flow = this.deviceFlow; + if (!flow || this.deviceFlowState !== "pending") return; + // Invalidate the generation before aborting so a fetcher that ignores its + // AbortSignal cannot apply a late denial or token response to this flow. + this.generation += 1; + this.deviceFlow = null; + this.deviceFlowState = "cancelled"; + flow.controller.abort(); + } + async signOut(): Promise { this.generation += 1; - this.cancelDeviceFlow(); + const flow = this.deviceFlow; + this.deviceFlow = null; + if (this.deviceFlowState === "pending") this.deviceFlowState = "cancelled"; + flow?.controller.abort(); this.tokens = null; this.refreshPromise = null; await this.enqueueStoreWrite(async () => { @@ -199,11 +230,6 @@ export class GithubAuthService { }); } - private cancelDeviceFlow(): void { - this.deviceFlow?.controller.abort(); - this.deviceFlow = null; - } - private isCurrentFlow(generation: number, signal: AbortSignal): boolean { return !signal.aborted && this.generation === generation && this.deviceFlow?.generation === generation; } @@ -220,6 +246,10 @@ export class GithubAuthService { while (this.now() - started < lifetimeMs) { await this.wait(interval * 1000, signal); if (!this.isCurrentFlow(generation, signal)) return; + if (this.now() - started >= lifetimeMs) { + this.finishDeviceFlow(generation, "expired"); + return; + } const body = new URLSearchParams({ client_id: this.clientId, @@ -246,9 +276,10 @@ export class GithubAuthService { interval = backedOff(interval); continue; } + if (!this.isCurrentFlow(generation, signal)) return; if (!isRecord(payload)) { - interval = backedOff(interval); - continue; + this.finishDeviceFlow(generation, "failed"); + return; } if (payload.error === "authorization_pending" || payload.error === "slow_down") { if (payload.error === "slow_down") { @@ -258,10 +289,21 @@ export class GithubAuthService { } continue; } - if (payload.error === "access_denied" || payload.error === "expired_token") return; + if (payload.error === "access_denied") { + this.finishDeviceFlow(generation, "denied"); + return; + } + if (payload.error === "expired_token") { + this.finishDeviceFlow(generation, "expired"); + return; + } if (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 +314,19 @@ export class GithubAuthService { login }; this.tokens = next; + this.deviceFlowState = "idle"; await this.persist(next, generation); return; } + this.finishDeviceFlow(generation, "failed"); return; } + this.finishDeviceFlow(generation, "expired"); + } + + private finishDeviceFlow(generation: number, state: GithubDeviceFlowState): void { + if (this.deviceFlow?.generation !== generation || this.deviceFlowState !== "pending") return; + this.deviceFlowState = state; } private async refreshAccessToken(expected: StoredTokens, generation: number): Promise { diff --git a/src/main/services/LaunchPipeline.ts b/src/main/services/LaunchPipeline.ts index 439370a5..969746b3 100644 --- a/src/main/services/LaunchPipeline.ts +++ b/src/main/services/LaunchPipeline.ts @@ -13,6 +13,8 @@ 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 { selectedAccountHome, selectedAccountId, ACCOUNTS_PLUGIN_ID } from "./accountHomeIsolation.ts"; +import { accountContributionDomains } from "./isolation/configuredApiDomains.ts"; /** A trusted plugin service that declared launch options (PluginManager.launchContributors). */ export interface LaunchContributor { @@ -20,6 +22,8 @@ export interface LaunchContributor { pluginName: string; serviceId: string; launch: PluginServiceLaunch; + /** Host-computed private data directory. Never sent to the plugin or accepted from its RPC response. */ + dataDir?: string; /** The plugin holds the `secrets` permission, so `secretEnv` may name its secrets. */ secrets: boolean; } @@ -41,6 +45,8 @@ export interface LaunchContext { profile: LaunchProfileId; role: SessionRole; cwd: string; + /** Original person-selected task project, retained when an environment relocates cwd. Host-owned. */ + projectRoot?: string; parentSessionId?: string; /** True when the app is bringing back a saved session. */ restoring: boolean; @@ -73,6 +79,11 @@ export type PreparedLaunch = envSources: Record; /** A contributor runs the agent on another model than its vendor's: "auto" becomes accept-edits. */ thirdPartyModel: boolean; + /** Host-derived selected Accounts CLI home, passed transiently to AgentIsolation for exact-path validation. */ + accountHome?: string; + accountId?:string; + /** Model API hosts of the selected model account (allowed-domains mode keeps them reachable). */ + apiDomains?: string[]; cleanup(): Promise; } | { ok: false; reason: string }; @@ -145,7 +156,8 @@ 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; 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; @@ -156,7 +168,7 @@ export class LaunchPipeline { 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. - if (context?.delegated && contributor.launch.delegable !== true) { + if (context?.delegated && contributor.launch.delegable !== true && !sameAsInherited(raw, context.inherited?.[pluginId])) { 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.`); } const { appliesTo, fields } = contributor.launch; @@ -267,12 +279,21 @@ export class LaunchPipeline { const args: string[] = []; const secrets: string[] = []; let thirdPartyModel = false; + let accountHome: string | undefined; + let accountId:string|undefined; + const apiDomains = new Set(); for (const answer of answers) { if ("refuse" in answer) return refuse(answer.refuse); } for (const answer of answers as Array<{ contributor: LaunchContributor; contribution: Contribution }>) { const { contributor, contribution } = answer; const name = contributor.pluginName; + if(contribution.accountId!==undefined) { + if(contributor.pluginId!==ACCOUNTS_PLUGIN_ID)return refuse(`${name} cannot attribute the selected model account.`); + if(contribution.accountId!==selectedAccountId(context.options))return refuse(`${name} returned an account attribution that differs from the selected account.`); + accountId=contribution.accountId; + } + if (contributor.pluginId === ACCOUNTS_PLUGIN_ID) for (const host of accountContributionDomains(contribution)) apiDomains.add(host); if (context.provider === "terminal" && contribution.args.length > 0) { return refuse(`${name} added arguments to a plain terminal, which takes none.`); } @@ -312,7 +333,12 @@ export class LaunchPipeline { for (const [key, value] of Object.entries(contribution.env)) { const conflict = claim(key); if (conflict) return refuse(conflict); - env[key] = expand(value); + const expanded = expand(value); + env[key] = expanded; + // Only the host's Accounts service may reopen its selected provider home below plugin-data. This value is + // derived from the trusted contributor record, not a plugin-returned grant or an inherited environment. + accountHome = selectedAccountHome(context.provider, contributor.pluginId, contributor.dataDir, + context.options[contributor.pluginId]?.account, { [key]: expanded }) ?? accountHome; } for (const [key, secretKey] of Object.entries(contribution.secretEnv)) { const conflict = claim(key); @@ -327,7 +353,8 @@ export class LaunchPipeline { args.push(...contributedArgs.map(expand)); thirdPartyModel ||= contribution.thirdPartyModel === true; } - return { ok: true, env, args, secrets, envSources, thirdPartyModel, cleanup }; + return { ok: true, env, args, secrets, envSources, thirdPartyModel, ...(accountHome ? { accountHome } : {}), ...(accountId ? {accountId} : {}), + ...(apiDomains.size ? { apiDomains: [...apiDomains] } : {}), cleanup }; } private async ask( @@ -370,6 +397,7 @@ interface Contribution { files: Array<{ relPath: string; content: string }>; /** Only ever restricts (auto → accept-edits), so a launch policy may set it too. */ thirdPartyModel?: boolean; + accountId?:string; refuse?: string; } @@ -377,7 +405,7 @@ interface Contribution { function validContribution(value: unknown): Contribution | string { if (value === null) return { env: {}, secretEnv: {}, args: [], files: [] }; if (!isRecord(value)) return "not an object"; - const unknown = Object.keys(value).find((key) => !["env", "secretEnv", "args", "files", "thirdPartyModel", "refuse"].includes(key)); + const unknown = Object.keys(value).find((key) => !["env", "secretEnv", "args", "files", "thirdPartyModel", "refuse", "accountId"].includes(key)); if (unknown) return `unknown key ${unknown.slice(0, 40)}`; if (value.refuse !== undefined) { const reason = isRecord(value.refuse) ? value.refuse.reason : undefined; @@ -385,6 +413,7 @@ function validContribution(value: unknown): Contribution | string { return { env: {}, secretEnv: {}, args: [], files: [], refuse: reason.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, MAX_REASON) }; } if (value.thirdPartyModel !== undefined && typeof value.thirdPartyModel !== "boolean") return "thirdPartyModel must be true or false"; + if(value.accountId!==undefined && (typeof value.accountId!=="string" || !/^[\w-]{1,80}$/.test(value.accountId)))return "accountId must be a bounded account identifier"; const env = stringMap(value.env, MAX_ENV, "env"); if (typeof env === "string") return env; for (const [key, entry] of Object.entries(env)) { @@ -419,7 +448,7 @@ function validContribution(value: unknown): Contribution | string { if (bytes > MAX_FILES_BYTES) return "files exceed 256 KB"; files.push({ relPath: file.relPath, content: file.content }); } - return { env, secretEnv, args: args as string[], files, ...(value.thirdPartyModel === true ? { thirdPartyModel: true } : {}) }; + return { env, secretEnv, args: args as string[], files, ...(value.thirdPartyModel === true ? { thirdPartyModel: true } : {}),...(typeof value.accountId==="string" ? {accountId:value.accountId} : {}) }; } /** @@ -575,3 +604,10 @@ export function errorText(error: unknown): string { export function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } + +/** The orchestrator's own choice repeated for its subagent: only values it inherited, each one the same. */ +function sameAsInherited(raw: unknown, inherited: Readonly> | undefined): boolean { + if (!inherited || !isRecord(raw)) return false; + const keys = Object.keys(raw); + return keys.length > 0 && keys.every((key) => Object.hasOwn(inherited, key) && inherited[key] === raw[key]); +} diff --git a/src/main/services/OrchestrationBudgetService.ts b/src/main/services/OrchestrationBudgetService.ts new file mode 100644 index 00000000..cdb4da8f --- /dev/null +++ b/src/main/services/OrchestrationBudgetService.ts @@ -0,0 +1,397 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; + +export interface OrchestrationBudgetLimits { + tokens: number | null; + costUsd: number | null; + durationMs: number | null; +} + +export interface OrchestrationBudgetUsage { + /** Null means the provider or timeline source does not report token usage. */ + tokens: number | null; + /** Null means no cost data is available. It is never estimated from token counts. */ + costUsd: number | null; + /** Time is measured from the root orchestrator's start and includes every descendant. */ + durationMs: number | null; + reportedAt?: number; +} + +export interface OrchestrationBudgetSnapshot { + rootSessionId: string; + limits: OrchestrationBudgetLimits; + usage: OrchestrationBudgetUsage; + remaining: OrchestrationBudgetUsage; + warning: boolean; + paused: boolean; + reason?: string; + data: { tokens: "available" | "none"; costUsd: "available" | "partial" | "none"; duration: "available" | "none" }; +} + +interface StoredBudget { + startedAt?: number; + sessions?:Record; + limits: OrchestrationBudgetLimits; + tokens: number | null; + costUsd: number | null; + reportedAt: number | null; + usageSource?:"direct"|"session-ledger"; + warned: boolean; + paused: boolean; + pauseCause?:"limit"|"cost-data"; + enforcementFailure?:string; +} + +interface StoreState { version: 1; budgets: Record } +interface BudgetCostStatus { incomplete: boolean; provisional: boolean } +interface BudgetCallbacks { + onWarning?(snapshot: OrchestrationBudgetSnapshot): void; + onPause?(snapshot: OrchestrationBudgetSnapshot): void; + onChange?(snapshot: OrchestrationBudgetSnapshot): void; +} + +const EMPTY_LIMITS: OrchestrationBudgetLimits = { tokens: null, costUsd: null, durationMs: null }; +const EMPTY_STATE: StoreState = { version: 1, budgets: {} }; +const MAX_ROOTS = 1_024; + +/** + * Persistent task-wide budgets. Token and cost usage must be supplied by a real provider/timeline source; unknown + * values remain null and never masquerade as zero. Budget controls are intentionally absent from the agent tools. + */ +export class OrchestrationBudgetService { + private readonly path: string; + private readonly callbacks: BudgetCallbacks; + private state: StoreState = structuredClone(EMPTY_STATE); + private loaded = false; + private writeQueue: Promise = Promise.resolve(); + private readonly listeners = new Set<(snapshot: OrchestrationBudgetSnapshot) => void>(); + private readonly deadlines = new Map>(); + + constructor(storagePath: string, callbacks: BudgetCallbacks = {}) { + this.path = resolve(storagePath); + this.callbacks = callbacks; + } + + async load(): Promise { + try { + const text = await readFile(this.path, "utf8"); + if (Buffer.byteLength(text, "utf8") > 1024 * 1024) throw new Error("Budget store exceeds 1 MB."); + const candidate: unknown = JSON.parse(text); + if (!isStore(candidate)) throw new Error("Budget store has an invalid format."); + this.state = candidate; + } catch (error) { + if (!isMissing(error)) throw new Error(`Could not load orchestration budgets: ${errorText(error)}`); + this.state = structuredClone(EMPTY_STATE); + } + this.loaded = true; + for(const [id,record] of Object.entries(this.state.budgets))if(record.startedAt!==undefined)this.snapshot(id,record.startedAt); + } + + async flush():Promise{await this.writeQueue;} + dispose():void {for(const timer of this.deadlines.values())clearTimeout(timer);this.deadlines.clear();} + snapshots():OrchestrationBudgetSnapshot[] {this.assertLoaded();return Object.keys(this.state.budgets).map(id=>this.snapshot(id));} + hasLimits(rootSessionId:string):boolean { + this.assertLoaded();requireId(rootSessionId); + const limits=this.state.budgets[rootSessionId]?.limits ?? EMPTY_LIMITS; + return limits.tokens!==null || limits.costUsd!==null || limits.durationMs!==null; + } + setEnforcementFailure(rootSessionId:string,failure?:string):void { + const record=this.record(rootSessionId); + if(record.enforcementFailure===failure)return; + if(failure){record.enforcementFailure=failure.slice(0,500);record.paused=true;delete record.pauseCause;} + else delete record.enforcementFailure; + this.persistQuietly(); + } + + subscribe(listener: (snapshot: OrchestrationBudgetSnapshot) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + /** Called only by the host UI/controller after the person changes limits. */ + async setLimits(rootSessionId: string, limits: OrchestrationBudgetLimits, rootStartedAt?: number): Promise { + this.assertLoaded(); + requireId(rootSessionId); + const normalized = normalizeLimits(limits); + const record = this.record(rootSessionId); + delete record.enforcementFailure; + record.limits = normalized; + const current = this.snapshot(rootSessionId, rootStartedAt); + const overLimit=isOver(current),unknownCost=record.limits.costUsd!==null && budgetCostStatus(record).incomplete; + if (!overLimit && !unknownCost) { + record.paused = false; + delete record.pauseCause; + record.warned = false; + } else { + record.paused = true; + record.pauseCause=overLimit ? "limit" : "cost-data"; + } + await this.persist(); + const updated = this.snapshot(rootSessionId, rootStartedAt); + this.emit(updated); + return updated; + } + + /** A single action that removes every limit and releases the task from a budget pause. */ + async clearLimits(rootSessionId: string, rootStartedAt?: number): Promise { + return this.setLimits(rootSessionId, { ...EMPTY_LIMITS }, rootStartedAt); + } + + /** Host-only callback for real CLI usage. Values are cumulative for the whole orchestration tree. */ + recordUsage(rootSessionId: string, usage: Pick, rootStartedAt?: number): OrchestrationBudgetSnapshot { + return this.writeUsage(rootSessionId,usage,rootStartedAt,"direct"); + } + + private writeUsage(rootSessionId:string,usage:Pick,rootStartedAt:number|undefined,source:"direct"|"session-ledger"):OrchestrationBudgetSnapshot { + this.assertLoaded(); + requireId(rootSessionId); + const record = this.record(rootSessionId); + record.tokens = normalizeUsage(usage.tokens, "tokens"); + record.costUsd = normalizeUsage(usage.costUsd, "costUsd"); + record.reportedAt = Date.now(); + record.usageSource=source; + const updated = this.snapshot(rootSessionId, rootStartedAt); + this.persistQuietly(); + this.emit(updated); + return updated; + } + + /** Individual source counters remain in the task ledger even after a card closes or an account hands off. */ + recordSessionUsage(rootSessionId:string,sessionId:string,usage:{tokens:number|null;costUsd:number|null},options:{rootStartedAt?:number;replaceCounterIds?:readonly string[];provisional?:boolean}={}):OrchestrationBudgetSnapshot { + this.assertLoaded();requireId(rootSessionId);requireId(sessionId); + const {rootStartedAt}=options; + const record=this.record(rootSessionId),sessions=record.sessions ?? (record.sessions={}); + // Replace only the host's pre-report placeholder; real conversation counters, + // including unknown prices from earlier conversations, remain in the ledger. + let removedPending=false; + for(const id of options.replaceCounterIds ?? []){ + requireId(id); + if(id!==sessionId && Object.hasOwn(sessions,id)){delete sessions[id];removedPending=true;} + } + const tokens=normalizeUsage(usage.tokens,"tokens"),costUsd=normalizeUsage(usage.costUsd,"costUsd"),previous=sessions[sessionId],provisional=options.provisional===true; + if(provisional && (tokens!==null || costUsd!==null))throw new Error("Provisional task usage cannot include observed tokens or cost."); + const legacyPending=provisional && previous!==undefined && !previous.provisional && previous.tokens===null && previous.costUsd===null; + if(provisional && previous && !previous.provisional && !legacyPending)return this.snapshot(rootSessionId,rootStartedAt); + if(legacyPending && record.usageSource===undefined)record.usageSource="session-ledger"; + const next={tokens:tokens===null ? previous?.tokens ?? null : Math.max(previous?.tokens ?? 0,tokens), + costUsd,...(provisional ? {provisional:true} : {})}; + if(!removedPending && previous && previous.tokens===next.tokens && previous.costUsd===next.costUsd && (previous.provisional===true)===provisional)return this.snapshot(rootSessionId,rootStartedAt); + if(!previous && Object.keys(sessions).length>=2048)throw new Error("Task usage ledger holds at most 2048 agents."); + sessions[sessionId]=next; + let hasObserved=false,knownTokens=0,tokenSum=0,hasCost=false,costSum=0; + for(const row of Object.values(sessions))if(row.provisional!==true) { + hasObserved=true; + if(row.tokens!==null){knownTokens++;tokenSum+=row.tokens;} + if(row.costUsd!==null)hasCost=true; + costSum+=row.costUsd ?? 0; + } + if(!hasObserved){this.persistQuietly();const updated=this.snapshot(rootSessionId,rootStartedAt);this.emit(updated);return updated;} + return this.writeUsage(rootSessionId,{tokens:knownTokens ? tokenSum : null,costUsd:hasCost ? costSum : null},rootStartedAt,"session-ledger"); + } + + /** Called before each spawn/send; also trips a time limit even if the CLI has emitted no further usage. */ + snapshot(rootSessionId: string, rootStartedAt?: number): OrchestrationBudgetSnapshot { + this.assertLoaded(); + requireId(rootSessionId); + const record = this.state.budgets[rootSessionId] ?? emptyBudget(); + if(record.startedAt===undefined && Number.isFinite(rootStartedAt)) { + record.startedAt=Number(rootStartedAt); + if(this.state.budgets[rootSessionId])this.persistQuietly(); + } + const start=record.startedAt ?? rootStartedAt; + const durationMs = Number.isFinite(start) ? Math.max(0, Date.now() - Number(start)) : null; + const usage: OrchestrationBudgetUsage = { + tokens: record.tokens, + costUsd: record.costUsd, + durationMs, + ...(record.reportedAt !== null ? { reportedAt: record.reportedAt } : {}) + }; + const remaining: OrchestrationBudgetUsage = { + tokens: difference(record.limits.tokens, usage.tokens), + costUsd: difference(record.limits.costUsd, usage.costUsd), + durationMs: difference(record.limits.durationMs, usage.durationMs) + }; + const thresholdView = { limits: record.limits, usage } as OrchestrationBudgetSnapshot; + const costStatus=budgetCostStatus(record); + const costIncomplete=costStatus.incomplete; + const unknownCostLimit=record.limits.costUsd!==null && costIncomplete; + const provisionalCost=costStatus.provisional; + if(costIncomplete || provisionalCost)remaining.costUsd=null; + const overLimit=isOver(thresholdView); + const over = overLimit || unknownCostLimit; + const atWarning = isAtWarning(thresholdView); + let pauseStateChanged=false; + if(record.paused && record.pauseCause==="cost-data" && overLimit){record.pauseCause="limit";pauseStateChanged=true;} + if(record.paused && record.pauseCause==="cost-data" && !overLimit && !unknownCostLimit && !record.enforcementFailure){ + record.paused=false;delete record.pauseCause;pauseStateChanged=true; + } + const pauseNow = !record.paused && over; + const warnNow = !record.warned && atWarning; + if (pauseNow) {record.paused = true;record.pauseCause=overLimit ? "limit" : "cost-data";pauseStateChanged=true;} + if (warnNow) record.warned = true; + const paused = record.paused || over; + const warning = record.warned || atWarning; + const reason = overReasons(record.limits, usage); + const snapshot: OrchestrationBudgetSnapshot = { + rootSessionId, + limits: { ...record.limits }, + usage, + remaining, + warning, + paused, + ...(paused ? { reason: unknownCostLimit + ? "Cost budget cannot be enforced because some agent costs are unavailable. Task paused; configure prices or remove the cost limit." + : reason ? `Budget reached: ${reason}. New subagents and agent input are paused.` + : "This budget pause remains active. Change or clear the task limits to resume." } : {}), + data: { + tokens: usage.tokens === null ? "none" : "available", + costUsd: usage.costUsd === null ? "none" : costIncomplete || provisionalCost ? "partial" : "available", + duration: usage.durationMs === null ? "none" : "available" + } + }; + if (pauseStateChanged || warnNow) { + this.persistQuietly(); + if (warnNow) try { this.callbacks.onWarning?.(snapshot); } catch { /* an app notification cannot affect the budget */ } + if (pauseNow) try { this.callbacks.onPause?.(snapshot); } catch { /* a UI callback cannot undo a hard limit */ } + } + if(record.enforcementFailure && !snapshot.reason?.includes(record.enforcementFailure))snapshot.reason=`${snapshot.reason ?? "Task budget is paused."} ${record.enforcementFailure}`; + this.scheduleDeadline(rootSessionId,record); + return snapshot; + } + + private scheduleDeadline(id:string,record:StoredBudget):void { + const existing=this.deadlines.get(id); + if(existing)clearTimeout(existing); + this.deadlines.delete(id); + if(record.paused || record.limits.durationMs===null || record.startedAt===undefined)return; + const due=record.startedAt+record.limits.durationMs*(record.warned ? 1 : 0.8); + const timer=setTimeout(()=>{this.deadlines.delete(id);this.emit(this.snapshot(id,record.startedAt));},Math.max(1,Math.min(2_147_483_647,due-Date.now()))); + timer.unref();this.deadlines.set(id,timer); + } + + isPaused(rootSessionId: string, rootStartedAt?: number): boolean { + return this.snapshot(rootSessionId, rootStartedAt).paused; + } + + private record(rootSessionId: string): StoredBudget { + if (!this.state.budgets[rootSessionId]) { + if (Object.keys(this.state.budgets).length >= MAX_ROOTS) throw new Error("Too many orchestration budget records."); + this.state.budgets[rootSessionId] = emptyBudget(); + } + return this.state.budgets[rootSessionId]!; + } + + private async persist(): Promise { + const snapshot = JSON.stringify(this.state); + if(Buffer.byteLength(snapshot)>1024*1024)throw new Error("Task budget store exceeds 1 MB."); + const operation = this.writeQueue.catch(() => undefined).then(async () => { + await mkdir(dirname(this.path), { recursive: true, mode: 0o700 }); + const temporary = `${this.path}.${randomUUID()}.tmp`; + await writeFile(temporary, snapshot, { encoding: "utf8", mode: 0o600, flag: "wx" }); + try { await rename(temporary, this.path); } + catch (error) { + const { unlink } = await import("node:fs/promises"); + await unlink(temporary).catch(() => undefined); + throw error; + } + }); + this.writeQueue = operation; + await operation; + } + + private persistQuietly(): void { + void this.persist().catch(() => undefined); + } + + private emit(snapshot: OrchestrationBudgetSnapshot): void { + try { this.callbacks.onChange?.(snapshot); } catch { /* events do not affect accounting */ } + for (const listener of this.listeners) try { listener(snapshot); } catch { /* one UI listener cannot affect another */ } + } + + private assertLoaded(): void { if (!this.loaded) throw new Error("Orchestration budget service must be loaded before use."); } +} + +function emptyBudget(): StoredBudget { return { limits: { ...EMPTY_LIMITS }, tokens: null, costUsd: null, reportedAt: null, warned: false, paused: false }; } +function normalizeLimits(value: OrchestrationBudgetLimits): OrchestrationBudgetLimits { + if (!value || typeof value !== "object") throw new Error("Budget limits are invalid."); + return { tokens: normalizeLimit(value.tokens, "tokens"), costUsd: normalizeLimit(value.costUsd, "costUsd"), durationMs: normalizeLimit(value.durationMs, "durationMs") }; +} +function normalizeLimit(value: unknown, name: string): number | null { + if (value === null || value === undefined) return null; + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) throw new Error(`${name} limit must be a positive finite number or null.`); + return value; +} +function normalizeUsage(value: unknown, name: string): number | null { + if (value === null || value === undefined) return null; + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) throw new Error(`${name} usage must be a nonnegative finite number or null.`); + return value; +} +function difference(limit: number | null, usage: number | null): number | null { return limit === null || usage === null ? null : Math.max(0, limit - usage); } +function reached(limit:number|null,usage:number|null,threshold=1):boolean { + return limit!==null && usage!==null && usage>=limit*threshold; +} +function isOver(value: Pick): boolean { + const {limits,usage}=value; + return reached(limits.tokens,usage.tokens) || reached(limits.costUsd,usage.costUsd) || reached(limits.durationMs,usage.durationMs); +} +function isAtWarning(value: Pick): boolean { + const {limits,usage}=value; + return reached(limits.tokens,usage.tokens,0.8) || reached(limits.costUsd,usage.costUsd,0.8) || reached(limits.durationMs,usage.durationMs,0.8); +} +function overReasons(limits: OrchestrationBudgetLimits, usage: OrchestrationBudgetUsage): string { + const reasons:string[]=[]; + if(reached(limits.tokens,usage.tokens))reasons.push("token limit"); + if(reached(limits.costUsd,usage.costUsd))reasons.push("cost limit"); + if(reached(limits.durationMs,usage.durationMs))reasons.push("time limit"); + return reasons.join(", "); +} +function requireId(value: unknown): asserts value is string { if (typeof value !== "string" || value.length < 1 || value.length > 160) throw new Error("Orchestration root session id is invalid."); } +function isMissing(error: unknown): boolean { return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT"); } +function errorText(error: unknown): string { return error instanceof Error ? error.message.slice(0, 200) : "file is unavailable"; } +function budgetCostStatus(record:StoredBudget):BudgetCostStatus { + let count=0,allProvisional=true,provisional=false,unknownObservedCost=false; + for(const session of Object.values(record.sessions ?? {})) { + count++; + if(session.provisional===true)provisional=true; + else {allProvisional=false;if(session.costUsd===null)unknownObservedCost=true;} + } + const onlyProvisionalLedger=record.usageSource==="session-ledger" && count>0 && allProvisional; + return { + incomplete:!onlyProvisionalLedger && record.reportedAt!==null && (record.costUsd===null || unknownObservedCost), + provisional + }; +} +function isStore(value: unknown): value is StoreState { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const record = value as Record; + if (record.version !== 1 || !record.budgets || typeof record.budgets !== "object" || Array.isArray(record.budgets)) return false; + const budgets = record.budgets as Record; + return Object.keys(budgets).length <= MAX_ROOTS && Object.values(budgets).every(isBudgetRecord); +} +function nonnegativeOrNull(value:unknown):boolean { + return value===null || typeof value==="number" && Number.isFinite(value) && value>=0; +} +function positiveOrNull(value:unknown):boolean { + return value===null || typeof value==="number" && Number.isFinite(value) && value>0; +} +function isUsageLedger(value:unknown):boolean { + if(!value || typeof value!=="object" || Array.isArray(value) || Object.keys(value).length>2048)return false; + return Object.values(value).every(row=>row && typeof row==="object" && nonnegativeOrNull(row.tokens) && nonnegativeOrNull(row.costUsd) + && (row.provisional===undefined || typeof row.provisional==="boolean")); +} +function isBudgetRecord(candidate:unknown):boolean { + if(!candidate || typeof candidate!=="object" || Array.isArray(candidate))return false; + const item=candidate as Record; + if(!item.limits || typeof item.limits!=="object" || Array.isArray(item.limits))return false; + if(item.sessions!==undefined && !isUsageLedger(item.sessions))return false; + if(item.startedAt!==undefined && !(typeof item.startedAt==="number" && Number.isFinite(item.startedAt) && item.startedAt>0))return false; + const limits=item.limits as Record; + return positiveOrNull(limits.tokens) && positiveOrNull(limits.costUsd) && positiveOrNull(limits.durationMs) + && nonnegativeOrNull(item.tokens) && nonnegativeOrNull(item.costUsd) + && (item.reportedAt===null || typeof item.reportedAt==="number" && Number.isFinite(item.reportedAt)) + && (item.usageSource===undefined || item.usageSource==="direct" || item.usageSource==="session-ledger") + && (item.pauseCause===undefined || item.pauseCause==="limit" || item.pauseCause==="cost-data") + && (item.enforcementFailure===undefined || typeof item.enforcementFailure==="string" && item.enforcementFailure.length<=500) + && typeof item.warned==="boolean" && typeof item.paused==="boolean"; +} diff --git a/src/main/services/OrchestrationTaskBoard.ts b/src/main/services/OrchestrationTaskBoard.ts new file mode 100644 index 00000000..08f8ea56 --- /dev/null +++ b/src/main/services/OrchestrationTaskBoard.ts @@ -0,0 +1,384 @@ +import { createHash, randomUUID } from "node:crypto"; +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; + +export type OrchestrationTaskStatus = "open" | "claimed" | "done" | "closed"; + +export interface OrchestrationTask { + id: string; + rootSessionId: string; + title: string; + description: string; + progress: string; + ownerSessionId: string | null; + ownerName: string | null; + status: OrchestrationTaskStatus; + dependencies: string[]; + result: string | null; + createdBySessionId: string; + createdAt: number; + updatedAt: number; +} + +export interface OrchestrationTaskBoardChange { + projectRoot: string; + rootSessionId: string; + tasks: OrchestrationTask[]; + revision: number; +} + +export interface NewOrchestrationTask { + title: string; + description?: string; + dependencies?: string[]; + ownerSessionId?: string | null; + ownerName?: string | null; +} + +export interface OrchestrationTaskPatch { + title?: string; + description?: string; + progress?: string; + status?: "open" | "claimed"; + ownerSessionId?: string | null; + ownerName?: string | null; + dependencies?: string[]; +} + +interface BoardState { + version: 1; + revision: number; + tasks: OrchestrationTask[]; +} + +const EMPTY_BOARD: BoardState = { version: 1, revision: 0, tasks: [] }; +const MAX_TASKS = 512; +const MAX_TITLE = 240; +const MAX_DESCRIPTION = 8_000; +const MAX_PROGRESS = 4_000; +const MAX_RESULT = 8_000; +const MAX_DEPENDENCIES = 64; +const locks = new Map>(); + +/** + * Atomic, project-keyed task storage outside the project tree. Agent shell tools can edit project files but cannot + * bypass these ownership checks by editing the board file. One root session owns one task group on the project board. + */ +export class OrchestrationTaskBoard { + private readonly storageRoot: string; + private readonly listeners = new Set<(change: OrchestrationTaskBoardChange) => void>(); + + constructor(storageRoot: string) { + this.storageRoot = resolve(storageRoot); + } + + subscribe(listener: (change: OrchestrationTaskBoardChange) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + /** Agent-facing list is always scoped to one root orchestrator. */ + async listTasks(projectRoot: string, rootSessionId: string): Promise<{ revision: number; tasks: OrchestrationTask[] }> { + requireId(rootSessionId, "rootSessionId"); + const state = await this.read(this.boardLocation(projectRoot).path); + return { revision: state.revision, tasks: state.tasks.filter((task) => task.rootSessionId === rootSessionId).map(copyTask) }; + } + + /** Human-facing project view groups tasks by orchestrator; it is not exposed through agent tools. */ + async listProjectTasks(projectRoot: string): Promise<{ revision: number; tasks: OrchestrationTask[] }> { + const state = await this.read(this.boardLocation(projectRoot).path); + return { revision: state.revision, tasks: state.tasks.map(copyTask) }; + } + + async addTask(projectRoot: string, rootSessionId: string, createdBySessionId: string, input: NewOrchestrationTask): Promise { + requireId(rootSessionId, "rootSessionId"); + requireId(createdBySessionId, "createdBySessionId"); + return this.mutate(projectRoot, rootSessionId, (state) => { + ensureCapacity(state); + const dependencies = validateDependencies(input.dependencies ?? []); + ensureDependenciesExist(state, rootSessionId, dependencies); + const now = Date.now(); + const next: OrchestrationTask = { + id: randomUUID(), rootSessionId, title: requiredText(input.title, "title", MAX_TITLE), + description: optionalText(input.description, "description", MAX_DESCRIPTION) ?? "", + progress: "", ownerSessionId: optionalId(input.ownerSessionId), ownerName: optionalName(input.ownerName), + status: input.ownerSessionId ? "claimed" : "open", dependencies, result: null, + createdBySessionId, createdAt: now, updatedAt: now + }; + state.tasks.push(next); + return copyTask(next); + }); + } + + /** Atomic claim: competing callers serialize through the store lock; the loser gets the current owner. */ + async claimTask(projectRoot: string, rootSessionId: string, actorSessionId: string, actorName: string, taskId: string): Promise { + requireId(rootSessionId, "rootSessionId"); + requireId(actorSessionId, "actorSessionId"); + requireId(taskId, "taskId"); + return this.mutate(projectRoot, rootSessionId, (state) => { + const task = findTask(state, rootSessionId, taskId); + if (task.status === "claimed" && task.ownerSessionId !== actorSessionId) { + const owner = task.ownerName || task.ownerSessionId || "another agent"; + throw new Error(`Task ${task.id} is already claimed by ${owner}.`); + } + if (task.status !== "open" && !(task.status === "claimed" && task.ownerSessionId === actorSessionId)) { + throw new Error(`Task ${task.id} is ${task.status} and cannot be claimed.`); + } + requireDependenciesDone(state, rootSessionId, task); + task.ownerSessionId = actorSessionId; + task.ownerName = optionalName(actorName) ?? actorSessionId; + task.status = "claimed"; + task.updatedAt = Date.now(); + return copyTask(task); + }); + } + + /** Agent edits are limited to the task it owns; only the root orchestrator may reassign or alter dependencies. */ + async updateTask(projectRoot: string, rootSessionId: string, actorSessionId: string, taskId: string, patch: OrchestrationTaskPatch): Promise { + return this.editTask(projectRoot, rootSessionId, actorSessionId, taskId, patch, false); + } + + private async editTask(projectRoot: string, rootSessionId: string, actorSessionId: string, taskId: string, patch: OrchestrationTaskPatch, person: boolean): Promise { + requireId(rootSessionId, "rootSessionId"); + requireId(actorSessionId, "actorSessionId"); + requireId(taskId, "taskId"); + if (!isRecord(patch) || Object.keys(patch).length === 0) throw new Error("At least one task field is required."); + const allowed = new Set(["title", "description", "progress", "status", "ownerSessionId", "ownerName", "dependencies"]); + const extra = Object.keys(patch).find((key) => !allowed.has(key)); + if (extra) throw new Error(`Unknown task field "${extra}".`); + return this.mutate(projectRoot, rootSessionId, (state) => { + const task = findTask(state, rootSessionId, taskId); + if (!person && (task.status === "closed" || task.status === "done")) { + throw new Error(`Task ${task.id} is ${task.status}; only the person may reopen or change it.`); + } + const isRoot = actorSessionId === rootSessionId; + if (!isRoot && task.ownerSessionId !== actorSessionId) throw new Error(`Task ${task.id} belongs to another agent.`); + if (!isRoot && (patch.ownerSessionId !== undefined || patch.ownerName !== undefined || patch.dependencies !== undefined)) { + throw new Error("Only the orchestrator or person may reassign a task or change its dependencies."); + } + if (patch.title !== undefined) task.title = requiredText(patch.title, "title", MAX_TITLE); + if (patch.description !== undefined) task.description = requiredText(patch.description, "description", MAX_DESCRIPTION); + if (patch.progress !== undefined) task.progress = requiredText(patch.progress, "progress", MAX_PROGRESS); + if (patch.dependencies !== undefined) { + task.dependencies = validateDependencies(patch.dependencies); + if (task.dependencies.includes(task.id)) throw new Error("A task cannot depend on itself."); + ensureDependenciesExist(state, rootSessionId, task.dependencies); + assertAcyclic(state.tasks.filter((item) => item.rootSessionId === rootSessionId), task.id); + } + if (patch.ownerSessionId !== undefined) { + task.ownerSessionId = optionalId(patch.ownerSessionId); + if (task.ownerSessionId === null) task.ownerName = null; + else if (patch.ownerName !== undefined) task.ownerName = optionalName(patch.ownerName) ?? task.ownerSessionId; + task.status = task.ownerSessionId ? "claimed" : "open"; + } else if (patch.ownerName !== undefined && isRoot) { + task.ownerName = optionalName(patch.ownerName); + } + if (patch.status !== undefined) { + if (patch.status === "claimed" && !task.ownerSessionId) throw new Error("A task must have an owner before it can be claimed."); + if (patch.status === "open") { + if (!isRoot && task.ownerSessionId !== actorSessionId) throw new Error("Only the task owner or orchestrator can release this task."); + task.ownerSessionId = null; + task.ownerName = null; + } + task.status = patch.status as "open" | "claimed"; + } + task.updatedAt = Date.now(); + return copyTask(task); + }); + } + + async completeTask(projectRoot: string, rootSessionId: string, actorSessionId: string, taskId: string, result: string): Promise { + requireId(rootSessionId, "rootSessionId"); + requireId(actorSessionId, "actorSessionId"); + requireId(taskId, "taskId"); + const text = requiredText(result, "result", MAX_RESULT); + return this.mutate(projectRoot, rootSessionId, (state) => { + const task = findTask(state, rootSessionId, taskId); + if (actorSessionId !== rootSessionId && task.ownerSessionId !== actorSessionId) throw new Error(`Task ${task.id} belongs to another agent.`); + if (task.status === "done" || task.status === "closed") throw new Error(`Task ${task.id} is already ${task.status}.`); + requireDependenciesDone(state, rootSessionId, task); + task.status = "done"; + task.result = text; + task.updatedAt = Date.now(); + return copyTask(task); + }); + } + + /** Person/UI operations use explicit methods that bypass agent ownership while retaining the same atomic store. */ + async assignTask(projectRoot: string, rootSessionId: string, taskId: string, ownerSessionId: string | null, ownerName?: string | null): Promise { + return this.editTask(projectRoot, rootSessionId, rootSessionId, taskId, { ownerSessionId, ...(ownerName !== undefined ? { ownerName } : {}) }, true); + } + + async closeTask(projectRoot: string, rootSessionId: string, taskId: string): Promise { + return this.mutate(projectRoot, rootSessionId, (state) => { + const task = findTask(state, rootSessionId, taskId); + task.status = "closed"; + task.updatedAt = Date.now(); + return copyTask(task); + }); + } + + async reopenTask(projectRoot: string, rootSessionId: string, taskId: string): Promise { + return this.mutate(projectRoot, rootSessionId, (state) => { + const task = findTask(state, rootSessionId, taskId); + if (task.status === "done") throw new Error("A completed task cannot be reopened without being edited first."); + task.status = task.ownerSessionId ? "claimed" : "open"; + task.updatedAt = Date.now(); + return copyTask(task); + }); + } + + /** Host-only workspace import. All ids are remapped, and a malformed graph writes nothing. */ + async importGroup(projectRoot:string,rootSessionId:string,rows:unknown,sessionIds:Record):Promise { + const imported = normalizeImportedTasks(rows, rootSessionId, sessionIds); + await this.mutate(projectRoot,rootSessionId,state=>{if(state.tasks.length+imported.length>MAX_TASKS)throw new Error("Workspace task board exceeds capacity.");state.tasks.push(...imported);}); + } + + private async read(path: string): Promise { + try { + const text = await readFile(path, "utf8"); + if (Buffer.byteLength(text, "utf8") > 2 * 1024 * 1024) throw new Error("Task board exceeds 2 MB."); + const candidate: unknown = JSON.parse(text); + if (!isBoardState(candidate)) throw new Error("Task board has an invalid format."); + return candidate; + } catch (error) { + if (isMissing(error)) return structuredClone(EMPTY_BOARD); + throw new Error(`Could not read orchestration task board: ${errorText(error)}`); + } + } + + private async mutate(projectRoot: string, rootSessionId: string, mutation: (state: BoardState) => T): Promise { + const location = this.boardLocation(projectRoot); + return withLock(location.path, async () => { + const state = await this.read(location.path); + const result = mutation(state); + state.revision += 1; + const payload = JSON.stringify(state); + if (Buffer.byteLength(payload, "utf8") > 2 * 1024 * 1024) throw new Error("Task board exceeds 2 MB."); + await mkdir(dirname(location.path), { recursive: true, mode: 0o700 }); + const temporary = `${location.path}.${randomUUID()}.tmp`; + await writeFile(temporary, payload, { encoding: "utf8", mode: 0o600, flag: "wx" }); + try { await rename(temporary, location.path); } + catch (error) { + const { unlink } = await import("node:fs/promises"); + await unlink(temporary).catch(() => undefined); + throw error; + } + const change: OrchestrationTaskBoardChange = { + projectRoot: location.normalizedRoot, + rootSessionId, + revision: state.revision, + tasks: state.tasks.filter((task) => task.rootSessionId === rootSessionId).map(copyTask) + }; + for (const listener of this.listeners) { + try { listener(change); } catch { /* one renderer listener cannot prevent persistence or another listener */ } + } + return result; + }); + } + + private boardLocation(projectRoot: string): {path:string;normalizedRoot:string} { + const normalizedRoot=resolve(projectRoot); + const digest=createHash("sha256").update(normalizedRoot).digest("hex").slice(0,40); + return {path:join(this.storageRoot,"orchestration-tasks",`${digest}.json`),normalizedRoot}; + } +} + +function withLock(key: string, run: () => Promise): Promise { + const previous = locks.get(key) ?? Promise.resolve(); + let release!: () => void; + const next = new Promise((resolveLock) => { release = resolveLock; }); + locks.set(key, next); + return previous.catch(() => undefined).then(run).finally(() => { + release(); + if (locks.get(key) === next) locks.delete(key); + }); +} + +function ensureCapacity(state: BoardState): void { if (state.tasks.length >= MAX_TASKS) throw new Error(`The project task board holds at most ${MAX_TASKS} tasks.`); } +function findTask(state: BoardState, rootSessionId: string, taskId: string): OrchestrationTask { + const task = state.tasks.find((item) => item.rootSessionId === rootSessionId && item.id === taskId); + if (!task) throw new Error("Task does not exist in this orchestrator's task group."); + return task; +} +function ensureDependenciesExist(state: BoardState, rootSessionId: string, dependencies: string[]): void { + for (const id of dependencies) findTask(state, rootSessionId, id); +} +function requireDependenciesDone(state: BoardState, rootSessionId: string, task: OrchestrationTask): void { + const waiting = task.dependencies.map((id) => findTask(state, rootSessionId, id)).filter((dependency) => dependency.status !== "done"); + if (waiting.length > 0) throw new Error(`Task ${task.id} is waiting for dependencies: ${waiting.map((item) => item.title).join(", ")}.`); +} +function assertAcyclic(tasks: readonly OrchestrationTask[], startAt?: string): void { + const byId = new Map(tasks.map((task) => [task.id, task])), active = new Set(), checked = new Set(); + const visit = (id: string): void => { + if (active.has(id)) throw new Error("Task dependencies cannot form a cycle."); + if (checked.has(id)) return; + active.add(id); + for (const dependency of byId.get(id)?.dependencies ?? []) visit(dependency); + active.delete(id); + checked.add(id); + }; + for (const id of startAt ? [startAt] : tasks.map((task) => task.id)) visit(id); +} +function validateDependencies(value: unknown): string[] { + if (!Array.isArray(value) || value.length > MAX_DEPENDENCIES || value.some((item) => typeof item !== "string" || item.length > 80)) { + throw new Error(`dependencies must be an array of at most ${MAX_DEPENDENCIES} task ids.`); + } + const result = [...new Set(value as string[])]; + if (result.length !== value.length) throw new Error("dependencies cannot contain duplicates."); + return result; +} +function requiredText(value: unknown, name: string, max: number): string { + if (typeof value !== "string" || value.trim().length === 0 || value.length > max) throw new Error(`${name} must contain 1 to ${max} characters.`); + return value.trim(); +} +function optionalText(value: unknown, name: string, max: number): string | undefined { + if (value === undefined || value === null) return undefined; + if (typeof value !== "string" || value.length > max) throw new Error(`${name} must be at most ${max} characters.`); + return value.trim(); +} +function requireId(value: unknown, name: string): asserts value is string { if (typeof value !== "string" || value.length < 1 || value.length > 160) throw new Error(`${name} is invalid.`); } +function optionalId(value: unknown): string | null { if (value === undefined || value === null || value === "") return null; requireId(value, "ownerSessionId"); return value; } +function optionalName(value: unknown): string | null { if (value === undefined || value === null || value === "") return null; if (typeof value !== "string" || value.length > 160) throw new Error("ownerName is invalid."); return value.trim(); } +function copyTask(task: OrchestrationTask): OrchestrationTask { return { ...task, dependencies: [...task.dependencies] }; } +function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } +function isBoardState(value: unknown): value is BoardState { + return isRecord(value) && value.version === 1 && Number.isInteger(value.revision) && Array.isArray(value.tasks) + && value.tasks.length <= MAX_TASKS && value.tasks.every(isOrchestrationTask); +} +function isOrchestrationTask(value: unknown): value is OrchestrationTask { + if (!isRecord(value)) return false; + return typeof value.id === "string" && typeof value.rootSessionId === "string" && typeof value.title === "string" + && typeof value.description === "string" && typeof value.progress === "string" + && (value.ownerSessionId === null || typeof value.ownerSessionId === "string") + && (value.ownerName === null || typeof value.ownerName === "string") + && ["open", "claimed", "done", "closed"].includes(value.status as string) + && Array.isArray(value.dependencies) && value.dependencies.every((dependency) => typeof dependency === "string") + && (value.result === null || typeof value.result === "string") && typeof value.createdBySessionId === "string" + && Number.isFinite(value.createdAt) && Number.isFinite(value.updatedAt); +} +function isMissing(error: unknown): boolean { return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT"); } +function errorText(error: unknown): string { return error instanceof Error ? error.message.slice(0, 240) : "file is unavailable"; } + +/** Canonical portable task validation, also used when retaining preset metadata. */ +export function normalizeImportedTasks(rows: unknown, rootSessionId: string, sessionIds: Record, + remapTaskId: (id: string) => string = () => randomUUID()): OrchestrationTask[] { + requireId(rootSessionId,"rootSessionId"); + if(!Array.isArray(rows) || rows.length>MAX_TASKS || !rows.every(isOrchestrationTask))throw new Error("Invalid workspace task board."); + const ids=new Map(rows.map(row=>[row.id,remapTaskId(row.id)])); + if(ids.size!==rows.length)throw new Error("Workspace tasks contain duplicate ids."); + const mappedSessionId=(id:string|null):string|null=>id!==null && Object.hasOwn(sessionIds,id) && typeof sessionIds[id]==="string" ? sessionIds[id] : null; + const imported:OrchestrationTask[]=rows.map(row=>({ + id:ids.get(row.id)!,rootSessionId,title:requiredText(row.title,"title",MAX_TITLE), + description:optionalText(row.description,"description",MAX_DESCRIPTION) ?? "", + progress:optionalText(row.progress,"progress",MAX_PROGRESS) ?? "", + ownerSessionId:mappedSessionId(row.ownerSessionId), + ownerName:mappedSessionId(row.ownerSessionId) ? optionalText(row.ownerName,"ownerName",200) ?? null : null, + status:row.status==="claimed" && !mappedSessionId(row.ownerSessionId) ? "open" : row.status, + dependencies:validateDependencies(row.dependencies).map(id=>{const mapped=ids.get(id);if(!mapped)throw new Error("Workspace task has a missing dependency.");return mapped;}), + result:optionalText(row.result,"result",MAX_RESULT) ?? null, + createdBySessionId:mappedSessionId(row.createdBySessionId) ?? rootSessionId,createdAt:row.createdAt,updatedAt:row.updatedAt + })); + assertAcyclic(imported); + return imported; +} diff --git a/src/main/services/OrchestrationTemplateService.ts b/src/main/services/OrchestrationTemplateService.ts new file mode 100644 index 00000000..82c4637c --- /dev/null +++ b/src/main/services/OrchestrationTemplateService.ts @@ -0,0 +1,360 @@ +import { createHash, randomUUID } from "node:crypto"; +import { mkdir, readdir, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises"; +import { dirname, isAbsolute, join, relative, sep } from "node:path"; +import { lazyRequire } from "../lazyRequire.ts"; +const yaml = lazyRequire("yaml"); +import type { ReasoningEffort } from "../../shared/launchModel.ts"; + +const FLOW_DIRECTORY = join(".canvastty", "flows"); +const MAX_FLOW_BYTES = 64 * 1024; +const MAX_FLOW_FILES = 128; +const MAX_ROLES = 16; +const MAX_TEXT = 8_000; +const ALLOWED_KEYS = new Set(["id", "name", "description", "roles", "finalStep"]); +const ROLE_KEYS = new Set(["id", "title", "instruction", "count", "model", "effort"]); +const EFFORTS = new Set(["minimal", "low", "medium", "high", "xhigh"]); + +export interface OrchestrationTemplateRole { + id: string; + title: string; + instruction: string; + count: number; + model?: string; + effort?: ReasoningEffort; +} + +/** A flow can choose role names, prompts, a model and reasoning effort; it cannot change the caller's permissions. */ +export interface OrchestrationTemplate { + id: string; + name: string; + description: string; + roles: OrchestrationTemplateRole[]; + finalStep: string; + expectedSubagents: number; + builtIn: boolean; + source?: string; + trusted?: boolean; + digest?: string; +} + +export interface OrchestrationTemplateIssue { + file: string; + line: number; + message: string; +} + +export interface OrchestrationTemplateList { + templates: OrchestrationTemplate[]; + errors: OrchestrationTemplateIssue[]; +} + +const BUILT_IN_FLOWS: readonly Omit[] = [ + { + id: "split-parallel-synthesize", + name: "Split, work in parallel, synthesize", + description: "Break a task into independent pieces, run the pieces concurrently, then combine the results.", + roles: [ + { id: "planner", title: "Plan the split", instruction: "Break the requested work into independent, bounded parts. Do not edit files. Return the parts and dependencies.", count: 1 }, + { id: "worker", title: "Complete one part", instruction: "Complete only the assigned part. State changed files and the result clearly.", count: 3 } + ], + finalStep: "Review every result, resolve conflicts, and present one concise combined outcome." + }, + { + id: "executor-reviewer", + name: "Executor and reviewer", + description: "One agent implements; a second independently reviews the diff and reports concrete issues.", + roles: [ + { id: "executor", title: "Implement", instruction: "Implement the requested change and report the files changed and verification performed.", count: 1 }, + { id: "reviewer", title: "Review", instruction: "Read the implementation diff and report only actionable bugs, regressions, and missing checks. Do not modify files.", count: 1, effort: "high" } + ], + finalStep: "Consider the review findings, ask the executor to fix valid issues, then report the final change and remaining risks." + }, + { + id: "three-options-judge", + name: "Three options and a judge", + description: "Explore three independent solutions and compare them against the request before choosing.", + roles: [ + { id: "option", title: "Propose one solution", instruction: "Develop one distinct solution. Explain tradeoffs and how it meets the request.", count: 3 }, + { id: "judge", title: "Compare the options", instruction: "Compare the three proposals against the user's constraints. Choose one and explain why.", count: 1, effort: "high" } + ], + finalStep: "Present the selected option, its reason, and the main tradeoff. Do not implement without the user's request." + }, + { + id: "find-and-verify-bugs", + name: "Find bugs and verify findings", + description: "Search independently for defects, then have another agent validate each candidate against the source.", + roles: [ + { id: "finder", title: "Find candidate bugs", instruction: "Inspect the assigned area and return specific candidate bugs with file and line evidence. Do not edit files.", count: 2 }, + { id: "verifier", title: "Verify candidate bugs", instruction: "Check each candidate against source and call paths. Reject false positives and provide evidence for validated bugs.", count: 1, effort: "high" } + ], + finalStep: "Report only independently verified findings, with impact and source evidence." + } +]; + +/** Loads project flows on every call so a new file is visible without restarting CanvasTTY. */ +export class OrchestrationTemplateService { + private readonly approvedTemplates = new WeakSet(); + private approvalQueue: Promise = Promise.resolve(); + private readonly approvalPath?: string; + constructor(approvalPath?: string) { this.approvalPath = approvalPath; } + + async list(projectRoot: string): Promise { + const templates: OrchestrationTemplate[] = BUILT_IN_FLOWS.map((flow) => ({ + ...structuredClone(flow), + expectedSubagents: flow.roles.reduce((sum, role) => sum + role.count, 0), + builtIn: true, trusted: true + })); + for (const template of templates) this.approvedTemplates.add(template); + const errors: OrchestrationTemplateIssue[] = []; + const root = await realpath(projectRoot); + let approvals = new Map(); + try { approvals = await this.readApprovals(); } + catch { errors.push({ file: "flow approvals", line: 0, message: "Flow approvals could not be read. Project flows require approval again before use." }); } + let flowDirectory: string | null; + try { flowDirectory = await projectFlowDirectory(root, false); } + catch { return { templates, errors: [{ file: FLOW_DIRECTORY, line: 1, message: "The flow directory must stay inside the project." }] }; } + if (!flowDirectory) return { templates, errors }; + let files: string[]; + try { + files = (await readdir(flowDirectory)).filter((file) => /\.(?:yaml|yml)$/iu.test(file)).sort().slice(0, MAX_FLOW_FILES); + } catch (error) { + if (isMissing(error)) return { templates, errors }; + return { templates, errors: [{ file: FLOW_DIRECTORY, line: 1, message: "The flow directory could not be read." }] }; + } + for (const file of files) { + const display = `${FLOW_DIRECTORY}/${file}`; + try { + const path = join(flowDirectory, file); + const [actual, info] = await Promise.all([realpath(path), stat(path)]); + if (!info.isFile()) { + errors.push({ file: display, line: 1, message: "Flow files must be regular files inside .canvastty/flows." }); + continue; + } + try { assertInsideProject(flowDirectory, actual); } + catch { + errors.push({ file: display, line: 1, message: "Flow files must be regular files inside .canvastty/flows." }); + continue; + } + if (info.size > MAX_FLOW_BYTES) { + errors.push({ file: display, line: 1, message: `Flow file exceeds ${MAX_FLOW_BYTES} bytes.` }); + continue; + } + const source = await readFile(actual, "utf8"); + if (Buffer.byteLength(source, "utf8") > MAX_FLOW_BYTES) throw new Error("Flow changed beyond its size limit."); + const parsed = parseFlow(source, display); + if ("issue" in parsed) errors.push(parsed.issue); + else if (templates.some((item) => item.id === parsed.template.id)) { + errors.push({ file: display, line: 1, message: `Flow id "${parsed.template.id}" is already in use.` }); + } else { + const digest = createHash("sha256").update(source).digest("hex"); + const trusted = approvals.get(JSON.stringify([root, parsed.template.id])) === digest; + const template = { ...parsed.template, digest, trusted }; + templates.push(template); + if (trusted) this.approvedTemplates.add(template); + } + } catch { + errors.push({ file: display, line: 1, message: "Flow file could not be read." }); + } + } + return { templates, errors }; + } + + /** Writes a validated project flow with an atomic replace. Built-in ids cannot be shadowed. */ + async save(projectRoot: string, flow: Omit): Promise { + const source = yaml().stringify(flow); + if (Buffer.byteLength(source, "utf8") > MAX_FLOW_BYTES) throw new Error(`Flow file exceeds ${MAX_FLOW_BYTES} bytes.`); + const parsed = parseFlow(source, "flow.yaml"); + if ("issue" in parsed) throw new Error(formatIssue(parsed.issue)); + if (BUILT_IN_FLOWS.some((builtIn) => builtIn.id === parsed.template.id)) throw new Error("A built-in flow cannot be overwritten."); + const directory = await projectFlowDirectory(await realpath(projectRoot), true); + const safeId = parsed.template.id.toLowerCase().replace(/[^a-z0-9-]+/gu, "-").replace(/^-+|-+$/gu, ""); + if (!safeId) throw new Error("Flow id must include a letter or number."); + const target = join(directory, `${safeId}.yaml`); + const temporary = join(directory, `.${safeId}.${randomUUID()}.tmp`); + await writeFile(temporary, source, { encoding: "utf8", mode: 0o600, flag: "wx" }); + try { await rename(temporary, target); } + catch (error) { + const { unlink } = await import("node:fs/promises"); + await unlink(temporary).catch(() => undefined); + throw error; + } + return target; + } + + /** The string a launcher can append to the orchestrator's initial prompt. */ + instructions(template: OrchestrationTemplate): string { + if (!this.approvedTemplates.has(template)) throw new Error("This project flow requires the person's approval of its current instructions."); + return buildOrchestrationTemplateInstructions(template); + } + + /** Desktop-only preview: the agent tools never return unapproved prompt fields. */ + async preview(projectRoot: string, id: string): Promise<{ instructions: string; digest: string | null }> { + const template = (await this.list(projectRoot)).templates.find(row => row.id === id); + if (!template) throw new Error("Flow no longer exists."); + return { instructions: buildOrchestrationTemplateInstructions(template), digest: template.digest ?? null }; + } + + /** Bind approval to the exact bytes shown by the desktop, outside every agent-writable project. */ + approve(projectRoot: string, id: string, expectedDigest: string): Promise { + const work = this.approvalQueue.then(async () => { + if (!this.approvalPath) throw new Error("Flow approval storage is unavailable."); + const root = await realpath(projectRoot); + const template = (await this.list(root)).templates.find(row => row.id === id); + if (!template || template.builtIn || !/^[a-f0-9]{64}$/u.test(expectedDigest) || template.digest !== expectedDigest) + throw new Error("Flow changed since the preview. Review its current instructions before approval."); + const approvals = await this.readApprovals(), key = JSON.stringify([root, id]); + if (!approvals.has(key) && approvals.size >= 4096) throw new Error("Flow approval storage is full."); + approvals.set(key, expectedDigest); + await mkdir(dirname(this.approvalPath), { recursive: true, mode: 0o700 }); + const temporary = `${this.approvalPath}.${randomUUID()}.tmp`; + try { + await writeFile(temporary, JSON.stringify([...approvals]), { mode: 0o600, flag: "wx" }); + await rename(temporary, this.approvalPath); + } finally { await rm(temporary, { force: true }); } + }); + this.approvalQueue = work.catch(() => undefined); + return work; + } + + private async readApprovals(): Promise> { + if (!this.approvalPath) return new Map(); + let text: string; + try { text = await readFile(this.approvalPath, "utf8"); } + catch (error) { if (isMissing(error)) return new Map(); throw error; } + if (Buffer.byteLength(text) > 4 * 1024 * 1024) throw new Error("Flow approvals are too large."); + const entries: unknown = JSON.parse(text); + if (!Array.isArray(entries) || entries.length > 4096) throw new Error("Invalid flow approvals."); + const map = new Map(); + for (const row of entries) { + if (!Array.isArray(row) || row.length !== 2 || typeof row[0] !== "string" || typeof row[1] !== "string" || !/^[a-f0-9]{64}$/u.test(row[1])) + throw new Error("Invalid flow approval."); + const key: unknown = JSON.parse(row[0]); + if (!Array.isArray(key) || key.length !== 2 || typeof key[0] !== "string" || !isAbsolute(key[0]) || typeof key[1] !== "string" || !/^[a-z0-9][a-z0-9-]{0,63}$/u.test(key[1])) + throw new Error("Invalid flow approval scope."); + map.set(row[0], row[1]); + } + return map; + } +} + +/** Resolve flow paths inside the canonical project root, creating missing components only for saves. */ +async function projectFlowDirectory(root: string, createIfMissing: true): Promise; +async function projectFlowDirectory(root: string, createIfMissing: false): Promise; +async function projectFlowDirectory(root: string, createIfMissing: boolean): Promise { + let directory = root; + for (const name of [".canvastty", "flows"]) { + const path = join(directory, name); + try { directory = await realpath(path); } + catch (error) { + if (!isMissing(error)) throw error; + if (!createIfMissing) return null; + await mkdir(path, { mode: 0o700 }); + directory = await realpath(path); + } + assertInsideProject(root, directory); + } + return directory; +} + +function assertInsideProject(root: string, path: string): void { + const rel = relative(root, path); + if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { + throw new Error("Orchestration flow paths must stay inside the project."); + } +} + +export function buildOrchestrationTemplateInstructions(template: OrchestrationTemplate): string { + return [ + `Orchestration flow: ${template.name}`, + template.description, + `Expected subagents: ${template.expectedSubagents}.`, + "Follow the roles below in order; start independent roles in parallel when dependencies allow.", + ...template.roles.map((role) => { + const defaults = [role.model ? `model=${role.model}` : "", role.effort ? `effort=${role.effort}` : ""].filter(Boolean).join(", "); + return `- ${role.count} × ${role.title} (${role.id})${defaults ? `; suggested ${defaults}` : ""}: ${role.instruction}`; + }), + `Final step: ${template.finalStep}`, + "A flow only sets a work pattern and model defaults. Preserve the orchestration permissions, project-folder limits, launch profile ceiling, and budgets already enforced by CanvasTTY.", + "User task:", + "{{TASK}}" + ].join("\n"); +} + +function parseFlow(source: string, file: string): { template: OrchestrationTemplate } | { issue: OrchestrationTemplateIssue } { + let document; + const lines=new (yaml().LineCounter)(); + try { document = yaml().parseDocument(source, { uniqueKeys: true, prettyErrors: false, lineCounter:lines }); } + catch (error) { return { issue: { file, line: 1, message: `Invalid YAML: ${message(error)}` } }; } + if (document.errors.length > 0) { + const error = document.errors[0]!; + const line = error.pos ? lines.linePos(error.pos[0]).line : 1; + return { issue: { file, line, message: `Invalid YAML: ${cleanMessage(error.message)}` } }; + } + const value: unknown = document.toJS(); + const issue = validateFlow(value, file); + if (issue) { + const role=/^roles\[(\d+)\](?:\.([a-zA-Z]+))?/u.exec(issue.message); + const quoted=/unknown field "([^"]+)"/iu.exec(issue.message); + const field=/^(id|name|description|finalStep|roles)\b/u.exec(issue.message)?.[1]; + const unknown=/^Unknown flow field "([^"]+)"/u.exec(issue.message)?.[1]; + const path=role ? ["roles",Number(role[1]),...(role[2] || quoted?.[1] ? [role[2] ?? quoted![1]] : [])] : [unknown ?? field ?? "id"]; + const node=document.getIn(path,true) as {range?:[number,number,number]}|undefined; + if(node?.range)issue.line=lines.linePos(node.range[0]).line; + return { issue }; + } + const candidate = value as Record; + const roles = candidate.roles as OrchestrationTemplateRole[]; + const id = candidate.id as string; + return { + template: { + id, + name: candidate.name as string, + description: candidate.description as string, + roles, + finalStep: candidate.finalStep as string, + expectedSubagents: roles.reduce((sum, role) => sum + role.count, 0), + builtIn: false, + source: file + } + }; +} + +function validateFlow(value: unknown, file: string): OrchestrationTemplateIssue | null { + const fail = (message: string, line = 1): OrchestrationTemplateIssue => ({ file, line, message }); + if (!isRecord(value)) return fail("Flow must be a YAML object."); + const extra = Object.keys(value).find((key) => !ALLOWED_KEYS.has(key)); + if (extra) return fail(`Unknown flow field "${extra}"; flows cannot set launch permissions, profiles or folders.`); + if (typeof value.id !== "string" || !/^[a-z0-9][a-z0-9-]{0,63}$/u.test(value.id)) return fail("id must be a lowercase slug of at most 64 characters."); + if (BUILT_IN_FLOWS.some((flow) => flow.id === value.id)) return fail("A project flow cannot replace a built-in flow."); + if (!nonEmpty(value.name, 120)) return fail("name must contain 1 to 120 characters."); + if (!nonEmpty(value.description, 500)) return fail("description must contain 1 to 500 characters."); + if (!nonEmpty(value.finalStep, MAX_TEXT)) return fail("finalStep must contain 1 to 8000 characters."); + if (!Array.isArray(value.roles) || value.roles.length < 1 || value.roles.length > MAX_ROLES) return fail(`roles must contain 1 to ${MAX_ROLES} entries.`); + let total = 0; + const ids = new Set(); + for (let index = 0; index < value.roles.length; index += 1) { + const role: unknown = value.roles[index]; + if (!isRecord(role)) return fail(`roles[${index}] must be an object.`); + const roleExtra = Object.keys(role).find((key) => !ROLE_KEYS.has(key)); + if (roleExtra) return fail(`roles[${index}] has unknown field "${roleExtra}"; role permissions are fixed by CanvasTTY.`); + if (typeof role.id !== "string" || !/^[a-z0-9][a-z0-9-]{0,47}$/u.test(role.id) || ids.has(role.id)) return fail(`roles[${index}].id must be a unique lowercase slug.`); + ids.add(role.id); + if (!nonEmpty(role.title, 120)) return fail(`roles[${index}].title must contain 1 to 120 characters.`); + if (!nonEmpty(role.instruction, MAX_TEXT)) return fail(`roles[${index}].instruction must contain 1 to ${MAX_TEXT} characters.`); + if (!Number.isInteger(role.count) || (role.count as number) < 1 || (role.count as number) > 12) return fail(`roles[${index}].count must be an integer from 1 to 12.`); + if (role.model !== undefined && !nonEmpty(role.model, 160)) return fail(`roles[${index}].model must contain 1 to 160 characters.`); + if (role.effort !== undefined && !EFFORTS.has(role.effort as ReasoningEffort)) return fail(`roles[${index}].effort is not supported.`); + total += role.count as number; + } + if (total > 32) return fail("A flow may expect at most 32 subagents."); + return null; +} + +function formatIssue(issue: OrchestrationTemplateIssue): string { + return `${issue.file}:${issue.line}: ${issue.message}`; +} +function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } +function nonEmpty(value: unknown, max: number): value is string { return typeof value === "string" && value.trim().length > 0 && value.length <= max; } +function isMissing(error: unknown): boolean { return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT"); } +function message(error: unknown): string { return cleanMessage(error instanceof Error ? error.message : "document could not be parsed"); } +function cleanMessage(value: string): string { return value.replace(/[\r\n\t]+/gu, " ").slice(0, 240); } diff --git a/src/main/services/OrchestrationUsageSync.ts b/src/main/services/OrchestrationUsageSync.ts new file mode 100644 index 00000000..0a8eec2e --- /dev/null +++ b/src/main/services/OrchestrationUsageSync.ts @@ -0,0 +1,100 @@ +import { createHash } from "node:crypto"; +import type { UsagePrice } from "../../shared/backlog.ts"; +import type { OrchestrationBudgetService, OrchestrationBudgetSnapshot } from "./OrchestrationBudgetService.ts"; +import type { SessionTimelineService, TimelineTaskBudgetData, TimelineUsageCounter } from "./SessionTimelineService.ts"; + +interface CounterTotal {tokens:number;costUsd:number;costKnown:boolean;replaceCounterIds:Set} +export interface OrchestrationUsageScope {rootSessionId:string;rootStartedAt:number;memberSessionIds:readonly string[];budgetEnabled?:boolean} + +/** Synchronize the timeline's indexed task deltas plus host-owned launch placeholders into the budget ledger. */ +export function refreshOrchestrationUsage( + budget: OrchestrationBudgetService, + timeline: SessionTimelineService, + rootSessionId: string, + rootStartedAt: number, + memberSessionIds: readonly string[], + prices: UsagePrice[] +): OrchestrationBudgetSnapshot { + return refreshOrchestrationUsageBatch(budget,timeline,[{rootSessionId,rootStartedAt,memberSessionIds}],prices).get(rootSessionId)!; +} + +/** Gather every active root's compact usage rows in shared timeline passes before updating its ledger. */ +export function refreshOrchestrationUsageBatch( + budget:OrchestrationBudgetService, + timeline:SessionTimelineService, + scopes:readonly OrchestrationUsageScope[], + prices:UsagePrice[], + onSnapshot?:(scope:OrchestrationUsageScope,snapshot:OrchestrationBudgetSnapshot|undefined)=>void, + beforeRefresh?:(scope:OrchestrationUsageScope)=>void +):Map { + if(scopes.length===0)return new Map(); + const budgetScopes=scopes.filter(scope=>scope.budgetEnabled!==false); + if(budgetScopes.length===0) { + for(const scope of scopes) {beforeRefresh?.(scope);onSnapshot?.(scope,undefined);} + return new Map(); + } + const dataByTask=timeline.taskBudgetUsageByTask(budgetScopes.map(scope=>({taskId:scope.rootSessionId, + sessionIds:[scope.rootSessionId,...scope.memberSessionIds]})),prices); + const snapshots=new Map(); + for(const scope of scopes) { + beforeRefresh?.(scope); + const snapshot=scope.budgetEnabled===false ? undefined : refreshOneTask(budget,timeline,scope,dataByTask.get(scope.rootSessionId)!); + if(snapshot)snapshots.set(scope.rootSessionId,snapshot); + onSnapshot?.(scope,snapshot); + } + return snapshots; +} + +function refreshOneTask( + budget:OrchestrationBudgetService, + timeline:SessionTimelineService, + scope:OrchestrationUsageScope, + data:TimelineTaskBudgetData +):OrchestrationBudgetSnapshot { + const {rootSessionId,rootStartedAt}=scope; + const scopeSessions=data.sessionIds; + const totals=new Map(); + for(const contribution of data.contributions) { + let total=totals.get(contribution.identity); + if(!total){total={tokens:0,costUsd:0,costKnown:true,replaceCounterIds:new Set()};totals.set(contribution.identity,total);} + total.tokens+=contribution.tokens; + if(contribution.costUsd===null)total.costKnown=false;else total.costUsd+=contribution.costUsd; + } + + const sessionsWithUsage=new Set(); + const aliasesByIdentity=new Map>(); + for(const sessionId of scopeSessions)for(const usage of timeline.usageCounters(sessionId)) { + sessionsWithUsage.add(sessionId); + const identity=counterIdentity(sessionId,usage),aliases=aliasesByIdentity.get(identity) ?? new Set(); + aliases.add(hashCounter(sessionId,usage.id)); + aliases.add(hashCounter(sessionId,usage.legacyId ?? sessionId)); + aliasesByIdentity.set(identity,aliases); + } + for(const [identity,total] of totals)for(const alias of aliasesByIdentity.get(identity) ?? [])total.replaceCounterIds.add(alias); + + const pendingIds=[...sessionsWithUsage].map(sessionId=>hashCounter(sessionId,sessionId)); + let first=true; + for(const [identity,total] of totals) { + const replaceCounterIds=[...total.replaceCounterIds,...(first ? pendingIds : [])];first=false; + budget.recordSessionUsage(rootSessionId,hashIdentity(identity),{ + tokens:total.tokens, + costUsd:total.costKnown ? total.costUsd : null + },{rootStartedAt,replaceCounterIds}); + } + for(const sessionId of scopeSessions)if(!sessionsWithUsage.has(sessionId)) { + budget.recordSessionUsage(rootSessionId,hashCounter(sessionId,sessionId),{tokens:null,costUsd:null},{rootStartedAt,provisional:true}); + } + return budget.snapshot(rootSessionId,rootStartedAt); +} + +function counterIdentity(sessionId:string,usage:TimelineUsageCounter):string { + return usage.cumulative && usage.counterId!==undefined + ? JSON.stringify([usage.provider ?? "",usage.accountId ?? "",usage.source,usage.counterId]) + : JSON.stringify([sessionId,usage.source,usage.counterId ?? ""]); +} +function hashCounter(sessionId:string,counterId:string):string { + return createHash("sha256").update(`${sessionId}:${counterId}`).digest("hex"); +} +function hashIdentity(identity:string):string { + return createHash("sha256").update(`task-usage:${identity}`).digest("hex"); +} diff --git a/src/main/services/PluginCards.ts b/src/main/services/PluginCards.ts index 076397b0..ddf74ada 100644 --- a/src/main/services/PluginCards.ts +++ b/src/main/services/PluginCards.ts @@ -6,6 +6,7 @@ import type { PluginCardTone } from "../../shared/contracts.ts"; import { cardActionMatches } from "../../shared/pluginCardActions.ts"; +import { normalizeCardActionInput, normalizeCardReview } from "./PluginChangeReviews.ts"; import type { PluginSessionSummary } from "./PluginSessions.ts"; /** A trusted plugin service that declared `cardActions` (PluginManager.cardActionProviders). */ @@ -33,6 +34,7 @@ export interface CardActionInvocation { actionId: string; sessionId: string; session: PluginSessionSummary; + input?: Record; } const CARD_ACTION_TIMEOUT_MS = 15_000; @@ -126,7 +128,7 @@ export class PluginCards { } /** The person chose an action on a card. Errors and timeouts come back as an error toast, nothing else. */ - async invoke(pluginId: string, actionId: string, sessionId: string): Promise { + async invoke(pluginId: string, actionId: string, sessionId: string, input?: unknown): Promise { // Action ids are unique within a plugin, whichever of its services declares them. const provider = this.providers() .find((candidate) => candidate.pluginId === pluginId && candidate.actions.some((action) => action.id === actionId)); @@ -136,7 +138,8 @@ export class PluginCards { if (!session) throw new Error("No card has that session id."); if (!cardActionMatches(action.when, session)) throw new Error("That action does not apply to this card."); const timeoutMs = this.deps.timeoutMs ?? CARD_ACTION_TIMEOUT_MS; - const params: CardActionInvocation = { actionId, sessionId, session }; + const normalizedInput = normalizeCardActionInput(input); + const params: CardActionInvocation = { actionId, sessionId, session, ...(normalizedInput ? { input: normalizedInput } : {}) }; let timer: NodeJS.Timeout | undefined; try { const answer = await Promise.race([ @@ -148,7 +151,9 @@ export class PluginCards { const record = answer && typeof answer === "object" && !Array.isArray(answer) ? answer as Record : {}; const tone = TONES.has(record.tone as PluginCardTone) ? record.tone as PluginCardTone : "neutral"; const message = typeof record.message === "string" && record.message.trim() ? this.message(record.message) : undefined; - return { tone, ...(message ? { message } : {}) }; + const actions = new Set(this.providers().filter(row => row.pluginId === pluginId).flatMap(row => row.actions.map(action => action.id))); + const review = record.review === undefined ? undefined : normalizeCardReview(record.review, this.deps.redact, actions); + return { tone, ...(message ? { message } : {}), ...(review ? { review } : {}) }; } catch (error) { return { tone: "error", message: this.message(`${provider.pluginName}: ${error instanceof Error ? error.message : "the action failed."}`) }; } finally { diff --git a/src/main/services/PluginChangeReviews.ts b/src/main/services/PluginChangeReviews.ts new file mode 100644 index 00000000..c56f009a --- /dev/null +++ b/src/main/services/PluginChangeReviews.ts @@ -0,0 +1,88 @@ +import type { PluginChangeReview, PluginChangeReviewFile, PluginReviewTextPage } from "../../shared/contracts.ts"; + +const MAX_REVIEW_BYTES = 512 * 1024; +// A normalized review may repeat selected paths as conflict-resolution keys in its action. +// Conflict metadata consumes review space, keeping generated actions below the 1 MiB transport frame. +const MAX_ACTION_BYTES = 2 * MAX_REVIEW_BYTES; +const MAX_TEXT = 16_384; + +function record(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Plugin review data is invalid."); + return value as Record; +} + +function integer(value: unknown): number { + if (!Number.isSafeInteger(value) || (value as number) < 0) throw new Error("Plugin review page is invalid."); + return value as number; +} + +function boundedJson(value: unknown, limit: number): string { + let json: string | undefined; + try { json = JSON.stringify(value); } catch { throw new Error("Card action data must be JSON."); } + if (!json || Buffer.byteLength(json) > limit) throw new Error("Card action data is too large."); + return json; +} + +/** Only the app window can supply this payload. Keep it separate from the host's session identity. */ +export function normalizeCardActionInput(value: unknown): Record | undefined { + if (value === undefined) return undefined; + record(value); + return record(JSON.parse(boundedJson(value, MAX_ACTION_BYTES))); +} + +/** Plugins provide text and declared action ids, never HTML or instructions to execute. */ +export function normalizeCardReview(value: unknown, redact: (text: string) => string, actions: ReadonlySet): PluginChangeReview { + boundedJson(value, MAX_REVIEW_BYTES); + const review = record(value); + const text = (value: unknown, limit = MAX_TEXT): string => { + if (typeof value !== "string") throw new Error("Plugin review text is invalid."); + return redact(value.replace(/[\u0000-\u0008\u000B-\u001F\u007F]/gu, "")).slice(0, limit); + }; + const page = (value: unknown): PluginReviewTextPage => { + const source = record(value); + if (typeof source.hasMore !== "boolean") throw new Error("Plugin review page is invalid."); + return { ...(source.label === undefined ? {} : { label: text(source.label, 160) }), text: text(source.text), + startLine: integer(source.startLine), totalLines: integer(source.totalLines), hasMore: source.hasMore }; + }; + const action = (value: unknown): string | undefined => { + if (value === undefined) return undefined; + if (typeof value !== "string" || !actions.has(value)) throw new Error("Plugin review action is not declared by this plugin."); + return value; + }; + if (!Array.isArray(review.groups) || review.groups.length > 24) throw new Error("Plugin review groups are invalid."); + let fileCount = 0; + const groups = review.groups.map(value => { + const group = record(value); + if (typeof group.sessionId !== "string" || !/^[\w-]{1,160}$/u.test(group.sessionId)) throw new Error("Plugin review session is invalid."); + if (!Array.isArray(group.files) || (fileCount += group.files.length) > 400) throw new Error("Plugin review files are invalid."); + const files = group.files.map(value => { + const file = record(value); + if (typeof file.path !== "string" || !file.path || file.path.length > 1000 || /[\u0000-\u001F\u007F]/u.test(file.path)) throw new Error("Plugin review file path is invalid."); + if (redact(file.path) !== file.path) throw new Error("Plugin review file names contain protected data."); + const result: PluginChangeReviewFile = { path: file.path, diff: text(file.diff) }; + if (file.status !== undefined) result.status = text(file.status, 80); + if (file.page !== undefined) result.page = integer(file.page); + if (file.hasMore !== undefined) { + if (typeof file.hasMore !== "boolean") throw new Error("Plugin review page is invalid."); + result.hasMore = file.hasMore; + } + result.truncated = file.truncated === true || (typeof file.diff === "string" && file.diff.length > MAX_TEXT); + if (file.conflict !== undefined) { + const conflict = record(file.conflict); + result.conflict = { current: page(conflict.current), agent: page(conflict.agent) }; + if (result.conflict.current.text.length === MAX_TEXT || result.conflict.agent.text.length === MAX_TEXT) result.truncated = true; + } + return result; + }); + return { sessionId: group.sessionId, title: text(group.title, 200), files, + ...(group.error !== undefined ? { error: text(group.error, 2000) } : {}) }; + }); + const acceptActionId = action(review.acceptActionId), rejectActionId = action(review.rejectActionId); + const result: PluginChangeReview = { + title: text(review.title, 200), groups, + ...(acceptActionId ? { acceptActionId } : {}), ...(rejectActionId ? { rejectActionId } : {}), + ...(review.nextOffset !== undefined ? { nextOffset: integer(review.nextOffset) } : {}) + }; + boundedJson(result, MAX_REVIEW_BYTES); + return result; +} diff --git a/src/main/services/PluginManager.ts b/src/main/services/PluginManager.ts index 927b553a..51e52db1 100644 --- a/src/main/services/PluginManager.ts +++ b/src/main/services/PluginManager.ts @@ -56,6 +56,7 @@ import type { AgentToolProvider } from "./PluginAgentTools.ts"; import type { CardActionProvider } from "./PluginCards.ts"; import type { BrowserEngineProvider } from "./browser/BrowserEngineTabs.ts"; import { AGENT_PROVIDERS } from "../../shared/contracts.ts"; +import { ACCOUNTS_PLUGIN_ID } from "./accountHomeIsolation.ts"; const MANIFEST_FILE = "canvastty.plugin.json"; /** Plugins keep their metadata (manifest, icon, etc.) in the metadata/ folder. */ @@ -221,15 +222,13 @@ export class PluginManager { private readonly downloadRepository: DownloadRepository; private readonly downloadFullRepository: DownloadRepository; private readonly downloadModuleFiles: DownloadModuleFiles; - private tokenProvider: () => Promise; private registryWrite = Promise.resolve(); private versionsWrite = Promise.resolve(); constructor( userDataPath: string, downloadRepository?: DownloadRepository, - downloadModuleFiles: DownloadModuleFiles = downloadGithubModuleFiles, - tokenProvider?: () => Promise + downloadModuleFiles: DownloadModuleFiles = downloadGithubModuleFiles ) { this.pluginRoot = join(userDataPath, "plugins"); this.stagingRoot = join(userDataPath, "plugin-staging"); @@ -241,23 +240,10 @@ export class PluginManager { this.downloadRepository = downloadRepository ?? downloadGithubManifest; this.downloadFullRepository = downloadRepository ?? downloadGithubRepository; this.downloadModuleFiles = downloadModuleFiles; - this.tokenProvider = tokenProvider ?? (async () => null); - } - - /** Resolves the GitHub token from env, then the OAuth session (if any). */ - private async githubToken(): Promise { - const envToken = process.env.GITHUB_TOKEN ?? process.env.CANVASTTY_GITHUB_TOKEN; - if (envToken) return envToken; - try { - return await this.tokenProvider(); - } catch { - return null; - } } /** Registers the OAuth-backed token provider used by module-level helpers. */ registerTokenProvider(provider: () => Promise): void { - this.tokenProvider = provider; registerGithubTokenProvider(provider); } @@ -326,6 +312,12 @@ export class PluginManager { return this.list(); } + /** The host's install record for one plugin: where it was installed from and what the person trusted. */ + installRecord(pluginId: string): { sourceUrl: string; enabled: boolean; nativeCodeTrusted: boolean } | null { + const plugin = this.plugins.get(pluginId); + return plugin ? { sourceUrl: plugin.sourceUrl, enabled: plugin.enabled, nativeCodeTrusted: plugin.nativeCodeTrusted } : null; + } + list(): InstalledPlugin[] { return [...this.plugins.values()] .sort((left, right) => left.manifest.name.localeCompare(right.manifest.name)) @@ -597,6 +589,7 @@ export class PluginManager { pluginName: manifest.name, serviceId: service.id, launch: structuredClone(service.launch), + ...(plugin.manifest.id === ACCOUNTS_PLUGIN_ID ? { dataDir: join(this.dataRoot, plugin.manifest.id) } : {}), secrets: manifest.permissions.includes("secrets") }); } @@ -1801,7 +1794,7 @@ function validateBrowserEngine(value: unknown): PluginBrowserEngine { return { id, title, ...(description ? { description } : {}), layout: value.layout === true }; } -const MAX_CARD_ACTIONS = 8; +const MAX_CARD_ACTIONS = 16; function validateCardActions(value: unknown): PluginCardAction[] { if (!Array.isArray(value) || value.length === 0 || value.length > MAX_CARD_ACTIONS) { @@ -2530,13 +2523,6 @@ async function fetchRemoteManifestVersions(sourceUrls: readonly string[]): Promi return versions; } -async function fetchRemoteManifestVersion(sourceUrl: string): Promise { - const versions = await fetchRemoteManifestVersions([sourceUrl]); - const version = versions.get(sourceUrl); - if (version === undefined) throw new Error("GitHub manifest could not be fetched."); - return version; -} - async function downloadGithubModuleFiles( sourceUrl: string, destination: string, diff --git a/src/main/services/PluginServiceSupervisor.ts b/src/main/services/PluginServiceSupervisor.ts index 1c1b061d..9b8f8fb0 100644 --- a/src/main/services/PluginServiceSupervisor.ts +++ b/src/main/services/PluginServiceSupervisor.ts @@ -34,6 +34,7 @@ export interface PluginServiceHost { emit(pluginId: string, serviceId: string, event: string, data: unknown): void; /** Adds values to the redaction registry: they are masked in every text another agent reads. */ registerSecrets?(pluginId: string, values: string[]): void; + maskSecrets?(text: string): string; /** One of the plugin's own secrets (already checked for `secrets`), or null when it is not set. */ secretGet?(pluginId: string, key: string): Promise; /** @@ -611,6 +612,11 @@ export class PluginServiceSupervisor { this.options.host.registerSecrets?.(spec.pluginId, values.values as string[]); return null; } + if (method === "redaction.mask") { + if (typeof values.text !== "string" || values.text.length > 65_536) throw new Error("Redaction text must be at most 64 KiB."); + if (!this.options.host.maskSecrets) throw new Error("Redaction is unavailable."); + return {text: this.options.host.maskSecrets(values.text)}; + } if (method === "cards.setBadge" && this.options.host.setBadge) { if (!spec.permissions.includes("cards:decorate")) throw new Error("Plugin does not have the cards:decorate permission."); return this.options.host.setBadge(spec.pluginId, params); diff --git a/src/main/services/PluginSessions.ts b/src/main/services/PluginSessions.ts index c7d18bba..d919a72e 100644 --- a/src/main/services/PluginSessions.ts +++ b/src/main/services/PluginSessions.ts @@ -12,6 +12,8 @@ import type { TerminalBufferSnapshot, TerminalDataEvent } from "../../shared/contracts.ts"; +import { createHmac, randomBytes } from "node:crypto"; +import { ASSISTANT_PLUGIN_ID, ASSISTANT_SERVICE_ID, isInstalledAssistant, type PluginInstallRecord } from "./AssistantLoopSignal.ts"; import { IPC } from "../../shared/contracts.ts"; import type { PersistedEnvironmentRef } from "./TerminalSessionStore.ts"; @@ -65,8 +67,16 @@ interface TerminalPort { export interface PluginSessionsDependencies { terminals: TerminalPort; + /** Live host install provenance; absent or untrusted records never receive activity. */ + installRecord?(pluginId:string):PluginInstallRecord|null; /** Sends a notification to a running service; false when it is not running. */ - notify(pluginId: string, serviceId: string, method: "canvastty.sessions.event", params: PluginSessionEvent): boolean; + notify(pluginId: string, serviceId: string, method: "canvastty.sessions.event" | "canvastty.activity", params: PluginSessionEvent | PluginActivity): boolean; +} +export interface PluginActivity { + type: string; sessionId: string; at: number; turnId?: string; turnEpoch?: number; evidenceId?: string; toolName?: string; normalizedAction?: string; + normalizedActionHash?: string; errorHash?: string; outputHash?: string; changedPathHashes?: string[]; + resultClass?: string; provider?: string; accountId?: string; resetAt?: number; + task?: string; parentSessionId?: string; status?: "failed"|"accepted"|"rejected"|"rework"; } interface Subscriber { @@ -79,8 +89,22 @@ interface Subscriber { const MAX_OWNED_PER_PLUGIN = 16; const MAX_SEND_CHARS = 16_000; const MAX_SCREEN_CHARS = 4_000; +const MAX_LOOP_EVIDENCE = 512; +const LOOP_EVIDENCE_TTL_MS = 60_000; const PROFILES = new Set(["normal", "yolo", "auto", "acceptEdits", "plan"]); +interface LoopEvidence { sessionId: string; turnEpoch: number; issuedAt: number } + +function boundedPluginText(value: string, limit: number): string { + const text = value.slice(0, limit); + return /[\uD800-\uDBFF]$/u.test(text) ? text.slice(0, -1) : text; +} + +function validTurnId(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 && value.length <= 160 + && /^[A-Za-z0-9._:-]+$/u.test(value) ? value : undefined; +} + /** * Session events and plugin-owned session control (EP-4). A service subscribes to card events (metadata only; * the redacted end of the output only with `sessions:read-screen`). It can start cards through the normal launch @@ -92,6 +116,8 @@ export class PluginSessions { private readonly deps: PluginSessionsDependencies; private readonly subscribers = new Map(); private readonly known = new Map(); + private readonly fingerprintKey = randomBytes(32); + private readonly loopEvidence = new Map(); constructor(deps: PluginSessionsDependencies) { this.deps = deps; @@ -134,6 +160,109 @@ export class PluginSessions { serviceStopped(pluginId: string, serviceId: string): void { this.subscribers.delete(`${pluginId}:${serviceId}`); } + activity(event: PluginActivity): void { + try { if(!isInstalledAssistant(this.deps.installRecord?.(ASSISTANT_PLUGIN_ID) ?? null))return; } + catch { return; } + let safeEvent = event.type === "tool-outcome" ? this.safeToolOutcome(event) + : event.type === "activity" || event.type === "pretool" ? this.safePretool(event) : event; + if (!safeEvent) return; + const context=this.deps.terminals.pluginContext(safeEvent.sessionId); + if (!context) return; + if ((safeEvent.type === "activity" || safeEvent.type === "tool-outcome") + && Number.isSafeInteger(safeEvent.turnEpoch) && safeEvent.turnEpoch! > 0 + && context.metadata.exitCode === null && context.metadata.status !== "done" && context.metadata.status !== "failed") { + const evidenceId = this.issueLoopEvidence(safeEvent.sessionId, safeEvent.turnEpoch!); + safeEvent = { ...safeEvent, evidenceId }; + } + for (const subscriber of this.subscribers.values()) { + if(subscriber.pluginId!==ASSISTANT_PLUGIN_ID || subscriber.serviceId!==ASSISTANT_SERVICE_ID)continue; + if (subscriber.ownedOnly && context.owner !== subscriber.pluginId) continue; + this.deps.notify(subscriber.pluginId,subscriber.serviceId,"canvastty.activity",safeEvent); + } + } + + /** Consume host evidence once, and only for the same still-live card and currently open turn. */ + consumeLoopEvidence(sessionId: string, evidenceId: string, currentTurnEpoch: number | null): boolean { + const evidence = this.loopEvidence.get(evidenceId); + if (!evidence) return false; + this.loopEvidence.delete(evidenceId); + const age = Date.now() - evidence.issuedAt; + if (evidence.sessionId !== sessionId || evidence.turnEpoch !== currentTurnEpoch + || !Number.isFinite(age) || age < 0 || age > LOOP_EVIDENCE_TTL_MS) return false; + const context = this.deps.terminals.pluginContext(sessionId); + return Boolean(context && context.metadata.exitCode === null + && context.metadata.status !== "done" && context.metadata.status !== "failed"); + } + + private issueLoopEvidence(sessionId: string, turnEpoch: number): string { + const now = Date.now(); + while (this.loopEvidence.size >= MAX_LOOP_EVIDENCE) this.loopEvidence.delete(this.loopEvidence.keys().next().value!); + const evidenceId = randomBytes(18).toString("base64url"); + this.loopEvidence.set(evidenceId, { sessionId, turnEpoch, issuedAt: now }); + return evidenceId; + } + + /** Equality survives inside one host session, while plugins cannot test guessed commands against plain SHA. */ + private fingerprint(sessionId:string,domain:string,hash:string):string { + return createHmac("sha256",this.fingerprintKey).update(JSON.stringify(["activity-v1",sessionId,domain,hash])).digest("hex"); + } + + private safeToolOutcome(event: PluginActivity): PluginActivity | null { + if (!Number.isFinite(event.at) || typeof event.sessionId !== "string" || !event.sessionId + || !["success", "error", "denied", "unknown"].includes(event.resultClass ?? "")) return null; + const changedPathHashes = Array.isArray(event.changedPathHashes) + ? [...new Set(event.changedPathHashes.filter((hash) => typeof hash === "string" && /^[a-f0-9]{64}$/u.test(hash)))].slice(0, 16) + : []; + const redactedName = typeof event.toolName === "string" + ? this.deps.terminals.redactSecrets(event.toolName.replace(/[\u0000-\u001f\u007f]/gu, "")) + : "unknown"; + const safeName = boundedPluginText(redactedName, 80); + const actionHash = typeof event.normalizedActionHash === "string" && /^[a-f0-9]{64}$/u.test(event.normalizedActionHash) + ? event.normalizedActionHash : undefined; + const errorHash = event.resultClass === "error" && typeof event.errorHash === "string" && /^[a-f0-9]{64}$/u.test(event.errorHash) + ? event.errorHash : undefined; + const outputHash = event.resultClass !== "error" && typeof event.outputHash === "string" && /^[a-f0-9]{64}$/u.test(event.outputHash) + ? event.outputHash : undefined; + const turnId = validTurnId(event.turnId); + return { + type: "tool-outcome", + sessionId: event.sessionId, + at: event.at, + ...(turnId ? { turnId } : {}), + ...(Number.isSafeInteger(event.turnEpoch) && (event.turnEpoch as number) > 0 ? { turnEpoch: event.turnEpoch as number } : {}), + toolName: safeName || "unknown", + resultClass: event.resultClass, + ...(actionHash ? { normalizedActionHash: this.fingerprint(event.sessionId,"action",actionHash) } : {}), + ...(errorHash ? { errorHash:this.fingerprint(event.sessionId,"error",errorHash) } : {}), + ...(outputHash ? { outputHash:this.fingerprint(event.sessionId,"output",outputHash) } : {}), + changedPathHashes:changedPathHashes.map(hash=>this.fingerprint(event.sessionId,"path",hash)) + }; + } + + /** Pre-tool summaries carry only hashes and bounded labels; tool input never reaches plugins. */ + private safePretool(event: PluginActivity): PluginActivity | null { + if (!Number.isFinite(event.at) || typeof event.sessionId !== "string" || !event.sessionId) return null; + const action = typeof event.normalizedAction === "string" ? event.normalizedAction : event.normalizedActionHash; + if (typeof action !== "string" || !/^[a-f0-9]{64}$/u.test(action)) return null; + const redactedName = typeof event.toolName === "string" + ? this.deps.terminals.redactSecrets(event.toolName.replace(/[\u0000-\u001f\u007f]/gu, "")) + : "unknown"; + const safeName = boundedPluginText(redactedName, 80); + const resultClass = ["deny", "ask", "allow"].includes(event.resultClass ?? "") ? event.resultClass : undefined; + const turnId = validTurnId(event.turnId); + return { + type: "activity", + sessionId: event.sessionId, + at: event.at, + toolName: safeName || "unknown", + normalizedAction: this.fingerprint(event.sessionId,"action",action), + ...(typeof event.normalizedActionHash==="string" && /^[a-f0-9]{64}$/u.test(event.normalizedActionHash) + ? {normalizedActionHash:this.fingerprint(event.sessionId,"action",event.normalizedActionHash)} : {}), + ...(Number.isSafeInteger(event.turnEpoch) && (event.turnEpoch as number) > 0 ? { turnEpoch: event.turnEpoch as number } : {}), + ...(resultClass ? { resultClass } : {}), + ...(turnId ? { turnId } : {}) + }; + } /** The EP-4 summary of one card, or null when it does not exist. */ summary(sessionId: string): PluginSessionSummary | null { @@ -162,6 +291,7 @@ export class PluginSessions { if (channel === IPC.terminalRemoved && "id" in payload && !("data" in payload)) { const last = this.known.get(payload.id); this.known.delete(payload.id); + for (const [evidenceId, evidence] of this.loopEvidence) if (evidence.sessionId === payload.id) this.loopEvidence.delete(evidenceId); if (last) this.dispatch("closed", last.summary, last.owner); return; } diff --git a/src/main/services/ProcessTreePause.ts b/src/main/services/ProcessTreePause.ts new file mode 100644 index 00000000..893cb527 --- /dev/null +++ b/src/main/services/ProcessTreePause.ts @@ -0,0 +1,178 @@ +import { execFileSync } from "node:child_process"; + +export type ProcessGroupSignal = "SIGSTOP" | "SIGCONT"; +export interface OwnedProcessGroup { + readonly pid: number; +} +export interface ProcessTreePauseResult { + supported: boolean; + failed?: string; +} +export interface ProcessRow { pid: number; ppid: number } + +type SignalGroup = (groupId: number, signal: ProcessGroupSignal) => void; +/** + * Descendant tracking. `list` returns every process with its parent; `signal` delivers to one pid. A process that + * called setsid() (or otherwise left the PTY's process group) is still a descendant by parent chain, so it is found + * here even though the group signal cannot reach it. + */ +export interface ProcessTreeAccess { + list(): ProcessRow[]; + signal(pid: number, signal: ProcessGroupSignal): void; +} + +interface PausedTree { groupId: number; descendants: number[] } + +/** Bounded rescans: a running descendant may fork between one listing and its own SIGSTOP. */ +const MAX_SCAN_ROUNDS = 8; +const MAX_DESCENDANTS = 4096; + +/** + * Suspends a PTY process group whose leader was started and remains owned by this host, plus every process that + * descends from that leader by parent pid at pause time, including ones that moved to their own session. + * node-pty's forkpty child is the process-group/session leader, so a negative pid targets that child's group rather + * than a caller-selected pid. Callers still retain the precise PTY object; a PID alone is never accepted as a target. + * + * Known limit: a process whose parent already exited before the pause was re-parented to init (or a subreaper) and + * has no parent chain to the PTY any more, so it cannot be attributed to the task and keeps running. + */ +export class ProcessTreePause { + private readonly pausedGroups = new WeakMap(); + private readonly signalGroup: SignalGroup; + private readonly tree: ProcessTreeAccess | null; + readonly supported: boolean; + + /** + * With no arguments the real process table and signals are used. An injected `signalGroup` without `tree` + * disables descendant tracking, so a test double can never reach real processes through `ps`. + */ + constructor( + platform = process.platform, + signalGroup?: SignalGroup, + tree?: ProcessTreeAccess | null + ) { + this.supported = platform !== "win32"; + this.signalGroup = signalGroup ?? ((groupId, signal) => process.kill(-groupId, signal)); + this.tree = tree !== undefined ? tree : signalGroup ? null : (this.supported ? systemProcessTree() : null); + } + + isPaused(process: OwnedProcessGroup):boolean {return this.pausedGroups.has(process);} + + pause(process: OwnedProcessGroup): ProcessTreePauseResult { + if (!this.supported) return unsupportedResult(); + const groupId = process.pid; + if (!Number.isSafeInteger(groupId) || groupId <= 0) { + return { supported: true, failed: "The owned PTY did not provide a valid process-group identifier." }; + } + if (this.pausedGroups.has(process)) return { supported: true }; + try { + this.signalGroup(groupId, "SIGSTOP"); + } catch (error) { + if (signalCode(error) !== "ESRCH") { + return { supported: true, failed: `Could not suspend the owned PTY process group (${signalCode(error) ?? "unknown error"}).` }; + } + } + const scan = this.stopDescendants(groupId); + this.pausedGroups.set(process, { groupId, descendants: scan.stopped }); + return { supported: true, ...(scan.failed ? { failed: scan.failed } : {}) }; + } + + resume(process: OwnedProcessGroup): ProcessTreePauseResult { + // No group can be recorded on an unsupported platform, so cleanup is a no-op and must not + // manufacture a failure for every ordinary PTY exit. + if (!this.supported) return { supported: true }; + const paused = this.pausedGroups.get(process); + if (paused === undefined) return { supported: true }; + try { + this.signalGroup(paused.groupId, "SIGCONT"); + } catch (error) { + if (signalCode(error) !== "ESRCH") { + return { supported: true, failed: `Could not resume the owned PTY process group (${signalCode(error) ?? "unknown error"}).` }; + } + } + const remaining: number[] = []; + let failure: string | undefined; + for (const pid of paused.descendants) { + try { this.tree?.signal(pid, "SIGCONT"); } + catch (error) { + if (signalCode(error) === "ESRCH") continue; + remaining.push(pid); + failure = `Could not resume a detached descendant process (${signalCode(error) ?? "unknown error"}).`; + } + } + if (failure) { + // Keep ownership of the descendants that are still stopped so a later resume can retry them. + this.pausedGroups.set(process, { groupId: paused.groupId, descendants: remaining }); + return { supported: true, failed: failure }; + } + this.pausedGroups.delete(process); + return { supported: true }; + } + + /** Stops every descendant of `rootPid` by parent chain, rescanning until a pass finds nothing new. */ + private stopDescendants(rootPid: number): { stopped: number[]; failed?: string } { + if (!this.tree) return { stopped: [] }; + const stopped = new Set(), known = new Set([rootPid]); + const failures = new Set(); + for (let round = 0; round < MAX_SCAN_ROUNDS; round++) { + let rows: ProcessRow[]; + try { rows = this.tree.list(); } + catch { + failures.add("Could not list descendant processes; a process that left the PTY's process group may keep running."); + break; + } + const children = new Map(); + for (const row of rows) { + const list = children.get(row.ppid); + if (list) list.push(row.pid); else children.set(row.ppid, [row.pid]); + } + // Earlier-seen descendants stay roots, so a child whose parent exited between rounds is still reached + // when it was already known. + const queue = [...known]; + let found = false; + while (queue.length) { + const parent = queue.pop()!; + for (const pid of children.get(parent) ?? []) { + if (pid === process.pid || pid <= 1 || known.has(pid)) continue; + known.add(pid); + queue.push(pid); + if (known.size > MAX_DESCENDANTS) break; + try { this.tree.signal(pid, "SIGSTOP"); stopped.add(pid); found = true; } + catch (error) { + if (signalCode(error) !== "ESRCH") failures.add(`Could not suspend a descendant process (${signalCode(error) ?? "unknown error"}).`); + } + } + } + if (!found || known.size > MAX_DESCENDANTS) break; + } + if (known.size > MAX_DESCENDANTS) failures.add("The task has too many descendant processes to suspend individually."); + return { stopped: [...stopped], ...(failures.size ? { failed: [...failures].join(" ") } : {}) }; + } +} + +function systemProcessTree(): ProcessTreeAccess { + return { + list() { + const text = execFileSync("ps", ["-A", "-o", "pid=,ppid="], { encoding: "utf8", timeout: 2_000, maxBuffer: 8 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] }); + const rows: ProcessRow[] = []; + for (const line of text.split("\n")) { + const match = /^\s*(\d+)\s+(\d+)\s*$/u.exec(line); + if (match) rows.push({ pid: Number(match[1]), ppid: Number(match[2]) }); + } + return rows; + }, + signal(pid, signal) { process.kill(pid, signal); } + }; +} + +function signalCode(error: unknown): string | null { + if (typeof error === "object" && error !== null && "code" in error && typeof error.code === "string") return error.code; + return null; +} + +function unsupportedResult(): ProcessTreePauseResult { + return { + supported: false, + failed: "Windows cannot safely suspend a PTY process tree; existing processes may continue while input and new launches are blocked." + }; +} diff --git a/src/main/services/ProviderUsageSource.ts b/src/main/services/ProviderUsageSource.ts new file mode 100644 index 00000000..6b1aac33 --- /dev/null +++ b/src/main/services/ProviderUsageSource.ts @@ -0,0 +1,201 @@ +import { readdir, realpath } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { Worker } from "node:worker_threads"; + +export interface ProviderUsage { + total: number; + input: number | null; + output: number | null; + model?: string; +} + +interface CachedUsage { signature: string; value: ProviderUsage } +interface WorkerUsageResult { usage?: ProviderUsage | null; signature?: string; unchanged?: boolean } + +const READ_TIMEOUT_MS = 2_000; +const MAX_ROLLOUT_BYTES = 1024 * 1024; +const MAX_ROLLOUT_LINE_CHARS = 256 * 1024; + +/** + * Read only published numeric counters from the local Codex index and rollout. + * Rollout JSON is parsed inside a bounded worker; message and transcript text + * never crosses the worker boundary. + */ +export class ProviderUsageSource { + private readonly home: string; + private readonly pending = new Map>(); + private readonly cache = new Map(); + + constructor(codexHome: string) { this.home = resolve(codexHome); } + + codexUsage(threadId: string): Promise { + if (!/^[\w-]{1,160}$/.test(threadId)) return Promise.resolve(null); + const current = this.pending.get(threadId); + if (current) return current; + + const cached = this.cache.get(threadId); + const read = this.read(threadId, cached?.signature).then((result) => { + if (result?.unchanged && cached) return cached.value; + if (!result?.usage || !result.signature) { + this.cache.delete(threadId); + return null; + } + this.cache.set(threadId, { signature: result.signature, value: result.usage }); + return result.usage; + }).catch(() => null).finally(() => this.pending.delete(threadId)); + this.pending.set(threadId, read); + return read; + } + + /** Compatibility for callers that only consume the CLI's aggregate count. */ + async codexTotal(threadId: string): Promise { + return (await this.codexUsage(threadId))?.total ?? null; + } + + private async read(threadId: string, previousSignature?: string): Promise { + const files = (await readdir(this.home)).filter((name) => /^state_\d+\.sqlite$/.test(name)) + .sort((a, b) => Number(b.match(/\d+/)![0]) - Number(a.match(/\d+/)![0])); + if (!files[0]) return null; + const database = join(this.home, files[0]); + const home = await realpath(this.home); + + return new Promise((resolveResult) => { + const worker = new Worker(CODEX_USAGE_WORKER, { + eval: true, + workerData: { database, home, threadId, previousSignature, maxRolloutBytes: MAX_ROLLOUT_BYTES, maxLineChars: MAX_ROLLOUT_LINE_CHARS } + }); + let finished = false; + const finish = (value: WorkerUsageResult | null): void => { + if (finished) return; + finished = true; + clearTimeout(timer); + void worker.terminate(); + resolveResult(value); + }; + const timer = setTimeout(() => finish(null), READ_TIMEOUT_MS); + worker.once("message", (value: unknown) => finish(isWorkerUsageResult(value) ? value : null)); + worker.once("error", () => finish(null)); + worker.once("exit", () => finish(null)); + }); + } +} + +/** Kept self-contained so every database and rollout operation stays off the main thread. */ +const CODEX_USAGE_WORKER = ` +(() => { + const { parentPort, workerData } = require("node:worker_threads"); + const { DatabaseSync } = require("node:sqlite"); + const fs = require("node:fs"); + const path = require("node:path"); + + function safeCount(value) { + return Number.isSafeInteger(value) && value >= 0 ? value : null; + } + function safeModel(value) { + return typeof value === "string" && value.length > 0 && value.length <= 200 + && !/[\\u0000-\\u001f\\u007f]/u.test(value) ? value.trim() || null : null; + } + function pathInsideHome(file) { + return path.isAbsolute(file) ? path.resolve(file) : path.resolve(workerData.home, file); + } + function readLatestCounters(file) { + const descriptor = fs.openSync(file, "r"); + try { + const size = fs.fstatSync(descriptor).size; + const length = Math.min(size, workerData.maxRolloutBytes); + const buffer = Buffer.alloc(length); + if (length) fs.readSync(descriptor, buffer, 0, length, size - length); + const text = buffer.toString("utf8"); + const lines = text.split(String.fromCharCode(10)); + if (size > length) lines.shift(); // Do not parse a partial JSON record at the range boundary. + let model; + let usage = null; + for (const line of lines) { + if (line.length > workerData.maxLineChars || !line.includes("token_count") && !line.includes("turn_context")) continue; + let event; + try { event = JSON.parse(line); } catch { continue; } + const payload = event && typeof event === "object" ? event.payload : null; + if (event?.type === "turn_context" || payload?.type === "turn_context") { + const context = payload?.type === "turn_context" ? payload : payload ?? event; + model = safeModel(context.model ?? context.model_slug ?? context.modelName ?? event.model) ?? model; + } + if (event?.type !== "event_msg" && event?.type !== "token_count" && payload?.type !== "token_count") continue; + const info = payload?.info ?? event?.info; + const totalUsage = info?.total_token_usage ?? payload?.total_token_usage ?? event?.total_token_usage; + if (!totalUsage || typeof totalUsage !== "object") continue; + const input = safeCount(totalUsage.input_tokens); + const output = safeCount(totalUsage.output_tokens); + const reportedTotal = safeCount(totalUsage.total_tokens); + const total = reportedTotal ?? (input !== null && output !== null ? safeCount(input + output) : null); + if (total !== null) usage = { total, input, output }; + } + return { usage, size, modified: fs.fstatSync(descriptor).mtimeMs, model }; + } finally { fs.closeSync(descriptor); } + } + + let db; + try { + db = new DatabaseSync(workerData.database, { readOnly: true }); + db.exec("PRAGMA query_only = ON; PRAGMA busy_timeout = 500"); + const columns = new Set(db.prepare("PRAGMA table_info(threads)").all().map((row) => row.name)); + if (!columns.has("id")) throw new Error("Codex thread index is unavailable."); + const selected = ["id", "tokens_used", "model", "rollout_path", "updated_at", "updated_at_ms"] + .filter((column) => columns.has(column)).map((column) => '"' + column + '"'); + const row = db.prepare("SELECT " + selected.join(",") + " FROM threads WHERE id = ? LIMIT 1").get(workerData.threadId); + db.close(); + db = null; + if (!row) { parentPort.postMessage(null); return; } + + const databaseTotal = safeCount(row.tokens_used); + let rollout = null; + let fileSignature = ""; + if (typeof row.rollout_path === "string" && row.rollout_path.length <= 4096) { + const candidate = pathInsideHome(row.rollout_path); + if (candidate) { + try { + // Both sides use the OS resolver, as the host's realpath() does: the JavaScript resolver keeps + // Windows 8.3 short names (RUNNER~1) and drive-letter case, so a file inside the home looked outside it. + const realHome = fs.realpathSync.native(workerData.home); + const realFile = fs.realpathSync.native(candidate); + if (realFile.startsWith(realHome + path.sep)) { + const info = fs.statSync(realFile); + fileSignature = JSON.stringify([row.updated_at_ms ?? row.updated_at ?? null, databaseTotal, row.model ?? null, realFile, info.size, info.mtimeMs]); + if (workerData.previousSignature === fileSignature) { + parentPort.postMessage({ unchanged: true, signature: fileSignature }); + return; + } + rollout = readLatestCounters(realFile); + } + } catch { /* The aggregate SQLite counter remains usable if the rollout has moved or is unavailable. */ } + } + } + + const total = rollout?.usage?.total ?? databaseTotal; + if (total === null) { parentPort.postMessage(null); return; } + const model = safeModel(rollout?.model) ?? safeModel(row.model); + const value = { + total, + input: rollout?.usage?.input ?? null, + output: rollout?.usage?.output ?? null, + ...(model ? { model } : {}) + }; + const signature = fileSignature || JSON.stringify([row.updated_at_ms ?? row.updated_at ?? null, databaseTotal, row.model ?? null, row.rollout_path ?? null]); + parentPort.postMessage({ usage: value, signature }); + } catch { + try { db?.close(); } catch { /* The worker has already failed. */ } + parentPort.postMessage(null); + } +})(); +`; + +function isWorkerUsageResult(value: unknown): value is WorkerUsageResult { + if (!value || typeof value !== "object") return false; + const result = value as WorkerUsageResult; + if (result.unchanged === true) return typeof result.signature === "string"; + if (!result.usage || typeof result.signature !== "string") return false; + const { total, input, output, model } = result.usage; + return Number.isSafeInteger(total) && total >= 0 + && (input === null || Number.isSafeInteger(input) && input >= 0) + && (output === null || Number.isSafeInteger(output) && output >= 0) + && (model === undefined || typeof model === "string" && model.length <= 200); +} diff --git a/src/main/services/ReviewDiffTracker.ts b/src/main/services/ReviewDiffTracker.ts new file mode 100644 index 00000000..647e6ae9 --- /dev/null +++ b/src/main/services/ReviewDiffTracker.ts @@ -0,0 +1,92 @@ +import { execFileSync } from "node:child_process"; +import { realpathSync } from "node:fs"; +import { isAbsolute, relative, resolve, sep } from "node:path"; + +export interface ReviewDiffBaseline { root: string; head: string; reason?: string } + +/** Host-owned attribution for managed PTYs. A shared or previously dirty tree cannot establish ownership. */ +export class ReviewDiffTracker { + private readonly active = new Map(); + private readonly baselines = new Map(); + + /** Synchronous by design: no other managed launch can interleave between this capture and spawnPty. */ + beforeSpawn(id: string, cwd: string, requested: boolean, parentCwd?: string): void { + this.forget(id); + let root: string | null = null; + try { root = this.root(cwd); } catch { /* A non-Git or inaccessible launch cannot supply a review diff. */ } + let canonicalCwd = resolve(cwd); + try { canonicalCwd = realpathSync(cwd); } catch { /* Retain the absolute path for conservative overlap checks. */ } + for (const baseline of this.baselines.values()) { + if (baseline.root === root || root === null && overlaps(canonicalCwd, baseline.root)) baseline.reason = "Another managed session started in this worktree; its changes cannot be attributed to this worker."; + } + const shared = [...this.active.values()].some(other => other.root === root || other.root === null && root !== null && overlaps(other.cwd, root)); + this.active.set(id, { root, cwd: canonicalCwd }); + if (!requested) return; + const baseline: ReviewDiffBaseline = { root: root ?? cwd, head: "" }; + this.baselines.set(id, baseline); + try { + if (!root) throw new Error("The worker's effective launch directory is not a verifiable Git worktree."); + if (!parentCwd || this.root(parentCwd) === root || shared) { + throw new Error("Automatic review requires a separate worktree; this worker shares its tree with its parent or another managed session."); + } + const safe = this.safeArguments(root); + if (this.git(root, [...safe, "ls-files", "--stage"]).split("\n").some(line => line.startsWith("160000 "))) { + throw new Error("Automatic review cannot attribute changes in a worktree containing submodules."); + } + if (this.git(root, [...safe, "status", "--porcelain=v1", "--untracked-files=all", "--ignore-submodules=all"])) { + throw new Error("The worker worktree was already dirty at launch; automatic review cannot attribute those changes to the worker."); + } + const head = this.git(root, [...safe, "rev-parse", "--verify", "HEAD^{commit}"]); + if (!/^[a-f0-9]{40,64}$/u.test(head)) throw new Error("The worker's initial Git commit could not be verified."); + baseline.head = head; + } catch (error) { + baseline.reason = error instanceof Error ? error.message : "The worker review baseline could not be captured."; + } + } + + baseline(id: string): ReviewDiffBaseline { + const baseline = this.baselines.get(id); + if (!baseline) throw new Error("No worker-specific review baseline was captured for this launch."); + if (baseline.reason) throw new Error(baseline.reason); + if (this.root(baseline.root) !== baseline.root) throw new Error("The worker's Git worktree changed after launch."); + return baseline; + } + + assertCurrent(id: string, expected: ReviewDiffBaseline): void { + if (this.baseline(id) !== expected) throw new Error("The worker was restarted or removed while its diff was being read."); + } + + stopped(id: string): void { this.active.delete(id); } + forget(id: string): void { this.active.delete(id); this.baselines.delete(id); } + + private root(cwd: string): string { + return realpathSync(this.git(cwd, ["rev-parse", "--show-toplevel"])); + } + + private safeArguments(cwd: string): string[] { + let filters = ""; + try { filters = this.git(cwd, ["config", "--get-regexp", "^filter\\..*\\.(clean|smudge|process|required)$"]); } + catch (error) { if ((error as {status?: number}).status !== 1) throw error; } + return filters.split("\n").flatMap(line => { + const key = line.split(/\s/u, 1)[0]; + return /^filter\..*\.(clean|smudge|process|required)$/u.test(key) ? ["-c", `${key}=${key.endsWith(".required") ? "false" : ""}`] : []; + }); + } + + private git(cwd: string, args: string[]): string { + const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_"))); + return execFileSync("git", ["--no-replace-objects", "-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", + "-c", "diff.external=", "-C", cwd, ...args], { + env: {...env, GIT_OPTIONAL_LOCKS: "0", GIT_TERMINAL_PROMPT: "0"}, timeout: 1500, maxBuffer: 2 * 1024 * 1024, + encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] + }).trimEnd(); + } +} + +function overlaps(first: string, second: string): boolean { + const inside = (root: string, path: string): boolean => { + const rel = relative(root, path); + return rel === "" || !isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`); + }; + return inside(first, second) || inside(second, first); +} diff --git a/src/main/services/SecretApiRequestWorker.d.mts b/src/main/services/SecretApiRequestWorker.d.mts new file mode 100644 index 00000000..f7e7d85b --- /dev/null +++ b/src/main/services/SecretApiRequestWorker.d.mts @@ -0,0 +1,23 @@ +export interface SecretApiWorkerInput { + secretId: string; + profile: { protocol: string; baseUrl: string }; + method: string; + path: string; + body?: Record; +} + +export interface SecretApiWorkerResult { + status: number; + body: string; + truncated: boolean; +} + +export function normalizePublicHttpsBaseUrl(value: unknown): string; +export function buildProviderApiUrl(baseUrl: string, path: string): string; +export function performSecretApiRequest( + input: SecretApiWorkerInput, + secret: string, + fetchImpl?: typeof fetch, + signal?: AbortSignal +): Promise; +export const SECRET_API_REQUEST_WORKER_SOURCE: string; diff --git a/src/main/services/SecretApiRequestWorker.mjs b/src/main/services/SecretApiRequestWorker.mjs new file mode 100644 index 00000000..f7240688 --- /dev/null +++ b/src/main/services/SecretApiRequestWorker.mjs @@ -0,0 +1,148 @@ +/** A self-contained factory so its bundled source never closes over renamed module bindings. */ +function createSecretApiWorker() { + const PROVIDER_SECRET_IDS = new Set([ + "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "XAI_API_KEY", "GOOGLE_API_KEY", + "ZAI_API_KEY", "MINIMAX_API_KEY", "OPENROUTER_API_KEY", "DEEPSEEK_API_KEY" + ]); + const API_PROTOCOLS = new Set(["openai-compatible", "anthropic-compatible", "google"]); + const METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]); + const INPUT_KEYS = new Set(["secretId", "secret", "profile", "method", "path", "body"]); + const MAX_PATH_CHARS = 2_048; + const MAX_BODY_BYTES = 64 * 1_024; + const MAX_RESPONSE_BYTES = 32 * 1_024; + + function normalizePublicHttpsBaseUrl(value) { + if (typeof value !== "string" || value.length < 1 || value.length > 500) { + throw new Error("API profile has no valid public HTTPS base URL."); + } + let url; + try { url = new URL(value.trim()); } catch { throw new Error("API profile has no valid public HTTPS base URL."); } + const hostname = url.hostname.toLowerCase().replace(/\.$/u, ""); + if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash || url.port + || hostname.includes(":") || /^\d+(?:\.\d+){3}$/u.test(hostname) + || !hostname.includes(".") || hostname === "localhost" || hostname.endsWith(".localhost") + || hostname.endsWith(".local") || hostname.endsWith(".internal") || hostname.endsWith(".test") + || hostname.endsWith(".invalid") || !hostname.split(".").every((part) => /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/u.test(part))) { + throw new Error("API profile must use a public HTTPS base URL without credentials or a custom port."); + } + url.hostname = hostname; + url.pathname = url.pathname.replace(/\/{2,}/gu, "/").replace(/\/$/u, "") || "/"; + return `${url.origin}${url.pathname === "/" ? "" : url.pathname}`; + } + + function buildProviderApiUrl(baseUrl, path) { + const normalizedBaseUrl = normalizePublicHttpsBaseUrl(baseUrl); + if (typeof path !== "string" || path.length < 1 || path.length > MAX_PATH_CHARS + || /[\u0000-\u0020\\#]/u.test(path) || path.startsWith("//") + || /^[a-z][a-z0-9+.-]*:/iu.test(path)) { + throw new Error("API request path must be relative to the selected profile."); + } + const relativePath = path.startsWith("/") ? path.slice(1) : path; + const pathname = relativePath.split("?", 1)[0] ?? ""; + if (!pathname || pathname.split("/").some((part) => { + let decoded; + try { decoded = decodeURIComponent(part); } catch { return true; } + return part === "." || part === ".." || decoded === "." || decoded === ".."; + })) throw new Error("API request path is invalid."); + const base = new URL(normalizedBaseUrl); + const basePath = base.pathname === "/" ? "/" : `${base.pathname.replace(/\/$/u, "")}/`; + const result = new URL(relativePath, `${base.origin}${basePath}`); + if (result.origin !== base.origin || !result.pathname.startsWith(basePath)) { + throw new Error("API request path escapes the selected profile."); + } + return result.href; + } + + async function performSecretApiRequest(input, secret, fetchImpl = fetch, signal) { + if (!input || typeof input !== "object" || Array.isArray(input) + || Object.keys(input).some((key) => !INPUT_KEYS.has(key)) + || !PROVIDER_SECRET_IDS.has(input.secretId) || typeof secret !== "string" || secret.length === 0 + || !input.profile || typeof input.profile !== "object" || !API_PROTOCOLS.has(input.profile.protocol) + || !METHODS.has(input.method)) { + throw new Error("API request is invalid."); + } + const url = buildProviderApiUrl(input.profile.baseUrl, input.path); + let body; + if (input.body !== undefined) { + if (!input.body || typeof input.body !== "object" || Array.isArray(input.body) || input.method === "GET") { + throw new Error("API request body must be a JSON object and cannot accompany GET."); + } + try { body = JSON.stringify(input.body); } catch { throw new Error("API request body is invalid."); } + if (typeof body !== "string" || new TextEncoder().encode(body).byteLength > MAX_BODY_BYTES) { + throw new Error("API request body exceeds 64 KB."); + } + } + const headers = { Accept: "application/json" }; + if (body !== undefined) headers["Content-Type"] = "application/json"; + if (input.profile.protocol === "openai-compatible") headers.Authorization = `Bearer ${secret}`; + else if (input.profile.protocol === "anthropic-compatible") { + headers["x-api-key"] = secret; + headers["anthropic-version"] = "2023-06-01"; + } else headers["x-goog-api-key"] = secret; + + const response = await fetchImpl(url, { method: input.method, headers, body, redirect: "error", signal }); + const reader = response.body?.getReader(); + if (!reader) return { status: response.status, body: "", truncated: false }; + const chunks = []; + let length = 0; + let truncated = false; + while (true) { + const item = await reader.read(); + if (item.done) break; + const chunk = item.value instanceof Uint8Array ? item.value : new Uint8Array(item.value); + if (length + chunk.byteLength > MAX_RESPONSE_BYTES) { + const remaining = MAX_RESPONSE_BYTES - length; + if (remaining > 0) { chunks.push(chunk.subarray(0, remaining)); length += remaining; } + truncated = true; + await reader.cancel().catch(() => undefined); + break; + } + chunks.push(chunk); + length += chunk.byteLength; + } + const bytes = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; } + // A capped stream may end inside a code point. Keep it pending rather than inventing a replacement. + let decoded = new TextDecoder().decode(bytes, { stream: truncated }); + const encoded = new TextEncoder().encode(decoded); + // Malformed input can expand to three-byte replacement characters during decoding. + if (encoded.byteLength > MAX_RESPONSE_BYTES) { + decoded = new TextDecoder().decode(encoded.subarray(0, MAX_RESPONSE_BYTES), { stream: true }); + truncated = true; + } + return { status: response.status, body: decoded, truncated }; + } + + async function readInput() { + const chunks = []; + let length = 0; + for await (const chunk of process.stdin) { + length += chunk.length; + if (length > 96 * 1024) throw new Error("request-too-large"); + chunks.push(chunk); + } + return JSON.parse(Buffer.concat(chunks).toString("utf8")); + } + + async function run() { + try { + const input = await readInput(); + if (!PROVIDER_SECRET_IDS.has(input.secretId)) throw new Error("secret-id-mismatch"); + const result = await performSecretApiRequest(input, input.secret); + process.stdout.write(JSON.stringify({ ok: true, result })); + } catch { + process.stdout.write(JSON.stringify({ ok: false })); + process.exitCode = 1; + } + } + + return { normalizePublicHttpsBaseUrl, buildProviderApiUrl, performSecretApiRequest, run }; +} + +const worker = createSecretApiWorker(); +export const normalizePublicHttpsBaseUrl = worker.normalizePublicHttpsBaseUrl; +export const buildProviderApiUrl = worker.buildProviderApiUrl; +export const performSecretApiRequest = worker.performSecretApiRequest; +// The factory is self-contained: compiled or minified names are all internal to its source text. +export const SECRET_API_REQUEST_WORKER_SOURCE = `void (${createSecretApiWorker.toString()})().run();`; diff --git a/src/main/services/SecretCommandExecutor.ts b/src/main/services/SecretCommandExecutor.ts new file mode 100644 index 00000000..0f5e2a22 --- /dev/null +++ b/src/main/services/SecretCommandExecutor.ts @@ -0,0 +1,120 @@ +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { StringDecoder } from "node:string_decoder"; +import type { AgentIsolation, WrappedLaunch } from "./isolation/AgentIsolation.ts"; +import type { SecretApiExecutionRequest, SecretApiExecutionResult } from "./SecretGrantService.ts"; +import { SECRET_API_REQUEST_WORKER_SOURCE } from "./SecretApiRequestWorker.mjs"; + +const MAX_WORKER_OUTPUT_BYTES = 256 * 1_024; +const MAX_RESPONSE_BYTES = 32 * 1_024; +const SUPPORTED_SECRET_IDS = new Set([ + "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "XAI_API_KEY", "GOOGLE_API_KEY", + "ZAI_API_KEY", "MINIMAX_API_KEY", "OPENROUTER_API_KEY", "DEEPSEEK_API_KEY" +]); + +/** Secrets enter only the fixed, host-bundled API worker, never a model-selected executable. */ +export function secretApiRequestExecutor(isolation: AgentIsolation) { + return async (request: SecretApiExecutionRequest): Promise => { + if (!SUPPORTED_SECRET_IDS.has(request.secretId)) throw new Error(`Provider API requests are not supported for ${request.secretId}.`); + if (!isolation.containment()) throw new Error("Provider API requests require enabled operating-system isolation."); + if (request.signal.aborted) throw new Error("Provider API request was canceled."); + + const environment: Record = {}; + for (const key of ["PATH", "HOME", "LANG", "LC_ALL", "LC_CTYPE", "SystemRoot", "WINDIR"]) { + const value = process.env[key]; + if (value) environment[key] = value; + } + environment.ELECTRON_RUN_AS_NODE = "1"; + + let launch: WrappedLaunch | null = null; + try { + launch = isolation.wrap({ + sessionId: `secret-api-${randomUUID()}`, + provider: request.provider, + profile: request.launchProfile, + cwd: request.cwd, + networkProjectRoot: request.networkProjectRoot, + command: process.execPath, + // Node's fetch ignores HTTP(S)_PROXY unless enabled explicitly. The OS isolation wrapper + // owns proxy variables in strict network modes, so force the bundled runtime to honor them. + args: ["--use-env-proxy", "-e", SECRET_API_REQUEST_WORKER_SOURCE], + env: environment + }); + const wrapped = launch; + const payload = JSON.stringify({ + secretId: request.secretId, + secret: request.secret, + profile: request.apiProfile, + method: request.method, + path: request.path, + ...(request.body ? { body: request.body } : {}) + }); + return await new Promise((resolve, reject) => { + const spawnedChild = spawn(wrapped.command, wrapped.args, { + cwd: request.cwd, + env: wrapped.env, + stdio: ["pipe", "pipe", "ignore"], + detached: process.platform !== "win32" + }); + let output = ""; + const decoder = new StringDecoder("utf8"); + let outputBytes = 0; + let failure: Error | null = null; + let settled = false; + const finishError = (error: Error): void => { + if (settled) return; + settled = true; + reject(error); + }; + const terminate = (message: string): void => { + failure ??= new Error(message); + try { + if (spawnedChild.pid && process.platform !== "win32") process.kill(-spawnedChild.pid, "SIGKILL"); + else spawnedChild.kill("SIGKILL"); + } catch { /* already exited */ } + }; + const abort = (): void => terminate("Provider API request was canceled or timed out."); + const timeout = setTimeout(() => terminate("Provider API request timed out."), request.timeoutMs); + request.signal.addEventListener("abort", abort, { once: true }); + if (request.signal.aborted) abort(); + spawnedChild.stdout.on("data", (chunk: Buffer) => { + outputBytes += chunk.length; + if (outputBytes > MAX_WORKER_OUTPUT_BYTES) return terminate("Provider API response exceeded its limit."); + output += decoder.write(chunk); + }); + spawnedChild.stdin.on("error", (error) => { failure ??= error; }); + spawnedChild.once("error", (error) => { failure = error; }); + spawnedChild.once("close", (exitCode) => { + clearTimeout(timeout); + request.signal.removeEventListener("abort", abort); + if (failure) return finishError(failure); + if (exitCode !== 0) return finishError(new Error("Provider API request failed.")); + output += decoder.end(); + try { + const envelope = JSON.parse(output) as { ok?: unknown; result?: unknown }; + if (envelope.ok !== true || !envelope.result || typeof envelope.result !== "object") { + throw new Error("invalid worker result"); + } + const result = envelope.result as Partial; + if (!Number.isInteger(result.status) || (result.status as number) < 100 || (result.status as number) > 599 + || typeof result.body !== "string" || typeof result.truncated !== "boolean" + || Buffer.byteLength(result.body, "utf8") > MAX_RESPONSE_BYTES) { + throw new Error("invalid worker result"); + } + settled = true; + resolve({ status: result.status as number, body: result.body, truncated: result.truncated }); + } catch { + finishError(new Error("Provider API request failed.")); + } + }); + spawnedChild.stdin.end(payload); + }); + } finally { + try { launch?.cleanup(); } + finally { + for (const key of Object.keys(environment)) delete environment[key]; + if (launch) for (const key of Object.keys(launch.env)) delete launch.env[key]; + } + } + }; +} diff --git a/src/main/services/SecretGrantService.ts b/src/main/services/SecretGrantService.ts new file mode 100644 index 00000000..e21bca1b --- /dev/null +++ b/src/main/services/SecretGrantService.ts @@ -0,0 +1,537 @@ +import { randomUUID } from "node:crypto"; +import type { ApiProfile, ApiProfileProtocol, LaunchProfileId, ProviderId, ProviderSecretId } from "../../shared/contracts.ts"; +import { API_PROFILE_PRESETS, PROVIDER_SECRET_IDS } from "../../shared/contracts.ts"; +import { buildProviderApiUrl, normalizePublicHttpsBaseUrl } from "./SecretApiRequestWorker.mjs"; + +export type SecretGrantDuration = "10m" | "turn" | "session"; + +const REQUEST_TTL_MS = 10 * 60_000; +const TEN_MINUTES_MS = 10 * 60_000; +const MAX_REASON_CHARS = 1_000; +const MAX_API_BODY_BYTES = 64 * 1024; +const MAX_OUTPUT_BYTES = 16 * 1024; +const DEFAULT_TIMEOUT_MS = 30_000; +const MAX_TIMEOUT_MS = 5 * 60_000; +const SECRET_IDS = new Set(PROVIDER_SECRET_IDS); +const METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]); + +export interface SecretCommandSession { + provider: ProviderId; + cwd: string; + networkProjectRoot?: string; + profile: LaunchProfileId; + /** Must be false for closed/disposed sessions. */ + active: boolean; +} + +export interface SecretRequestSnapshot { + id: string; + sessionId: string; + secretId: ProviderSecretId; + reason: string; + createdAt: number; + expiresAt: number; + turnAvailable: boolean; +} + +export interface SecretGrantSnapshot { + sessionId: string; + secretId: ProviderSecretId; + duration: SecretGrantDuration; + approvedAt: number; + expiresAt: number | null; +} + +export interface SecretApiExecutionRequest { + secretId: ProviderSecretId; + apiProfile: { protocol: ApiProfileProtocol; baseUrl: string }; + method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + path: string; + body?: Record; + /** The host executor forwards this value only in the fixed helper's environment. */ + secret: string; + cwd: string; + networkProjectRoot?: string; + launchProfile: LaunchProfileId; + provider: ProviderId; + timeoutMs: number; + signal: AbortSignal; +} + +export interface SecretApiExecutionResult { + status: number; + body: string; + truncated: boolean; +} + +export interface SecretGrantServiceOptions { + /** Main-process-only ProviderSecretsService.get callback. */ + getSecret(secretId: ProviderSecretId): Promise; + /** Resolve trusted session metadata from the host, never from tool arguments. */ + getSession(sessionId: string): SecretCommandSession | null; + /** Current trusted launch/turn/input identity, or null when this launch cannot observe turn completion. */ + getTurnIdentity?(sessionId: string): string | null; + /** Notify after host input changes the captured turn; callbacks never carry secret material. */ + watchTurn?(sessionId: string, changed: () => void): () => void; + /** Human-owned profiles only. API origins are never accepted from agent tool arguments. */ + getApiProfiles?(): readonly ApiProfile[]; + /** Must execute the fixed host-owned API helper inside the host's approved isolation boundary. */ + execute?(request: SecretApiExecutionRequest): Promise; + /** Register key material with the process-wide terminal redactor before it can be output. */ + rememberSecret?(value: string): void; + /** Full-text masker, typically TerminalManager.redactSecrets. */ + redact?(value: string): string; + onRequest?(request: SecretRequestSnapshot): void; + onDecision?(event: { request: SecretRequestSnapshot; decision: "approved" | "denied" | "expired" | "revoked"; duration?: SecretGrantDuration }): void; + onRevoke?(event: { sessionId: string; secretId: ProviderSecretId; reason: "manual" | "turn-ended" | "session-ended" | "expired" }): void; + now?: () => number; +} + +interface SecretApiRequestInput { + secretId: string; apiProfileId?: string; method: string; path: string; + body?: Record; timeoutMs?: number; +} + +interface PendingRequest extends Omit { readonly originTurnIdentity: string | null; turnIdentity: string | null; } +interface Grant extends SecretGrantSnapshot { expiresAt: number | null; turnIdentity: string | null; } + +/** + * Host-owned, memory-only grants for using a provider secret in one isolated provider API request. + * Agent tools can request and consume a grant but cannot approve or extend it. Approval + * and revocation methods are intended for trusted main-process/UI IPC callers only. + */ +export class SecretGrantService { + private readonly options: SecretGrantServiceOptions; + private readonly pendingRequests = new Map(); + private readonly grants = new Map(); + private readonly turnWatches = new Map void>(); + private readonly activeRuns = new Set<{ sessionId: string; secretIds: Set; controller: AbortController }>(); + + constructor(options: SecretGrantServiceOptions) { + this.options = options; + } + + requestSecret(sessionId: string, secretId: string, reason: string): SecretRequestSnapshot { + const session = this.activeSession(sessionId); + if (session.provider === "terminal") throw new Error("Provider secrets are available to agent sessions only."); + if (!isSecretId(secretId)) throw new Error("Unknown provider secret."); + if (typeof reason !== "string" || !reason.trim() || reason.length > MAX_REASON_CHARS) { + throw new Error("Give a short reason for requesting this provider secret."); + } + this.pruneExpired(); + const originTurnIdentity = this.currentTurn(sessionId); + // Only a genuinely observed new turn gets a separate request. Idle/unsupported repeats keep the prior TTL. + const duplicate = [...this.pendingRequests.values()].reverse().find((item) => item.sessionId === sessionId && item.secretId === secretId + && (originTurnIdentity === null || item.originTurnIdentity === originTurnIdentity)); + if (duplicate) return this.requestSnapshot(duplicate); + const now = this.now(); + const request: PendingRequest = { + id: randomUUID(), + sessionId, + secretId, + reason: this.mask(reason.trim()).slice(0, MAX_REASON_CHARS), + createdAt: now, + expiresAt: now + REQUEST_TTL_MS, + originTurnIdentity, + turnIdentity: originTurnIdentity + }; + this.pendingRequests.set(request.id, request); + if (!this.turnWatches.has(sessionId) && this.options.watchTurn) { + this.turnWatches.set(sessionId, this.options.watchTurn(sessionId, () => this.revalidateTurn(sessionId))); + } + this.emit(() => this.options.onRequest?.(this.requestSnapshot(request))); + return this.requestSnapshot(request); + } + + /** Safe for a human-facing UI; secret values are never included. */ + pending(sessionIds?: readonly string[]): SecretRequestSnapshot[] { + this.pruneExpired(); + const allowed = sessionIds ? new Set(sessionIds) : null; + return [...this.pendingRequests.values()] + .filter((item) => !allowed || allowed.has(item.sessionId)) + .sort((a, b) => a.createdAt - b.createdAt) + .map((item) => this.requestSnapshot(item)); + } + + /** Safe for a human-facing UI; grants contain metadata only, never key material. */ + listGrants(sessionIds?: readonly string[]): SecretGrantSnapshot[] { + this.pruneExpired(); + const allowed = sessionIds ? new Set(sessionIds) : null; + return [...this.grants.values()] + .filter((item) => !allowed || allowed.has(item.sessionId)) + .sort((a, b) => a.approvedAt - b.approvedAt) + .map((item) => this.grantSnapshot(item)); + } + + /** UI-only. Call only after the person explicitly approves the pending request. */ + approve(requestId: string, duration: SecretGrantDuration): SecretGrantSnapshot { + if (!isDuration(duration)) throw new Error("Invalid secret grant duration."); + this.pruneExpired(); + const request = this.pendingRequests.get(requestId); + if (!request) throw new Error("Secret request expired or is no longer pending."); + const session = this.activeSession(request.sessionId); + if (session.provider === "terminal") throw new Error("Provider secrets are available to agent sessions only."); + if (duration === "turn" && (!request.turnIdentity || request.turnIdentity !== this.currentTurn(request.sessionId))) { + throw new Error("Turn-scoped approval is unavailable for this launch or the requested turn has ended."); + } + const now = this.now(); + const grant: Grant = { + sessionId: request.sessionId, + secretId: request.secretId, + duration, + approvedAt: now, + expiresAt: duration === "10m" ? now + TEN_MINUTES_MS : null, + turnIdentity: duration === "turn" ? request.turnIdentity : null + }; + this.pendingRequests.delete(request.id); + this.grants.set(grantKey(request.sessionId, request.secretId), grant); + this.emit(() => this.options.onDecision?.({ request: this.requestSnapshot(request), decision: "approved", duration })); + return this.grantSnapshot(grant); + } + + /** UI-only. */ + deny(requestId: string): void { + this.pruneExpired(); + const request = this.pendingRequests.get(requestId); + if (!request) throw new Error("Secret request expired or is no longer pending."); + this.pendingRequests.delete(requestId); + this.emit(() => this.options.onDecision?.({ request: this.requestSnapshot(request), decision: "denied" })); + } + + /** UI-only. Revokes all grants for a session, or one selected secret. */ + revoke(sessionId: string, secretId?: string): number { + if (secretId !== undefined && !isSecretId(secretId)) throw new Error("Unknown provider secret."); + let count = 0; + for (const grant of [...this.grants.values()]) { + if (grant.sessionId !== sessionId || (secretId !== undefined && grant.secretId !== secretId)) continue; + this.removeGrant(grant, "manual"); + count += 1; + } + for (const run of this.activeRuns) { + if (run.sessionId === sessionId && (secretId === undefined || run.secretIds.has(secretId as ProviderSecretId))) run.controller.abort(); + } + return count; + } + + turnEnded(sessionId: string): void { + for (const grant of [...this.grants.values()]) { + if (grant.sessionId === sessionId && grant.duration === "turn") this.removeGrant(grant, "turn-ended"); + } + this.abortRuns(sessionId); + // Human approval may arrive after the reply ends. Keep its original TTL and longer scopes, + // but never let this pending request borrow the ended turn or any later turn. + for (const request of this.pendingRequests.values()) { + if (request.sessionId === sessionId) request.turnIdentity = null; + } + } + + /** Revoke promptly on launch/input/hook changes; longer grants retain their established lifetime. */ + revalidateTurn(sessionId: string): void { + const current = this.currentTurn(sessionId); + for (const grant of [...this.grants.values()]) { + if (grant.sessionId === sessionId && grant.duration === "turn" && (!current || grant.turnIdentity !== current)) { + this.removeGrant(grant, "turn-ended"); + } + } + } + + sessionEnded(sessionId: string): void { + this.turnWatches.get(sessionId)?.(); + this.turnWatches.delete(sessionId); + for (const grant of [...this.grants.values()]) { + if (grant.sessionId === sessionId) this.removeGrant(grant, "session-ended"); + } + this.abortRuns(sessionId); + this.clearPending(sessionId); + } + + /** Raw model-selected executables are never given provider secrets. */ + async runSecretCommand( + _sessionId: string, + _input: { command: string; args: string[]; secretIds: string[]; timeoutMs?: number } + ): Promise { + throw new Error("Arbitrary secret-bearing commands are disabled. Use a typed provider API request."); + } + + async runSecretRequest( + sessionId: string, + input: SecretApiRequestInput, + signal?: AbortSignal + ): Promise<{ status: number; body: string; truncated: boolean }> { + if (signal?.aborted) throw new Error("Provider API request was canceled."); + const controller = new AbortController(); + if (!signal) return this.performSecretRequest(sessionId, input, controller); + let cancel!: () => void; + const canceled = new Promise((_resolve, reject) => { + cancel = () => { controller.abort(); reject(new Error("Provider API request was canceled.")); }; + signal.addEventListener("abort", cancel, { once: true }); + }); + try { + const result = await Promise.race([this.performSecretRequest(sessionId, input, controller), canceled]); + if (signal.aborted) throw new Error("Provider API request was canceled."); + return result; + } finally { + signal.removeEventListener("abort", cancel); + } + } + + private async performSecretRequest( + sessionId: string, + input: SecretApiRequestInput, + controller: AbortController + ): Promise<{ status: number; body: string; truncated: boolean }> { + controller.signal.throwIfAborted(); + const session = this.activeSession(sessionId); + if (session.provider === "terminal") throw new Error("Provider secrets are available to agent sessions only."); + const request = validateApiRequest(input); + const profile = resolveApiProfile(request.secretId, request.apiProfileId, this.options.getApiProfiles?.() ?? []); + const baseUrl = normalizePublicHttpsBaseUrl(profile.baseUrl); + // Validate the path and profile origin before touching the credential store. + buildProviderApiUrl(baseUrl, request.path); + if (!this.options.execute) throw new Error("Isolated provider API requests are unavailable."); + this.pruneExpired(); + const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; + if (!Number.isInteger(timeoutMs) || timeoutMs < 1_000 || timeoutMs > MAX_TIMEOUT_MS) { + throw new Error("API request timeout must be between 1 and 300 seconds."); + } + + this.revalidateTurn(sessionId); + const grant = this.grants.get(grantKey(sessionId, request.secretId)); + if (!grant || (grant.expiresAt !== null && grant.expiresAt <= this.now())) { + if (grant) this.removeGrant(grant, "expired"); + throw new Error(`Human approval is required for ${request.secretId}.`); + } + + let secretValue = ""; + try { + const value = await this.options.getSecret(request.secretId); + controller.signal.throwIfAborted(); + if (typeof value !== "string" || value.length === 0) throw new Error("missing"); + secretValue = value; + this.options.rememberSecret?.(value); + } catch { + if (controller.signal.aborted) throw new Error("Provider API request was canceled."); + throw new Error("An approved provider secret is unavailable."); + } + + // A revoke/session close may race while ProviderSecretsService reads from disk. + try { + this.activeSession(sessionId); + this.revalidateTurn(sessionId); + controller.signal.throwIfAborted(); + if (this.grants.get(grantKey(sessionId, request.secretId)) !== grant || (grant.expiresAt !== null && grant.expiresAt <= this.now())) { + throw new Error("Provider secret approval was revoked or expired."); + } + } catch (error) { + secretValue = ""; + throw error; + } + const run = { sessionId, secretIds: new Set([request.secretId]), controller }; + this.activeRuns.add(run); + let timedOut = false; + let grantExpired = false; + let expiryHandle: ReturnType | undefined; + const expireGrant = (): void => { + grantExpired = true; + // removeGrant checks identity: an older run must not revoke a replacement approval. + this.removeGrant(grant, "expired"); + controller.abort(); + }; + let timeoutHandle: ReturnType | undefined; + let abortListener: (() => void) | undefined; + try { + if (grant.expiresAt !== null) expiryHandle = setTimeout(expireGrant, Math.max(0, grant.expiresAt - this.now())); + const apiRequestPromise = this.options.execute({ + secretId: request.secretId, + apiProfile: { protocol: profile.protocol, baseUrl }, + method: request.method, + path: request.path, + ...(request.body ? { body: request.body } : {}), + secret: secretValue, + cwd: session.cwd, + ...(session.networkProjectRoot ? {networkProjectRoot:session.networkProjectRoot} : {}), + launchProfile: session.profile, + provider: session.provider, + timeoutMs, + signal: controller.signal + }); + const timeoutPromise = new Promise((_resolve, reject) => { + timeoutHandle = setTimeout(() => { + timedOut = true; + controller.abort(); + reject(new Error("timeout")); + }, timeoutMs); + }); + const abortPromise = new Promise((_resolve, reject) => { + abortListener = () => reject(new Error("aborted")); + controller.signal.addEventListener("abort", abortListener, { once: true }); + if (controller.signal.aborted) abortListener(); + }); + const result = await Promise.race([apiRequestPromise, timeoutPromise, abortPromise]); + if (grant.expiresAt !== null && grant.expiresAt <= this.now()) expireGrant(); + this.revalidateTurn(sessionId); + controller.signal.throwIfAborted(); + if (!result || !Number.isInteger(result.status) || result.status < 100 || result.status > 599 + || typeof result.body !== "string" || typeof result.truncated !== "boolean") { + throw new Error("invalid result"); + } + const maskedBody = this.mask(result.body); + return { + status: result.status, + body: this.capOutput(maskedBody), + truncated: result.truncated || Buffer.byteLength(maskedBody, "utf8") > MAX_OUTPUT_BYTES + }; + } catch { + throw new Error(grantExpired ? "Provider secret approval expired." : timedOut ? "Provider API request timed out." : controller.signal.aborted ? "Provider API request was canceled." : "Provider API request failed."); + } finally { + if (expiryHandle !== undefined) clearTimeout(expiryHandle); + if (timeoutHandle !== undefined) clearTimeout(timeoutHandle); + if (abortListener) controller.signal.removeEventListener("abort", abortListener); + secretValue = ""; + this.activeRuns.delete(run); + } + } + + private currentTurn(sessionId: string): string | null { + try { return this.options.getTurnIdentity?.(sessionId) ?? null; } catch { return null; } + } + + private requestSnapshot(request: PendingRequest): SecretRequestSnapshot { + const { turnIdentity, originTurnIdentity: _origin, ...snapshot } = request; + return { ...snapshot, turnAvailable: Boolean(turnIdentity && turnIdentity === this.currentTurn(request.sessionId)) }; + } + + private grantSnapshot(grant: Grant): SecretGrantSnapshot { + const { turnIdentity: _identity, ...snapshot } = grant; + return snapshot; + } + + private activeSession(sessionId: string): SecretCommandSession { + const session = this.options.getSession(sessionId); + if (!session || session.active !== true || !session.cwd || !session.profile || session.provider === "terminal") { + throw new Error("Secret operations require an active agent session."); + } + return session; + } + + private pruneExpired(): void { + const now = this.now(); + for (const request of [...this.pendingRequests.values()]) { + if (request.expiresAt <= now) { + this.pendingRequests.delete(request.id); + this.emit(() => this.options.onDecision?.({ request: this.requestSnapshot(request), decision: "expired" })); + } + } + for (const sessionId of new Set([...this.grants.values()].filter(grant => grant.duration === "turn").map(grant => grant.sessionId))) this.revalidateTurn(sessionId); + for (const grant of [...this.grants.values()]) { + if (grant.expiresAt !== null && grant.expiresAt <= now) this.removeGrant(grant, "expired"); + } + } + + private removeGrant(grant: Grant, reason: "manual" | "turn-ended" | "session-ended" | "expired"): void { + const key = grantKey(grant.sessionId, grant.secretId); + if (this.grants.get(key) !== grant) return; + this.grants.delete(key); + for (const run of this.activeRuns) { + if (run.sessionId === grant.sessionId && run.secretIds.has(grant.secretId)) run.controller.abort(); + } + this.emit(() => this.options.onRevoke?.({ sessionId: grant.sessionId, secretId: grant.secretId, reason })); + } + + private abortRuns(sessionId: string): void { + for (const run of this.activeRuns) if (run.sessionId === sessionId) run.controller.abort(); + } + + private clearPending(sessionId: string): void { + for (const request of [...this.pendingRequests.values()]) { + if (request.sessionId !== sessionId) continue; + this.pendingRequests.delete(request.id); + this.emit(() => this.options.onDecision?.({ request: this.requestSnapshot(request), decision: "revoked" })); + } + } + + private mask(value: string): string { + try { return this.options.redact?.(value) ?? value; } catch { return "[output redacted]"; } + } + + private capOutput(value: string): string { + const bytes = Buffer.from(value, "utf8"); + if (bytes.byteLength <= MAX_OUTPUT_BYTES) return value; + let start = bytes.byteLength - MAX_OUTPUT_BYTES + 3; + // The ellipsis uses three bytes; skip any partial leading code point in the retained UTF-8 tail. + while ((bytes[start]! & 0xc0) === 0x80) start += 1; + return `…${bytes.subarray(start).toString("utf8")}`; + } + + private now(): number { return this.options.now?.() ?? Date.now(); } + + private emit(callback: () => void): void { try { callback(); } catch { /* audit/UI callbacks must not break secret controls */ } } +} + +function validateApiRequest(input: { + secretId: string; apiProfileId?: string; method: string; path: string; + body?: Record; timeoutMs?: number; +}): { + secretId: ProviderSecretId; apiProfileId?: string; method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + path: string; body?: Record; +} { + const allowedKeys = new Set(["secretId", "apiProfileId", "method", "path", "body", "timeoutMs"]); + if (!input || typeof input !== "object" || Object.keys(input).some((key) => !allowedKeys.has(key))) { + throw new Error("Provider API request is invalid."); + } + if (!isSecretId(input.secretId)) throw new Error("Unknown provider secret."); + if (input.secretId === "DEVIN_API_KEY" || input.secretId === "CURSOR_API_KEY") { + throw new Error(`Provider API requests are not supported for ${input.secretId}.`); + } + if (input.apiProfileId !== undefined && (typeof input.apiProfileId !== "string" || input.apiProfileId.length < 1 || input.apiProfileId.length > 64)) { + throw new Error("API profile id is invalid."); + } + if (typeof input.method !== "string" || !METHODS.has(input.method)) throw new Error("API request method is invalid."); + if (typeof input.path !== "string" || input.path.length < 1 || input.path.length > 2_048) throw new Error("API request path is invalid."); + let body: Record | undefined; + if (input.body !== undefined) { + if (!input.body || typeof input.body !== "object" || Array.isArray(input.body) || input.method === "GET") { + throw new Error("API request body must be a JSON object and cannot accompany GET."); + } + let encoded: string | undefined; + try { encoded = JSON.stringify(input.body); } catch { throw new Error("API request body is invalid."); } + if (typeof encoded !== "string" || Buffer.byteLength(encoded, "utf8") > MAX_API_BODY_BYTES) { + throw new Error("API request body exceeds 64 KB."); + } + try { body = JSON.parse(encoded) as Record; } + catch { throw new Error("API request body is invalid."); } + } + return { + secretId: input.secretId, + ...(input.apiProfileId ? { apiProfileId: input.apiProfileId } : {}), + method: input.method as "GET" | "POST" | "PUT" | "PATCH" | "DELETE", + path: input.path, + ...(body ? { body } : {}) + }; +} + +function resolveApiProfile(secretId: ProviderSecretId, apiProfileId: string | undefined, configured: readonly ApiProfile[]): ApiProfile { + const presets = API_PROFILE_PRESETS.filter((candidate) => candidate.secretRef === secretId); + if (presets.length === 0) throw new Error(`Provider API requests are not supported for ${secretId}.`); + if (apiProfileId) { + const profile = configured.find((candidate) => candidate.id === apiProfileId) + ?? API_PROFILE_PRESETS.find((candidate) => candidate.id === apiProfileId); + if (!profile) throw new Error("Unknown provider API profile."); + if (profile.secretRef !== secretId) throw new Error("The selected API profile uses a different provider secret."); + return profile; + } + const standard = presets[0]!; + return configured.find((candidate) => candidate.id === standard.id && candidate.secretRef === secretId) ?? standard; +} + +function isSecretId(value: string): value is ProviderSecretId { + return SECRET_IDS.has(value); +} + +function isDuration(value: string): value is SecretGrantDuration { + return value === "10m" || value === "turn" || value === "session"; +} + +function grantKey(sessionId: string, secretId: string): string { + return `${sessionId}\u0000${secretId}`; +} diff --git a/src/main/services/SessionReports.ts b/src/main/services/SessionReports.ts new file mode 100644 index 00000000..40afa621 --- /dev/null +++ b/src/main/services/SessionReports.ts @@ -0,0 +1,65 @@ +import {createHash,randomUUID} from "node:crypto"; +import {mkdir,rename,unlink,writeFile} from "node:fs/promises"; +import {join} from "node:path"; + +/** Completion snapshots use only the existing timeline. A later turn invalidates an older report. */ +export class SessionReports { + private readonly generations=new Map(); + private readonly ready=new Set(); + private readonly pending=new Map>(); + private readonly directory:string; + private readonly generate:(id:string)=>Promise; + private readonly changed:(id:string,readyAt:number|null)=>void; + private readonly now:()=>number; + private writes:Promise=Promise.resolve(); + constructor(userDataPath:string,generate:(id:string)=>Promise, + changed:(id:string,readyAt:number|null)=>void,now=Date.now){ + this.directory=join(userDataPath,"session-reports"); + this.generate=generate;this.changed=changed;this.now=now; + } + complete(id:string):Promise{ + const current=this.pending.get(id);if(current)return current; + const generation=(this.generations.get(id) ?? 0)+1;this.generations.set(id,generation); + const operation=this.save(id,generation).finally(()=>{if(this.pending.get(id)===operation)this.pending.delete(id);}); + this.pending.set(id,operation);return operation; + } + invalidate(id:string):void { + this.generations.set(id,(this.generations.get(id) ?? 0)+1);this.pending.delete(id); + if(this.ready.delete(id))this.changed(id,null); + } + forget(id:string):void{ + this.invalidate(id); + this.writes=this.writes.catch(()=>undefined).then(()=>unlink(this.path(id)).catch(()=>undefined)); + } + async report(id:string):Promise{ + await this.pending.get(id); + const generation=this.generations.get(id); + const report=await this.generate(id); + // Git audits and usage hooks can finish after the terminal reports completion. + if(this.ready.has(id) && generation!==undefined){ + const write=this.writes.catch(()=>undefined).then(()=>this.write(id,generation,report)); + this.writes=write;await write; + } + return report; + } + async flush():Promise{await Promise.allSettled(this.pending.values());await this.writes;} + private path(id:string):string{return join(this.directory,createHash("sha256").update(id).digest("hex")+".md");} + private async save(id:string,generation:number):Promise{ + const report=await this.generate(id); + if(this.generations.get(id)!==generation)return; + const write=this.writes.catch(()=>undefined).then(()=>this.write(id,generation,report)); + this.writes=write;await write; + } + private async write(id:string,generation:number,report:string):Promise{ + if(this.generations.get(id)!==generation)return; + await mkdir(this.directory,{recursive:true,mode:0o700}); + const file=this.path(id),temporary=`${file}.${randomUUID()}.tmp`; + try{ + await writeFile(temporary,report,{mode:0o600,flag:"wx"}); + if(this.generations.get(id)!==generation)return; + await rename(temporary,file); + if(this.generations.get(id)!==generation)return; + this.ready.add(id);this.changed(id,this.now()); + }finally{await unlink(temporary).catch(()=>undefined);} + } +} diff --git a/src/main/services/SessionTimelineIndexWorker.ts b/src/main/services/SessionTimelineIndexWorker.ts new file mode 100644 index 00000000..7453a25b --- /dev/null +++ b/src/main/services/SessionTimelineIndexWorker.ts @@ -0,0 +1,8 @@ +import { parentPort, workerData } from "node:worker_threads"; +import { scanTimelineDirectory } from "./TimelineIndexScan.ts"; + +if (!parentPort || typeof workerData?.directory !== "string") throw new Error("Session timeline index worker requires its journal directory."); +void scanTimelineDirectory(workerData.directory).then( + value => parentPort!.postMessage(value), + error => parentPort!.postMessage({ error: error instanceof Error ? error.message : "Timeline scan failed." }) +); diff --git a/src/main/services/SessionTimelineService.ts b/src/main/services/SessionTimelineService.ts new file mode 100644 index 00000000..88a7d92d --- /dev/null +++ b/src/main/services/SessionTimelineService.ts @@ -0,0 +1,634 @@ +import { randomUUID } from "node:crypto"; +import { appendFile, mkdir, readdir, stat, unlink } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { existsSync } from "node:fs"; +import { Worker, type WorkerOptions } from "node:worker_threads"; +import { applyTimelineBudgetUsageEvent, budgetCounterIdentity, isTimelineCount, MAX_INDEXED_SEGMENTS, nextTimelineUsage, scanTimelineDirectory, timelineUsageContext, timelineUsageKey, + indexTimelineTaskSession, type BudgetCounterState, type BudgetUsageContribution, type TimelineDirectoryIndex, type TimelineIndexedUsage, type TimelineTaskSessionIndex, + type TimelineUsageContext } from "./TimelineIndexScan.ts"; +import { forEachTimelineEvent } from "./TimelineJournalReader.ts"; +import type { TimelineEvent, UsageSummary, UsageBreakdown, UsagePrice } from "../../shared/backlog.ts"; +import { addTimelineSearchEvent, createTimelineSegmentSearchIndex, timelineSegmentMayMatch, type TimelineSegmentSearchIndex } from "./TimelineSearchIndex.ts"; + +const SEGMENT_BYTES = 2 * 1024 * 1024; +const DEFAULT_MAX_BYTES = 200 * 1024 * 1024; +const MAX_EVENT_CHARS = 16_384; +const MAX_REPORT_EVENTS = 10_000; +export interface UsageContext extends TimelineUsageContext {} +export interface CumulativeUsage { input:number|null; output:number|null; total:number; costUsd?:number|null } +export interface TimelineUsageCounter { + id:string;legacyId?:string;tokens:number|null;costUsd:number|null;inputTokens:number|null;outputTokens:number|null; + provider?:string;model?:string;accountId?:string;taskId?:string;source:string;counterId?:string;cumulative:boolean; + reportedCostUsd:number|null; +} +export interface TimelineTaskBudgetUsage {identity:string;sessionId:string;taskId?:string;tokens:number;costUsd:number|null} +export interface TimelineTaskBudgetScope {taskId:string;sessionIds:readonly string[]} +export interface TimelineTaskBudgetData {sessionIds:string[];contributions:TimelineTaskBudgetUsage[]} +type Usage = TimelineIndexedUsage & { timelineOrder?:number }; +type TimelinePageCandidate = { event: TimelineEvent; index: number }; +type TimelinePageScope={taskId:string;legacySessionIds:string[]}; + +function createTimelineRing(capacity:number):{push(value:T):void;newestFirst():T[]} { + const values:Array=new Array(capacity); + let size=0,next=0; + return { + push(value:T) { + values[next]=value; + next=(next+1)%capacity; + if(sizerow.input!==null) ? values.reduce((sum,row)=>sum+row.input!,0) : null; + const output = values.every(row=>row.output!==null) ? values.reduce((sum,row)=>sum+row.output!,0) : null; + const totals=values.map(totalTokens); + const total=totals.every((value):value is number=>value!==null) ? totals.reduce((sum,value)=>sum+value,0) : null; + const costs=values.map(row=>pricedCost(row,prices)); + return { tokens: {input, output, total}, + cost: costs.every((value):value is number=>value!==null) ? costs.reduce((sum,value)=>sum+value,0) : null, + currency: "USD", source: [...new Set(values.map((row) => row.source))].join(", ") }; +} + +function pricedCost(value:Usage,prices:UsagePrice[]):number|null { + if(typeof value.cost==="number" && Number.isFinite(value.cost) && value.cost>=0)return value.cost; + if(value.input===null || value.output===null || !value.provider || !value.model)return null; + const price=priceForUsage(value,prices); + return price ? (value.input*price.inputPerMillion+value.output*price.outputPerMillion)/1_000_000 : null; +} +function priceForUsage(value:Usage,prices:UsagePrice[]):UsagePrice|undefined { + return value.provider && value.model ? prices.find(row=>row.provider===value.provider && row.model===value.model) : undefined; +} +function addKnown(previous:number|null|undefined,current:number|null):number|null { + return current===null ? previous ?? null : (previous ?? 0)+current; +} +function counterDelta(current:number|null,previous:number|null):number|null { + return current===null || previous===null ? null : current>=previous ? current-previous : current; +} + +/** Hook events only: terminal output is never copied into this journal. */ +export class SessionTimelineService { + private queue: Promise = Promise.resolve(); + private segment = ""; + private segmentBytes = 0; + private totalBytes = 0; + private readonly usageBySession = new Map(); + private readonly usageKeysBySession = new Map>(); + private nextUsageOrder=0; + private nextSampleOrder=0; + private readonly latestCounterSamples=new Map(); + private budgetUsageContributions=new Map(); + private budgetCounterBaselines=new Map(); + private readonly usageListeners = new Set<(sessionId: string) => void>(); + private segmentIndexes = new Map(); + private taskSessionsByTask = new Map>(); + private sessionContextResolver?: (sessionId:string)=>{taskId:string;title:string}|undefined; + readonly directory: string; + private readonly redact: (text:string)=>string; + private readonly maxBytes: number; + private readonly now: ()=>number; + private lastSegmentAt = 0; + private pageCache: {name: string; size: number; mtimeMs: number; events: TimelineEvent[]} | null = null; + private fileListCache: string[] | null = null; + constructor(userDataPath: string, redact: (text: string) => string, + maxBytes = DEFAULT_MAX_BYTES, now = Date.now) { + this.redact=redact;this.maxBytes=maxBytes;this.now=now; + this.directory = join(userDataPath, "session-timeline"); + } + + configureSessionContext(resolver:(sessionId:string)=>{taskId:string;title:string}|undefined):void { + this.sessionContextResolver=resolver; + } + + async load(): Promise { + await mkdir(this.directory, { recursive: true, mode: 0o700 }); + this.fileListCache = null; + this.pageCache = null; + this.totalBytes = await this.prune(); + // Scan journals incrementally on a worker. Startup only receives compact usage + // totals and bounded segment indexes, never hundreds of megabytes of events. + const index = await scanTimelineIndex(this.directory).catch(async error => { + console.warn("CanvasTTY timeline index worker unavailable; using the bounded journal scanner.", error instanceof Error ? error.message : "Worker failed."); + return scanTimelineDirectory(this.directory); + }).catch((error): TimelineDirectoryIndex => { + // Startup must not depend on old journals: without an index, usage totals start empty and pages read + // segments directly, while new events are still recorded. + console.warn("CanvasTTY timeline is degraded: its journal index is unavailable, so earlier usage totals are not shown.", + error instanceof Error ? error.message : "Scan failed."); + return {lastSegmentAt:0,usage:new Map(),budgetUsage:new Map(),budgetCounters:new Map(),segments:new Map(),taskSessions:new Map()}; + }); + this.lastSegmentAt = Math.max(this.lastSegmentAt, index.lastSegmentAt); + this.usageBySession.clear(); + this.usageKeysBySession.clear(); + this.nextUsageOrder=0; + this.nextSampleOrder=0; + this.latestCounterSamples.clear(); + for (const [key, usage] of index.usage) this.storeUsage(key, usageSession(key), usage); + this.budgetUsageContributions=index.budgetUsage; + this.budgetCounterBaselines=index.budgetCounters; + for(const [taskId,sessions] of index.taskSessions) { + for(const [sessionId,descriptor] of sessions) { + if(typeof descriptor.id!=="string" || descriptor.id!==sessionId || descriptor.taskId!==taskId + || typeof descriptor.title!=="string" || typeof descriptor.lastSegment!=="string" || !(descriptor.types instanceof Map)) { + sessions.delete(sessionId); + continue; + } + descriptor.title=this.redact(descriptor.title).slice(0,200); + } + if(!sessions.size)index.taskSessions.delete(taskId); + } + this.taskSessionsByTask=index.taskSessions; + this.segmentIndexes=index.segments; + } + + append(sessionId: string, type: string, summary: string, detail?: string, source?: string): Promise { + let context:{taskId:string;title:string}|undefined; + try { context=this.sessionContextResolver?.(sessionId); } catch { /* A closed or transient card keeps its ordinary session-only event. */ } + const taskId=context && typeof context.taskId==="string" && context.taskId.length>0 && context.taskId.length<=200 + ? context.taskId : undefined; + const sessionTitle=context && typeof context.title==="string" && context.title.length>0 + ? this.redact(context.title).slice(0,200) : undefined; + const event: TimelineEvent = { + id: randomUUID(), sessionId, at: this.now(), type, + summary: this.redact(summary).slice(0, 1000), + ...(detail !== undefined ? { detail: this.redact(detail).slice(0, MAX_EVENT_CHARS) } : {}), + ...(source ? { source: this.redact(source).slice(0, 100) } : {}), + ...(taskId ? {taskId} : {}), + ...(taskId && sessionTitle ? {sessionTitle} : {}) + }; + this.applyUsageEvent(event); + applyTimelineBudgetUsageEvent(this.budgetUsageContributions,this.budgetCounterBaselines,event); + const work = this.queue.then(async () => { + const line = JSON.stringify(event) + "\n"; + const bytes = Buffer.byteLength(line); + if (!this.segment || this.segmentBytes + bytes > Math.min(SEGMENT_BYTES, this.maxBytes)) { + this.lastSegmentAt=Math.max(this.now(),this.lastSegmentAt+1); + this.segment = `${String(this.lastSegmentAt).padStart(16, "0")}-${randomUUID()}.ndjson`; + this.segmentBytes = 0; + this.fileListCache = null; + } + await appendFile(join(this.directory, this.segment), line, { mode: 0o600 }); + this.pageCache = null; + this.segmentBytes += bytes; + this.totalBytes += bytes; + let index = this.segmentIndexes.get(this.segment); + if (!index) { + index = createTimelineSegmentSearchIndex(); + this.segmentIndexes.set(this.segment, index); + while (this.segmentIndexes.size > MAX_INDEXED_SEGMENTS) this.segmentIndexes.delete(this.segmentIndexes.keys().next().value!); + } + addTimelineSearchEvent(index, event); + index.size += bytes; + indexTimelineTaskSession(this.taskSessionsByTask,event,this.segment); + if (this.totalBytes > this.maxBytes) this.totalBytes = await this.prune(); + }); + this.queue = work.catch(() => undefined); + return work; + } + + /** Usage is accepted only from an identified real provider source, never synthesized from limits. */ + recordUsage(sessionId: string, input: number, output: number, source: string, cost?: number, context:UsageContext={}): Promise { + if (!isTimelineCount(input) || !isTimelineCount(output) + || !source || cost !== undefined && (!Number.isFinite(cost) || cost < 0)) { + return Promise.reject(new Error("Invalid provider usage record.")); + } + return this.append(sessionId, "usage", "Provider usage", JSON.stringify({ input, output, cost: cost ?? null, source,...timelineUsageContext(context) }), source); + } + + recordTokenTotal(sessionId:string,total:number,source:string,counterId?:string,context:UsageContext={}):Promise { + return this.recordCumulativeUsage(sessionId,{input:null,output:null,total},source,counterId,context); + } + + /** + * Replace a provider's cumulative counter; repeated lifecycle signals never add the same tokens twice. `resumed` + * says the card resumed this conversation: when the journal holds no earlier sample of its counter, this one is + * stored as a baseline, so usage from before the resume is not counted as this period's or this task's. + */ + recordCumulativeUsage(sessionId:string,usage:CumulativeUsage,source:string,counterId?:string,context:UsageContext={},options:{resumed?:boolean}={}):Promise { + const {input,output,total,costUsd}=usage; + if(!isTimelineCount(total) || input!==null && !isTimelineCount(input) || output!==null && !isTimelineCount(output) + || !source || costUsd!==undefined && costUsd!==null && (!Number.isFinite(costUsd) || costUsd<0) + || counterId!==undefined && (typeof counterId!=="string" || counterId.length>200))return Promise.reject(new Error("Invalid provider token counter.")); + const cost=costUsd ?? null,normalizedContext=timelineUsageContext(context); + const previous=this.usageBySession.get(timelineUsageKey(sessionId,{source,counterId,...normalizedContext})); + const latest=this.latestCounterSamples.get(snapshotIdentity(sessionId,{input,output,total,cost,source,counterId,...normalizedContext})); + if(previous?.cumulative && previous.sampleOrder===latest?.sampleOrder && previous.input===input && previous.output===output && previous.total===total + && previous.cost===cost && previous.source===source && previous.counterId===counterId + && Object.keys(normalizedContext).every(key=>previous[key as keyof UsageContext]===normalizedContext[key as keyof UsageContext]))return Promise.resolve(); + const baseline=options.resumed===true + && !this.budgetCounterBaselines.has(budgetCounterIdentity(sessionId,{input,output,total,cost,source,...(counterId!==undefined ? {counterId} : {}),...normalizedContext})); + return this.append(sessionId,"usage","CLI cumulative token counter",JSON.stringify({input,output,total,cost,source,cumulative:true,...(counterId ? {counterId} : {}),...normalizedContext,...(baseline ? {baseline:true} : {})}),source); + } + + async page(sessionId: string, cursor?: string, limit = 100,filter?:{query?:string;types?:string[];sessionIds?:string[]},scope?:TimelinePageScope): Promise<{items: TimelineEvent[]; nextCursor: string | null}> { + await this.queue; + const take = Math.min(500, Math.max(1, Math.floor(limit) || 100)); + const result: TimelineEvent[] = []; + const ids=new Set(filter?.sessionIds ?? [sessionId]),types=filter?.types?.length ? new Set(filter.types) : null,needle=filter?.query?.toLocaleLowerCase(); + const legacySessionIds=scope ? new Set(scope.legacySessionIds) : null; + const matches=(event:TimelineEvent):boolean=>ids.has(event.sessionId) + && (!types || types.has(event.type)) + && (!needle || `${event.summary}\n${event.detail ?? ""}`.toLocaleLowerCase().includes(needle)) + && (!scope || event.taskId===scope.taskId || event.taskId===undefined && legacySessionIds!.has(event.sessionId)); + const position = cursor?.startsWith("v1:") ? /^v1:(\d{16}-[\w-]+\.ndjson):(\d+)$/u.exec(cursor) : null; + if (cursor?.startsWith("v1:") && (!position || !Number.isSafeInteger(Number(position[2])))) throw new Error("Invalid timeline cursor."); + let passed = !cursor || Boolean(position); + let lastPosition = ""; + for (const name of [...(await this.files())].reverse()) { + if (position && name > position[1]) continue; + if (position && name === position[1] && Number(position[2]) === 0) continue; + const cachedPageSegment=this.pageCache?.name===name ? this.pageCache : null; + const recordedSegmentIndex=this.segmentIndexes.get(name); + const metadata=await stat(join(this.directory,name)); + if(cachedPageSegment + && (cachedPageSegment.size!==metadata.size || cachedPageSegment.mtimeMs!==metadata.mtimeMs))this.pageCache=null; + if(recordedSegmentIndex + && (recordedSegmentIndex.size!==metadata.size + || recordedSegmentIndex.mtimeMs!==0 && recordedSegmentIndex.mtimeMs!==metadata.mtimeMs)) { + this.segmentIndexes.delete(name); + if(this.segment===name){this.segment="";this.segmentBytes=0;} + } + const segmentIndex = this.segmentIndexes.get(name); + if (passed && segmentIndex && !timelineSegmentMayMatch(segmentIndex, ids, types, needle)) continue; + const segmentSize=metadata.size; + if(segmentSize>SEGMENT_BYTES) { + const capacity=take-result.length+1; + if(!passed) { + // Legacy ID cursors resolve to the newest matching ID in the newest segment + // that contains it, as the old reverse array walk did. Find that valid-row + // ordinal first so duplicate IDs need no page-sized snapshots while scanning. + let validIndex=0,latestCursorIndex:number|null=null; + await this.scan(name,event=>{ + if(event.id===cursor)latestCursorIndex=validIndex; + validIndex++; + }); + if(latestCursorIndex===null)continue; + passed=true; + const window=await this.scanPageWindow(name,capacity,latestCursorIndex,matches); + for(const candidate of window.candidates) { + if(result.length===take)return {items:result,nextCursor:lastPosition}; + result.push(candidate.event); + lastPosition=`v1:${name}:${candidate.index}`; + } + continue; + } + const cursorIndex=position && name===position[1] ? Number(position[2]) : undefined; + const window=await this.scanPageWindow(name,capacity,cursorIndex,matches); + if(cursorIndex!==undefined && cursorIndex>=window.validCount)throw new Error("Invalid timeline cursor."); + for(const candidate of window.candidates) { + if(result.length===take)return {items:result,nextCursor:lastPosition}; + result.push(candidate.event); + lastPosition=`v1:${name}:${candidate.index}`; + } + continue; + } + const events = await this.readPageSegment(name); + if (position && name === position[1] && Number(position[2]) >= events.length) throw new Error("Invalid timeline cursor."); + const start = position && name === position[1] ? Number(position[2]) - 1 : events.length - 1; + for (let index = start; index >= 0; index--) { + const event = events[index]; + if (!passed) { if (event.id === cursor) passed = true; continue; } + if (!matches(event)) continue; + if (result.length === take) return { items: result, nextCursor: lastPosition }; + result.push(event); + lastPosition = `v1:${name}:${index}`; + } + } + return { items: result, nextCursor: null }; + } + + usage(sessionIds?: string[],prices:UsagePrice[]=[]): UsageSummary { + const scope=sessionIds ? new Set(sessionIds) : null; + const entries:Array<[string,Usage]>=scope ? [] : [...this.usageBySession]; + if (scope) { + for(const sessionId of scope)for(const key of this.usageKeysBySession.get(sessionId) ?? []) { + const value=this.usageBySession.get(key);if(value)entries.push([key,value]); + } + if(scope.size>1)entries.sort((left,right)=>(left[1].timelineOrder ?? 0)-(right[1].timelineOrder ?? 0)); + } + const values=new Map(); + for(const [key,value] of entries) { + // Cumulative snapshots follow a conversation across cards. Keep its latest observation, including a + // lower reset value, not the largest value or a resumed budget baseline. Additive rows remain per-card. + const identity=value.cumulative ? `counter:${snapshotIdentity(usageSession(key),value)}` : `additive:${key}`; + const previous=values.get(identity); + if(!previous || (value.sampleOrder ?? 0)>(previous.sampleOrder ?? 0))values.set(identity,value); + } + return usageSummary([...values.values()],prices); + } + + usageCounters(sessionId:string,prices:UsagePrice[]=[]):TimelineUsageCounter[] { + return [...(this.usageKeysBySession.get(sessionId) ?? [])].map(key=>{ + const value=this.usageBySession.get(key)!; + return {id:key,legacyId:value.counterId ?? sessionId,tokens:totalTokens(value),costUsd:pricedCost(value,prices), + inputTokens:value.input,outputTokens:value.output,provider:value.provider,model:value.model,accountId:value.accountId,taskId:value.taskId, + source:value.source,counterId:value.counterId,cumulative:value.cumulative===true,reportedCostUsd:value.cost}; + }); + } + + /** One traversal supplies all active task roots while preserving legacy session-only rows. */ + taskBudgetUsageByTask(scopes:readonly TimelineTaskBudgetScope[],prices:UsagePrice[]=[]):Map { + const sessionsByTask=new Map>(),rowsByTask=new Map>(); + for(const scope of scopes) { + let sessions=sessionsByTask.get(scope.taskId); + if(!sessions){sessions=new Set();sessionsByTask.set(scope.taskId,sessions);rowsByTask.set(scope.taskId,new Map());} + for(const sessionId of scope.sessionIds)sessions.add(sessionId); + } + const taskIds=new Set(sessionsByTask.keys()); + // Match sessionIds(taskId)'s historical ordering: explicit current members first, + // then usage-bearing sessions in usage map insertion order. + for(const [key,value] of this.usageBySession) { + const sessionId=usageSession(key); + if(taskIds.has(sessionId))sessionsByTask.get(sessionId)!.add(sessionId); + if(value.taskId!==undefined && taskIds.has(value.taskId))sessionsByTask.get(value.taskId)!.add(sessionId); + } + const legacyTasksBySession=new Map(); + for(const [taskId,sessions] of sessionsByTask)for(const sessionId of sessions) { + let tasks=legacyTasksBySession.get(sessionId); + if(!tasks){tasks=[];legacyTasksBySession.set(sessionId,tasks);} + tasks.push(taskId); + } + const add=(taskId:string,contribution:BudgetUsageContribution):void=>{ + const rows=rowsByTask.get(taskId)!; + const key=JSON.stringify([contribution.sessionId,contribution.identity,contribution.taskId ?? ""]); + let row=rows.get(key); + if(!row){row={identity:contribution.identity,sessionId:contribution.sessionId,...(contribution.taskId ? {taskId:contribution.taskId} : {}),tokens:0,costUsd:0,costKnown:true};rows.set(key,row);} + row.tokens+=contribution.tokens; + const cost=contribution.mode==="covered" ? 0 + : contribution.mode==="reported" || contribution.mode==="reported-base" ? contribution.reportedCostUsd + : contribution.input!==null && contribution.output!==null && contribution.provider && contribution.model + ? (()=>{const price=prices.find(item=>item.provider===contribution.provider && item.model===contribution.model);return price ? (contribution.input!*price.inputPerMillion+contribution.output!*price.outputPerMillion)/1_000_000 : null;})() + : null; + if(cost===null)row.costKnown=false;else row.costUsd+=cost; + }; + for(const contribution of this.budgetUsageContributions.values()) { + if(contribution.taskId!==undefined) { + if(taskIds.has(contribution.taskId))add(contribution.taskId,contribution); + } else for(const taskId of legacyTasksBySession.get(contribution.sessionId) ?? [])add(taskId,contribution); + } + const result=new Map(); + for(const [taskId,sessions] of sessionsByTask) { + const rows=rowsByTask.get(taskId)!; + result.set(taskId,{sessionIds:[...sessions],contributions:[...rows.values()].map(row=>({identity:row.identity,sessionId:row.sessionId, + ...(row.taskId ? {taskId:row.taskId} : {}),tokens:row.tokens,costUsd:row.costKnown ? row.costUsd : null}))}); + } + return result; + } + + /** Latest sessions with real usage, including closed members whose counters still belong to a task. */ + sessionIds(taskId?:string):string[] { + return [...new Set([...this.usageBySession].filter(([key,value])=>taskId===undefined || usageSession(key)===taskId || value.taskId===taskId).map(([key])=>usageSession(key)))]; + } + + /** Session and event-type facets derived only from retained, host-stamped journal rows. */ + taskSessions(taskId:string):Array<{id:string;title:string;types:string[]}> { + return [...(this.taskSessionsByTask.get(taskId)?.values() ?? [])] + .map(row=>({id:row.id,title:this.redact(row.title).slice(0,200),types:[...row.types.keys()].sort()})) + .sort((left,right)=>left.id.localeCompare(right.id)); + } + + /** Periods measure observed increases; when no baseline was ever persisted, the first sample is the available real counter. */ + async breakdown(period:"all"|"day"|"week",taskId?:string,prices:UsagePrice[]=[]):Promise { + await this.queue; + const cutoff=period==="all" ? 0 : this.now()-(period==="day" ? 86400_000 : 7*86400_000); + const counters=new Map(); + const rows=new Map(); + for(const file of await this.files())await this.scan(file,event=>{ + if(event.type!=="usage" || !event.detail)return; + let value:Usage;try{value=JSON.parse(event.detail);}catch{return;} + if(typeof value.source!=="string")return; + // A resumed provider conversation can move to a new CanvasTTY card: share its baseline by counter identity, + // while keeping the displayed delta attributed to the card that produced it. + const counterKey=snapshotIdentity(event.sessionId,value); + // All-time rows are snapshots too: attribute a shared counter to its latest in-scope card once. + const rowKey=period==="all" && value.cumulative===true + ? JSON.stringify(["cumulative",counterKey]) : JSON.stringify([event.sessionId,counterKey]); + const base:UsageBreakdown={sessionId:event.sessionId,provider:value.provider ?? null,model:value.model ?? null,accountId:value.accountId ?? null,taskId:value.taskId ?? null, + tokens:{input:null,output:null,total:null},costUsd:null,costSource:null,source:value.source,period}; + if(value.cumulative===true) { + if(!isTimelineCount(value.total))return; + const current={input:isTimelineCount(value.input)?value.input:null,output:isTimelineCount(value.output)?value.output:null,total:value.total, + cost:typeof value.cost==="number" && Number.isFinite(value.cost) && value.cost>=0 ? value.cost : null}; + const previous=counters.get(counterKey);counters.set(counterKey,current); + if(event.at=0; + const cost=pricedCost(value,prices); + rows.set(rowKey,{...base,tokens:{input,output,total:input+output},costUsd:cost!==null && (!previousRow || previousRow.costUsd!==null) ? (previousRow?.costUsd ?? 0)+cost : null, + costSource:reported ? "reported" : price ? "human-price" : null}); + }); + return [...rows.values()]; + } + + async report(sessionId: string): Promise { + await this.queue; + const events: TimelineEvent[] = []; + let truncated = false; + for (const name of [...(await this.files())].reverse()) { + const capacity=MAX_REPORT_EVENTS-events.length; + const recent=createTimelineRing(capacity); + let seen=0,overflow=false; + await this.scan(name,event=>{ + if(event.sessionId!==sessionId)return; + if(capacity===0){truncated=true;return true;} + if(seen++===capacity)overflow=true; + recent.push(event); + }); + events.push(...recent.newestFirst()); + if(overflow)truncated=true; + if(truncated)break; + } + const sections: Array<[string, string[]]> = [ + ["Turns", ["lifecycle", "status"]], ["Tools, commands and files", ["tool", "command", "file"]], + ["Protection and isolation", ["decision", "isolation", "git-risk", "budget", "network-policy"]], + ["Human decisions", ["human", "secret-request", "secret-decision", "secret-revoked", "checkpoint"]] + ]; + const lines = ["# CanvasTTY session report", "", `Session: ${sessionId}`, "", + "Network visibility is incomplete: only addresses explicitly reported by hooks are known.", ""]; + for (const [title, types] of sections) { + lines.push(`## ${title}`, ""); + const matching = events.filter((event) => types.includes(event.type)).reverse(); + lines.push(...(matching.length ? matching.map((event) => `- ${new Date(event.at).toISOString()} ${event.summary}${event.detail ? ` — ${event.detail}` : ""}`) : ["No events were reported by this provider."]), ""); + } + if (truncated) lines.push("Report limited to the latest 10,000 events."); + return this.redact(lines.join("\n")); + } + + async flush(): Promise { await this.queue; } + subscribeUsage(listener: (sessionId: string) => void): () => void { + this.usageListeners.add(listener); + return () => this.usageListeners.delete(listener); + } + private applyUsageEvent(event: TimelineEvent): void { + const update = nextTimelineUsage(this.usageBySession, event); + if (!update) return; + const previous = this.usageBySession.get(update.key); + const latest=this.latestCounterSamples.get(snapshotIdentity(event.sessionId,update.value)); + this.storeUsage(update.key, event.sessionId, {...update.value,sampleOrder:this.nextSampleOrder++}); + if (usageCountersChanged(previous, update.value) || update.value.cumulative && previous?.sampleOrder!==latest?.sampleOrder) this.notifyUsageChanged(event.sessionId); + } + private storeUsage(key:string,sessionId:string,value:Usage):void { + const previous=this.usageBySession.get(key); + this.nextSampleOrder=Math.max(this.nextSampleOrder,(value.sampleOrder ?? -1)+1); + if(value.cumulative) { + const identity=snapshotIdentity(sessionId,value),latest=this.latestCounterSamples.get(identity); + if(!latest || (value.sampleOrder ?? 0)>(latest.sampleOrder ?? 0))this.latestCounterSamples.set(identity,value); + } + this.usageBySession.set(key,{...value,timelineOrder:previous?.timelineOrder ?? this.nextUsageOrder++}); + let keys=this.usageKeysBySession.get(sessionId); + if(!keys){keys=new Set();this.usageKeysBySession.set(sessionId,keys);} + keys.add(key); + } + private notifyUsageChanged(sessionId:string):void { + for(const listener of this.usageListeners)try{listener(sessionId);}catch{ /* A refresh listener cannot break timeline persistence. */ } + } + private async files(): Promise { + if (this.fileListCache) return this.fileListCache; + this.fileListCache = (await readdir(this.directory)).filter((name) => /^\d{16}-[\w-]+\.ndjson$/.test(name)).sort(); + return this.fileListCache; + } + private async read(name: string): Promise { + const events: TimelineEvent[] = []; + await this.scan(name, event => { events.push(event); }); + return events; + } + private scan(name:string,visit:(event:TimelineEvent)=>boolean|void):Promise { + return forEachTimelineEvent(join(this.directory,name),visit); + } + private async scanPageWindow(name:string,capacity:number,beforeIndex:number|undefined, + matches:(event:TimelineEvent)=>boolean):Promise<{validCount:number;candidates:TimelinePageCandidate[]}> { + const ring=createTimelineRing(capacity); + let validCount=0; + await this.scan(name,event=>{ + const index=validCount++; + if((beforeIndex===undefined || index { + const cached = this.pageCache; + if (cached?.name === name) return cached.events; + const metadata = await stat(join(this.directory, name)); + const events = await this.read(name); + // Keep only one bounded segment. Large imported journals still page without retaining a large cache. + this.pageCache = metadata.size <= SEGMENT_BYTES ? {name, size:metadata.size, mtimeMs:metadata.mtimeMs, events} : null; + return events; + } + private async prune(): Promise { + const files = await this.files(); + const sizes = await Promise.all(files.map((name) => stat(join(this.directory, name)).then((s) => s.size))); + let total = sizes.reduce((sum, size) => sum + size, 0); + for (let i = 0; i < files.length && total > this.maxBytes; i++) { + await unlink(join(this.directory, files[i])); total -= sizes[i]; + this.fileListCache = null; + if (this.pageCache?.name === files[i]) this.pageCache = null; + this.segmentIndexes.delete(files[i]); + this.removeTaskSessionSegment(files[i]); + if (files[i] === this.segment) { this.segment = ""; this.segmentBytes = 0; } + } + return total; + } + + private removeTaskSessionSegment(segment:string):void { + for(const [taskId,sessions] of this.taskSessionsByTask) { + for(const [sessionId,descriptor] of sessions) { + for(const [type,lastSegment] of descriptor.types)if(lastSegment===segment)descriptor.types.delete(type); + // Pruning removes segments oldest-first, so no retained row exists for this actor when its latest segment is removed. + if(descriptor.lastSegment===segment)sessions.delete(sessionId); + } + if(!sessions.size)this.taskSessionsByTask.delete(taskId); + } + } +} + +function scanTimelineIndex(directory: string): Promise { + const built = fileURLToPath(new URL("./SessionTimelineIndexWorker.js", import.meta.url)); + const source = fileURLToPath(new URL("./SessionTimelineIndexWorker.ts", import.meta.url)); + const path = existsSync(built) ? built : existsSync(source) ? source : null; + if (!path) return Promise.reject(new Error("Session timeline index worker entry is missing.")); + const options: WorkerOptions = path.endsWith(".ts") ? { execArgv: ["--experimental-strip-types"] } : {}; + return new Promise((resolveResult, rejectResult) => { + const worker = new Worker(path, { ...options, workerData: { directory } }); + let finished = false; + const finish = (error: Error | null, value?: unknown): void => { + if (finished) return; + finished = true; + void worker.terminate(); + if (error) rejectResult(error); + else if (!isTimelineDirectoryIndex(value)) { + rejectResult(new Error("Session timeline index worker returned invalid data.")); + } else resolveResult(value); + }; + worker.once("message", (value: unknown) => { + if (!value || typeof value !== "object" || "error" in value) { + const message = value && typeof value === "object" && "error" in value && typeof value.error === "string" ? value.error : "Session timeline index worker failed."; + finish(new Error(message)); + return; + } + finish(null, value); + }); + worker.once("error", error => finish(error instanceof Error ? error : new Error("Session timeline index worker failed."))); + worker.once("exit", code => { + if (!finished && code !== 0) finish(new Error(`Session timeline index worker exited (${code}).`)); + else if (!finished) finish(new Error("Session timeline index worker stopped before returning its index.")); + }); + }); +} + +function isTimelineDirectoryIndex(value: unknown): value is TimelineDirectoryIndex { + if (!value || typeof value !== "object") return false; + const index=value as Partial; + return Number.isSafeInteger(index.lastSegmentAt) && index.usage instanceof Map && index.budgetUsage instanceof Map + && index.budgetCounters instanceof Map && index.segments instanceof Map && index.taskSessions instanceof Map; +} diff --git a/src/main/services/SettingsStore.ts b/src/main/services/SettingsStore.ts index d8963b07..593c3cab 100644 --- a/src/main/services/SettingsStore.ts +++ b/src/main/services/SettingsStore.ts @@ -994,7 +994,7 @@ function normalizePluginCanvas(candidate: unknown, fallback: readonly PluginCanv return instances; } -function normalizeCanvasRegions( +export function normalizeCanvasRegions( candidate: unknown, fallback: readonly CanvasRegion[] = [] ): CanvasRegion[] { @@ -1053,7 +1053,7 @@ export function normalizeStickyNotes( return notes; } -function normalizeBrowserCanvas(candidate: unknown, fallback: BrowserCanvasState | null): BrowserCanvasState | null { +export function normalizeBrowserCanvas(candidate: unknown, fallback: BrowserCanvasState | null): BrowserCanvasState | null { if (candidate === null) return null; if (!candidate || typeof candidate !== "object") return fallback ? structuredClone(fallback) : null; const source = candidate as Partial; diff --git a/src/main/services/SubagentWorktreeResolver.ts b/src/main/services/SubagentWorktreeResolver.ts new file mode 100644 index 00000000..12751baa --- /dev/null +++ b/src/main/services/SubagentWorktreeResolver.ts @@ -0,0 +1,15 @@ +import type { AgentControlOptions } from "./AgentControlService.ts"; +import type { EnvironmentProvider } from "./EnvironmentRegistry.ts"; + +/** Selection uses the trusted project root; worktree creation remains in the existing plugin. */ +export function subagentWorktreeResolver(dependencies: { + isGitProject(projectRoot: string): Promise; + providers(): EnvironmentProvider[]; +}): NonNullable { + return async request => { + if (!await dependencies.isGitProject(request.projectRoot)) return null; + const provider = dependencies.providers().find(row => row.kinds.some(kind => kind.kind === "worktree" + && (!kind.appliesTo || kind.appliesTo.includes(request.provider)))); + return provider ? { pluginId: provider.pluginId, kind: "worktree" } : null; + }; +} diff --git a/src/main/services/TerminalManager.ts b/src/main/services/TerminalManager.ts index 157be296..e3e0cc63 100644 --- a/src/main/services/TerminalManager.ts +++ b/src/main/services/TerminalManager.ts @@ -1,4 +1,9 @@ +import { TerminalPasteMode } from "./TerminalPasteMode.ts"; +import { ReviewDiffTracker } from "./ReviewDiffTracker.ts"; +import { GitCheckpoints } from "./GitCheckpoints.ts"; +import { constants as osConstants } from "node:os"; import { randomUUID } from "node:crypto"; +import { stripVTControlCharacters } from "node:util"; import { realpathSync, statSync } from "node:fs"; import { basename, dirname } from "node:path"; import { isPathInside } from "../../agent-runtime/path-inside.mjs"; @@ -64,11 +69,13 @@ 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 type { AgentNetworkMode } from "./isolation/networkPolicy.ts"; import { controlGrantFolder } from "./isolation/AgentIsolation.ts"; import { auditRepositories, neutralizeRepositories, type GitRiskRepository } from "./isolation/gitAudit.ts"; import { LaunchRefusal } from "./launchRefusal.ts"; import { configuredMode } from "./configuredMode.ts"; import { envKey, RESERVED_ENV, type LaunchPipeline, type PreparedLaunch } from "./LaunchPipeline.ts"; +import { ACCOUNTS_PLUGIN_ID, selectedAccountId } from "./accountHomeIsolation.ts"; import type { EnvironmentRegistry } from "./EnvironmentRegistry.ts"; import { persistedTerminalSession, @@ -84,6 +91,8 @@ import { initialSessionStatus, type ProviderLifecycleParser } from "./providerLifecycle.ts"; +import { ProcessTreePause, type ProcessTreePauseResult } from "./ProcessTreePause.ts"; +import { isHostOwnedDiffOnlyReviewWorkspace, type DiffOnlyReviewWorkspace } from "./DiffOnlyReviewWorkspace.ts"; const MAX_SCROLLBACK_CHARS = 240_000; const OUTPUT_BATCH_MS = 16; @@ -101,6 +110,8 @@ const MIN_TERMINAL_SIZE = { width: 420, height: 260 }; const MAX_TERMINAL_SIZE = { width: 1_600, height: 1_100 }; interface ManagedSession { + accountId?:string; + accountHome?:string; metadata: SessionMetadata; process: IPty | null; cols: number; @@ -120,10 +131,19 @@ interface ManagedSession { /** The provider's own conversation id, once its hook reported it (or from the saved record). */ threadId?: string; captureResult: boolean; + captureReviewDiff?: boolean; + inputBracketedPaste?: boolean; + pasteMode?: TerminalPasteMode; /** Turns the agent started (its status became working) since launch. */ turnStarts?: number; /** turnStarts when the last submitted prompt was delivered; undefined while none was. */ promptTurnMark?: number; + /** Last accepted lifecycle state, also maintained when lifecycle UI updates are disabled. */ + acceptedLifecycleState?: ProviderLifecycleSignal["state"]; + inputGeneration?: number; + answerTurnGeneration?: number; + providerTurnId?: string; + providerTurnGenerations?: Map; /** 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 }; /** @@ -135,14 +155,25 @@ 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 created this card's run folders, retained across host reconfiguration/restart. */ + launchFilePipelines?: 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; /** Bumped by every launch the person or the app asks for (create, restart, restore); input waits for one. */ launchEpoch: number; + /** Initial measured-grid launch was requested while its task budget was paused. */ + budgetDeferredLaunch?: boolean; /** Input waiting for this launch to start (deliverInput): woken whenever the launch moves on. */ launchWaiters: Set<() => void>; + inputQueue?: Promise; + cliInputReady?: boolean; + readinessOutput?: string; /** Brought back from the saved sessions at startup (plugins see a "restored" event, not "created"). */ restored?: boolean; /** What the CLI's own title last showed (Claude: spinner working, «✳» no turn running). */ @@ -151,6 +182,16 @@ interface ManagedSession { hookSignals?: number; /** Set after the person answered a hooked Claude prompt; see settleAnsweredPrompt. */ answeredPromptTimer?: ReturnType; + /** Removed when a transient diff-only review PTY exits or its card is disposed. */ + reviewWorkspace?: DiffOnlyReviewWorkspace; +} + +interface ReadOnlyReviewerLaunch { + taskRootSessionId: string; + workspace: DiffOnlyReviewWorkspace; + deniedReadPaths: string[]; + /** A worker's model account, prepared for this reviewer before its card exists (prepareReviewerAccount). */ + account?: { id: string; contribution: LaunchContribution }; } type EnvironmentService = Pick (() => void) | void>>(); + private readonly reviewDiffTracker = new ReviewDiffTracker(); private readonly spawnPty: typeof pty.spawn; + private readonly processTreePause: ProcessTreePause; + private readonly checkpointUnavailableGenerations=new Map(); + private checkpointRestore: {cwd:string;ids:Set;processes:Map;deferredResume:Set;closes:Map;done:Promise} | null=null; + private readonly budgetPausedTaskRoots = new Set(); + private readonly budgetResumeWaiters = new Map void>>(); + /** + * The conversation each card's latest launch resumed: its id, or null until the provider reports which one a + * "resume latest" launch picked. Usage counted before the resume belongs to earlier runs, not to this card. + */ + private readonly resumedThreads = new Map(); // Renderer-reported card visibility, keyed by session and holding the // outputOffset at the moment it was hidden: the last offset the card saw. // Output keeps flowing through emit while hidden, addressed to the observers @@ -265,15 +318,19 @@ export class TerminalManager { // spawned while it is set; null while the endpoint is off. private controlConnection: ControlConnection | null = null; // The operating-system isolation layer (null: none configured, e.g. in unit tests). - private isolation: Pick | null = null; + private isolation: Pick & Partial> | null = null; // Removes a launch's isolation folder (profile, TMPDIR) once its process ended or the card closed. private readonly isolationCleanups = new Map void>(); /** Open git risk reports by id: the card it belongs to (none once closed) and what neutralize removes. */ private readonly gitRisks = new Map(); /** When isolated launches started whose card did not exist yet (see wrapIsolated). */ private readonly isolationStarts = new Map(); + /** Host-owned platform restriction found while wrapping a launch, before its card may exist. */ + private readonly pendingIsolationReasons = new Map(); // Plugin owners of cards being created (before the card exists), so their first launch counts as delegated. private readonly startingOwners = new Map(); + /** Reviewer-only launch authority is keyed by the exact host-created request object. */ + private readonly readOnlyReviewerRequests = new WeakMap(); // What a card's first launch found in its CLI's own configuration, before the card exists. private readonly pendingConfiguredModes = new Map(); // Whether the person acknowledged YOLO for a CLI (Settings: acknowledgedDangerousProfiles); unset allows it. @@ -282,6 +339,7 @@ export class TerminalManager { // the main process reads it from its emit callback to tell a failure that is // merely re-derived state from one the user just caused. private emittingFailureOrigin: FailureOrigin | null = null; + private inputGate: ((id:string)=>void) | null = null; constructor( emit: Emit, @@ -289,18 +347,20 @@ export class TerminalManager { agentBrowser?: AgentBrowserLaunchCoordinator, agentRuntime?: AgentRuntimeLaunchCoordinator, lifecycleHooksEnabled = true, - spawnPty: typeof pty.spawn = pty.spawn + spawnPty: typeof pty.spawn = pty.spawn, + processTreePause = new ProcessTreePause() ) { this.emit = emit; this.providerClis = providerClis; this.agentBrowser = agentBrowser; this.agentRuntime = agentRuntime; this.spawnPty = spawnPty; + this.processTreePause = processTreePause; this.lifecycleHooksEnabled = lifecycleHooksEnabled; } /** The operating-system isolation layer around delegated and non-manual agents (isolation/AgentIsolation.ts). */ - configureIsolation(isolation: Pick | null): void { + configureIsolation(isolation: Pick & Partial> | null): void { this.isolation = isolation; } @@ -341,7 +401,356 @@ export class TerminalManager { configureRedaction(registry: SecretRedactionRegistry): void { this.redaction = registry; } + configureInputGate(gate: ((id:string)=>void) | null): void {this.inputGate=gate;} + + /** Host-only observer at the guarded PTY boundary; the returned callback runs only after a successful write. */ + observeInputWrites(id: string, observer: (data: string, submitted: boolean) => (() => void) | void): () => void { + if (!this.sessions.has(id)) throw new Error("Terminal session does not exist."); + let observers = this.inputWriteObservers.get(id); + if (!observers) { observers = new Set(); this.inputWriteObservers.set(id, observers); } + observers.add(observer); + return () => { + observers.delete(observer); + if (observers.size === 0 && this.inputWriteObservers.get(id) === observers) this.inputWriteObservers.delete(id); + }; + } + + /** + * Host-only entry point for an automatic reviewer. Its launch authority is tied to an unforgeable temporary + * workspace object and never becomes a field on the renderer/control request schema. + */ + /** + * Prepares the worker's model account (only the Accounts plugin's delegable options) for a diff-only reviewer, so + * the reviewer runs on the same key and model when the agent has no sign-in of its own. The reviewer still gets no + * agent or plugin tools; only the account's environment, arguments and run files reach its launch. + */ + async prepareReviewerAccount(input: { + taskRootSessionId: string; + provider: ProviderId; + workspace: DiffOnlyReviewWorkspace; + launchOptions: NonNullable; + }): Promise<{ id: string; contribution: LaunchContribution }> { + const pipeline = this.launchPipeline; + if (!pipeline) throw new LaunchRefusal("The model account of the worker is not available for its reviewer."); + const accountOnly = Object.fromEntries(Object.entries(input.launchOptions).filter(([pluginId]) => pluginId === ACCOUNTS_PLUGIN_ID)); + const options = pipeline.normalizeOptions(input.provider, accountOnly, { delegated: true }); + if (!options) throw new LaunchRefusal("The worker has no model account for its reviewer."); + const id = randomUUID(); + try { + const prepared = await pipeline.prepare({ + sessionId: id, + provider: input.provider, + profile: "plan", + role: "subagent", + cwd: realpathSync(input.workspace.directory), + projectRoot: this.taskScopeFor(input.taskRootSessionId).cwd, + parentSessionId: input.taskRootSessionId, + restoring: false, + resume: false, + options: structuredClone(options) as Record>, + environment: null + }); + if (!prepared.ok) throw new LaunchRefusal(prepared.reason); + // A reviewer id has one run and cannot be restarted. Its account contribution therefore owns both the run + // and its parent even before a card adopts it; cancellation/failed creation use this same cleanup handle. + const cleanup = prepared.cleanup; + let released: Promise | undefined; + prepared.cleanup = () => released ??= (async () => { + try { await cleanup(); } + finally { await pipeline.forgetSession(id); } + })(); + return { id, contribution: prepared }; + } catch (error) { + await pipeline.forgetSession(id).catch(() => undefined); + throw error; + } + } + + createReadOnlyReviewer(input: { + taskRootSessionId: string; + provider: ProviderId; + /** The reviewer's model; omitted when the worker's model account (prepareReviewerAccount) decides it. */ + model?: string; + account?: { id: string; contribution: LaunchContribution }; + title: string; + workspace: DiffOnlyReviewWorkspace; + }): SessionSnapshot { + if (input.provider === "terminal" || (!input.model && !input.account) || !isHostOwnedDiffOnlyReviewWorkspace(input.workspace)) { + throw new LaunchRefusal("The diff-only reviewer workspace is unavailable; the reviewer was not started."); + } + const parent = this.sessions.get(input.taskRootSessionId); + if (!parent) throw new LaunchRefusal("The review task ended before its isolated reviewer could start."); + const cwd = realpathSync(input.workspace.directory); + if (cwd !== input.workspace.directory) throw new LaunchRefusal("The diff-only reviewer workspace changed before launch."); + + const denied = new Set(); + const addDirectory = (candidate: string): void => { + let real: string; + try { + real = realpathSync(candidate); + if (!statSync(real).isDirectory()) throw new Error("not a directory"); + } catch { + throw new LaunchRefusal("A project or session root could not be verified for diff-only review."); + } + if (isPathInside(cwd, real) || isPathInside(real, cwd)) { + throw new LaunchRefusal("The diff-only reviewer workspace overlaps a project or session root."); + } + denied.add(real); + }; + const taskRoot = this.taskScopeFor(input.taskRootSessionId); + addDirectory(taskRoot.cwd); + for (const session of this.sessions.values()) { + addDirectory(session.metadata.cwd); + addDirectory(session.metadata.taskScope?.cwd ?? this.taskScopeFor(session.metadata.id).cwd); + } + if (denied.size === 0) throw new LaunchRefusal("No project roots were available to isolate the diff-only reviewer."); + + const request: CreateSessionRequest = { + provider: input.provider, + cwd, + profile: "plan", + title: input.title.slice(0, 80), + ...(input.model ? { model: input.model } : {}), + position: parent.metadata.position, + role: "subagent", + parentSessionId: input.taskRootSessionId + }; + this.readOnlyReviewerRequests.set(request, { + taskRootSessionId: input.taskRootSessionId, + workspace: input.workspace, + deniedReadPaths: [...denied], + ...(input.account ? { account: input.account } : {}) + }); + try { + return this.create(request, { origin: "subagent", continueTaskFrom: input.taskRootSessionId }); + } catch (error) { + this.readOnlyReviewerRequests.delete(request); + throw error; + } + } + + /** + * The model account this card was launched on (the Accounts plugin's selected account), or undefined when it runs on + * its CLI's own sign-in. A subagent inherits it unless its spawn names an account (AgentControlService). + */ + modelAccountOf(id: string): string | undefined { + const options = this.sessions.get(id)?.extras.options; + const account = selectedAccountId(options); + return account === "default" ? undefined : account; + } + + setTaskMetadata(id: string, patch: Pick): void { + const session=this.sessions.get(id);if (!session) return; + if (patch.reviewRequested !== undefined) session.metadata.reviewRequested=patch.reviewRequested; + this.schedulePersistence(); + this.emitSession(session.metadata); + } + setTaskBudget(id:string,value:SessionMetadata["taskBudget"]):void { + const session=this.sessions.get(id);if(!session || JSON.stringify(session.metadata.taskBudget)===JSON.stringify(value))return; + if(value)session.metadata.taskBudget={...value};else delete session.metadata.taskBudget; + this.emitSession(session.metadata); + } + /** Restoration controls only this host's managed PTYs, not external editors or other OS processes. */ + isCheckpointRestoreActive(id:string):boolean {return this.checkpointRestore?.ids.has(id) ?? false;} + + canCaptureCheckpoint(id:string):boolean { + const session=this.sessions.get(id); + return Boolean(session && !this.isCheckpointRestoreActive(id) && this.checkpointUnavailableGenerations.get(id)!==(session.inputGeneration ?? 0)); + } + + private assertCheckpointLaunchAllowed(cwd:string,contributed=false):void { + const lease=this.checkpointRestore;if(!lease)return; + const folder=realpathSync(cwd); + if(contributed || isPathInside(lease.cwd,folder) || isPathInside(folder,lease.cwd)) { + throw new LaunchRefusal("Workspace checkpoint restoration is in progress. Try the launch again after it finishes."); + } + } + + /** Hold every overlapping owned process stopped across the complete Git restore transaction. */ + async withCheckpointRestore(id:string,restore:(cwd:string)=>Promise):Promise { + if(this.checkpointRestore)throw new Error("Another checkpoint restoration is already in progress."); + const target=this.sessions.get(id);if(!target)throw new Error("Terminal session does not exist."); + const cwd=realpathSync(this.launchContexts.get(id)?.cwd ?? target.metadata.cwd); + const targetScopes=new Set([cwd]);let targetScopeAmbiguous=false; + for(const path of [target.metadata.cwd,target.metadata.taskScope?.cwd ?? this.networkProjectRoot(target.metadata.cwd,target.metadata.parentSessionId)]) { + try {targetScopes.add(realpathSync(path));}catch {targetScopeAmbiguous=true;} + } + const affected:ManagedSession[]=[]; + for(const session of this.sessions.values()) { + // A pending plugin launch has not established its final cwd yet. Do not race its preparation/wrapping. + if(session.launchTasks?.size)throw new Error("Wait for pending agent launches before restoring files."); + const isolated=session.extras.isolatedEnvironmentScopes; + if(isolated && (isolated.ambiguous || (isolated.roots.length>0 && (targetScopeAmbiguous || isolated.roots.some(root=>[...targetScopes].some(target=>isPathInside(root,target) || isPathInside(target,root))))))) { + throw new Error("Checkpoint restoration is unavailable for a related isolated environment: pausing or exiting its local wrapper does not prove that the remote/container workload stopped. Stop and verify that workload independently; CanvasTTY has no environment suspension guarantee."); + } + let folder:string; + try {folder=realpathSync(this.launchContexts.get(session.metadata.id)?.cwd ?? session.metadata.cwd);} + catch(error) {if(session.metadata.exitCode!==null)continue;throw error;} + if(!isPathInside(cwd,folder) && !isPathInside(folder,cwd))continue; + if(session.metadata.exitCode===null) { + const progress=this.turnProgress(session.metadata.id); + if(!session.process || session.acceptedLifecycleState!=="idle" || (progress?.promptSent && !progress.turnStartedSincePrompt)) { + throw new Error("Stop the active turn and wait for observed agent idle before restoring files."); + } + if(!this.processTreePause.supported)throw new Error("This platform cannot safely suspend live agents for checkpoint restoration. Close them first."); + } + affected.push(session); + } + let complete!:()=>void; + const lease={cwd,ids:new Set(affected.map(session=>session.metadata.id)),processes:new Map(),deferredResume:new Set(),closes:new Map(),done:new Promise(resolve=>{complete=resolve;})}; + // Closing an ancestor closes its children and may release their environment; defer the whole close. + for(const session of affected) { + let parent=session.metadata.parentSessionId; + while(parent && !lease.ids.has(parent)){lease.ids.add(parent);parent=this.sessions.get(parent)?.metadata.parentSessionId;} + } + this.checkpointRestore=lease; + try { + for(const session of affected) { + // Existing pre-turn guards/cache entries must not describe the tree after a restoration. + session.inputGeneration=(session.inputGeneration ?? 0)+1; + // A delayed old hook must not label post-restore edits as a new before-turn snapshot. + this.checkpointUnavailableGenerations.set(session.metadata.id,session.inputGeneration); + const process=session.process;if(!process)continue; + lease.processes.set(process,{session,wasPaused:this.processTreePause.isPaused(process)}); + const paused=this.processTreePause.pause(process); + if(!paused.supported || paused.failed)throw new Error(`Could not safely suspend agents for restoration: ${paused.failed ?? "unsupported platform"}`); + } + return await restore(cwd); + } finally { + const failures:string[]=[]; + try { + for(const [process,{session,wasPaused}] of lease.processes) { + // An exited PTY no longer has another owner that can release its stopped descendants. + if(session.process===process && ((wasPaused && !lease.deferredResume.has(process)) || this.isSessionBudgetPaused(session)))continue; + const resumed=this.processTreePause.resume(process); + if(resumed.failed) { + // Never reopen input to a process that failed to resume. + this.budgetPausedTaskRoots.add(this.taskScopeFor(session.metadata.id).id); + failures.push(resumed.failed); + } + } + } finally { + this.checkpointRestore=null; + complete(); + for(const [closed,options] of lease.closes)this.dispose(closed,options); + for(const session of this.sessions.values()) { + const root=this.taskScopeFor(session.metadata.id).id; + if(!this.budgetPausedTaskRoots.has(root))this.launchDeferredBudgetSessions(root); + } + } + if(failures.length)throw new Error(`An agent remains budget-paused after checkpoint restoration cleanup because resuming failed: ${failures.join(" ")}`); + } + } + + /** Host-only process suspension; Windows explicitly reports that an active process tree cannot be paused. */ + setBudgetPaused(taskRootId:string,paused:boolean):ProcessTreePauseResult { + if(paused)this.budgetPausedTaskRoots.add(taskRootId); + if(!paused && !this.processTreePause.supported) { + this.budgetPausedTaskRoots.delete(taskRootId); + this.wakeBudgetResumeWaiters(taskRootId); + this.launchDeferredBudgetSessions(taskRootId); + return {supported:true}; + } + const failures:string[]=[]; + let supported=!paused || this.processTreePause.supported; + const resumedGroups:IPty[]=[]; + for(const session of this.sessions.values()) { + if(this.taskScopeFor(session.metadata.id).id!==taskRootId || !session.process)continue; + if(!paused && this.checkpointRestore?.processes.has(session.process)){this.checkpointRestore.deferredResume.add(session.process);continue;} + const wasPaused=this.processTreePause.isPaused(session.process); + const result=paused ? this.processTreePause.pause(session.process) : this.processTreePause.resume(session.process); + supported=supported && result.supported; + if(result.failed)failures.push(result.failed); + else if(!paused && wasPaused)resumedGroups.push(session.process); + } + if(paused) { + if(!supported && !failures.length)failures.push("The platform cannot safely suspend an active PTY process tree."); + } else if(failures.length) { + // A failed SIGCONT must not make a stopped task appear runnable. Keep the host gate closed so input and + // child/restart launches cannot run ahead of a process group that may still be stopped. Re-stop every + // group already resumed in this batch, so one failed resume cannot leave the rest of the task running. + for(const process of resumedGroups) { + const rollback=this.processTreePause.pause(process); + if(rollback.failed)failures.push(`Could not re-suspend a process group after another group failed to resume: ${rollback.failed}`); + } + this.budgetPausedTaskRoots.add(taskRootId); + } else { + this.budgetPausedTaskRoots.delete(taskRootId); + this.wakeBudgetResumeWaiters(taskRootId); + this.launchDeferredBudgetSessions(taskRootId); + } + return {supported,...(failures.length ? {failed:[...new Set(failures)].join(" ")} : {})}; + } + + /** False on Windows: a budget pause there blocks input and launches but cannot stop running processes. */ + processSuspensionSupported():boolean {return this.processTreePause.supported;} + + /** Startup orchestration replays persisted paused roots before restoring PTYs. */ + isBudgetPaused(taskRootId:string):boolean {return this.budgetPausedTaskRoots.has(taskRootId);} + + private wakeBudgetResumeWaiters(taskRootId:string):void { + for(const wake of [...(this.budgetResumeWaiters.get(taskRootId) ?? [])])wake(); + this.budgetResumeWaiters.delete(taskRootId); + } + private launchDeferredBudgetSessions(taskRootId:string):void { + for(const session of this.sessions.values()) { + if(this.taskScopeFor(session.metadata.id).id===taskRootId && session.budgetDeferredLaunch) { + if(this.checkpointRestore)continue; + session.budgetDeferredLaunch=false; + this.launchAwaitingSession(session.metadata.id,session); + } + } + } + + setObservedUsage(id:string,usage:NonNullable,reviewUsage?:SessionMetadata["reviewUsage"]):void { + const session=this.sessions.get(id);if(!session)return; + if(JSON.stringify(session.metadata.usage)===JSON.stringify(usage) && JSON.stringify(session.metadata.reviewUsage)===JSON.stringify(reviewUsage))return; + session.metadata.usage=structuredClone(usage); + if(reviewUsage)session.metadata.reviewUsage=structuredClone(reviewUsage);else delete session.metadata.reviewUsage; + this.emitSession(session.metadata); + } + + setSessionReport(id:string,readyAt:number|null):void { + const session=this.sessions.get(id);if(!session || session.metadata.sessionReport?.readyAt===readyAt)return; + if(readyAt===null){if(!session.metadata.sessionReport)return;delete session.metadata.sessionReport;} + else session.metadata.sessionReport={readyAt}; + this.emitSession(session.metadata); + } + + taskScopeFor(sourceId:string):NonNullable { + const source=this.sessions.get(sourceId);if(!source)throw new Error("Handoff card is unavailable."); + let root=source; + const seen=new Set([sourceId]); + while(root.metadata.parentSessionId && !seen.has(root.metadata.parentSessionId)) { + seen.add(root.metadata.parentSessionId);const parent=this.sessions.get(root.metadata.parentSessionId);if(!parent)break;root=parent; + } + const scope=root.metadata.taskScope ?? {id:root.metadata.id,cwd:root.metadata.cwd,startedAt:root.metadata.startedAt}; + return {...scope}; + } + private isSessionBudgetPaused(session:ManagedSession):boolean { + try {return this.budgetPausedTaskRoots.has(this.taskScopeFor(session.metadata.id).id);} catch {return false;} + } + private async awaitBudgetResume(session:ManagedSession):Promise { + while(this.isSessionBudgetPaused(session)) { + if(this.sessions.get(session.metadata.id)!==session || session.metadata.exitCode!==null)return false; + const taskRootId=this.taskScopeFor(session.metadata.id).id; + await new Promise(resolve=>{ + const waiters=this.budgetResumeWaiters.get(taskRootId) ?? new Set<() => void>(); + this.budgetResumeWaiters.set(taskRootId,waiters); + const wake=()=>{ + waiters.delete(wake); + session.launchWaiters.delete(wake); + if(waiters.size===0)this.budgetResumeWaiters.delete(taskRootId); + resolve(); + }; + waiters.add(wake); + session.launchWaiters.add(wake); + if(!this.budgetPausedTaskRoots.has(taskRootId))wake(); + }); + } + return this.sessions.get(session.metadata.id)===session && session.metadata.exitCode===null; + } /** Masks known secrets and key shapes in text another agent reads (observe, result, control screen, failures). */ redactSecrets(text: T): T { return (text === null ? text : this.redaction.redact(text)) as T; @@ -384,6 +793,17 @@ export class TerminalManager { }; } + /** Host-only attribution from the accepted Accounts contribution; paths never enter plugin summaries. */ + /** Whether this card's latest launch resumed the provider conversation `threadId` (rather than starting it). */ + resumedConversation(id:string,threadId:string):boolean { + return this.resumedThreads.get(id)===threadId; + } + + usageAccount(id:string):{id:string;home?:string} { + const session=this.sessions.get(id); + return {id:session?.accountId ?? "default",...(session?.accountHome ? {home:session.accountHome} : {})}; + } + /** Records the plugin that started a card (EP-4); saved with the card so control survives a restore. */ setPluginOwner(id: string, pluginId: string): void { const session = this.sessions.get(id); @@ -467,6 +887,7 @@ export class TerminalManager { } async shutdown(): Promise { + if(this.checkpointRestore)await this.checkpointRestore.done; if (this.persistenceTimer !== null) { clearTimeout(this.persistenceTimer); this.persistenceTimer = null; @@ -526,11 +947,45 @@ export class TerminalManager { return this.liveProcesses.size; } - private allProcessesExited(timeoutMs: number): Promise { + /** Stops only this card's owned PTY and waits for its existing exit watcher before it can reuse a workspace. */ + async stopSubagentPtyForRetry(id: string): Promise { + if(this.isCheckpointRestoreActive(id))throw new Error("Workspace checkpoint restoration is in progress."); + const session = this.sessions.get(id); + if (!session || session.metadata.role !== "subagent" || session.metadata.provider === "terminal") { + throw new Error("The retry source is no longer an available subagent."); + } + const process = session.process; + if (!process) { + if (session.metadata.exitCode !== null) return; + throw new Error("The retry source has no observable PTY to stop safely."); + } + const exited = this.liveProcesses.get(process); + if (!exited) throw new Error("The retry source PTY has no host exit watcher; its workspace was not reused."); + + const resumed = this.processTreePause.resume(process); + if (resumed.failed) throw new Error(`The retry source PTY could not be resumed before stopping: ${resumed.failed}`); + try { process.kill(); } catch { /* It may have exited as the quiet result was reported. */ } + if (await this.waitForOwnedProcess(exited, PTY_EXIT_WAIT_MS)) return; + + try { + // On Windows a PTY accepts no signal. On other hosts force only this exact owned PTY after the grace period. + if (globalThis.process.platform === "win32") process.kill(); + else process.kill("SIGKILL"); + } catch { /* The captured exit promise below decides whether it stopped. */ } + if (!await this.waitForOwnedProcess(exited, PTY_KILL_WAIT_MS)) { + throw new Error("The retry source PTY did not exit; its workspace was not reused."); + } + } + + private async waitForOwnedProcess(exited: Promise, timeoutMs: number): Promise { let timer: ReturnType | undefined; const timedOut = new Promise((resolve) => { timer = setTimeout(() => resolve(false), timeoutMs); }); - const exited = Promise.all(this.liveProcesses.values()).then(() => true as const); - return Promise.race([exited, timedOut]).finally(() => clearTimeout(timer)); + try { return await Promise.race([exited.then(() => true as const), timedOut]); } + finally { clearTimeout(timer); } + } + + private allProcessesExited(timeoutMs: number): Promise { + return this.waitForOwnedProcess(Promise.all(this.liveProcesses.values()).then(() => undefined), timeoutMs); } /** @@ -585,11 +1040,12 @@ export class TerminalManager { return this.sessions.get(id)?.outputOffset ?? null; } - readBuffer(id: string): TerminalBufferSnapshot { + readBuffer(id: string, maxChars = MAX_SCROLLBACK_CHARS): TerminalBufferSnapshot { const session = this.sessions.get(id); if (!session) throw new Error("Terminal session does not exist."); + if (!Number.isSafeInteger(maxChars) || maxChars < 0) throw new Error("Invalid terminal buffer limit."); return { - buffer: session.bufferChunks.slice(session.bufferStart).join(""), + buffer: scrollbackTail(session, Math.min(maxChars, MAX_SCROLLBACK_CHARS)), outputOffset: session.outputOffset }; } @@ -605,9 +1061,12 @@ export class TerminalManager { create( request: CreateSessionRequest, - control: { captureResult?: boolean; answerCaptureGrantExpiresAt?: number; origin?: LaunchOrigin; ownerPluginId?: string } = {} + control: { captureReviewDiff?: boolean; captureResult?: boolean; answerCaptureGrantExpiresAt?: number; origin?: LaunchOrigin; ownerPluginId?: string; continueTaskFrom?:string } = {} ): SessionSnapshot { + const reviewerControl = this.readOnlyReviewerRequests.get(request); + if (reviewerControl) this.readOnlyReviewerRequests.delete(request); assertCreateRequest(request, this.containment()); + this.assertCheckpointLaunchAllowed(request.cwd,Boolean(request.environment || request.launchOptions)); const origin: LaunchOrigin = request.role === "subagent" ? "subagent" : control.origin ?? "person"; if (request.profile === "yolo" && request.provider !== "terminal") { // YOLO is the person's decision, made in the launcher for that CLI; nothing else starts it on their behalf. @@ -625,6 +1084,11 @@ export class TerminalManager { } const resume: ResumeRequest = threadId ? { threadId } : null; const modelChoice = launchModelChoice(request.provider, request.model, request.effort); + // A model account passes its own --model; a second one from the request made OpenCode crash at start + // ("U.split is not a function"). The account decides the model: say so instead of starting a broken CLI. + if (modelChoice.model !== undefined && selectedAccountId(request.launchOptions) !== "default") { + throw new LaunchRefusal(`Model account ${selectedAccountId(request.launchOptions).slice(0, 40)} sets the model of this launch; omit model, or choose an account for the model you want (list_routes / pick_account).`); + } if (modelChoice.model !== undefined) { let unknown: string | null = null; try { unknown = this.modelCheck(request.provider, modelChoice.model); } catch { unknown = null; } @@ -641,19 +1105,35 @@ export class TerminalManager { } const delegated = origin === "subagent" || control.ownerPluginId !== undefined; - const launchOptions = this.launchPipeline - ? this.launchPipeline.normalizeOptions(request.provider, request.launchOptions, origin === "subagent" ? { delegated: true } : undefined) + // The orchestrator's own account is the person's choice: its subagent may run on it even where the plugin did not + // declare its options delegable. Any other account needs the plugin's delegable declaration. + const parentAccount = origin === "subagent" && request.parentSessionId !== undefined ? this.modelAccountOf(request.parentSessionId) : undefined; + const launchOptions = reviewerControl ? undefined : this.launchPipeline + ? this.launchPipeline.normalizeOptions(request.provider, request.launchOptions, origin === "subagent" + ? { delegated: true, ...(parentAccount ? { inherited: { [ACCOUNTS_PLUGIN_ID]: { account: parentAccount } } } : {}) } : 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. - const decision = this.decideIsolation(request.provider, request.profile, delegated, null); + // Host consent supplies continuation identity before any process or capability is launched. + const continuationScope=control.continueTaskFrom ? this.taskScopeFor(control.continueTaskFrom) : undefined; + const inheritedTaskRootId=continuationScope?.id ?? (request.parentSessionId ? this.taskScopeFor(request.parentSessionId).id : undefined); + if(inheritedTaskRootId && this.budgetPausedTaskRoots.has(inheritedTaskRootId)) { + throw new LaunchRefusal("This task's usage budget is paused. Clear or raise the task budget before starting another agent."); + } + const networkProjectRoot = continuationScope?.cwd ?? this.networkProjectRoot(request.cwd, request.parentSessionId); + const decision = this.decideIsolation(request.provider, request.profile, delegated, null, networkProjectRoot); if (decision.refuse) throw new LaunchRefusal(decision.refuse); + if (reviewerControl && (!decision.apply || decision.profile !== "plan")) { + throw new LaunchRefusal("A diff-only reviewer requires the operating-system isolation layer and a read-only Plan profile. The reviewer was not started."); + } if (decision.profile !== request.profile) request = { ...request, profile: decision.profile }; - const environmentChoice = this.environments + const environmentChoice = reviewerControl ? null : this.environments ? this.environments.normalizeChoice(request.provider, request.environment) ?? null : request.environment === undefined ? null : failWith("Plugin environments are not available."); - const id = randomUUID(); + const id = reviewerControl?.account?.id ?? randomUUID(); + const startedAt=Date.now(); + const captureResult = control.captureResult === true || Boolean(reviewerControl); const metadata: SessionMetadata = { id, revision: 0, @@ -667,16 +1147,17 @@ export class TerminalManager { role, ...(request.parentSessionId !== undefined ? { parentSessionId: request.parentSessionId } : {}), status: initialSessionStatus(request.provider), - startedAt: Date.now(), + startedAt, + ...(continuationScope ? {taskScope:continuationScope} : environmentChoice && !request.parentSessionId ? {taskScope:{id,cwd:request.cwd,startedAt}} : {}), exitCode: null, failureDetails: null, ...modelChoice, ...(decision.isolation ? { isolation: decision.isolation } : {}) }; - const awaitMeasuredGrid = request.provider === "grok" + const awaitMeasuredGrid = !reviewerControl && request.provider === "grok" && this.providerClis.get(request.provider).state === "available"; // With launch options, an environment or a launch policy the plugins answer first; the card waits and launches when they do. - const contributed = (Boolean(launchOptions) || Boolean(environmentChoice) || this.policyApplies(request.provider)) && !awaitMeasuredGrid; + const contributed = !reviewerControl && (Boolean(launchOptions) || Boolean(environmentChoice) || this.policyApplies(request.provider)) && !awaitMeasuredGrid; this.startingModels.set(id, modelChoice); if (control.ownerPluginId !== undefined) this.startingOwners.set(id, control.ownerPluginId); let launched: ReturnType | { process: null; agentBrowser: null; agentRuntime: null; agentOrchestration: null; failure: null }; @@ -684,8 +1165,8 @@ export class TerminalManager { launched = awaitMeasuredGrid || contributed ? { process: null, agentBrowser: null, agentRuntime: null, agentOrchestration: null, failure: null } : this.spawnProcess(id, request.provider, request.profile, request.cwd, - INITIAL_TERMINAL_COLS, INITIAL_TERMINAL_ROWS, resume, control.captureResult, role, - control.answerCaptureGrantExpiresAt, null, request.parentSessionId); + INITIAL_TERMINAL_COLS, INITIAL_TERMINAL_ROWS, resume, captureResult, role, + control.answerCaptureGrantExpiresAt, reviewerControl?.account?.contribution ?? null, request.parentSessionId, networkProjectRoot, reviewerControl, control.captureReviewDiff === true); } finally { this.startingModels.delete(id); this.startingOwners.delete(id); @@ -715,7 +1196,9 @@ export class TerminalManager { awaitingInitialResize: awaitMeasuredGrid, resumeOnLaunch: resume, ...(threadId ? { threadId } : {}), - captureResult: control.captureResult === true, + captureResult, + captureReviewDiff: control.captureReviewDiff === true, + ...(reviewerControl ? { reviewWorkspace: reviewerControl.workspace } : {}), extras: { ...(launchOptions ? { options: launchOptions } : {}), ...(environmentChoice ? { environmentChoice } : {}), @@ -729,6 +1212,13 @@ export class TerminalManager { launchWaiters: new Set() }; this.sessions.set(id, session); + const reviewerAccount = reviewerControl?.account?.contribution; + if (reviewerAccount) { + this.addLaunchSecrets(session, reviewerAccount.secrets); + session.accountId = reviewerAccount.accountId ?? reviewerControl!.account!.id; + session.accountHome = reviewerAccount.accountHome; + session.launchCleanup = reviewerAccount.cleanup; + } this.adoptIsolationStart(id, session); if (launched.process) this.bindProcess(id, session, launched.process); if (contributed) this.launchContributed(id, session, resume, null, control.answerCaptureGrantExpiresAt); @@ -748,23 +1238,127 @@ export class TerminalManager { return session ? snapshot(session) : null; } - restart(id: string, options: { resume?: boolean } = {}): SessionSnapshot { + private readonly retryingLaunches = new Set(); + private readonly retryLaunchValidators = new Map void>(); + + /** Replace the process, retaining this card's task ownership, environment and diagnostic scrollback. */ + async retryAgentLaunch(id: string, prompt: string, validate: () => void, signal?: AbortSignal, beforeStop?: () => void): Promise { const session = this.sessions.get(id); - if (!session) throw new Error("Terminal session does not exist."); - if (session.metadata.exitCode === null) throw new Error("Terminal session is still running."); + if (!session || session.metadata.role !== "subagent") throw new LaunchRefusal("Only an existing subagent can be retried."); + if (this.retryingLaunches.has(id)) throw new LaunchRefusal("A retry of this agent is already in progress."); + const ancestors: Array<{ session: ManagedSession; epoch: number }> = []; + const seen = new Set([id]); + for (let parentId = session.metadata.parentSessionId; parentId;) { + if (seen.has(parentId)) throw new LaunchRefusal("The subagent's parent lineage is invalid."); + seen.add(parentId); + const parent = this.sessions.get(parentId); + if (!parent) throw new LaunchRefusal("The subagent's parent was closed."); + ancestors.push({ session: parent, epoch: parent.launchEpoch }); + parentId = parent.metadata.parentSessionId; + } + let epoch = session.launchEpoch; + const current = (): boolean => this.sessions.get(id) === session && session.launchEpoch === epoch; + const check = (): void => { + if (signal?.aborted) throw new DOMException("The retry was canceled.", "AbortError"); + if (!current() || ancestors.some(({ session: parent, epoch: parentEpoch }) => + this.sessions.get(parent.metadata.id) !== parent || parent.launchEpoch !== parentEpoch)) { + throw new LaunchRefusal("The agent or its parent was closed or restarted during retry."); + } + validate(); + this.validateRestart(session); + assertDirectory(session.metadata.cwd); + if (session.metadata.provider === "terminal" || this.providerClis.get(session.metadata.provider).state !== "available") throw new LaunchRefusal("The agent CLI is unavailable."); + }; + const previousFailure = session.metadata.failureDetails; + let replacementStarted = false; + this.retryingLaunches.add(id); + try { + check(); + beforeStop?.(); + // Never start a successor while the old PTY might still be alive. A timeout keeps its live slot occupied. + await this.stopRetryProcess(session); + check(); + // restartSession advances the epoch synchronously before starting either launch path. Set the expected + // epoch first so synchronous spawn failures are ours to clean up, and contributed guards see it immediately. + epoch += 1; + replacementStarted = true; + this.retryLaunchValidators.set(id, check); + this.restartSession(id); + const delivery = await this.deliverInput(id, `${prompt}\r`, undefined, signal); + check(); + if (!delivery.delivered) throw new LaunchRefusal(`The retry prompt was not delivered: ${delivery.reason}`); + return publicSessionMetadata(session); + } catch (error) { + if (replacementStarted && current()) { + // Stop only our replacement. Closing/restarting elsewhere must never let this cleanup kill a later launch. + try { await this.stopRetryProcess(session); } + catch (stopError) { + throw new LaunchRefusal(`Retry failed and its process could not be stopped: ${this.redactSecrets(String(stopError))}`); + } + if (current()) { + session.metadata.status = "failed"; + session.metadata.exitCode = session.metadata.exitCode || 1; + session.metadata.failureDetails = this.redactSecrets([ + previousFailure, "The previous process was stopped; it was not restored.", + `Retry failed: ${error instanceof Error ? error.message : String(error)}` + ].filter(Boolean).join("\n")).slice(0, 8_000); + this.emitSession(session.metadata); + this.schedulePersistence(); + } + } + throw error; + } finally { + this.retryingLaunches.delete(id); + this.retryLaunchValidators.delete(id); + } + } + + private async stopRetryProcess(session: ManagedSession): Promise { + const process = session.process; + if (process) { + await this.stopSubagentPtyForRetry(session.metadata.id); + } else if (session.metadata.exitCode === null) { + // Fence asynchronous plugin preparation even when no PTY has been adopted yet. + session.launchToken += 1; + this.wakeLaunchWaiters(session); + session.metadata.exitCode = 1; + session.metadata.status = "failed"; + session.metadata.failureDetails = session.metadata.failureDetails ?? "The pending launch was stopped before retry completed."; + void this.cleanupLaunchFiles(session).catch(() => undefined); + this.emitSession(session.metadata); + this.schedulePersistence(); + } + } + + private validateRestart(session: ManagedSession): void { + if (session.reviewWorkspace) throw new LaunchRefusal("A diff-only reviewer session cannot be restarted; request a new isolated review instead."); + if (this.isSessionBudgetPaused(session)) throw new LaunchRefusal("This task's usage budget is paused. Clear or raise the task budget before restarting it."); + if(this.isCheckpointRestoreActive(session.metadata.id))throw new LaunchRefusal("Workspace checkpoint restoration is in progress."); + this.assertCheckpointLaunchAllowed(session.metadata.cwd,Boolean(session.extras.environment || session.extras.environmentChoice || session.extras.options)); const environment = session.extras.environment; if (environment && !this.environmentUsable(environment)) { - // Never run a placed session locally instead of where it belongs. throw new Error(`This card runs in ${environment.label} from plugin ${environment.pluginId}, which is not available. It was not started locally.`); } - const pendingChoice = session.extras.environment ? undefined : session.extras.environmentChoice; - if (pendingChoice && !this.environments?.available(pendingChoice)) { - throw new Error(`${this.pendingEnvironmentReason(pendingChoice)} It was not started locally.`); - } + const pendingChoice = environment ? undefined : session.extras.environmentChoice; + if (pendingChoice && !this.environments?.available(pendingChoice)) throw new Error(`${this.pendingEnvironmentReason(pendingChoice)} It was not started locally.`); const missingPlugins = this.unavailableLaunchPlugins(session.extras.options); if (missingPlugins.length > 0) throw new Error(`Launch refused: ${missingLaunchPlugins(missingPlugins)}`); + } + + restart(id: string, options: { resume?: boolean } = {}): SessionSnapshot { + if (this.retryingLaunches.has(id)) throw new LaunchRefusal("A retry of this agent is already in progress."); + return this.restartSession(id, options); + } + + private restartSession(id: string, options: { resume?: boolean } = {}): SessionSnapshot { + const session = this.sessions.get(id); + if (!session) throw new Error("Terminal session does not exist."); + this.validateRestart(session); + if (session.metadata.exitCode === null) throw new Error("Terminal session is still running."); delete session.extras.heldState; delete session.metadata.restoreNote; + this.reviewDiffTracker.forget(id); + this.checkpointUnavailableGenerations.delete(id); // Input queued for the launch that ended never reaches this one. session.launchEpoch += 1; this.wakeLaunchWaiters(session); @@ -795,7 +1389,7 @@ export class TerminalManager { : null; session.awaitingInitialResize = true; session.resumeOnLaunch = resume; - session.metadata.startedAt = Date.now(); + session.metadata.startedAt = Math.max(Date.now(), session.metadata.startedAt + 1); session.metadata.status = initialSessionStatus(session.metadata.provider); session.metadata.turnCompleted = false; session.metadata.exitCode = null; @@ -815,7 +1409,7 @@ export class TerminalManager { session.lifecycle = this.lifecycleHooksEnabled ? createProviderLifecycleParser(session.metadata.provider, session.metadata.cwd) : null; - session.metadata.startedAt = Date.now(); + session.metadata.startedAt = Math.max(Date.now(), session.metadata.startedAt + 1); session.metadata.status = initialSessionStatus(session.metadata.provider); session.metadata.exitCode = null; session.metadata.failureDetails = null; @@ -845,7 +1439,7 @@ export class TerminalManager { session.lifecycle = this.lifecycleHooksEnabled ? createProviderLifecycleParser(session.metadata.provider, session.metadata.cwd) : null; - session.metadata.startedAt = Date.now(); + session.metadata.startedAt = Math.max(Date.now(), session.metadata.startedAt + 1); // A restart is a launch the user asked for, so its failure is news even // though the card already showed "failed" before they clicked. let failureOrigin: FailureOrigin | null = null; @@ -876,15 +1470,39 @@ export class TerminalManager { return Boolean(session && session.metadata.exitCode === null && !session.process); } + /** Host input readiness includes OpenCode's first prompt, before it creates a conversation. */ + inputReady(id: string): boolean { + const session = this.sessions.get(id); + return Boolean(session?.process && session.metadata.exitCode === null + && (session.hookSignals || session.titleState || session.cliInputReady)); + } + /** - * 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. + * Serialize controller prompts within one launch, waiting for the PTY and supported CLI readiness. + * Confirm hooked CLI input through a new turn or an echoed unsubmitted prompt. Submitted text is + * written once; only Enter may be retried after a complete echo. A failed acknowledgement is ambiguous + * and asks the caller to inspect the card. Cancellation, restart and deadlines never replay queued text + * into a later launch. Unhooked terminals retain ordinary PTY delivery. */ - async deliverInput(id: string, data: string, waitMs = LAUNCH_INPUT_WAIT_MS, signal?: AbortSignal): Promise { + deliverInput(id: string, data: string, waitMs = LAUNCH_INPUT_WAIT_MS, signal?: AbortSignal): Promise { + const session = this.sessions.get(id); + if (!session) return Promise.resolve({ delivered: false, reason: "The session does not exist." }); + const epoch = session.launchEpoch; + const deadline = Date.now() + waitMs; + const previous = session.inputQueue; + const run = async (): Promise => { + if (this.sessions.get(id) !== session || session.launchEpoch !== epoch) { + return { delivered: false, reason: "The session was closed or restarted before delivery." }; + } + return this.deliverQueuedInput(id, data, Math.max(0, deadline - Date.now()), signal); + }; + const pending = previous ? previous.then(run, run) : run(); + session.inputQueue = pending; + void pending.finally(() => { if (session.inputQueue === pending) delete session.inputQueue; }).catch(() => {}); + return pending; + } + + private async deliverQueuedInput(id: string, data: string, waitMs: number, signal?: AbortSignal): Promise { const canceled: InputDelivery = { delivered: false, reason: "The delivery was cancelled." }; if (signal?.aborted) return canceled; const session = this.sessions.get(id); @@ -918,23 +1536,97 @@ export class TerminalManager { ? `The session did not start: ${this.redactSecrets(session.metadata.failureDetails)}` : "The session has already exited." }; } + // Only these providers have a startup hook and a turn acknowledgement. Without an installed runtime + // (e.g. hooks disabled or a remote environment) keep the ordinary PTY delivery contract. + const confirm = (this.lifecycleHooksEnabled || session.captureResult) && session.agentRuntime !== null + && ["opencode", "claude", "codex"].includes(session.metadata.provider); + const valid = (): boolean => this.sessions.get(id) === session && session.launchEpoch === epoch + && session.metadata.exitCode === null && !signal?.aborted; + const poll = async (): Promise => { + await new Promise((resolve) => { + const wake = (): void => { clearTimeout(timer); signal?.removeEventListener("abort", wake); resolve(); }; + const timer = setTimeout(wake, Math.max(0, Math.min(50, deadline - Date.now()))); + signal?.addEventListener("abort", wake, { once: true }); + }); + }; + if (confirm) { + while (valid() && !this.inputReady(id) && Date.now() < deadline) await poll(); + if (signal?.aborted) return canceled; + if (!valid()) return { delivered: false, reason: "The session closed, exited or restarted before CLI readiness." }; + if (!this.inputReady(id)) return { delivered: false, reason: "The CLI did not become ready before the delivery deadline; no text was sent." }; + } + if (Date.now() >= deadline) return { delivered: false, reason: "The input delivery deadline expired; no text was sent." }; // A submitted prompt: the agent's next turn is the one that answers it (turnProgress). + const offset = session.outputOffset; const mark = session.turnStarts ?? 0; if (!this.inputChecked(id, data)) return { delivered: false, reason: "The terminal no longer accepts input." }; - if (data.endsWith("\r")) session.promptTurnMark = mark; - return { delivered: true }; + if (!confirm) return { delivered: true }; + // Never replay the text on an ambiguous acknowledgement: that can run a task twice. Retry only Enter, + // and only after observing the complete text echoed by an idle CLI (the text itself was accepted). + const text = data.replace(/[\r\n]$/u, "").replace(/\s+/gu, ""); + let submits = 1; + let retryAt = Date.now() + 1000; + let observedOffset = offset; + let echoed = false; + while (valid() && Date.now() < deadline) { + if ((session.turnStarts ?? 0) > mark) return { delivered: true }; + if (session.outputOffset !== observedOffset) { + observedOffset = session.outputOffset; + const fresh = scrollbackTail(session, observedOffset - offset); + echoed = text.length > 0 && stripVTControlCharacters(fresh).replace(/\s+/gu, "").includes(text); + } + if (echoed && !/[\r\n]$/u.test(data)) return { delivered: true }; + if (echoed && (session.acceptedLifecycleState ?? session.metadata.status) === "idle" && Date.now() >= retryAt && submits < 3) { + if (!this.inputChecked(id, "\r", { acknowledgementRetry: true })) break; + submits++; + retryAt = Date.now() + 1000; + } + await poll(); + } + if (signal?.aborted) return canceled; + return { delivered: false, reason: "The CLI did not confirm accepting the prompt before the delivery deadline. Inspect the card before sending again; the text was not replayed." }; } private wakeLaunchWaiters(session: ManagedSession): void { for (const wake of [...session.launchWaiters]) wake(); } - inputChecked(id: string, data: string): boolean { + /** Paste follows the current PTY mode; only host callers can request a separate submission suffix. */ + pasteClipboard(id: string, text: string, startedAt: number, options: { submit?: boolean } = {}): void { + const session = this.sessions.get(id); + if (typeof text !== "string" || !text || !Number.isFinite(startedAt) || !session + || session.metadata.startedAt !== startedAt || !session.pasteMode + || !this.inputChecked(id, session.pasteMode.paste(text) + + (options.submit ? "\r" : ""), { expectedStartedAt: startedAt })) { + throw new Error("Clipboard paste could not be delivered to the current terminal."); + } + } + + inputChecked(id: string, data: string, internal: { acknowledgementRetry?: boolean; expectedStartedAt?: number } = {}): boolean { + if(this.isCheckpointRestoreActive(id))return false; + if (data !== "\x03") { try { this.inputGate?.(id); } catch { return false; } } 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; + if (!session || session.metadata.exitCode !== null || !session.process + || (internal.expectedStartedAt !== undefined && session.metadata.startedAt !== internal.expectedStartedAt)) return false; + if(data!=="\x03" && this.isSessionBudgetPaused(session))return false; const process = session.process; + const mark = session.turnStarts ?? 0; + const pasteStart = data.lastIndexOf("\x1b[200~"), pasteEnd = data.lastIndexOf("\x1b[201~"); + if (!internal.acknowledgementRetry && (pasteStart >= 0 || pasteEnd >= 0)) session.inputBracketedPaste = pasteStart > pasteEnd; + const submitted = /[\r\n]$/u.test(data) && !session.inputBracketedPaste; + // All routes, including renderer and companion input, notify only after every no-write guard passed. + const afterWrite = internal.acknowledgementRetry ? [] + : [...(this.inputWriteObservers.get(id) ?? [])].map(observer => observer(data, submitted)); + if (!internal.acknowledgementRetry && submitted) { + if ((session.acceptedLifecycleState ?? session.metadata.status) !== "needs_approval") session.inputGeneration = (session.inputGeneration ?? 0) + 1; + session.promptTurnMark = mark; + delete session.answer; + } const written = tryPtyOperation(() => process.write(data)); + if (written && !internal.acknowledgementRetry) { + for (const callback of afterWrite) callback?.(); + } if (written && ANSWERS_PROMPT.test(data)) this.settleAnsweredPrompt(id, session); return written; } @@ -1005,14 +1697,50 @@ export class TerminalManager { return publicSessionMetadata(session); } + /** A host-only freshness token for a bounded pre-ack hook barrier; no renderer authority is granted. */ + providerSignalGuard(id: string, signal: ProviderLifecycleSignal): () => boolean { + const session = this.sessions.get(id), generation = session?.inputGeneration ?? 0; + return () => Boolean(session && this.sessions.get(id) === session && (session.inputGeneration ?? 0) === generation + && this.canApplyProviderSignal(id, signal)); + } + + /** Read-only host check before a lifecycle hook is acknowledged; applying the signal still happens once later. */ + canApplyProviderSignal(id: string, signal: ProviderLifecycleSignal): boolean { + if(this.isCheckpointRestoreActive(id))return false; + const session = this.sessions.get(id); + if (!session || session.metadata.status === "done" || session.metadata.status === "failed") return false; + if (session.metadata.provider !== "opencode" || !signal.requestId) return true; + const known = session.providerTurnGenerations?.get(signal.requestId); + return known === undefined + ? signal.event === "session.status:busy" || signal.event === "session.status:retry" + : known === (session.inputGeneration ?? 0) && session.providerTurnId === signal.requestId; + } + /** `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 { + 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 (!session || session.metadata.status === "done" || session.metadata.status === "failed") return false; + if (source === "hook" && !this.canApplyProviderSignal(id, signal)) return false; + let startsProviderTurn = false; + if (source === "hook" && session.metadata.provider === "opencode" && signal.requestId) { + const turns = session.providerTurnGenerations ??= new Map(); + const generation = session.inputGeneration ?? 0; + const known = turns.get(signal.requestId); + const starts = signal.event === "session.status:busy" || signal.event === "session.status:retry"; + if (known !== undefined && (known !== generation || session.providerTurnId !== signal.requestId)) return false; + if (known === undefined) { + if (!starts) return false; + startsProviderTurn = true; + turns.set(signal.requestId, generation); + session.providerTurnId = signal.requestId; + while (turns.size > 64) turns.delete(turns.keys().next().value!); + } + } if (source === "hook") session.hookSignals = (session.hookSignals ?? 0) + 1; const threadId = normalizeThreadId(session.metadata.provider, signal.threadId); const threadChanged = Boolean(threadId && threadId !== session.threadId); + if (threadId && this.resumedThreads.get(id) === null) this.resumedThreads.set(id, threadId); if (threadChanged) { session.threadId = threadId; this.schedulePersistence(); @@ -1020,24 +1748,56 @@ 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; + if (nextStatus === "working" && (startsProviderTurn || (session.acceptedLifecycleState ?? session.metadata.status) !== "working")) session.turnStarts = (session.turnStarts ?? 0) + 1; + session.acceptedLifecycleState = nextStatus; // A new turn: the previous answer is no longer this turn's. - if (nextStatus === "working") session.answer = undefined; + if (nextStatus === "working") { session.answer = undefined; session.answerTurnGeneration = session.inputGeneration ?? 0; } + // Result correlation remains active when the user disables lifecycle UI updates. + if (!this.lifecycleHooksEnabled) return true; 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 true; session.metadata.status = nextStatus; session.metadata.turnCompleted = nextTurnCompleted; this.emitSession(session.metadata); + return true; + } + + /** Result transport completion is independent of the person's lifecycle UI preference. */ + resultLifecycleState(id: string): ProviderLifecycleSignal["state"] | null { + const session = this.sessions.get(id); + return session?.captureResult ? session.acceptedLifecycleState ?? null : null; + } + + /** Host-only turn authority: accepted working/approval state for this input generation, on a live PTY. */ + observedTurnGeneration(id: string): number | null { + const session = this.sessions.get(id); + if (!session?.process || session.metadata.exitCode !== null || session.metadata.status === "failed" + || session.metadata.status === "done" || this.isCheckpointRestoreActive(id) + || !["working", "needs_approval"].includes(session.acceptedLifecycleState ?? "")) return null; + const generation = session.inputGeneration ?? 0; + return session.answerTurnGeneration === generation ? generation : null; + } + + /** Capture before starting an asynchronous answer read when the source has no provider turn id. */ + answerCaptureGeneration(id: string): number | null { + const session = this.sessions.get(id); + return session ? session.inputGeneration ?? 0 : 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. + * Keeps a final answer only for its current turn/input generation. A no-hook source must pass the token captured + * before its asynchronous read; an uncorrelated answer cannot acknowledge a newly submitted prompt. + * In memory only, read back masked by answer(), never saved. */ - recordAnswer(id: string, result: { text: string; truncated: boolean }): void { + recordAnswer(id: string, result: { text: string; truncated: boolean }, correlation?: { turnId?: string | null; generation?: number }): void { const session = this.sessions.get(id); if (!session?.captureResult || typeof result?.text !== "string") return; + const generation = session.inputGeneration ?? 0; + if (correlation?.generation !== undefined && correlation.generation !== generation) return; + 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 && correlation?.generation === undefined && session.answerTurnGeneration !== generation) return; session.answer = { text: result.text, truncated: result.truncated === true, at: Date.now() }; } @@ -1114,11 +1874,43 @@ 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 of session.launchFilePipelines ?? []) { + try { await pipeline.forgetSession(session.metadata.id); } 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. */ dispose(id: string, options: { keepEnvironmentData?: boolean } = {}): void { + if(this.isCheckpointRestoreActive(id)){this.checkpointRestore!.closes.set(id,options);return;} const session = this.sessions.get(id); if (!session) return; // A subagent belongs to its parent: closing the parent closes its subagents first (deepest first), so none keeps @@ -1131,7 +1923,14 @@ export class TerminalManager { this.flushOutput(id, session); this.sessions.delete(id); + this.checkpointUnavailableGenerations.delete(id); + this.inputWriteObservers.delete(id); + this.reviewDiffTracker.forget(id); + this.resumedThreads.delete(id); + session.reviewWorkspace?.cleanup(); + delete session.reviewWorkspace; this.isolationStarts.delete(id); + this.pendingIsolationReasons.delete(id); this.wakeLaunchWaiters(session); // Closed: an open report goes to the app; a pending audit runs and reports there. if (session.metadata.gitRisk) { @@ -1146,13 +1945,15 @@ 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(() => undefined); session.agentBrowser?.cleanup(); session.agentRuntime?.cleanup(); session.agentOrchestration?.cleanup(); if (session.process) { + const resumed=this.processTreePause.resume(session.process); + if(resumed.failed)console.warn(`Budget-paused PTY ${id} could not be resumed before close.`,resumed.failed); try { session.process.kill(); } catch (error) { @@ -1162,7 +1963,7 @@ export class TerminalManager { const environment = session.extras.environment; if (environment && this.environments) { if (!this.quitting) { - void this.environments.release(environment, id, { keepData: options.keepEnvironmentData !== false, reason: "closed" }); + void this.environments.release(environment, id, { keepData: options.keepEnvironmentData ?? environment.kind!=="worktree", reason: "closed" }); } else if (this.sessionRestoreMode === "off") { // Nothing is saved, so the environment will not come back: stop its compute, keep its data. this.quitReleases.push(this.environments.release(environment, id, { @@ -1201,6 +2002,7 @@ export class TerminalManager { size: descriptor.size, role: descriptor.role, ...(descriptor.parentSessionId !== undefined ? { parentSessionId: descriptor.parentSessionId } : {}), + ...(descriptor.taskScope ? {taskScope:{...descriptor.taskScope}} : {}), status: initialSessionStatus(descriptor.provider), startedAt: Date.now(), exitCode: null, @@ -1208,9 +2010,11 @@ export class TerminalManager { ...(step.note ? { restoreNote: step.note } : {}), ...(descriptor.environment ? { environment: environmentBadge(descriptor.environment) } : {}), ...(descriptor.model !== undefined ? { model: descriptor.model } : {}), - ...(descriptor.effort !== undefined ? { effort: descriptor.effort } : {}) + ...(descriptor.effort !== undefined ? { effort: descriptor.effort } : {}), + ...(descriptor.reviewRequested !== undefined ? {reviewRequested:descriptor.reviewRequested} : {}) }; const extras: PersistedSessionExtras = { + ...(descriptor.isolatedEnvironmentScopes ? {isolatedEnvironmentScopes:structuredClone(descriptor.isolatedEnvironmentScopes)} : {}), ...(descriptor.options ? { options: descriptor.options } : {}), ...(descriptor.environment ? { environment: descriptor.environment } : {}), ...(descriptor.environmentChoice && !descriptor.environment ? { environmentChoice: descriptor.environmentChoice } : {}), @@ -1268,9 +2072,13 @@ export class TerminalManager { } } const resume: ResumeRequest = step.launch === "stopped" ? null : step.launch; + const restoreTaskRootId=descriptor.taskScope?.id + ?? (descriptor.parentSessionId && this.sessions.has(descriptor.parentSessionId) + ? this.taskScopeFor(descriptor.parentSessionId).id + : descriptor.id); + const deferForBudget=this.budgetPausedTaskRoots.has(restoreTaskRootId); const awaitMeasuredGrid = directoryReady - && descriptor.provider === "grok" - && this.providerClis.get(descriptor.provider).state === "available"; + && (deferForBudget || descriptor.provider === "grok" && this.providerClis.get(descriptor.provider).state === "available"); const contributed = directoryReady && !awaitMeasuredGrid && (Boolean(extras.options) || Boolean(extras.environment) || this.policyApplies(descriptor.provider)); @@ -1405,10 +2213,16 @@ export class TerminalManager { private adoptIsolationStart(id: string, session: ManagedSession): void { const started = this.isolationStarts.get(id); - if (started === undefined) return; - this.isolationStarts.delete(id); - session.extras.gitAuditSince ??= started; - this.schedulePersistence(); + const reason = this.pendingIsolationReasons.get(id); + if (started !== undefined) { + this.isolationStarts.delete(id); + session.extras.gitAuditSince ??= started; + } + if (reason !== undefined) { + this.pendingIsolationReasons.delete(id); + if (session.metadata.isolation) session.metadata.isolation = { ...session.metadata.isolation, reason }; + } + if (started !== undefined || reason !== undefined) this.schedulePersistence(); } /** The person's answer to a git risk report: neutralize removes what was found; keep leaves it. Both close it. */ @@ -1427,12 +2241,18 @@ export class TerminalManager { this.schedulePersistence(); } + /** Current host-owned card descriptors, independent of restart persistence and excluding temporary reviewers. */ + archiveDescriptors(): PersistedTerminalSession[] { + return [...this.sessions.values()].filter(session => !session.reviewWorkspace) + .map(session => persistedTerminalSession(session.metadata, session.threadId, session.extras)); + } + private persistSessions(): Promise { if (this.sessionRestoreMode === "off" || this.suppressPersistence || !this.sessionStore) { return Promise.resolve(); } return this.sessionStore.replace( - [...this.sessions.values()].map((session) => persistedTerminalSession(session.metadata, session.threadId, session.extras)) + this.archiveDescriptors() ); } @@ -1470,7 +2290,12 @@ export class TerminalManager { private launchAwaitingSession(id: string, session: ManagedSession): void { if (!session.awaitingInitialResize) return; + if(this.isSessionBudgetPaused(session)) { + session.budgetDeferredLaunch=true; + return; + } session.awaitingInitialResize = false; + session.budgetDeferredLaunch=false; const resume = session.resumeOnLaunch; session.resumeOnLaunch = null; if (this.contributed(session)) { @@ -1531,7 +2356,10 @@ export class TerminalManager { role: SessionRole = "agent", answerCaptureGrantExpiresAt?: number, contribution: LaunchContribution | null = null, - parentSessionId?: string + parentSessionId?: string, + networkProjectRoot?: string, + reviewerControl?: ReadOnlyReviewerLaunch, + captureReviewDiff = this.sessions.get(id)?.captureReviewDiff === true ): { process: IPty | null; agentBrowser: PreparedAgentBrowserPtyLaunch | null; @@ -1540,11 +2368,15 @@ export class TerminalManager { failure: LaunchFailure | null; } { const none = { process: null, agentBrowser: null, agentRuntime: null, agentOrchestration: null }; - const decision = this.launchIsolation(id, provider, profile, role, null); + const projectRoot = networkProjectRoot ?? this.networkProjectRoot(cwd, parentSessionId); + const decision = this.launchIsolation(id, provider, profile, role, null, projectRoot); if (decision.refuse) return { ...none, failure: { diagnostic: `Launch refused: ${decision.refuse}`, exitCode: 1 } }; + if (reviewerControl && (!decision.apply || decision.profile !== "plan")) { + return { ...none, failure: { diagnostic: "A diff-only reviewer requires the operating-system isolation layer and a read-only Plan profile. The reviewer was not started.", exitCode: 1 } }; + } profile = decision.profile; const planned = this.planSpawn(id, provider, profile, cwd, resume, captureResult, role, answerCaptureGrantExpiresAt, contribution, - this.personTrustedFolder(parentSessionId, cwd), false, decision.apply); + reviewerControl ? undefined : this.personTrustedFolder(parentSessionId, cwd), false, decision.apply, decision.isolation?.network?.mode ?? "open", Boolean(reviewerControl)); if ("failure" in planned) { return { ...none, failure: planned.failure }; } @@ -1552,13 +2384,15 @@ 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, projectRoot, contribution?.accountHome, reviewerControl?.deniedReadPaths, contribution?.apiDomains); } catch (error) { planned.cleanup(); return { ...none, failure: { diagnostic: `Launch refused: ${error instanceof Error ? error.message : String(error)}`, exitCode: 1 } }; } } try { + this.assertCheckpointLaunchAllowed(planned.cwd); + this.reviewDiffTracker.beforeSpawn(id, planned.cwd, captureReviewDiff, this.reviewParentDirectory(parentSessionId)); const process = this.spawnPty(spawn.command, spawn.args, { name: "xterm-256color", cols, rows, cwd: planned.cwd, env: spawn.env }); @@ -1571,12 +2405,26 @@ export class TerminalManager { failure: null }; } catch (error) { + this.reviewDiffTracker.forget(id); planned.cleanup(); this.releaseIsolation(id); throw error; } } + private reviewParentDirectory(parentSessionId?: string): string | undefined { + if (!parentSessionId) return undefined; + return this.launchContexts.get(parentSessionId)?.cwd ?? this.sessions.get(parentSessionId)?.metadata.cwd; + } + + /** Only the launch-owned baseline can attribute a diff to this worker. */ + async readReviewDiff(id: string): Promise { + const baseline = this.reviewDiffTracker.baseline(id); + const diff = await new GitCheckpoints(text => this.redactSecrets(text)).workingDiff(baseline.root, baseline.head); + this.reviewDiffTracker.assertCurrent(id, baseline); + return diff; + } + /** In the manual profile the CLI's own configuration decides: the card says when it skips approvals. */ private noteConfiguredMode(id: string, provider: ProviderId, profile: LaunchProfile, env: Record, cwd: string): void { const found = profile === "normal" && provider !== "terminal" ? configuredMode(provider, env, cwd) : null; @@ -1594,9 +2442,47 @@ export class TerminalManager { return role === "subagent" || Boolean(this.sessions.get(id)?.extras.ownerPluginId ?? this.startingOwners.get(id)); } - private decideIsolation(provider: ProviderId, profile: LaunchProfile, delegated: boolean, environment: { isolated: boolean; label: string } | null): IsolationDecision { + /** Project policy follows the user's logical task root through child worktrees and approved handoffs. */ + private networkProjectRoot(cwd: string, parentSessionId?: string): string { + let current = parentSessionId ? this.sessions.get(parentSessionId) : undefined; + let root: ManagedSession | undefined; + const visited = new Set(); + while (current && !visited.has(current.metadata.id)) { + visited.add(current.metadata.id); + root = current; + current = current.metadata.parentSessionId ? this.sessions.get(current.metadata.parentSessionId) : undefined; + } + return root?.metadata.taskScope?.cwd ?? root?.metadata.cwd ?? cwd; + } + + /** Revokes live browser capabilities as soon as a policy becomes restrictive. Existing PTYs still need a restart + * for OS-level egress enforcement; newly planned launches omit the browser bridge entirely. */ + revokeBrowserCapabilitiesForStrictNetwork(projectRoot?: string): number { + const targetRoot = projectRoot === undefined ? undefined : canonicalProjectPath(projectRoot); + let revoked = 0; + for (const session of this.sessions.values()) { + if (!session.agentBrowser || session.metadata.provider === "terminal") continue; + const root = session.metadata.taskScope?.cwd ?? this.networkProjectRoot(session.metadata.cwd, session.metadata.parentSessionId); + if (targetRoot !== undefined && canonicalProjectPath(root) !== targetRoot) continue; + let mode: AgentNetworkMode | undefined; + try { mode = this.isolation?.networkPolicyFor?.(root, session.metadata.provider)?.mode; } + catch { mode = "offline"; } + if (!mode || mode === "open") continue; + const browser = session.agentBrowser; + session.agentBrowser = null; + try { browser.cleanup(); } catch { /* a revoked gateway lease must not interrupt other session cleanup */ } + revoked++; + } + return revoked; + } + + private decideIsolation(provider: ProviderId, profile: LaunchProfile, delegated: boolean, environment: { isolated: boolean; label: string } | null, + cwd?: string): IsolationDecision { if (!this.isolation) return { apply: false, profile }; - return this.isolation.decide({ provider, profile, delegated, environment }); + const decision = this.isolation.decide({ provider, profile, delegated, environment, cwd }); + const network = this.isolation.networkPolicyFor?.(cwd, provider) ?? null; + if (decision.isolation && network) decision.isolation = { ...decision.isolation, network }; + return decision; } /** @@ -1604,9 +2490,10 @@ export class TerminalManager { * and a subagent whose layer is gone runs in normal from now on. */ private launchIsolation(id: string, provider: ProviderId, profile: LaunchProfile, role: SessionRole, - environment: { isolated: boolean; label: string } | null): IsolationDecision { - const decision = this.decideIsolation(provider, profile, this.isDelegated(id, role), environment); + environment: { isolated: boolean; label: string } | null, projectRoot?: string): IsolationDecision { const metadata = this.sessions.get(id)?.metadata; + const networkProjectRoot = projectRoot ?? metadata?.taskScope?.cwd ?? this.networkProjectRoot(metadata?.cwd ?? "", metadata?.parentSessionId); + const decision = this.decideIsolation(provider, profile, this.isDelegated(id, role), environment, networkProjectRoot); if (metadata && !decision.refuse) { if (decision.isolation) metadata.isolation = decision.isolation; else delete metadata.isolation; @@ -1616,7 +2503,9 @@ export class TerminalManager { } /** Wraps a planned launch in the isolation layer (throws when the layer cannot start: the launch is refused). */ - private wrapIsolated(id: string, provider: ProviderId, profile: LaunchProfile, planned: { command: string; args: string[] | string; cwd: string; env: Record }): { command: string; args: string[]; env: Record } { + private wrapIsolated(id: string, provider: ProviderId, profile: LaunchProfile, + planned: { command: string; args: string[] | string; cwd: string; env: Record }, networkProjectRoot: string, + accountHome?: string, deniedReadPaths?: readonly string[], apiDomains?: readonly string[]): { command: string; args: string[]; env: Record } { if (!this.isolation) throw new LaunchRefusal("agent isolation is not configured; the agent was not started without it."); if (typeof planned.args === "string") throw new LaunchRefusal("a Windows batch launcher cannot run inside agent isolation."); const grant = controlGrantFolder(planned.env); @@ -1624,14 +2513,30 @@ export class TerminalManager { sessionId: id, provider, cwd: planned.cwd, + networkProjectRoot, command: planned.command, args: planned.args, env: planned.env, profile, + ...(deniedReadPaths ? { deniedReadPaths } : {}), + ...(deniedReadPaths ? { restrictHomeReads:true, runtimeReadable:[planned.command, + ...(this.agentRuntime?.readableRuntimePaths?.() ?? [])] } : {}), + ...(accountHome ? { accountHome } : {}), + ...(apiDomains?.length ? { apiDomains } : {}), ...(grant ? { grantedPrivate: [grant] } : {}) }); this.releaseIsolation(id); this.isolationCleanups.set(id, wrapped.cleanup); + if (wrapped.isolationReason) { + const session = this.sessions.get(id); + if (session) { + if (session.metadata.isolation) session.metadata.isolation = { ...session.metadata.isolation, reason: wrapped.isolationReason }; + this.schedulePersistence(); + this.emitSession(session.metadata); + } else { + this.pendingIsolationReasons.set(id, wrapped.isolationReason); + } + } // Its repositories are audited once it ends (and on close or restore): from the earliest session not audited yet. // A first launch wraps before its card exists: adoptIsolationStart records it then. const session = this.sessions.get(id); @@ -1666,33 +2571,39 @@ export class TerminalManager { contribution: LaunchContribution | null, trustedFolder?: string, environmentWrapped = false, - isolated = false + isolated = false, + networkMode: AgentNetworkMode = "open", + reviewOnly = false ): PlannedSpawn | { failure: LaunchFailure } { const providerCli = provider === "terminal" ? undefined : this.providerClis.get(provider); if (providerCli?.state === "unavailable") return { failure: providerCli }; + if (resume === null || provider === "terminal") this.resumedThreads.delete(id); + else this.resumedThreads.set(id, typeof resume === "object" ? resume.threadId : null); // 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" ? { + const claudeHttp = !reviewOnly && provider === "claude" && providerCli?.state === "available" ? { executable: providerCli.executable, profile, environmentWrapped, env: { ...terminalEnvironment(), ...providerCli.environment, ...(contribution?.env ?? {}) }, args: contribution?.args ?? [], - cwd + cwd, + networkMode } : undefined; const agentRuntime = provider === "terminal" ? null : this.agentRuntime?.prepareLaunch({ terminalSessionId: id, provider, cwd, - ...(captureResult ? { captureResult: true } : {}), + ...(captureResult || reviewOnly ? { captureResult: true } : {}), + ...(reviewOnly ? { decisions: false } : {}), ...(claudeHttp ? { claudeHttp } : {}), ...(answerCaptureGrantExpiresAt === undefined ? {} : { answerCaptureGrantExpiresAt }) }) ?? null; let pluginTools: string[] = []; try { - pluginTools = provider === "terminal" ? [] : this.pluginToolNames(role, provider); + pluginTools = provider === "terminal" || reviewOnly ? [] : this.pluginToolNames(role, provider); } catch { // Plugins never block a launch; the session simply gets no plugin tools. } const bridged = role === "orchestrator" || pluginTools.length > 0; - const agentOrchestration = bridged && this.agentOrchestration?.isEnabled + const agentOrchestration = !reviewOnly && bridged && this.agentOrchestration?.isEnabled ? this.agentOrchestration.prepareLaunch({ terminalSessionId: id }) : null; let agentBrowser: PreparedAgentBrowserPtyLaunch | null = null; @@ -1708,7 +2619,7 @@ export class TerminalManager { // and minimax until its MCP configuration is wired (plain PTY for now). // devin is cloud-session oriented and takes no browser adapter yet, // and antigravity keeps plain PTY integration for the same reason. - agentBrowser = provider === "terminal" || provider === "grok" || provider === "omp" || provider === "pi" || provider === "cursor" || provider === "minimax" || provider === "devin" || provider === "antigravity" + agentBrowser = reviewOnly || networkMode !== "open" || provider === "terminal" || provider === "grok" || provider === "omp" || provider === "pi" || provider === "cursor" || provider === "minimax" || provider === "devin" || provider === "antigravity" ? null : this.agentBrowser?.prepareLaunch({ terminalSessionId: id, @@ -1729,7 +2640,7 @@ export class TerminalManager { : { ...browserEnvironment, ...runtimeEnvironment }), ...orchestrationEnvironment, // Orchestrators alone learn where the control descriptor and CLI are. - ...controlEnvironment(role, this.controlConnection, id) + ...(!reviewOnly ? controlEnvironment(role, this.controlConnection, id) : {}) }; // 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)); @@ -1850,7 +2761,12 @@ export class TerminalManager { ): void { const token = ++session.launchToken; const { metadata } = session; - void this.runContributedLaunch(id, session, token, resume, restoring, answerCaptureGrantExpiresAt) + const tasks = session.launchTasks ??= new Set(); + // Register before invoking plugin code, which may synchronously close or restart its card. + const task = Promise.resolve().then(() => { + this.assertCheckpointLaunchAllowed(metadata.cwd,true); + return this.runContributedLaunch(id, session, token, resume, restoring, answerCaptureGrantExpiresAt); + }) .catch((error: unknown): LaunchOutcome => { metadata.failureDetails = this.redactSecrets(`Launch refused: ${error instanceof Error ? error.message : String(error)}`); return "failed"; @@ -1864,7 +2780,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( @@ -1877,25 +2795,44 @@ export class TerminalManager { ): Promise { const { metadata } = session; const live = (): boolean => this.sessions.get(id) === session && session.launchToken === token && !session.process; + const waitIfBudgetPaused = async (): Promise => { + while(this.isSessionBudgetPaused(session)) { + if(!await this.awaitBudgetResume(session))return false; + } + return live(); + }; const refuse = (reason: string): LaunchOutcome => { // "Launch refused" leads, so the failure summary quotes the reason as the cause. metadata.failureDetails = this.redactSecrets(`Launch refused: ${reason}`); return "failed"; }; const environments = this.environments; + const requestedCwd=metadata.cwd; + + if(this.isSessionBudgetPaused(session) && !await waitIfBudgetPaused())return "superseded"; + 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. const choice = session.extras.environmentChoice; if (choice && !session.extras.environment) { if (!environments) return refuse("plugin environments are not available."); - const placed = await environments.prepare({ sessionId: id, provider: metadata.provider, cwd: metadata.cwd, choice }); + const placed = await environments.prepare({ sessionId: id, provider: metadata.provider, cwd: metadata.cwd, choice, + projectRoot:metadata.taskScope?.cwd ?? this.networkProjectRoot(metadata.cwd,metadata.parentSessionId) }); if (!live()) { // An answer for a launch that no longer exists (the card was closed or restarted, or the app is quitting) // is never adopted or saved: nobody used it, so it is released at once and nothing is kept. if (placed.ok) void environments.release(placed.environment, id, { keepData: false, reason: "closed" }); return "superseded"; } + if(this.isSessionBudgetPaused(session) && !await waitIfBudgetPaused()) { + if(placed.ok)void environments.release(placed.environment,id,{keepData:false,reason:"closed"}); + return "superseded"; + } + if(!live()) { + if(placed.ok)void environments.release(placed.environment,id,{keepData:false,reason:"closed"}); + return "superseded"; + } if (!placed.ok) return refuse(placed.reason); delete session.extras.environmentChoice; session.environmentReady = true; @@ -1918,148 +2855,181 @@ export class TerminalManager { } const resumed = await environments.resume(environment, id); if (!live()) return "superseded"; + if(this.isSessionBudgetPaused(session) && !await waitIfBudgetPaused())return "superseded"; + if(!live())return "superseded"; if (!resumed.ok) return refuse(`environment stopped: ${resumed.reason}`); session.environmentReady = true; } // 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 ?? {}))); + (session.launchFilePipelines ??= new Set()).add(pipeline); + const placedIn = session.extras.environment; + const prepared = await pipeline.prepare({ + sessionId: id, + provider: metadata.provider, + profile: metadata.profile, + role: metadata.role, + cwd: metadata.cwd, + projectRoot: metadata.taskScope?.cwd ?? this.networkProjectRoot(metadata.cwd,metadata.parentSessionId), + ...(metadata.parentSessionId !== undefined ? { parentSessionId: metadata.parentSessionId } : {}), + restoring, + resume: resume !== null, + options: structuredClone(session.extras.options ?? {}) as Record>, + environment: placedIn ? { pluginId: placedIn.pluginId, kind: placedIn.kind } : null, + ...(trustedFolder ? { trustedFolder } : {}) + }); + if (prepared.ok) contribution = prepared; + if (!live()) return "superseded"; + if (this.isSessionBudgetPaused(session) && !await waitIfBudgetPaused()) return "superseded"; + if (!live()) return "superseded"; + if (!prepared.ok) return refuse(prepared.reason); + session.accountId=prepared.accountId ?? selectedAccountId(session.extras.options); + session.accountHome=prepared.accountHome; + 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 ? environments?.keeps?.(environment) ?? {} : {}; + const isolatedEnvironment=Boolean(environment && keeps.isolated===true); + if (environment && keeps.launch !== true && metadata.profile !== "normal") { + return refuse(`${environment.label} does not pass the launch on unchanged (the plugin does not declare it), so the ${metadata.profile} profile's settings and CanvasTTY's hooks would not reach the agent there. Launch it in normal, or use an environment that keeps them.`); + } + const networkProjectRoot = metadata.taskScope?.cwd ?? this.networkProjectRoot(metadata.cwd, metadata.parentSessionId); + const decision = this.launchIsolation(id, metadata.provider, metadata.profile, metadata.role, + environment ? { isolated: keeps.isolated === true, label: environment.label } : null, networkProjectRoot); + if (decision.refuse) { + return refuse(decision.refuse); + } + if (environment && keeps.launch !== true) { + metadata.isolation = { ...(metadata.isolation ?? { state: "environment" }), + reason: `${metadata.isolation?.reason ? `${metadata.isolation.reason} ` : ""}Base protection and CanvasTTY's hooks do not reach the agent in ${environment.label}.` }; + } - // 5. The host spawns the PTY; an environment only rewrites what is spawned, and the isolation layer wraps that. - let planned: PlannedSpawn | { failure: LaunchFailure }; - try { - planned = this.planSpawn(id, metadata.provider, decision.profile, metadata.cwd, resume, - session.captureResult, metadata.role, answerCaptureGrantExpiresAt, contribution, trustedFolder, Boolean(environment), decision.apply); - } catch (error) { - dropContribution(); - metadata.failureDetails = this.redactSecrets(error instanceof Error ? error.message : String(error)); - return "failed"; - } - if ("failure" in planned) { - dropContribution(); - applyLaunchFailure(metadata, planned.failure); - return "failed"; - } - const abandon = (): void => { - planned.cleanup(); - dropContribution(); - }; - this.noteConfiguredMode(id, metadata.provider, decision.profile, planned.env, planned.cwd); - let spawn: { command: string; args: string[] | string; cwd: string; env: Record } = planned; - if (environment && environments) { - if (typeof planned.args === "string") { - abandon(); - return refuse("this provider's Windows batch launcher cannot run in a plugin environment."); + // 5. The host spawns the PTY; an environment only rewrites what is spawned, and the isolation layer wraps that. + let planned: PlannedSpawn | { failure: LaunchFailure }; + try { + planned = this.planSpawn(id, metadata.provider, decision.profile, metadata.cwd, resume, + session.captureResult, metadata.role, answerCaptureGrantExpiresAt, contribution, trustedFolder, Boolean(environment), decision.apply, + decision.isolation?.network?.mode ?? "open"); + } 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(); + if ("failure" in planned) { + applyLaunchFailure(metadata, planned.failure); + return "failed"; + } + 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(this.isSessionBudgetPaused(session) && !await waitIfBudgetPaused()) { + return "superseded"; + } + 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 } }; + } + if (decision.apply) { + try { + spawn = { ...this.wrapIsolated(id, metadata.provider, decision.profile, spawn, networkProjectRoot, contribution?.accountHome, undefined, contribution?.apiDomains), cwd: spawn.cwd }; + } catch (error) { + return refuse(error instanceof Error ? error.message : String(error)); + } + } + if(this.isSessionBudgetPaused(session) && !await waitIfBudgetPaused()) { return "superseded"; } - if (!wrapped.ok) { - abandon(); - return refuse(wrapped.reason); + if(!live()) { + return "superseded"; } - 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) { + let process: IPty; try { - spawn = { ...this.wrapIsolated(id, metadata.provider, decision.profile, spawn), cwd: spawn.cwd }; + this.assertCheckpointLaunchAllowed(spawn.cwd); + this.retryLaunchValidators.get(id)?.(); + this.reviewDiffTracker.beforeSpawn(id, spawn.cwd, session.captureReviewDiff === true, this.reviewParentDirectory(metadata.parentSessionId)); + 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.reviewDiffTracker.forget(id); + this.releaseIsolation(id); + metadata.failureDetails = this.redactSecrets(error instanceof Error ? error.message : String(error)); + return "failed"; + } + session.process = process; + if(environment)session.extras.isolatedEnvironmentScopes ??= {roots:[],ambiguous:false}; + if(isolatedEnvironment) { + const evidence=session.extras.isolatedEnvironmentScopes!; + // Keep the declaration used for this launch, never re-read a changed/removed plugin at restore time. + for(const path of [requestedCwd,networkProjectRoot,metadata.cwd,spawn.cwd]) { + try { + const root=realpathSync(path); + if(!evidence.roots.includes(root)) { + if(evidence.roots.length<32)evidence.roots.push(root);else evidence.ambiguous=true; + } + } catch {evidence.ambiguous=true;} + } + } + if (planned.launchEnvironment.CANVASTTY_CODEX_KEYBOARD) { + metadata.nativeEditor = JSON.parse(planned.launchEnvironment.CANVASTTY_CODEX_KEYBOARD); + } else { + delete metadata.nativeEditor; + } + this.launchContexts.set(id, { cwd: spawn.cwd, configDir: spawn.env.CLAUDE_CONFIG_DIR ?? null }); + session.agentBrowser = planned.agentBrowser; + session.agentRuntime = planned.agentRuntime; + session.agentOrchestration = planned.agentOrchestration; + session.launchCleanup = contribution?.cleanup ?? null; + adopted = true; + metadata.status = initialSessionStatus(metadata.provider); + metadata.exitCode = null; + metadata.failureDetails = null; + this.bindProcess(id, session, process); + const runtimeStatus = this.agentRuntime?.currentStatus(id); + if (runtimeStatus) metadata.status = runtimeStatus; + if (environment) this.describeEnvironment(id, session, environment); + return "launched"; + } finally { + // Unadopted resources remain owned by this tracked launch, including throws during plugin wrapping. + if (!adopted) { + try { plannedCleanup?.(); } + finally { await contribution?.cleanup().catch(() => undefined); } } } - let process: IPty; - try { - process = this.spawnPty(spawn.command, spawn.args, { - name: "xterm-256color", cols: session.cols, rows: session.rows, cwd: spawn.cwd, env: spawn.env - }); - } catch (error) { - abandon(); - this.releaseIsolation(id); - metadata.failureDetails = this.redactSecrets(error instanceof Error ? error.message : String(error)); - return "failed"; - } - session.process = process; - if (planned.launchEnvironment.CANVASTTY_CODEX_KEYBOARD) { - metadata.nativeEditor = JSON.parse(planned.launchEnvironment.CANVASTTY_CODEX_KEYBOARD); - } else { - delete metadata.nativeEditor; - } - this.launchContexts.set(id, { cwd: spawn.cwd, configDir: spawn.env.CLAUDE_CONFIG_DIR ?? null }); - session.agentBrowser = planned.agentBrowser; - session.agentRuntime = planned.agentRuntime; - session.agentOrchestration = planned.agentOrchestration; - session.launchCleanup = contribution?.cleanup ?? null; - metadata.status = initialSessionStatus(metadata.provider); - metadata.exitCode = null; - metadata.failureDetails = null; - this.bindProcess(id, session, process); - const runtimeStatus = this.agentRuntime?.currentStatus(id); - if (runtimeStatus) metadata.status = runtimeStatus; - if (environment) this.describeEnvironment(id, session, environment); - return "launched"; } private addLaunchSecrets(session: ManagedSession, secrets: readonly string[]): void { @@ -2078,11 +3048,24 @@ export class TerminalManager { } private bindProcess(id: string, session: ManagedSession, process: IPty): void { + session.pasteMode = new TerminalPasteMode(); session.agentBrowser?.retainUntilExit?.(); process.onData((data) => { const current = this.sessions.get(id); if (!current || current !== session || current.process !== process) return; + current.pasteMode?.accept(data); + + // OpenCode creates its first conversation only after submission, so a fresh home screen has no + // session.created hook yet. Its rendered prompt and command hints are the startup readiness signal. + if (current.metadata.provider === "opencode" && !current.cliInputReady) { + current.readinessOutput = ((current.readinessOutput ?? "") + data).slice(-16_000); + const screen = stripVTControlCharacters(current.readinessOutput); + if (/Ask anything[….]/u.test(screen) && /ctrl\+p\s*commands/u.test(screen)) { + current.cliInputReady = true; + delete current.readinessOutput; + } + } const lifecycleState = current.lifecycle?.push(data); if (lifecycleState) current.titleState = lifecycleState; if (lifecycleState && !titleDefersToHooks(current, lifecycleState)) { @@ -2096,37 +3079,48 @@ 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); + const resumed=this.checkpointRestore?.processes.has(process) ? {supported:true} : this.processTreePause.resume(process); + if(resumed.failed)console.warn(`Budget-paused PTY ${id} could not be resumed after exit.`,resumed.failed); + 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.reviewDiffTracker.stopped(id); + current.process = null; this.flushOutput(id, current); this.releaseIsolation(id); + current.reviewWorkspace?.cleanup(); 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 + // (an OpenCode killed with SIGKILL showed "done, exit 0" and could not be retried). Shells report it as 128+signal. + 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); // Recorded at the moment of exit, so a finished agent is never relaunched. this.schedulePersistence(); @@ -2228,7 +3222,16 @@ function resetLaunchSignals(session: ManagedSession): void { delete session.answer; delete session.turnStarts; delete session.promptTurnMark; + session.inputGeneration = (session.inputGeneration ?? 0) + 1; + delete session.acceptedLifecycleState; + delete session.answerTurnGeneration; + delete session.providerTurnId; + delete session.providerTurnGenerations; + delete session.inputBracketedPaste; + delete session.pasteMode; delete session.hookSignals; + delete session.cliInputReady; + delete session.readinessOutput; delete session.titleState; if (session.answeredPromptTimer) clearTimeout(session.answeredPromptTimer); session.answeredPromptTimer = undefined; @@ -2395,6 +3398,11 @@ function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } +function canonicalProjectPath(path: string): string { + try { return realpathSync(path).normalize("NFC"); } + catch { return onDiskPath(path).normalize("NFC"); } +} + function publicSessionMetadata(session: ManagedSession): SessionMetadata { const metadata = structuredClone(session.metadata); if (session.threadId) metadata.threadId = session.threadId; @@ -2452,3 +3460,9 @@ function appendScrollback(session: ManagedSession, data: string): void { session.bufferStart = 0; } } + +/** "Killed by signal SIGKILL (9)." from node-pty's signal number. */ +function signalExitText(signal: number): string { + const name = Object.entries(osConstants.signals).find(([, value]) => value === signal)?.[0]; + return `The process was killed by signal ${name ? `${name} (${signal})` : signal}.`; +} diff --git a/src/main/services/TerminalOutputHistory.ts b/src/main/services/TerminalOutputHistory.ts new file mode 100644 index 00000000..d687326f --- /dev/null +++ b/src/main/services/TerminalOutputHistory.ts @@ -0,0 +1,393 @@ +import { existsSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { randomBytes } from "node:crypto"; +import { fork, type ChildProcess } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import type { TerminalOutputContext, TerminalOutputSearchResult } from "../../shared/contracts.ts"; + +export interface TerminalOutputRedactionSnapshot { + revision: number; + values: string[]; +} + +export interface TerminalHistoryRecoverySnapshot { + sessionId: string; + buffer: string; + outputOffset: number; +} + +interface WorkerReply { requestId: number; ok: boolean; value?: unknown; error?: string } +interface PendingRequest { resolve(value: unknown): void; reject(error: Error): void } +interface OutboundBatch { worker: ChildProcess; requestId: number; items: object[]; dataChars: number; promise: Promise } +export interface TerminalOutputHistoryOptions { + /** + * A private directory for the worker's crash-recovery spill: output encrypted with a key held only in this + * process's memory, bounded like the in-memory history, wiped on a fresh start and on close. + */ + spillDirectory?: string; +} + +function historyWorkerEnvironment(): NodeJS.ProcessEnv { + return { + ELECTRON_RUN_AS_NODE: "1", + ...(process.platform === "win32" && process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}) + }; +} + +const MAX_QUEUED_CHARS = 8_000_000; +const MAX_IPC_BATCH_REQUESTS = 64; +const MAX_IPC_BATCH_DATA_CHARS = 256_000; +// A 50M UTF-16 history plus masked search caches kept the output worker's RSS +// hundreds of megabytes above startup. Keep the worker's raw and recovery +// budgets aligned so a restart cannot briefly recreate the old peak. +const MAX_RECOVERY_CHARS = 16_000_000; +const MAX_RECOVERY_SESSIONS = 1_024; + +/** Read newest tails first without copying data the worker would immediately prune. */ +export function collectTerminalHistoryRecoverySnapshots( + sessionIds: string[], + readBuffer: (sessionId: string, maxChars: number) => { buffer: string; outputOffset: number }, + maxChars = MAX_RECOVERY_CHARS +): TerminalHistoryRecoverySnapshot[] { + if (!Number.isSafeInteger(maxChars) || maxChars < 0 || maxChars > MAX_RECOVERY_CHARS) throw new Error("Invalid terminal history recovery limit."); + if (sessionIds.length > MAX_RECOVERY_SESSIONS || sessionIds.some(id => !validId(id))) throw new Error("Unsupported terminal history recovery session count or IDs."); + let remaining = maxChars; + const snapshots: TerminalHistoryRecoverySnapshot[] = []; + for (const sessionId of [...sessionIds].reverse()) { + try { + const snapshot = readBuffer(sessionId, remaining); + const buffer = remaining > 0 ? snapshot.buffer.slice(-remaining) : ""; + remaining -= buffer.length; + // Keep offsets even when no tail fits: omitted live sessions must report a history gap. + snapshots.push({ sessionId, buffer, outputOffset: snapshot.outputOffset }); + } catch { /* A card may have been removed immediately before recovery. */ } + } + return snapshots.reverse(); +} + +/** + * Bounded output history for search and context reads. Raw text reaches disk only encrypted, under a key that never + * leaves this process's memory (the optional crash-recovery spill); only the trusted worker can materialize a + * complete masked copy, using the host registry snapshot for the current revision. + */ +export class TerminalOutputHistory { + private worker: ChildProcess | null = null; + private emptyWorkerTimer: NodeJS.Timeout | null = null; + private readonly pending = new Map(); + private outbound: OutboundBatch | null = null; + private outboundFlush: NodeJS.Immediate | null = null; + private readonly spillDirectory: string | null; + private readonly spillKey = randomBytes(32); + private readonly gapPending = new Set(); + private readonly activeSessionIds = new Set(); + private nextRequestId = 1; + private queuedChars = 0; + private closed = false; + private needsRecovery = false; + private generation = 0; + private secretsRevision = -1; + private readonly getRedactionSnapshot: () => TerminalOutputRedactionSnapshot; + private readonly getRecoverySnapshots: () => TerminalHistoryRecoverySnapshot[]; + + constructor(getRedactionSnapshot: () => TerminalOutputRedactionSnapshot, + getRecoverySnapshots: () => TerminalHistoryRecoverySnapshot[] = () => [], options: TerminalOutputHistoryOptions = {}) { + this.getRedactionSnapshot = getRedactionSnapshot; + this.getRecoverySnapshots = getRecoverySnapshots; + this.spillDirectory = typeof options.spillDirectory === "string" && options.spillDirectory ? options.spillDirectory : null; + } + + /** Append a raw PTY batch in FIFO order. Duplicate startup snapshots are discarded by absolute source offset. */ + append(sessionId: string, data: string, outputOffset: number): Promise { + this.assertOpen(); + if (!validId(sessionId) || typeof data !== "string" || data.length > 1_000_000 || !Number.isSafeInteger(outputOffset) || outputOffset < 0) { + return Promise.reject(new Error("Invalid terminal output history chunk.")); + } + if (this.gapPending.has(sessionId)) return Promise.resolve(); + if (this.queuedChars + data.length > MAX_QUEUED_CHARS) { + this.gapPending.add(sessionId); + const work = this.request("gap", { sessionId, outputOffset }); + const generation = this.generation; + return work.then(() => undefined).finally(() => { if (generation === this.generation) this.gapPending.delete(sessionId); }); + } + this.queuedChars += data.length; + const work = this.request("append", { sessionId, data, outputOffset }); + const generation = this.generation; + return work + .then(() => undefined) + .finally(() => { if (generation === this.generation) this.queuedChars = Math.max(0, this.queuedChars - data.length); }); + } + + remove(sessionId: string): Promise { + if (!validId(sessionId)) return Promise.resolve(); + this.gapPending.delete(sessionId); + return this.request("remove", { sessionId }).then(() => undefined); + } + + async search(query: string, sessionIds?: string[]): Promise { + this.assertOpen(); + if (query.length > 200 || !query.trim()) return { matches: [], prunedSessionIds: [] }; + if (sessionIds && (sessionIds.length > 256 || sessionIds.some((id) => !validId(id)))) throw new Error("Invalid terminal output search scope."); + await this.syncRedactionSnapshot(); + return await this.request("search", { query, sessionIds: sessionIds ?? null }) as TerminalOutputSearchResult; + } + + async readContext(sessionId: string, offset: number): Promise { + this.assertOpen(); + if (!validId(sessionId) || !Number.isSafeInteger(offset) || offset < 0) throw new Error("Invalid terminal output context request."); + await this.syncRedactionSnapshot(); + return await this.request("context", { sessionId, offset }) as TerminalOutputContext; + } + + async close(): Promise { + if (this.closed) return; + this.closed = true; + this.clearEmptyWorkerTimer(); + this.clearOutboundQueue(); + const worker = this.worker; + if (!worker) { + if (this.spillDirectory) await rm(this.spillDirectory, { recursive: true, force: true }).catch(() => undefined); + return; + } + const error = new Error("Terminal output history is shutting down."); + for (const entry of this.pending.values()) entry.reject(error); + this.pending.clear(); + await stopWorker(worker); + this.worker = null; + if (this.spillDirectory) await rm(this.spillDirectory, { recursive: true, force: true }).catch(() => undefined); + } + + private async syncRedactionSnapshot(): Promise { + this.ensureWorker(); + const worker = this.worker; + const snapshot = this.redactionSnapshot(); + if (snapshot.revision === this.secretsRevision) return; + await this.request("secrets", { revision: snapshot.revision, values: snapshot.values }); + if (this.worker === worker) this.secretsRevision = snapshot.revision; + } + + private redactionSnapshot(): TerminalOutputRedactionSnapshot { + const snapshot = this.getRedactionSnapshot(); + if (!snapshot || !Number.isSafeInteger(snapshot.revision) || snapshot.revision < 0 || !Array.isArray(snapshot.values) + || snapshot.values.length > 32_768 || snapshot.values.some((value) => typeof value !== "string" || value.length > 65_536)) { + throw new Error("Terminal output redaction is unavailable."); + } + return snapshot; + } + + private request(type: string, data: Record): Promise { + if (this.closed && type !== "close") return Promise.reject(new Error("Terminal output history is closed.")); + let worker: ChildProcess; + try { + this.ensureWorker(); + if (!this.worker) throw new Error("Terminal output history worker could not start."); + worker = this.worker; + this.clearEmptyWorkerTimer(); + if ((type === "append" || type === "gap") && typeof data.sessionId === "string") this.activeSessionIds.add(data.sessionId); + else if (type === "remove" && typeof data.sessionId === "string") this.activeSessionIds.delete(data.sessionId); + } + catch (error) { return Promise.reject(error instanceof Error ? error : new Error("Terminal output history worker could not start.")); } + if (type === "append" || type === "gap" || type === "remove") return this.enqueue(worker, { type, ...data }); + // Queries and snapshots go after every queued write, as one message each. + this.flushOutbound(worker); + const requestId = this.nextRequestId++; + return new Promise((resolve, reject) => { + this.pending.set(requestId, { resolve, reject }); + this.sendNow(worker, { requestId, type, ...data }); + }); + } + + /** + * Writes travel in batches that share one acknowledgement: a busy terminal produces many small PTY chunks, and a + * reply, a pending entry and a promise per chunk cost more than the chunk itself. + */ + private enqueue(worker: ChildProcess, item: Record): Promise { + const dataChars = typeof item.data === "string" ? item.data.length : 0; + if (this.outbound && (this.outbound.worker !== worker || this.outbound.dataChars + dataChars > MAX_IPC_BATCH_DATA_CHARS)) this.flushOutbound(this.outbound.worker); + if (!this.outbound) { + const requestId = this.nextRequestId++; + const promise = new Promise((resolve, reject) => this.pending.set(requestId, { resolve, reject })); + this.outbound = { worker, requestId, items: [], dataChars: 0, promise }; + this.scheduleOutboundFlush(); + } + const batch = this.outbound; + batch.items.push(item); + batch.dataChars += dataChars; + if (batch.items.length >= MAX_IPC_BATCH_REQUESTS || batch.dataChars >= MAX_IPC_BATCH_DATA_CHARS) this.flushOutbound(worker); + return batch.promise; + } + + private ensureWorker(): void { + if (this.worker) return; + this.assertOpen(); + const recovering = this.needsRecovery; + const snapshots = recovering ? this.getRecoverySnapshots() : []; + if (!Array.isArray(snapshots) || snapshots.length > MAX_RECOVERY_SESSIONS || snapshots.some(snapshot => + !validId(snapshot.sessionId) || typeof snapshot.buffer !== "string" || snapshot.buffer.length > 1_000_000 + || !Number.isSafeInteger(snapshot.outputOffset) || snapshot.outputOffset < snapshot.buffer.length)) { + throw new Error("Terminal history recovery snapshots are invalid."); + } + const path = workerEntryPath(); + const redaction = this.redactionSnapshot(); + const worker = fork(path, [], { + // Direct Node tests can fork the source .ts worker on Node versions that require this flag. + // Electron production loads the built .js worker instead. + execArgv: [ + ...(path.endsWith(".ts") ? ["--experimental-strip-types"] : []), + // Raw history lives outside the V8 heap; keep the heap itself compact instead of fast-growing. + "--optimize-for-size", + "--max-old-space-size=128", + "--max-semi-space-size=2" + ], + env: historyWorkerEnvironment(), + stdio: ["ignore", "ignore", "ignore", "ipc"], + serialization: "advanced" + }); + this.worker = worker; + if (recovering) { + this.activeSessionIds.clear(); + for (const snapshot of snapshots) this.activeSessionIds.add(snapshot.sessionId); + } + worker.on("message", (reply: WorkerReply | WorkerReply[]) => { + if (this.worker !== worker) return; + for (const item of Array.isArray(reply) ? reply : [reply]) this.onReply(item); + }); + worker.on("error", (error) => this.fail(worker, error instanceof Error ? error : new Error(String(error)))); + worker.once("exit", (code) => { + if (!this.closed) this.fail(worker, new Error(`Terminal output history worker exited (${code ?? 1}).`)); + }); + this.needsRecovery = false; + this.secretsRevision = redaction.revision; + // A fresh start wipes an earlier run's spill; a restart after a crash reads it back first. + if (this.spillDirectory) this.sendNow(worker, { requestId: 0, type: "spill", directory: this.spillDirectory, key: this.spillKey, recover: recovering }); + this.sendNow(worker, { requestId: 0, type: "secrets", revision: redaction.revision, values: redaction.values }); + // FIFO messages seed recent PTY tails before queries; positive source offsets mark lost history. + let remaining = MAX_RECOVERY_CHARS; + const bounded = snapshots.map(snapshot => ({ ...snapshot })); + for (let index = bounded.length - 1; index >= 0; index--) { + const snapshot = bounded[index]!; + snapshot.buffer = remaining > 0 ? snapshot.buffer.slice(-remaining) : ""; + remaining -= snapshot.buffer.length; + } + for (const snapshot of bounded) this.sendNow(worker, { requestId: 0, type: "append", sessionId: snapshot.sessionId, + data: snapshot.buffer, outputOffset: snapshot.outputOffset }); + } + + private onReply(reply: WorkerReply): void { + if (!reply || !Number.isSafeInteger(reply.requestId)) return; + const entry = this.pending.get(reply.requestId); + if (!entry) return; + this.pending.delete(reply.requestId); + if (!reply.ok) entry.reject(new Error(typeof reply.error === "string" ? reply.error : "Terminal output history request failed.")); + else entry.resolve(reply.value); + this.scheduleEmptyWorkerTermination(); + } + + private scheduleOutboundFlush(): void { + if (this.outboundFlush) return; + const scheduled = setImmediate(() => { + if (this.outboundFlush !== scheduled) return; + this.outboundFlush = null; + if (this.outbound) this.flushOutbound(this.outbound.worker); + }); + this.outboundFlush = scheduled; + } + + private flushOutbound(worker: ChildProcess): void { + const batch = this.outbound; + if (!batch || batch.worker !== worker) return; + if (this.outboundFlush) clearImmediate(this.outboundFlush); + this.outboundFlush = null; + this.outbound = null; + if (this.worker !== worker) return; + this.sendNow(worker, { requestId: batch.requestId, type: "batch", items: batch.items }); + } + + private sendNow(worker: ChildProcess, message: object): void { + if (this.worker !== worker) return; + if (!worker.connected) { + this.fail(worker, new Error("Terminal output history process is disconnected.")); + return; + } + try { + worker.send(message, error => { if (error) this.fail(worker, error); }); + } catch (reason) { + this.fail(worker, reason instanceof Error ? reason : new Error("Terminal output history worker rejected a request.")); + } + } + + private clearOutboundQueue(): void { + if (this.outboundFlush) clearImmediate(this.outboundFlush); + this.outboundFlush = null; + this.outbound = null; + } + + private fail(worker: ChildProcess, error: Error): void { + if (this.worker !== worker) return; + this.clearEmptyWorkerTimer(); + this.clearOutboundQueue(); + this.worker = null; + this.needsRecovery = true; + this.generation++; + this.secretsRevision = -1; + for (const entry of this.pending.values()) entry.reject(error); + this.pending.clear(); + this.queuedChars = 0; + this.gapPending.clear(); + void stopWorker(worker); + } + + private scheduleEmptyWorkerTermination(): void { + this.clearEmptyWorkerTimer(); + if (this.activeSessionIds.size > 0 || this.pending.size > 0 || !this.worker || this.closed) return; + const worker = this.worker; + this.emptyWorkerTimer = setTimeout(() => { + this.emptyWorkerTimer = null; + if (this.worker !== worker || this.activeSessionIds.size > 0 || this.pending.size > 0 || this.closed) return; + // Keep full history searchable while any session remains. Once every session has been removed and + // the worker is quiescent, its raw and masked heaps have no remaining user-visible value. + this.worker = null; + this.needsRecovery = true; + this.secretsRevision = -1; + void stopWorker(worker); + }, 0); + this.emptyWorkerTimer.unref(); + } + + private clearEmptyWorkerTimer(): void { + if (!this.emptyWorkerTimer) return; + clearTimeout(this.emptyWorkerTimer); + this.emptyWorkerTimer = null; + } + + private assertOpen(): void { + if (this.closed) throw new Error("Terminal output history is closed."); + } +} + +function validId(value: unknown): value is string { return typeof value === "string" && value.length > 0 && value.length <= 256; } + +function stopWorker(worker: ChildProcess): Promise { + if (worker.exitCode !== null || worker.signalCode !== null) return Promise.resolve(); + return new Promise(resolve => { + const onExit = (): void => resolve(); + worker.once("exit", onExit); + try { + if (!worker.kill()) { + worker.removeListener("exit", onExit); + resolve(); + } + } catch { + worker.removeListener("exit", onExit); + resolve(); + } + }); +} + +function workerEntryPath(): string { + // electron-vite emits the dedicated worker beside out/main/index.js; direct Node tests run the source .ts file. + const built = fileURLToPath(new URL("./TerminalOutputHistoryWorker.js", import.meta.url)); + if (existsSync(built)) return built; + const source = fileURLToPath(new URL("./TerminalOutputHistoryWorker.ts", import.meta.url)); + if (existsSync(source)) return source; + throw new Error("Terminal output history worker entry is missing."); +} diff --git a/src/main/services/TerminalOutputHistoryWorker.ts b/src/main/services/TerminalOutputHistoryWorker.ts new file mode 100644 index 00000000..74a55165 --- /dev/null +++ b/src/main/services/TerminalOutputHistoryWorker.ts @@ -0,0 +1,754 @@ +import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto"; +import { appendFileSync, mkdirSync, readdirSync, readFileSync, rmSync, unlinkSync } from "node:fs"; +import { join } from "node:path"; +import { redactionRegistryFromWorkerSnapshot } from "./safety/SecretRedaction.ts"; + +interface WorkerReply { requestId: number; ok: boolean; value?: unknown; error?: string } + +/** + * Raw output is kept outside the V8 heap: one-byte text as latin1 bytes, other text as UTF-16. The worker runs with a + * small heap limit, and 16M retained characters as heap strings left most of that limit (and its garbage-collection + * slack) to the history alone. + */ +type Chunk = { data: Buffer; wide: boolean; length: number; start: number; owner: History; sessionNext: Chunk | null; globalPrevious: Chunk | null; globalNext: Chunk | null }; +type AnsiState = "text" | "escape" | "csi" | "osc" | "osc-escape" | "string" | "string-escape"; +/** A masked piece of history, ending at a boundary SecretRedaction proved no match crosses. */ +type MaskedSegment = { text: string; sourceEnd: number; newlines: number }; +/** + * The masked copy that search and context read. It is built when a query arrives (never per PTY append): segments + * up to `frontier` stay valid while output grows, so a query masks only what arrived since the previous one. A new + * secret set starts a new copy. Pruned raw output drops whole segments from the head. + */ +type MaskedStore = { + revision: number; + segments: MaskedSegment[]; + head: number; + frontier: number; + /** Absolute offset and line number of the first retained segment (what context reports as its base). */ + viewStart: number; + lineStart: number; + /** The masked text after `frontier`, for the source up to `upTo`. */ + tail: { upTo: number; text: string; newlines: number } | null; + /** Characters held in segments from `head` on. */ + chars: number; +}; +type History = { + head: Chunk | null; + tail: Chunk | null; + rawEnd: number; + nextOffset: number; + baseOffset: number; + baseLine: number; + pruned: boolean; + /** Constant-space ECMA-48 decoder state; control-string contents are never retained. */ + ansiState: AnsiState; + masked: MaskedStore | null; + spill: SpillState | null; +}; +type SpillState = { directory: string; file: string | null; fileChars: number; queue: Buffer[]; queuedChars: number }; + +const MAX_GLOBAL_CHARS = 16_000_000; +const MAX_CHUNK_CHARS = 16_384; +/** Source characters masked per step; far beyond any held value's reach (see windowChars). */ +const MASK_WINDOW_CHARS = 1_048_576; +/** Segments shorter than this absorb the next one, so frequent queries do not leave thousands of tiny parts. */ +const MIN_SEGMENT_CHARS = 65_536; +/** + * Masked copies of every session together: a segment straddling a session's pruned start is kept whole, so the copies + * can exceed the raw history slightly; past this, copies of sessions not being queried are dropped and rebuilt later. + */ +const MAX_MASKED_CHARS = 24_000_000; +const ASCII_NEEDLE = /^[\u0000-\u007f]*$/u; +/** Non-ASCII characters whose lower case holds ASCII letters (dotted I, Kelvin): a line holding one is folded. */ +const ASCII_FOLDING_CHARS = /[\u0130\u212a]/u; +const ASCII_FOLD_CHARS = /[A-Z\u0130\u212a]/u; +const WIDE_CHARS = /[^\u0000-\u00ff]/u; +const GAP_MARKER = "[CanvasTTY: terminal output is missing here]\n"; +/** Encrypted crash-recovery spill: flushed at most this often, or when this much is queued. */ +const SPILL_FLUSH_MS = 1_000; +const SPILL_FLUSH_CHARS = 262_144; +const SPILL_FILE_CHARS = 2_097_152; +const histories = new Map(); +let globalHead: Chunk | null = null; +let globalTail: Chunk | null = null; +let globalLength = 0; +let secretRevision = -1; +let redactor = redactionRegistryFromWorkerSnapshot([]); +let spill: { root: string; key: Buffer; files: Array<{ path: string; chars: number; owner: string }>; chars: number; sequence: number; timer: NodeJS.Timeout | null } | null = null; + +if (typeof process.send !== "function") throw new Error("Terminal output history worker requires an IPC parent."); + +process.on("message", (message: any) => { + if (message?.type === "batch" && Array.isArray(message.items)) { + // Output, gaps and removals arrive in batches with one acknowledgement each. + let error: string | undefined; + for (const item of message.items) { + const result = handleRequest(item); + if (!result.reply.ok && error === undefined) error = result.reply.error; + } + process.send?.(error === undefined ? { requestId: message.requestId, ok: true } : { requestId: message.requestId, ok: false, error }); + return; + } + const result = handleRequest(message); + process.send?.(result.reply); + if (result.disconnect) process.disconnect?.(); +}); + +function handleRequest(message: any): { reply: WorkerReply; disconnect: boolean } { + const id = typeof message?.requestId === "number" ? message.requestId : 0; + try { + let value: unknown; + let disconnect = false; + switch (message?.type) { + case "spill": + configureSpill(message.directory, message.key, message.recover === true); + value = true; + break; + case "secrets": + if (Number.isSafeInteger(message.revision) && message.revision >= 0 && Array.isArray(message.values) + && message.values.length <= 32_768 && message.values.every((entry: unknown) => typeof entry === "string" && entry.length <= 65_536)) { + if (message.revision !== secretRevision) { + secretRevision = message.revision; + redactor = redactionRegistryFromWorkerSnapshot(message.values); + for (const history of histories.values()) history.masked = null; + } + value = { revision: secretRevision }; + } else throw new Error("Invalid terminal-history redaction snapshot."); + break; + case "append": + append(message.sessionId, message.data, message.outputOffset); + value = true; + break; + case "gap": + markGap(message.sessionId, message.outputOffset); + value = true; + break; + case "remove": + remove(message.sessionId); + value = true; + break; + case "search": + value = search(message.query, message.sessionIds); + break; + case "context": + value = context(message.sessionId, message.offset); + break; + case "close": + disconnect = true; + value = true; + break; + default: + throw new Error("Unknown terminal-history operation."); + } + return { reply: { requestId: id, ok: true, value }, disconnect }; + } catch (error) { + return { reply: { requestId: id, ok: false, error: error instanceof Error ? error.message : "Terminal history operation failed." }, disconnect: false }; + } +} + +function historyFor(sessionId: string): History { + let history = histories.get(sessionId); + if (!history) { + history = { head: null, tail: null, rawEnd: 0, nextOffset: 0, baseOffset: 0, baseLine: 0, pruned: false, ansiState: "text", masked: null, spill: null }; + histories.set(sessionId, history); + } + return history; +} + +function append(sessionId: unknown, data: unknown, outputOffset: unknown): void { + if (typeof sessionId !== "string" || sessionId.length < 1 || sessionId.length > 256 + || typeof data !== "string" || data.length > 1_000_000 || !Number.isSafeInteger(outputOffset) || (outputOffset as number) < 0) { + throw new Error("Invalid terminal output history chunk."); + } + const history = historyFor(sessionId); + const end = outputOffset as number; + const start = end - data.length; + if (end <= history.rawEnd) return; // A startup seed and the live PTY stream can cover the same output. + let suffix = data; + if (start < history.rawEnd) suffix = data.slice(history.rawEnd - start); + if (start > history.rawEnd) openGap(sessionId, history); + const clean = stripAnsi(suffix, history.ansiState); + history.ansiState = clean.state; + history.rawEnd = end; + if (clean.text) appendText(sessionId, history, clean.text); +} + +function markGap(sessionId: unknown, outputOffset: unknown): void { + if (typeof sessionId !== "string" || !Number.isSafeInteger(outputOffset) || (outputOffset as number) < 0) throw new Error("Invalid terminal history gap."); + const history = historyFor(sessionId); + if ((outputOffset as number) > history.rawEnd) { + openGap(sessionId, history); + history.rawEnd = outputOffset as number; + } +} + +/** Lost output: the history says so where it happened, and search reports the session as incomplete. */ +function openGap(sessionId: string, history: History): void { + history.ansiState = "text"; + history.pruned = true; + if (history.nextOffset > 0) appendText(sessionId, history, history.tail && lastChar(history.tail) !== "\n" ? `\n${GAP_MARKER}` : GAP_MARKER); +} + +function lastChar(chunk: Chunk): string { + return chunk.wide ? chunk.data.toString("utf16le", chunk.data.length - 2) : chunk.data.toString("latin1", chunk.data.length - 1); +} + +function appendText(sessionId: string, history: History, text: string): void { + for (let cursor = 0; cursor < text.length;) { + const part = text.length <= MAX_CHUNK_CHARS && cursor === 0 ? text : text.slice(cursor, cursor + MAX_CHUNK_CHARS); + const wide = WIDE_CHARS.test(part); + const chunk: Chunk = { data: Buffer.from(part, wide ? "utf16le" : "latin1"), wide, length: part.length, start: history.nextOffset, + owner: history, sessionNext: null, globalPrevious: globalTail, globalNext: null }; + if (history.tail) history.tail.sessionNext = chunk; + else history.head = chunk; + history.tail = chunk; + if (globalTail) globalTail.globalNext = chunk; + else globalHead = chunk; + globalTail = chunk; + history.nextOffset += part.length; + globalLength += part.length; + cursor += part.length; + queueSpill(sessionId, history, chunk); + } + // A whole append is queued by now, so a record never claims PTY output it does not hold. + if (history.spill && history.spill.queuedChars >= SPILL_FLUSH_CHARS) flushHistorySpill(history.spill); + while (globalLength > MAX_GLOBAL_CHARS && globalHead) pruneChunk(globalHead); +} + +function pruneChunk(chunk: Chunk): void { + const owner = chunk.owner; + owner.baseOffset = chunk.start + chunk.length; + owner.baseLine += countNewlines(decode(chunk)); + owner.pruned = true; + globalLength -= chunk.length; + // Global eviction always removes the oldest remaining block of its session. + owner.head = chunk.sessionNext; + if (!owner.head) owner.tail = null; + globalHead = chunk.globalNext; + if (globalHead) globalHead.globalPrevious = null; + else globalTail = null; + chunk.sessionNext = null; + chunk.globalNext = null; +} + +function remove(sessionId: unknown): void { + if (typeof sessionId !== "string") return; + const history = histories.get(sessionId); + if (!history) return; + for (let chunk = history.head; chunk;) { + const next = chunk.sessionNext; + globalLength -= chunk.length; + if (chunk.globalPrevious) chunk.globalPrevious.globalNext = chunk.globalNext; + else globalHead = chunk.globalNext; + if (chunk.globalNext) chunk.globalNext.globalPrevious = chunk.globalPrevious; + else globalTail = chunk.globalPrevious; + chunk.globalPrevious = null; + chunk.globalNext = null; + chunk.sessionNext = null; + chunk = next; + } + histories.delete(sessionId); + dropSpill(sessionId); +} + +function decode(chunk: Chunk): string { return chunk.data.toString(chunk.wide ? "utf16le" : "latin1"); } + +/** The retained source text in [from, to), joined from at most one window's chunks. */ +function collectText(history: History, from: number, to: number): string { + const parts: string[] = []; + for (let chunk = history.head; chunk && chunk.start < to; chunk = chunk.sessionNext) { + const chunkEnd = chunk.start + chunk.length; + if (chunkEnd <= from) continue; + const text = decode(chunk); + parts.push(from > chunk.start || to < chunkEnd ? text.slice(Math.max(0, from - chunk.start), Math.min(chunk.length, to - chunk.start)) : text); + } + return parts.length === 1 ? parts[0]! : parts.join(""); +} + +/** Bring a history's masked copy up to its newest output; returns the parts search and context read. */ +function maskedView(history: History): { parts: string[]; start: number; line: number; store: MaskedStore } { + let store = history.masked; + if (!store || store.revision !== secretRevision) store = history.masked = freshStore(history); + while (store.head < store.segments.length && store.segments[store.head]!.sourceEnd <= history.baseOffset) { + const dropped = store.segments[store.head++]!; + store.chars -= dropped.text.length; + store.viewStart += dropped.text.length; + store.lineStart += dropped.newlines; + } + if (store.head > 64 && store.head * 2 > store.segments.length) { + store.segments = store.segments.slice(store.head); + store.head = 0; + } + if (store.frontier < history.baseOffset) { + // Output newer than the last query was pruned before it was masked: continue from what is retained. + const restart = freshStore(history); + store.viewStart += Math.max(0, restart.viewStart - store.frontier); + store.frontier = restart.frontier; + store.lineStart = Math.max(store.lineStart, restart.lineStart); + store.tail = null; + } + const window = windowChars(); + while (store.frontier < history.nextOffset) { + const end = Math.min(history.nextOffset, store.frontier + window); + const text = collectText(history, store.frontier, end); + let cut = 0; + let masked = ""; + const stable = redactor.redactStablePrefix(text); + if (stable.sourceLength > 0) { + cut = stable.sourceLength; + masked = stable.maskedText; + } else if (text.length >= window) { + // A whole window without a line start that is safe to cut at (one line of megabytes). + const forced = redactor.forcedBoundary(text, text.length - 1); + if (forced !== null) { + cut = forced; + masked = redactor.redact(text.slice(0, forced)); + } + } + if (cut <= 0) break; + const previous = store.segments.length > store.head ? store.segments[store.segments.length - 1]! : null; + if (previous && previous.text.length < MIN_SEGMENT_CHARS) { + previous.text += masked; + previous.sourceEnd = store.frontier + cut; + previous.newlines += countNewlines(masked); + } else store.segments.push({ text: masked, sourceEnd: store.frontier + cut, newlines: countNewlines(masked) }); + store.chars += masked.length; + store.frontier += cut; + store.tail = null; + } + if (store.frontier < history.nextOffset) { + if (store.tail?.upTo !== history.nextOffset) { + const text = redactor.redact(collectText(history, store.frontier, history.nextOffset)); + store.tail = { upTo: history.nextOffset, text, newlines: countNewlines(text) }; + } + } else store.tail = null; + const parts: string[] = []; + for (let index = store.head; index < store.segments.length; index++) parts.push(store.segments[index]!.text); + if (store.tail?.text) parts.push(store.tail.text); + return { parts, start: store.viewStart, line: store.lineStart, store }; +} + +/** Where masking may begin: at the retained start, or after its first line break when older output was cut off. */ +function freshStore(history: History): MaskedStore { + let frontier = history.baseOffset; + let lineStart = history.baseLine; + if (history.baseOffset > 0 && history.head) { + // Pruning cuts at arbitrary characters; a key cut in half there would no longer be recognised. + const lookahead = collectText(history, history.baseOffset, Math.min(history.nextOffset, history.baseOffset + 65_536)); + const newline = lookahead.indexOf("\n"); + if (newline >= 0) { + frontier += newline + 1; + lineStart += 1; + } + } + return { revision: secretRevision, segments: [], head: 0, frontier, viewStart: frontier, lineStart, tail: null, chars: 0 }; +} + +function windowChars(): number { return Math.max(MASK_WINDOW_CHARS, 2 * redactor.lookahead() + 65_536); } + +/** Drop other sessions' masked copies (oldest sessions first) while all copies exceed their budget. */ +function boundMaskedCopies(keep: History): void { + let total = 0; + for (const history of histories.values()) total += history.masked?.chars ?? 0; + for (const history of histories.values()) { + if (total <= MAX_MASKED_CHARS) return; + if (history === keep || !history.masked) continue; + total -= history.masked.chars; + history.masked = null; + } +} + +type Match = { sessionId: string; line: number; text: string; offset: number }; + +function search(query: unknown, requested: unknown): { matches: Match[]; prunedSessionIds: string[] } { + if (typeof query !== "string" || query.length > 200 || !query.trim() || query.includes("\n") || query.includes("\r")) return { matches: [], prunedSessionIds: [] }; + if (requested !== undefined && requested !== null && (!Array.isArray(requested) || requested.length > 256 || requested.some((id) => typeof id !== "string"))) throw new Error("Invalid terminal output search scope."); + const allowed = Array.isArray(requested) ? new Set(requested) : null; + const matches: Match[] = []; + const needle = query.toLocaleLowerCase(); + const asciiNeedle = ASCII_NEEDLE.test(needle); + const prunedSessionIds = [...histories].filter(([id, history]) => (!allowed || allowed.has(id)) && history.pruned).map(([id]) => id); + for (const [sessionId, history] of histories) { + if (allowed && !allowed.has(sessionId)) continue; + const view = maskedView(history); + boundMaskedCopies(history); + searchView(view.parts, view.line, needle, asciiNeedle, (line, text, offset) => { + matches.push({ sessionId, line, text, offset }); + return matches.length < 100; + }); + if (matches.length >= 100) break; + } + return { matches, prunedSessionIds }; +} + +/** + * Report each line holding the needle once, in order. Parts are scanned in place with a case-insensitive pattern; + * no line or whole-session copy is made except for parts that hold characters whose lower case differs from what + * the pattern folds (they take the line-by-line path). + */ +function searchView(parts: string[], firstLine: number, needle: string, asciiNeedle: boolean, report: (line: number, text: string, offset: number) => boolean): void { + if (!asciiNeedle || parts.some(part => ASCII_FOLDING_CHARS.test(part))) { + // Unicode folding (and the few non-ASCII letters that fold to ASCII) goes line by line, as before. + forEachLine(parts, (text, line, offset) => { + const folded = asciiNeedle && !ASCII_FOLD_CHARS.test(text) ? text : text.toLocaleLowerCase(); + return !folded.includes(needle) || report(line, text.slice(0, 500), offset); + }, firstLine); + return; + } + const pattern = new RegExp(needle.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"), "gi"); + const starts: number[] = []; + let total = 0; + for (const part of parts) { starts.push(total); total += part.length; } + let line = firstLine; + let counted = 0; + let lastLineStart = -1; + const reportAt = (position: number): boolean => { + if (position > counted) { + line += countNewlinesIn(parts, starts, counted, position); + counted = position; + } + const lineStart = lineStartAt(parts, starts, position); + if (lineStart === lastLineStart) return true; + lastLineStart = lineStart; + const lineEnd = lineEndAt(parts, starts, position, total); + return report(line, sliceParts(parts, starts, lineStart, Math.min(lineEnd, lineStart + 500)), lineStart); + }; + for (let index = 0; index < parts.length; index++) { + const part = parts[index]!; + const base = starts[index]!; + pattern.lastIndex = 0; + for (let found = pattern.exec(part); found; found = pattern.exec(part)) { + if (!reportAt(base + found.index)) return; + // One result per line: continue after this line. + const next = part.indexOf("\n", found.index); + if (next < 0) break; + pattern.lastIndex = next + 1; + } + // A match may run over the joint with the next part. + if (index + 1 < parts.length && needle.length > 1) { + const from = Math.max(base, base + part.length - needle.length + 1); + const at = sliceParts(parts, starts, from, Math.min(total, base + part.length + needle.length - 1)).toLowerCase().indexOf(needle); + if (at >= 0 && from + at < base + part.length && !reportAt(from + at)) return; + } + } +} + +function partIndexAt(starts: number[], position: number): number { + let low = 0; + let high = starts.length - 1; + while (low < high) { + const middle = (low + high + 1) >> 1; + if (starts[middle]! <= position) low = middle; + else high = middle - 1; + } + return low; +} + +function lineStartAt(parts: string[], starts: number[], position: number): number { + let index = partIndexAt(starts, position); + let local = position - starts[index]! - 1; + for (; index >= 0; index--) { + if (local >= 0) { + const found = parts[index]!.lastIndexOf("\n", local); + if (found >= 0) return starts[index]! + found + 1; + } + if (index > 0) local = parts[index - 1]!.length - 1; + } + return 0; +} + +function lineEndAt(parts: string[], starts: number[], position: number, total: number): number { + for (let index = partIndexAt(starts, position); index < parts.length; index++) { + const part = parts[index]!; + const found = part.indexOf("\n", Math.max(0, position - starts[index]!)); + if (found >= 0) return starts[index]! + found; + } + return total; +} + +function sliceParts(parts: string[], starts: number[], from: number, to: number): string { + if (to <= from) return ""; + const pieces: string[] = []; + for (let index = partIndexAt(starts, from); index < parts.length && starts[index]! < to; index++) { + const part = parts[index]!; + pieces.push(part.slice(Math.max(0, from - starts[index]!), Math.min(part.length, to - starts[index]!))); + } + return pieces.join(""); +} + +function countNewlinesIn(parts: string[], starts: number[], from: number, to: number): number { + if (to <= from) return 0; + let count = 0; + for (let index = partIndexAt(starts, from); index < parts.length && starts[index]! < to; index++) { + const part = parts[index]!; + count += countNewlines(part, Math.max(0, from - starts[index]!), Math.min(part.length, to - starts[index]!)); + } + return count; +} + +function context(sessionId: unknown, offset: unknown): { text: string; firstLine: number; targetLine: number; historyTruncated: boolean; historyBaseOffset: number } { + if (typeof sessionId !== "string" || !Number.isSafeInteger(offset) || (offset as number) < 0) throw new Error("Invalid terminal output context request."); + const history = histories.get(sessionId); + if (!history) return { text: "", firstLine: 0, targetLine: 0, historyTruncated: false, historyBaseOffset: 0 }; + const view = maskedView(history); + const starts: number[] = []; + let total = 0; + for (const part of view.parts) { starts.push(total); total += part.length; } + const target = Math.min(total, offset as number); + // The start of the line holding the character 4 000 before the target (a line break there counts as its end). + const start = total === 0 ? 0 : lineStartAt(view.parts, starts, Math.min(total, Math.max(0, target - 4_000) + 1)); + const end = Math.min(total, target + 4_000); + const firstLine = view.line + countNewlinesIn(view.parts, starts, 0, start); + const targetLine = firstLine + countNewlinesIn(view.parts, starts, start, target); + return { text: sliceParts(view.parts, starts, start, end), firstLine, targetLine, historyTruncated: history.pruned, historyBaseOffset: view.start }; +} + +/** Walk masked parts line by line; whole-session strings are never joined. */ +function forEachLine(parts: string[], visit: (text: string, line: number, offset: number) => boolean, firstLine: number): boolean { + let line = firstLine; + let lineStart = 0; + let pendingLine = ""; + for (const part of parts) { + let cursor = 0; + for (;;) { + const end = part.indexOf("\n", cursor); + if (end < 0) break; + const text = pendingLine + part.slice(cursor, end); + if (!visit(text, line, lineStart)) return false; + lineStart += text.length + 1; + line++; + pendingLine = ""; + cursor = end + 1; + } + pendingLine += part.slice(cursor); + } + if (pendingLine && !visit(pendingLine, line, lineStart)) return false; + return true; +} + +function countNewlines(text: string, start = 0, end = text.length): number { + let count = 0; + for (let at = text.indexOf("\n", start); at >= 0 && at < end; at = text.indexOf("\n", at + 1)) count++; + return count; +} + +// ---- crash-recovery spill ---- + +/** + * Output is also appended, encrypted with a key that exists only in the host's memory, to bounded files in a + * private directory. A restarted worker reads them back, so a crash does not lose the older searchable history. + * The files are useless once the host is gone; the host deletes them on quit and a fresh start wipes them. + */ +function configureSpill(directory: unknown, key: unknown, recover: boolean): void { + if (typeof directory !== "string" || !directory || !(key instanceof Uint8Array) || key.length !== 32) throw new Error("Invalid terminal history spill."); + spill = { root: directory, key: Buffer.from(key), files: [], chars: 0, sequence: 0, timer: null }; + mkdirSync(directory, { recursive: true, mode: 0o700 }); + if (!recover) { + for (const entry of readdirSync(directory)) rmSync(join(directory, entry), { recursive: true, force: true }); + return; + } + const files: Array<{ path: string; owner: string; sequence: number }> = []; + for (const owner of readdirSync(directory)) { + if (!/^[0-9a-f]{32}$/u.test(owner)) continue; + for (const name of readdirSync(join(directory, owner))) { + const match = /^(\d{1,15})\.bin$/u.exec(name); + if (match) files.push({ path: join(directory, owner, name), owner, sequence: Number(match[1]) }); + } + } + files.sort((left, right) => left.sequence - right.sequence); + for (const file of files) { + let chars = 0; + try { chars = replaySpillFile(file.path); } + catch { /* An unreadable or truncated file holds no recoverable output. */ } + spill.files.push({ path: file.path, chars, owner: file.owner }); + spill.chars += chars; + spill.sequence = Math.max(spill.sequence, file.sequence + 1); + } + for (const history of histories.values()) history.spill = null; // new output goes to new files + pruneSpill(); +} + +function replaySpillFile(path: string): number { + const data = readFileSync(path); + let chars = 0; + for (let at = 0; at + 32 <= data.length;) { + const length = data.readUInt32LE(at); + if (at + 32 + length > data.length) break; + const decipher = createDecipheriv("aes-256-gcm", spill!.key, data.subarray(at + 4, at + 16)); + decipher.setAuthTag(data.subarray(at + 16, at + 32)); + const plain = Buffer.concat([decipher.update(data.subarray(at + 32, at + 32 + length)), decipher.final()]); + at += 32 + length; + for (let entry = 0; entry < plain.length;) { + const headerEnd = plain.indexOf(10, entry); + if (headerEnd < 0) break; + const header = JSON.parse(plain.subarray(entry, headerEnd).toString("utf8")) as { id: string; s: number; p: number; a: AnsiState; w: boolean; n: number; g?: boolean }; + if (typeof header.id !== "string" || !Number.isSafeInteger(header.s) || !Number.isSafeInteger(header.p) || !Number.isSafeInteger(header.n)) break; + const text = plain.subarray(headerEnd + 1, headerEnd + 1 + header.n).toString(header.w ? "utf16le" : "latin1"); + entry = headerEnd + 1 + header.n; + chars += replayEntry(header, text); + } + } + return chars; +} + +function replayEntry(header: { id: string; s: number; p: number; a: AnsiState; g?: boolean }, text: string): number { + const history = historyFor(header.id); + if (header.s < history.nextOffset) return 0; + if (header.s > history.nextOffset) { + // Older output of this session was pruned before the crash. + if (!history.head && history.nextOffset === 0) { history.nextOffset = header.s; history.baseOffset = header.s; } + history.pruned = true; + } + if (header.g) history.pruned = true; + const spilling = spill; + spill = null; // replayed text is already on disk + try { if (text) appendText(header.id, history, text); } + finally { spill = spilling; } + history.rawEnd = Math.max(history.rawEnd, header.p); + history.ansiState = header.a; + return text.length; +} + +/** Queue a retained chunk's own bytes, with where it belongs; nothing is copied until the flush encrypts it. */ +function queueSpill(sessionId: string, history: History, chunk: Chunk): void { + if (!spill) return; + const state = history.spill ??= { directory: join(spill.root, ownerKey(sessionId)), file: null, fileChars: 0, queue: [], queuedChars: 0 }; + state.queue.push(Buffer.from(`${JSON.stringify({ id: sessionId, s: chunk.start, p: history.rawEnd, a: history.ansiState, w: chunk.wide, n: chunk.data.length, + ...(history.pruned ? { g: true } : {}) })}\n`, "utf8"), chunk.data); + state.queuedChars += chunk.length; + if (!spill.timer) { + spill.timer = setTimeout(flushSpill, SPILL_FLUSH_MS); + spill.timer.unref?.(); + } +} + +function flushSpill(): void { + if (!spill) return; + if (spill.timer) clearTimeout(spill.timer); + spill.timer = null; + for (const history of histories.values()) if (history.spill?.queue.length) flushHistorySpill(history.spill); +} + +/** One authenticated record per flush: length, IV, tag, then the queued entries (a header line and its bytes each). */ +function flushHistorySpill(state: SpillState): void { + if (!spill || !state.queue.length) return; + try { + if (!state.file || state.fileChars >= SPILL_FILE_CHARS) { + mkdirSync(state.directory, { recursive: true, mode: 0o700 }); + state.file = join(state.directory, `${spill.sequence++}.bin`); + state.fileChars = 0; + spill.files.push({ path: state.file, chars: 0, owner: state.directory }); + } + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", spill.key, iv); + const pieces: Buffer[] = [Buffer.alloc(4), iv, Buffer.alloc(16)]; + let length = 0; + for (const piece of state.queue) { + const encrypted = cipher.update(piece); + pieces.push(encrypted); + length += encrypted.length; + } + const final = cipher.final(); + pieces.push(final); + length += final.length; + pieces[0]!.writeUInt32LE(length); + pieces[2] = cipher.getAuthTag(); + appendFileSync(state.file, Buffer.concat(pieces), { mode: 0o600 }); + const entry = spill.files.find(file => file.path === state.file); + if (entry) entry.chars += state.queuedChars; + state.fileChars += state.queuedChars; + spill.chars += state.queuedChars; + } catch { + // Recovery is best effort; search keeps working from memory. + } + state.queue = []; + state.queuedChars = 0; + pruneSpill(); +} + +/** Keep about as much on disk as in memory: drop the oldest files first. */ +function pruneSpill(): void { + if (!spill) return; + while (spill.chars > MAX_GLOBAL_CHARS + SPILL_FILE_CHARS && spill.files.length > 1) { + const oldest = spill.files.shift()!; + spill.chars -= oldest.chars; + try { unlinkSync(oldest.path); } catch { /* already gone */ } + for (const history of histories.values()) if (history.spill?.file === oldest.path) history.spill.file = null; + } +} + +function dropSpill(sessionId: string): void { + if (!spill) return; + const directory = join(spill.root, ownerKey(sessionId)); + spill.files = spill.files.filter(file => { + if (!file.path.startsWith(`${directory}/`) && !file.path.startsWith(`${directory}\\`)) return true; + spill!.chars -= file.chars; + return false; + }); + try { rmSync(directory, { recursive: true, force: true }); } catch { /* best effort */ } +} + +function ownerKey(sessionId: string): string { return createHash("sha256").update(sessionId).digest("hex").slice(0, 32); } + +/** Strip ECMA-48 terminal controls with constant-space state across PTY chunks. */ +function stripAnsi(source: string, initialState: AnsiState): { text: string; state: AnsiState } { + // PTYs mostly emit ordinary text: keep the batch as it is when it holds no control at all. + const input = source; + if (initialState === "text" && !CONTROL_START.test(input)) return { text: input, state: "text" }; + if (initialState === "text") { + // Colour and cursor sequences are the bulk of controls: drop complete ones natively. When they were the only + // controls, this is exactly what the decoder below yields; otherwise it decodes the original batch. + const withoutCsi = input.replace(COMPLETE_CSI, ""); + if (!CONTROL_START.test(withoutCsi)) return { text: withoutCsi, state: "text" }; + } + let state = initialState; + const visible: string[] = []; + let cursor = 0; + while (cursor < input.length) { + if (state === "text") { + // Jump over ordinary text natively instead of visiting each character. + CONTROL_SCAN.lastIndex = cursor; + const found = CONTROL_SCAN.exec(input); + const at = found ? found.index : input.length; + if (at > cursor) visible.push(input.slice(cursor, at)); + if (!found) break; + const code = input.charCodeAt(at); + state = code === 0x1b ? "escape" : code === 0x9b ? "csi" : code === 0x9d ? "osc" : code === 0x9c ? "text" : "string"; + cursor = at + 1; + continue; + } + const code = input.charCodeAt(cursor++); + switch (state) { + case "escape": + if (code === 0x5b) state = "csi"; + else if (code === 0x5d) state = "osc"; + else if (code === 0x50 || code === 0x58 || code === 0x5e || code === 0x5f) state = "string"; + else state = "text"; // Unsupported two-byte ESC controls are discarded. + break; + case "csi": + if (code >= 0x40 && code <= 0x7e) state = "text"; + break; + case "osc": + if (code === 0x07 || code === 0x9c) state = "text"; + else if (code === 0x1b) state = "osc-escape"; + break; + case "osc-escape": + if (code === 0x5c) state = "text"; + else state = code === 0x1b ? "osc-escape" : "osc"; + break; + case "string": + if (code === 0x9c) state = "text"; + else if (code === 0x1b) state = "string-escape"; + break; + case "string-escape": + if (code === 0x5c) state = "text"; + else state = code === 0x1b ? "string-escape" : "string"; + break; + } + } + return { text: visible.length === 1 ? visible[0]! : visible.join(""), state }; +} +const CONTROL_START = /[\u001b\u0090\u0098\u009b\u009c\u009d\u009e\u009f]/u; +/** ESC [ or CSI, parameter bytes, intermediate bytes, a final byte (ECMA-48 5.4). */ +const COMPLETE_CSI = /(?:\u001b\[|\u009b)[\u0030-\u003f]*[\u0020-\u002f]*[\u0040-\u007e]/gu; +const CONTROL_SCAN = /[\u001b\u0090\u0098\u009b\u009c\u009d\u009e\u009f]/gu; diff --git a/src/main/services/TerminalPasteMode.ts b/src/main/services/TerminalPasteMode.ts new file mode 100644 index 00000000..1c1a26ad --- /dev/null +++ b/src/main/services/TerminalPasteMode.ts @@ -0,0 +1,78 @@ +/** Tracks only the terminal output protocol needed for clipboard paste, before scrollback is truncated. + * CSI parameters have the same 32-entry/int32 bounds as xterm; strings retain no payload. + */ +export class TerminalPasteMode { + private state: "ground" | "escape" | "escapeIntermediate" | "csiEntry" | "csiParam" | "csiIntermediate" | "csiIgnore" | "osc" | "string" | "dcsHeader" | "dcs" = "ground"; + private enabled = false; + private collect = 0; + private params: number[] = [0]; + private subparam = false; + private overflow = false; + + accept(data: string): void { + for (const char of data) { + const code = char.codePointAt(0)!; + if (code === 0x1b) { this.state = "escape"; this.collect = 0; continue; } + if (code === 0x9b) { this.beginCsi(); continue; } + if (code === 0x9d) { this.state = "osc"; continue; } + if (code === 0x90) { this.state = "dcsHeader"; continue; } + if (code === 0x98 || code === 0x9e || code === 0x9f) { this.state = "string"; continue; } + if (code === 0x18 || code === 0x1a || code === 0x9c || (code >= 0x80 && code <= 0x9a)) { + this.state = "ground"; continue; + } + if (this.state === "osc") { if (code === 7) this.state = "ground"; continue; } + if (this.state === "string" || this.state === "dcs") continue; + if (code < 0x20 || code === 0x7f) continue; + if (this.state === "dcsHeader") { if (code >= 0x40 && code <= 0x7e) this.state = "dcs"; continue; } + if (this.state === "ground") continue; + if (this.state === "escape" || this.state === "escapeIntermediate") { + if (this.state === "escape") { + if (char === "[") { this.beginCsi(); continue; } + if (char === "]") { this.state = "osc"; continue; } + if (char === "P") { this.state = "dcsHeader"; continue; } + if (char === "X" || char === "^" || char === "_") { this.state = "string"; continue; } + if (char === "c") this.enabled = false; // RIS + } + if (code >= 0x20 && code <= 0x2f) this.state = "escapeIntermediate"; + else this.state = "ground"; + continue; + } + if (code >= 0x40 && code <= 0x7e) { + if (this.state !== "csiIgnore") { + if (this.collect === 0x3f && (char === "h" || char === "l") && this.params.includes(2004)) this.enabled = char === "h"; + if (this.collect === 0x21 && char === "p") this.enabled = false; // DECSTR also resets xterm's private modes. + } + this.state = "ground"; + } else if (this.state !== "csiIgnore") { + if (code >= 0x20 && code <= 0x2f) { + this.collect = (this.collect << 8) | code; + this.state = "csiIntermediate"; + } else if (this.state === "csiIntermediate") this.state = "csiIgnore"; + else if (code >= 0x3c && code <= 0x3f && this.state === "csiEntry") { + this.collect = code; this.state = "csiParam"; + } else if (code >= 0x30 && code <= 0x39) { + if (!this.subparam && !this.overflow) { + const i = this.params.length - 1; + this.params[i] = Math.min(0x7fffffff, this.params[i] * 10 + code - 0x30); + } + this.state = "csiParam"; + } else if (char === ";" || char === ":") { + this.subparam = char === ":"; + if (!this.subparam) { + if (this.params.length < 32) this.params.push(0); else this.overflow = true; + } + this.state = "csiParam"; + } else this.state = "csiIgnore"; + } + } + } + + paste(text: string): string { + const normalized = text.replace(/\r?\n/g, "\r"); + return this.enabled ? `\x1b[200~${normalized}\x1b[201~` : normalized; + } + + private beginCsi(): void { + this.state = "csiEntry"; this.collect = 0; this.params = [0]; this.subparam = false; this.overflow = false; + } +} diff --git a/src/main/services/TerminalSessionStore.ts b/src/main/services/TerminalSessionStore.ts index 269c49bd..5c20289d 100644 --- a/src/main/services/TerminalSessionStore.ts +++ b/src/main/services/TerminalSessionStore.ts @@ -1,4 +1,4 @@ -import { dirname, join } from "node:path"; +import { dirname, join, isAbsolute, normalize } from "node:path"; import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises"; import type { LaunchProfileId, @@ -25,6 +25,9 @@ export const MAX_PLUGIN_SLOT_BYTES = 4_096; const MAX_OPTION_PLUGINS = 16; export interface PersistedTerminalSession { + /** Host-only launch history; missing evidence on a legacy placed card is conservatively unknown. */ + isolatedEnvironmentScopes?: {roots:string[];ambiguous:boolean}; + taskScope?:{id:string;cwd:string;startedAt:number}; id: string; provider: ProviderId; profile: LaunchProfileId; @@ -57,6 +60,7 @@ export interface PersistedTerminalSession { /** The model and reasoning effort its launches ask the CLI for (launchModel.ts). */ model?: string; effort?: ReasoningEffort; + reviewRequested?: boolean; /** An isolated session ran since then and its repositories were not audited yet, or a report is still open. */ gitAuditSince?: number; } @@ -218,7 +222,7 @@ function normalizeStoredThreadId(provider: ProviderId, candidate: unknown): stri } /** What core keeps beside the live metadata: nothing here is scrollback, prompts or secrets. */ -export type PersistedSessionExtras = Pick & { +export type PersistedSessionExtras = Pick & { /** Overrides the derived state while a card is held stopped (its environment is unavailable). */ heldState?: PersistedLastState; }; @@ -242,20 +246,34 @@ export function persistedTerminalSession( position: { ...metadata.position }, size: { ...metadata.size }, ...(metadata.parentSessionId !== undefined ? { parentSessionId: metadata.parentSessionId } : {}), + ...(metadata.taskScope ? {taskScope:{...metadata.taskScope}} : {}), ...(normalizedThreadId !== undefined ? { threadId: normalizedThreadId } : {}), lastState, ...(lastState !== "running" ? { exitCode: metadata.exitCode } : {}), restore: metadata.skipRestore !== true, + ...(extras.isolatedEnvironmentScopes ? {isolatedEnvironmentScopes:structuredClone(extras.isolatedEnvironmentScopes)} : {}), ...(extras.options ? { options: structuredClone(extras.options) } : {}), ...(extras.environment ? { environment: structuredClone(extras.environment) } : {}), ...(extras.environmentChoice && !extras.environment ? { environmentChoice: structuredClone(extras.environmentChoice) } : {}), ...(extras.ownerPluginId ? { ownerPluginId: extras.ownerPluginId } : {}), ...(extras.gitAuditSince !== undefined ? { gitAuditSince: extras.gitAuditSince } : {}), ...(metadata.model !== undefined ? { model: metadata.model } : {}), - ...(metadata.effort !== undefined ? { effort: metadata.effort } : {}) + ...(metadata.effort !== undefined ? { effort: metadata.effort } : {}), + ...(metadata.reviewRequested !== undefined ? { reviewRequested: metadata.reviewRequested } : {}) }; } +/** Invalid or pre-evidence environment records must never silently regain local restoration authority. */ +function normalizeIsolationEvidence(value:unknown,placed:boolean):PersistedTerminalSession["isolatedEnvironmentScopes"] { + if(value===undefined)return placed ? {roots:[],ambiguous:true} : undefined; + const invalid={roots:[],ambiguous:true}; + if(!value || typeof value!=="object" || Array.isArray(value))return invalid; + const evidence=value as {roots?:unknown;ambiguous?:unknown}; + if(Object.keys(value).some(key=>key!=="roots" && key!=="ambiguous") || typeof evidence.ambiguous!=="boolean" || !Array.isArray(evidence.roots) || evidence.roots.length>32)return invalid; + if(evidence.roots.some(root=>typeof root!=="string" || root.length===0 || root.length>4096 || root.includes("\0") || !isAbsolute(root) || normalize(root)!==root))return invalid; + return {roots:[...new Set(evidence.roots as string[])],ambiguous:evidence.ambiguous}; +} + export function normalizePersistedTerminalSessions(candidate: unknown): PersistedTerminalSessionState { if (!candidate || typeof candidate !== "object") return structuredClone(EMPTY_STATE); const source = candidate as { version?: unknown; sessions?: unknown }; @@ -308,6 +326,7 @@ export function normalizePersistedTerminalSessions(candidate: unknown): Persiste // Likewise a launch whose environment was chosen but not prepared yet. const environmentChoice = environment ? undefined : normalizeEnvironmentChoice(session.environmentChoice); if (!environment && session.environmentChoice !== undefined && !environmentChoice) continue; + const isolation=normalizeIsolationEvidence(session.isolatedEnvironmentScopes,Boolean(environment)); sessions.push({ id: session.id, provider: session.provider as ProviderId, @@ -322,12 +341,14 @@ export function normalizePersistedTerminalSessions(candidate: unknown): Persiste height: clamp(session.size.height, 260, 1_100) }, ...(parentSessionId !== undefined ? { parentSessionId } : {}), + ...(isTaskScope(session.taskScope) ? {taskScope:{...session.taskScope}} : {}), ...(threadId !== undefined ? { threadId } : {}), lastState, ...(lastState !== "running" ? { exitCode } : {}), restore: session.restore !== false, ...(options ? { options } : {}), ...(environment ? { environment } : {}), + ...(isolation ? {isolatedEnvironmentScopes:isolation} : {}), ...(environmentChoice ? { environmentChoice } : {}), ...(isPluginId(session.ownerPluginId) ? { ownerPluginId: session.ownerPluginId } : {}), ...(typeof session.gitAuditSince === "number" && Number.isFinite(session.gitAuditSince) && session.gitAuditSince > 0 @@ -336,7 +357,8 @@ export function normalizePersistedTerminalSessions(candidate: unknown): Persiste ...(session.provider !== "terminal" && session.model !== undefined && launchModelProblem(session.provider as ProviderId, session.model) === null ? { model: session.model } : {}), ...(session.provider !== "terminal" && session.effort !== undefined && launchEffortProblem(session.provider as ProviderId, session.effort) === null - ? { effort: session.effort } : {}) + ? { effort: session.effort } : {}), + ...(typeof session.reviewRequested === "boolean" ? { reviewRequested: session.reviewRequested } : {}) }); ids.add(session.id); } @@ -427,3 +449,7 @@ function isReadableState(value: unknown): boolean { function isMissingFile(error: unknown): boolean { return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT"); } + +function isTaskScope(value:unknown):value is {id:string;cwd:string;startedAt:number} { + return isRecord(value) && typeof value.id==="string" && /^[\w-]{1,160}$/.test(value.id) && typeof value.cwd==="string" && value.cwd.length>0 && value.cwd.length<=4096 && typeof value.startedAt==="number" && Number.isFinite(value.startedAt) && value.startedAt>0; +} diff --git a/src/main/services/TimelineIndexScan.ts b/src/main/services/TimelineIndexScan.ts new file mode 100644 index 00000000..10d37280 --- /dev/null +++ b/src/main/services/TimelineIndexScan.ts @@ -0,0 +1,265 @@ +import { createReadStream } from "node:fs"; +import { readdir, stat } from "node:fs/promises"; +import { join } from "node:path"; +import { addTimelineSearchEvent, createTimelineSegmentSearchIndex, type TimelineSegmentSearchIndex } from "./TimelineSearchIndex.ts"; +import type { TimelineEvent } from "../../shared/backlog.ts"; + +export interface TimelineUsageContext { provider?: string; model?: string; accountId?: string; taskId?: string } +export type TimelineIndexedUsage = TimelineUsageContext & { + input: number | null; + output: number | null; + total?: number; + cost: number | null; + source: string; + cumulative?: boolean; + counterId?: string; + /** A resumed conversation's first sample: its counter already held usage from earlier runs. */ + baseline?: boolean; + /** Host index sequence of the last observation; rebuilt from journal order on load. */ + sampleOrder?: number; +}; +type Usage=TimelineIndexedUsage; +export interface BudgetUsageContribution { + sessionId:string; + identity:string; + taskId?:string; + provider?:string; + model?:string; + mode:"reported"|"priced"|"covered"|"reported-base"; + tokens:number; + input:number|null; + output:number|null; + reportedCostUsd:number|null; +} +export interface BudgetCounterState {current:TimelineIndexedUsage;taskOwner:string|null;mixedOwners:boolean;reportedCostSeen:boolean; + /** Started from a resumed conversation's baseline: its total so far, cost included, predates this run. */ + fromBaseline?:boolean} +export interface TimelineTaskSessionIndex { + id:string; + taskId:string; + title:string; + types:Map; + lastSegment:string; +} + +export interface TimelineDirectoryIndex { + lastSegmentAt:number; + usage:Map; + budgetUsage:Map; + budgetCounters:Map; + segments:Map; + taskSessions:Map>; +} + +const MAX_EVENT_LINE_CHARS = 64 * 1024; +export const MAX_INDEXED_SEGMENTS = 256; +const NAME_PATTERN = /^\d{16}-[\w-]+\.ndjson$/u; +export async function scanTimelineDirectory(directory: string): Promise { + const names = (await readdir(directory)).filter((name: string) => NAME_PATTERN.test(name)).sort(); + const usage = new Map(); + const budgetUsage=new Map(); + const budgetCounters=new Map(); + const taskSessions=new Map>(); + const segments=new Map(); + let lastSegmentAt = 0, sampleOrder = 0; + for (const name of names) { + lastSegmentAt = Math.max(lastSegmentAt, Number(name.slice(0, 16)) || 0); + const metadata = await stat(join(directory, name)); + const index = createTimelineSegmentSearchIndex(metadata.size, metadata.mtimeMs); + for await (const line of boundedLines(join(directory, name))) { + if (line === null) { index.complete = false; continue; } + if (!line) continue; + let event: any; + try { event = JSON.parse(line); } catch { continue; } + if (!event || typeof event.id !== "string" || typeof event.sessionId !== "string" || typeof event.at !== "number") continue; + addTimelineSearchEvent(index, event); + indexTimelineTaskSession(taskSessions,event,name); + const usageUpdate = nextTimelineUsage(usage, event); + if (usageUpdate) usage.set(usageUpdate.key, {...usageUpdate.value,sampleOrder:sampleOrder++}); + applyTimelineBudgetUsageEvent(budgetUsage,budgetCounters,event); + } + segments.set(name,index); + if (segments.size > MAX_INDEXED_SEGMENTS) segments.delete(segments.keys().next().value!); + } + return {lastSegmentAt,usage,budgetUsage,budgetCounters,segments,taskSessions}; +} + +async function* boundedLines(path: string): AsyncGenerator { + let pending = ""; + let oversized = false; + for await (const chunk of createReadStream(path, { encoding: "utf8" })) { + let from = 0; + while (from < chunk.length) { + const newline = chunk.indexOf("\n", from); + const end = newline < 0 ? chunk.length : newline; + if (!oversized) { + const part = chunk.slice(from, end); + if (pending.length + part.length > MAX_EVENT_LINE_CHARS) { pending = ""; oversized = true; } + else pending += part; + } + if (newline < 0) break; + yield oversized ? null : pending.endsWith("\r") ? pending.slice(0, -1) : pending; + pending = ""; + oversized = false; + from = newline + 1; + } + } + if (oversized) yield null; + else if (pending) yield pending; +} + +/** A card may use the same provider counter name under different accounts. */ +export function timelineUsageKey(sessionId:string,row:Pick):string { + return JSON.stringify([sessionId,row.source,row.counterId ?? "",row.provider ?? "",row.accountId ?? ""]); +} + +export function nextTimelineUsage( + usage: ReadonlyMap, + event: TimelineEvent +): { key: string; value: Usage } | null { + if (event.type !== "usage" || typeof event.detail !== "string" || !event.detail) return null; + try { + const row = JSON.parse(event.detail) as Usage; + const key=timelineUsageKey(event.sessionId,row); + if (row.cumulative === true) { + if (isTimelineCount(row.total) && typeof row.source === "string") { + const input = row.input === null || isTimelineCount(row.input) ? row.input : null; + const output = row.output === null || isTimelineCount(row.output) ? row.output : null; + const cost = typeof row.cost === "number" && Number.isFinite(row.cost) && row.cost >= 0 ? row.cost : null; + return { key, value: { input, output, total: row.total, cost, source: row.source, + cumulative: true, + ...(typeof row.counterId === "string" && row.counterId.length <= 200 ? { counterId: row.counterId } : {}), + ...timelineUsageContext(row) } }; + } + return null; + } + if (!isTimelineCount(row.input) || !isTimelineCount(row.output) || typeof row.source !== "string") return null; + const previous = usage.get(key); + return { key, value: { input: (previous?.input ?? 0) + row.input!, output: (previous?.output ?? 0) + row.output!, + cost: row.cost !== null && typeof row.cost === "number" && Number.isFinite(row.cost) && (!previous || previous.cost !== null) + ? (previous?.cost ?? 0) + row.cost : null, + source: row.source,...timelineUsageContext(row) } }; + } catch { /* An invalid or truncated journal line has no usage effect. */ return null; } +} + +export function timelineUsageContext(value: { provider?: unknown; model?: unknown; accountId?: unknown; taskId?: unknown }): TimelineUsageContext { + const context: TimelineUsageContext = {}; + for (const key of ["provider", "model", "accountId", "taskId"] as const) { + const entry = value[key]; + if (typeof entry === "string" && entry.length > 0 && entry.length <= 200) context[key] = entry; + } + return context; +} + +export function isTimelineCount(value: unknown): value is number { return Number.isSafeInteger(value) && (value as number) >= 0; } + +export function indexTimelineTaskSession( + taskSessions:Map>, + event:TimelineEvent, + segment:string +):void { + if(typeof event.taskId!=="string" || !event.taskId || event.taskId.length>200)return; + let sessions=taskSessions.get(event.taskId); + if(!sessions){sessions=new Map();taskSessions.set(event.taskId,sessions);} + let descriptor=sessions.get(event.sessionId); + if(!descriptor) { + descriptor={id:event.sessionId,taskId:event.taskId, + title:typeof event.sessionTitle==="string" && event.sessionTitle.length<=200 && event.sessionTitle ? event.sessionTitle : event.sessionId.slice(0,200), + types:new Map(),lastSegment:segment}; + sessions.set(event.sessionId,descriptor); + } + if(typeof event.type==="string" && event.type)descriptor.types.set(event.type,segment); + if(typeof event.sessionTitle==="string" && event.sessionTitle.length<=200 && event.sessionTitle)descriptor.title=event.sessionTitle; + descriptor.lastSegment=segment; +} + +export function applyTimelineBudgetUsageEvent( + contributions:Map, + baselines:Map, + event:any +):void { + if(event.type!=="usage" || typeof event.detail!=="string" || !event.detail)return; + try { + const row=JSON.parse(event.detail) as Usage; + if(typeof row.source!=="string")return; + const context=timelineUsageContext(row),identity=budgetCounterIdentity(event.sessionId,{...row,...context}); + if(row.cumulative===true) { + if(!isTimelineCount(row.total))return; + const current:Usage={input:row.input===null || isTimelineCount(row.input) ? row.input : null, + output:row.output===null || isTimelineCount(row.output) ? row.output : null,total:row.total, + cost:typeof row.cost==="number" && Number.isFinite(row.cost) && row.cost>=0 ? row.cost : null, + source:row.source,cumulative:true, + ...(typeof row.counterId==="string" && row.counterId.length<=200 ? {counterId:row.counterId} : {}),...context}; + const previousState=baselines.get(identity),previous=previousState?.current; + if(previous===undefined && row.baseline===true) { + // Usage before the resume belongs to earlier runs; later samples count from here. + baselines.set(identity,{current,taskOwner:taskOwner(event.sessionId,current),mixedOwners:false,reportedCostSeen:current.cost!==null,fromBaseline:true}); + return; + } + const reset=previous!==undefined && current.total!=0; + addBudgetContribution(contributions,{sessionId:event.sessionId,identity,...context, + mode:reported ? "reported" : "priced",tokens:row.input+row.output,input:row.input,output:row.output, + reportedCostUsd:reported ? row.cost! : null}); + } catch { /* An invalid or truncated journal line has no budget effect. */ } +} + +export function budgetCounterIdentity(sessionId:string,row:Usage):string { + return row.counterId===undefined + ? JSON.stringify([sessionId,row.source,""]) + : JSON.stringify([row.provider ?? "",row.accountId ?? "",row.source,row.counterId]); +} +function budgetCounterDelta(current:number|null,previous:number|null,reset:boolean):number|null { + if(current===null)return null; + if(previous===null || reset)return current; + return current>=previous ? current-previous : current; +} +function addBudgetContribution(map:Map,row:BudgetUsageContribution):void { + const key=budgetContributionKey(row); + const previous=map.get(key); + if(!previous){map.set(key,{...row});return;} + previous.tokens+=row.tokens; + previous.input=previous.input===null || row.input===null ? null : previous.input+row.input; + previous.output=previous.output===null || row.output===null ? null : previous.output+row.output; + if(row.mode==="reported")previous.reportedCostUsd=(previous.reportedCostUsd ?? 0)+(row.reportedCostUsd ?? 0); +} +function budgetContributionKey(row:BudgetUsageContribution):string { + return JSON.stringify([row.sessionId,row.identity,row.taskId ?? "",row.provider ?? "",row.model ?? "",row.mode]); +} +function taskOwner(sessionId:string,row:Usage):string {return row.taskId===undefined ? `session:${sessionId}` : `task:${row.taskId}`;} +function coverEarlierPricedCost(contributions:Map,identity:string,taskId:string|undefined,sessionId:string):void { + for(const [key,row] of [...contributions]) { + if(row.identity!==identity || row.mode!=="priced")continue; + if(taskId!==undefined ? row.taskId!==taskId : row.sessionId!==sessionId || row.taskId!==undefined)continue; + contributions.delete(key);row.mode="covered";contributions.set(budgetContributionKey(row),row); + } +} +function setReportedCostBase(contributions:Map,sessionId:string,identity:string,row:Usage,cost:number):void { + const base:BudgetUsageContribution={sessionId,identity,...timelineUsageContext(row),mode:"reported-base",tokens:0,input:null,output:null,reportedCostUsd:cost}; + const owner=row.taskId ?? `session:${sessionId}`; + contributions.set(JSON.stringify(["reported-base",identity,owner]),base); +} diff --git a/src/main/services/TimelineJournalReader.ts b/src/main/services/TimelineJournalReader.ts new file mode 100644 index 00000000..66152bea --- /dev/null +++ b/src/main/services/TimelineJournalReader.ts @@ -0,0 +1,60 @@ +import { createReadStream } from "node:fs"; +import type { TimelineEvent } from "../../shared/backlog.ts"; + +const STREAM_HIGH_WATER_MARK = 64 * 1024; +const EVENT_LOOP_YIELD_CHARS = 256 * 1024; + +/** + * Visits valid journal records without retaining a whole segment or an event array. + * Malformed records are ignored, as they are by SessionTimelineService.read(). + * A line is kept intact until JSON parsing so valid oversized imported records keep + * their existing behavior. + */ +export async function forEachTimelineEvent( + path: string, + visit: (event: TimelineEvent) => boolean | void +): Promise { + const stream = createReadStream(path, { encoding: "utf8", highWaterMark: STREAM_HIGH_WATER_MARK }); + let pendingParts: string[] = []; + let workSinceYield = 0; + let yieldCount = 0; + + const deliver = (line: string): boolean => { + let event: TimelineEvent; + try { event = JSON.parse(line) as TimelineEvent; } + catch { return false; } + if (!event || typeof event.id !== "string" || typeof event.sessionId !== "string" || typeof event.at !== "number") return false; + return visit(event) === true; + }; + const yieldToEventLoop = (): Promise => new Promise(resolve => setImmediate(resolve)); + + for await (const chunk of stream) { + let from = 0; + while (from < chunk.length) { + const newline = chunk.indexOf("\n", from); + const end = newline < 0 ? chunk.length : newline; + const part = chunk.slice(from, end); + if (part) { + pendingParts.push(part); + workSinceYield += part.length; + } + from = newline < 0 ? chunk.length : newline + 1; + if (newline >= 0) { + const line = pendingParts.length === 0 ? "" + : pendingParts.length === 1 ? pendingParts[0] + : pendingParts.join(""); + pendingParts.length = 0; + if (deliver(line)) return yieldCount; + workSinceYield++; + } + if (workSinceYield >= EVENT_LOOP_YIELD_CHARS) { + workSinceYield = 0; + await yieldToEventLoop(); + yieldCount++; + } + } + } + + if (pendingParts.length > 0) deliver(pendingParts.length === 1 ? pendingParts[0] : pendingParts.join("")); + return yieldCount; +} diff --git a/src/main/services/TimelineSearchIndex.ts b/src/main/services/TimelineSearchIndex.ts new file mode 100644 index 00000000..c418fbf0 --- /dev/null +++ b/src/main/services/TimelineSearchIndex.ts @@ -0,0 +1,64 @@ +export interface TimelineSegmentSearchIndex { + size: number; + mtimeMs: number; + sessionIds: Set; + types: Set; + /** False when a malformed oversized line could not be indexed; such a segment must be read. */ + complete: boolean; + /** Four 32K-bit bloom filters per segment, with no false negatives. */ + bloom: Uint8Array; +} + +const BLOOM_BYTES = 16 * 1024; +const BLOOM_BITS = BLOOM_BYTES * 8; +const BLOOM_MASK = BLOOM_BITS - 1; + +export function createTimelineSegmentSearchIndex(size = 0, mtimeMs = 0): TimelineSegmentSearchIndex { + return { size, mtimeMs, sessionIds: new Set(), types: new Set(), complete: true, bloom: new Uint8Array(BLOOM_BYTES) }; +} + +export function addTimelineSearchEvent( + index: TimelineSegmentSearchIndex, + event: { sessionId: string; type: string; summary?: string; detail?: string } +): void { + index.sessionIds.add(event.sessionId); + if (event.type) index.types.add(event.type); + // Match page()'s template interpolation even for legacy rows with malformed optional fields. + const text = `${String(event.summary)}\n${event.detail === null || event.detail === undefined ? "" : String(event.detail)}`.toLocaleLowerCase(); + for (let offset = 0; offset + 2 < text.length; offset++) { + const first = text.charCodeAt(offset); + const second = text.charCodeAt(offset + 1); + const third = text.charCodeAt(offset + 2); + const hash = (Math.imul(first, 0x1f1f1f1f) ^ Math.imul(second, 0x45d9f3b) ^ Math.imul(third, 0x119de1f3)) >>> 0; + setBit(index.bloom, hash & BLOOM_MASK); + setBit(index.bloom, Math.imul(hash, 0x9e3779b1) >>> 17 & BLOOM_MASK); + setBit(index.bloom, Math.imul(hash ^ 0x85ebca6b, 0xc2b2ae35) >>> 17 & BLOOM_MASK); + setBit(index.bloom, Math.imul(hash ^ 0x27d4eb2f, 0x165667b1) >>> 17 & BLOOM_MASK); + } +} + +export function timelineSegmentMayMatch( + index: Pick, + sessionIds: ReadonlySet, + types: ReadonlySet | null, + query: string | undefined +): boolean { + if (!index.complete) return true; + if (sessionIds.size === 0 || ![...sessionIds].some(id => index.sessionIds.has(id))) return false; + if (types && ![...types].some(type => index.types.has(type))) return false; + if (!query || query.length < 3) return true; + for (let offset = 0; offset + 2 < query.length; offset++) { + const first = query.charCodeAt(offset); + const second = query.charCodeAt(offset + 1); + const third = query.charCodeAt(offset + 2); + const hash = (Math.imul(first, 0x1f1f1f1f) ^ Math.imul(second, 0x45d9f3b) ^ Math.imul(third, 0x119de1f3)) >>> 0; + if (!hasBit(index.bloom, hash & BLOOM_MASK) + || !hasBit(index.bloom, Math.imul(hash, 0x9e3779b1) >>> 17 & BLOOM_MASK) + || !hasBit(index.bloom, Math.imul(hash ^ 0x85ebca6b, 0xc2b2ae35) >>> 17 & BLOOM_MASK) + || !hasBit(index.bloom, Math.imul(hash ^ 0x27d4eb2f, 0x165667b1) >>> 17 & BLOOM_MASK)) return false; + } + return true; +} + +function setBit(bits: Uint8Array, offset: number): void { bits[offset >>> 3] = bits[offset >>> 3]! | (1 << (offset & 7)); } +function hasBit(bits: Uint8Array, offset: number): boolean { return (bits[offset >>> 3]! & (1 << (offset & 7))) !== 0; } diff --git a/src/main/services/UsagePrices.ts b/src/main/services/UsagePrices.ts new file mode 100644 index 00000000..2da88ec3 --- /dev/null +++ b/src/main/services/UsagePrices.ts @@ -0,0 +1,37 @@ +import {mkdir,readFile,rename,writeFile} from "node:fs/promises"; +import {dirname} from "node:path"; +import {randomUUID} from "node:crypto"; +import type {UsagePrice} from "../../shared/backlog.ts"; + +/** Human-entered prices. Empty by default; missing model/breakdown never produces a guessed cost. */ +export class UsagePrices { + private readonly path:string; + private rows:UsagePrice[]=[]; + private writes:Promise=Promise.resolve(); + private listeners=new Set<()=>void>(); + constructor(path:string){this.path=path;} + async load():Promise{ + try{const text=await readFile(this.path,"utf8");if(text.length>256_000)throw new Error("Price table is too large.");this.rows=validate(JSON.parse(text));} + catch(error){if(!(error && typeof error==='object' && 'code' in error && error.code==='ENOENT'))throw error;} + } + get():UsagePrice[]{return structuredClone(this.rows);} + subscribe(listener:()=>void):()=>void {this.listeners.add(listener);return ()=>this.listeners.delete(listener);} + async set(rows:UsagePrice[]):Promise{ + const next=validate(rows),payload=JSON.stringify(next); + const operation=this.writes.catch(()=>undefined).then(async()=>{ + await mkdir(dirname(this.path),{recursive:true,mode:0o700});const temporary=`${this.path}.${randomUUID()}.tmp`; + await writeFile(temporary,payload,{mode:0o600,flag:'wx'});await rename(temporary,this.path);this.rows=next; + });this.writes=operation;await operation;for(const listener of this.listeners)listener();return this.get(); + } +} +function validate(value:unknown):UsagePrice[]{ + if(!Array.isArray(value) || value.length>200)throw new Error("Price table accepts at most 200 models."); + const keys=new Set(); + return value.map(row=>{ + if(!row || typeof row!=='object' || typeof row.provider!=='string' || !/^[a-z][a-z0-9-]{0,39}$/.test(row.provider) + || typeof row.model!=='string' || !row.model.trim() || row.model.length>200 + || ![row.inputPerMillion,row.outputPerMillion].every(n=>typeof n==='number' && Number.isFinite(n) && n>=0 && n<=1_000_000))throw new Error("Invalid model price."); + const model=row.model.trim(),key=`${row.provider}:${model}`;if(keys.has(key))throw new Error("Duplicate model price.");keys.add(key); + return {provider:row.provider,model,inputPerMillion:row.inputPerMillion,outputPerMillion:row.outputPerMillion}; + }); +} diff --git a/src/main/services/WorkspaceArchive.ts b/src/main/services/WorkspaceArchive.ts new file mode 100644 index 00000000..7f68d048 --- /dev/null +++ b/src/main/services/WorkspaceArchive.ts @@ -0,0 +1,209 @@ +import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import type { CreateSessionRequest, SessionSnapshot } from "../../shared/contracts.ts"; +import type { WorkspacePreset } from "../../shared/backlog.ts"; +import { normalizeImportedTasks } from "./OrchestrationTaskBoard.ts"; +import { normalizeCanvasRegions, normalizeStickyNotes, normalizeBrowserCanvas } from "./SettingsStore.ts"; +import { normalizePersistedTerminalSessions, type PersistedTerminalSession } from "./TerminalSessionStore.ts"; + +function validPresetName(value: unknown): value is string { + return typeof value === "string" && Boolean(value.trim()) && value.length <= 80; +} + +export interface WorkspaceArchiveDependencies { + descriptors(): PersistedTerminalSession[]; + create(request: CreateSessionRequest): SessionSnapshot; + setBounds(id: string, bounds: {position: {x:number;y:number};size:{width:number;height:number}}): void; + available(provider: string): boolean; + /** Host-owned launcher acknowledgement, never authority from the imported snapshot. */ + bypassAcknowledged?(provider: string): boolean; + redact(text: string): string; +} + +/** Portable descriptors, never PTY buffers, credentials or plugin native-code trust. */ +export class WorkspaceArchive { + private readonly file: string; + private queue: Promise = Promise.resolve(); + private readonly deps: WorkspaceArchiveDependencies; + constructor(userDataPath: string, deps: WorkspaceArchiveDependencies) { + this.deps=deps; + this.file = join(userDataPath, "workspace-presets.json"); + } + export(): string { + const sessions=this.deps.descriptors(); + if(sessions.length>100)throw new Error("Workspace export supports at most 100 cards; close cards before exporting."); + return JSON.stringify({format: "canvastty-workspace", version: 1, exportedAt: Date.now(), + sessions: sessions.map((record) => this.sanitize(this.portableRecord(record)))}, null, 2); + } + private portableRecord(record: PersistedTerminalSession, fresh = false): PersistedTerminalSession { + return { + id: record.id, provider: record.provider, profile: record.profile, role: record.role, + title: record.title, titleCustomized: record.titleCustomized, cwd: record.cwd, + position: {x: record.position.x, y: record.position.y}, size: {width: record.size.width, height: record.size.height}, + lastState: "running", restore: true, + ...(record.parentSessionId !== undefined ? {parentSessionId: record.parentSessionId} : {}), + ...(!fresh && record.threadId !== undefined ? {threadId: record.threadId} : {}), + ...(record.model !== undefined ? {model: record.model} : {}), + ...(record.effort !== undefined ? {effort: record.effort} : {}), + // Keep placed cards inert on import without disclosing the plugin's private connection payload. + ...(record.environment ? {environment: {pluginId: record.environment.pluginId, kind: record.environment.kind, + ref: null, label: "Reconnect environment"}} : {}) + }; + } + private sanitize(value: unknown): unknown { + if (typeof value === "string") return this.deps.redact(value); + if (Array.isArray(value)) return value.map((entry) => this.sanitize(entry)); + if (value && typeof value === "object") return Object.fromEntries(Object.entries(value) + .filter(([key]) => !/^(?:password|token|apiKey|secret|authorization|capability|ownerPluginId)$/i.test(key)) + .map(([key, entry]) => [key, this.sanitize(entry)])); + return value; + } + private parse(text: string): PersistedTerminalSession[] { + if (typeof text !== "string" || Buffer.byteLength(text) > 2 * 1024 * 1024) throw new Error("Workspace snapshot is too large (maximum 2 MB)."); + let value: {format?:unknown;version?:unknown;sessions?:unknown;canvas?:unknown}; + try { value = JSON.parse(text) as typeof value; } catch { throw new Error("Workspace snapshot is not valid JSON."); } + if (!value || value.format !== "canvastty-workspace" || value.version !== 1 || !Array.isArray(value.sessions)) throw new Error("Unsupported workspace snapshot format/version."); + if(value.canvas!==undefined) { + if(!value.canvas || typeof value.canvas!=="object" || Array.isArray(value.canvas))throw new Error("Invalid workspace canvas."); + if((value.canvas as Record).version!==1)throw new Error("Unsupported workspace canvas version."); + } + const normalized = normalizePersistedTerminalSessions({version: 2, sessions: value.sessions}); + if (normalized.sessions.length !== value.sessions.length || normalized.sessions.length > 100) throw new Error("Workspace contains invalid cards or more than 100 cards."); + const ids = new Set(normalized.sessions.map((record) => record.id)); + for (const record of normalized.sessions) { + if (record.parentSessionId && !ids.has(record.parentSessionId)) throw new Error("Workspace has a missing parent card."); + const ancestry = new Set([record.id]); + let parent = record.parentSessionId; + while (parent) { + if (ancestry.has(parent)) throw new Error("Workspace has a cycle in its task tree."); + ancestry.add(parent); parent = normalized.sessions.find((candidate) => candidate.id === parent)?.parentSessionId; + } + } + return normalized.sessions; + } + async preview(text: string): Promise<{warnings:string[];count:number}> { + const records = this.parse(text); + return {warnings: await this.warnings(records), count: records.length}; + } + private async warnings(records: PersistedTerminalSession[]): Promise { + const warnings: string[] = []; + for (const record of records) { + if (!this.deps.available(record.provider)) warnings.push(`${record.title}: ${record.provider} CLI is unavailable.`); + if (!await stat(record.cwd).then((entry) => entry.isDirectory()).catch(() => false)) warnings.push(`${record.title}: folder is missing (${record.cwd}).`); + if (record.environmentChoice || record.options) warnings.push(`${record.title}: imported plugin launcher and account choices are not applied; reselect them in the launcher.`); + if (record.environment) warnings.push(`${record.title}: saved ${record.environment.pluginId} environment needs to be reconnected; this card will be skipped.`); + if (record.profile === "yolo") warnings.push(`${record.title}: Bypass requires confirmation.`); + } + return warnings; + } + async import(text: string, confirmBypass: boolean): Promise<{warnings:string[];sessions:SessionSnapshot[];restoredIds:Record}> { + const records = this.parse(text); + if (records.some((record) => record.profile === "yolo") && confirmBypass !== true) throw new Error("Confirm Bypass profiles before opening this workspace."); + const warnings = await this.warnings(records); + const candidates = new Set(); + for (const record of records) { + if (this.deps.available(record.provider) && !record.environment + && await stat(record.cwd).then(entry => entry.isDirectory()).catch(() => false)) candidates.add(record.id); + } + const byId = new Map(records.map(record => [record.id, record])); + for (const record of records) { + if (record.profile !== "yolo" || record.provider === "terminal") continue; + let ancestor: PersistedTerminalSession | undefined = record; + while (ancestor && candidates.has(ancestor.id)) ancestor = ancestor.parentSessionId ? byId.get(ancestor.parentSessionId) : undefined; + if (ancestor) continue; // This card or an ancestor will be skipped, not launched locally. + if (record.role === "subagent") throw new Error("Bypass is never permitted for subagents; change this workspace card's profile before importing."); + if (this.deps.bypassAcknowledged?.(record.provider) !== true) { + throw new Error(`Acknowledge Bypass for ${record.provider} in CanvasTTY's launcher, then retry this import. No cards were created.`); + } + } + const pending = [...records], sessions: SessionSnapshot[] = [], idMap = new Map(); + while (pending.length) { + const index = pending.findIndex((record) => !record.parentSessionId || idMap.has(record.parentSessionId) + || !pending.some((candidate) => candidate.id === record.parentSessionId)); + if (index < 0) break; + const record = pending.splice(index,1)[0]; + if (!candidates.has(record.id)) continue; + if (record.parentSessionId && !idMap.has(record.parentSessionId)) { warnings.push(`${record.title}: parent could not be opened.`); continue; } + try { + const created = this.deps.create({provider: record.provider, cwd: record.cwd, profile: record.profile, + position: record.position, title: record.title, role: record.role, + ...(record.parentSessionId ? {parentSessionId:idMap.get(record.parentSessionId)!} : {}), + ...(record.threadId ? {resumeThreadId:record.threadId} : {}), + ...(record.model ? {model:record.model} : {}), ...(record.effort ? {effort:record.effort} : {})}); + this.deps.setBounds(created.id, {position:record.position,size:record.size}); + idMap.set(record.id,created.id); sessions.push({...created,size:record.size}); + } catch (error) { warnings.push(`${record.title}: ${error instanceof Error ? error.message : "could not be opened"}`); } + } + return {warnings,sessions,restoredIds:Object.fromEntries(idMap)}; + } + async presets(): Promise { + await this.queue; + return this.readPresets(); + } + savePreset(preset: WorkspacePreset): Promise { + if (!preset || !/^[\w-]{1,100}$/.test(preset.id) || !validPresetName(preset.name)) return Promise.reject(new Error("Invalid workspace preset.")); + this.parse(preset.snapshot); + const row: WorkspacePreset = {id:preset.id,name:this.deps.redact(preset.name),snapshot:this.freshSnapshot(preset.snapshot)}; + const work = this.queue.then(async () => { + const rows = await this.readPresets(); + const next = [...rows.filter((entry) => entry.id !== row.id), row]; + if (next.length > 50) throw new Error("Maximum 50 workspace presets."); + await this.writePresets(next); return row; + }); + this.queue = work.then(() => undefined, () => undefined); return work; + } + deletePreset(id: string): Promise { + const work = this.queue.then(async () => this.writePresets((await this.readPresets()).filter((row) => row.id !== id))); + this.queue = work.catch(() => undefined); return work; + } + private async readPresets(): Promise { + try { return this.validatePresets(JSON.parse(await readFile(this.file,"utf8"))); } + catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; throw error; } + } + private validatePresets(values: unknown): WorkspacePreset[] { + if (!Array.isArray(values)) return []; + const rows: WorkspacePreset[] = []; + const seen = new Set(); + let skipped = 0; + for (const value of values) { + const row = (value && typeof value === "object" ? value : {}) as Partial; + try { + if (typeof row.id !== "string" || !/^[\w-]{1,100}$/.test(row.id) || seen.has(row.id) || + !validPresetName(row.name) || typeof row.snapshot !== "string") throw new Error("invalid preset"); + rows.push({ id: row.id, name: row.name, snapshot: this.freshSnapshot(row.snapshot) }); + seen.add(row.id); + } catch { + // A damaged record is skipped on read so it cannot hide its valid neighbors. + skipped += 1; + } + } + if (skipped > 0) console.warn(`Skipped ${skipped} damaged workspace preset${skipped === 1 ? "" : "s"}.`); + return rows; + } + private async writePresets(rows: WorkspacePreset[]): Promise { + await mkdir(dirname(this.file), {recursive:true,mode:0o700}); + await writeFile(`${this.file}.tmp`,JSON.stringify(rows),{mode:0o600}); await rename(`${this.file}.tmp`,this.file); + } + private freshSnapshot(text:string):string { + const sessions=this.parse(text).map(record=>this.portableRecord(record,true)); + const source=JSON.parse(text) as Record; + const sessionIds=Object.fromEntries(sessions.map(row=>[row.id,row.id])); + let tasks; + if(source.tasks!==undefined) { + if(!Array.isArray(source.tasks) || source.tasks.length>100)throw new Error("Invalid workspace task groups."); + tasks=source.tasks.map(group=>{ + if(!group || typeof group.rootSessionId!=="string" || !Object.hasOwn(sessionIds,group.rootSessionId))throw new Error("Invalid workspace task group root."); + return {rootSessionId:group.rootSessionId,tasks:normalizeImportedTasks(group.tasks,group.rootSessionId,sessionIds,id=>id)}; + }); + } + let canvas; + if(source.canvas!==undefined) { + // parse() has already validated the optional canvas envelope. + const value=source.canvas as Record; + canvas={version:1,canvasRegions:normalizeCanvasRegions(value.canvasRegions),stickyNotes:normalizeStickyNotes(value.stickyNotes), + browserCanvas:normalizeBrowserCanvas(value.browserCanvas,null)}; + } + return JSON.stringify(this.sanitize({format:"canvastty-workspace",version:1,sessions, + ...(tasks!==undefined?{tasks}:{}),...(canvas!==undefined?{canvas}:{})})); + } +} diff --git a/src/main/services/accountHomeIsolation.ts b/src/main/services/accountHomeIsolation.ts new file mode 100644 index 00000000..fec140bd --- /dev/null +++ b/src/main/services/accountHomeIsolation.ts @@ -0,0 +1,80 @@ +import { lstatSync, realpathSync } from "node:fs"; +import { basename, dirname, isAbsolute, join, resolve } from "node:path"; +import type { ProviderId } from "../../shared/contracts.ts"; + +/** The Accounts service id is fixed by the host; plugin replies cannot nominate a private-data exception. */ +export const ACCOUNTS_PLUGIN_ID = "canvastty-accounts"; + +/** The account a launch is billed to: an empty or "none" selection means the provider's own default sign-in. */ +export function selectedAccountId(options: Readonly> | undefined): string { + const values = options?.[ACCOUNTS_PLUGIN_ID]; + const selected = values && typeof values === "object" ? (values as Record).account : undefined; + return typeof selected === "string" && selected !== "" && selected !== "none" ? selected : "default"; +} + +/** + * Returns a candidate account home only for the host's Accounts launch contributor and matching CLI provider. + * The data directory is populated by PluginManager, never by plugin RPC. Filesystem links and existence are checked + * again immediately before the operating-system sandbox is built. + */ +export function selectedAccountHome( + provider: ProviderId, + pluginId: string, + dataDir: string | undefined, + selectedAccountId: unknown, + contributedEnv: Readonly> +): string | undefined { + if (pluginId !== ACCOUNTS_PLUGIN_ID || !dataDir || !isAbsolute(dataDir)) return undefined; + if (typeof selectedAccountId !== "string" || !selectedAccountId || selectedAccountId === "none") return undefined; + const variable = provider === "codex" ? "CODEX_HOME" : provider === "claude" ? "CLAUDE_CONFIG_DIR" : undefined; + if (!variable) return undefined; + const value = contributedEnv[variable]; + if (!value || !isAbsolute(value)) return undefined; + + const accountData = resolve(dataDir); + const homes = join(accountData, "homes"); + const selected = resolve(value); + const prefix = `${provider}-`; + if (basename(accountData) !== ACCOUNTS_PLUGIN_ID || basename(dirname(accountData)) !== "plugin-data" + || selected !== value || dirname(selected) !== homes || basename(selected) !== `${prefix}${selectedAccountId}`) return undefined; + return selected; +} + +/** + * Checks the candidate against this app's own private data directory at wrap time. The plugin-data directory, + * Accounts directory, homes directory and selected child must all be real directories, not symlinks. + */ +export function validateSelectedAccountHome(userDataPath: string, provider: ProviderId, candidate: string): string { + if (provider !== "codex" && provider !== "claude") { + throw new Error("Only the Accounts Codex or Claude home can be reopened by isolation. The agent was not started."); + } + const userData = resolve(userDataPath); + const realUserData = realpathSync.native(userData); + const pluginData = join(userData, "plugin-data"); + const accountsData = join(pluginData, ACCOUNTS_PLUGIN_ID); + const homes = join(accountsData, "homes"); + const selected = resolve(candidate); + const prefix = `${provider}-`; + if (!isAbsolute(candidate) || candidate !== selected || dirname(selected) !== homes + || !basename(selected).startsWith(prefix) || basename(selected).length <= prefix.length) { + throw new Error("The selected Accounts CLI home must be a direct child for this provider. The agent was not started."); + } + + const expected = [ + [pluginData, join(realUserData, "plugin-data")], + [accountsData, join(realUserData, "plugin-data", ACCOUNTS_PLUGIN_ID)], + [homes, join(realUserData, "plugin-data", ACCOUNTS_PLUGIN_ID, "homes")], + [selected, join(realUserData, "plugin-data", ACCOUNTS_PLUGIN_ID, "homes", basename(selected))] + ] as const; + try { + for (const [path, canonical] of expected) { + const info = lstatSync(path); + if (!info.isDirectory() || info.isSymbolicLink() || realpathSync.native(path) !== canonical) { + throw new Error("not a real directory"); + } + } + } catch { + throw new Error("The selected Accounts CLI home is missing or crosses a symbolic link. The agent was not started."); + } + return expected.at(-1)![1]; +} diff --git a/src/main/services/agent-browser/AgentBrowserBridge.ts b/src/main/services/agent-browser/AgentBrowserBridge.ts index 06fa4f53..047ee64d 100644 --- a/src/main/services/agent-browser/AgentBrowserBridge.ts +++ b/src/main/services/agent-browser/AgentBrowserBridge.ts @@ -10,6 +10,8 @@ export interface PrepareAgentBrowserLaunchInput { terminalSessionId: string; provider: AgentProvider; cwd: string; + /** Strict OS network modes must never receive the host-side browser capability. */ + networkMode?: "open" | "allowed-domains" | "offline"; /** Include the canvastty_agents MCP server: orchestrators, and sessions a plugin tool applies to. */ includeOrchestration?: boolean; /** The canvastty_agents tools this session may use (default: the core tools). */ @@ -63,6 +65,11 @@ export class AgentBrowserBridge implements AgentBrowserLaunchCoordinator { } prepareLaunch(input: PrepareAgentBrowserLaunchInput): PreparedAgentBrowserPtyLaunch | null { + // The browser gateway can make external requests from the host, outside an agent's OS network policy. Strict + // launches may retain orchestration only; never issue a browser token or write browser credentials into config. + if (input.networkMode && input.networkMode !== "open") { + return input.includeOrchestration ? this.prepareOrchestrationOnly(input) : null; + } // 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; diff --git a/src/main/services/agent-browser/OrchestrationTools.ts b/src/main/services/agent-browser/OrchestrationTools.ts index ba6afebf..de2ead5c 100644 --- a/src/main/services/agent-browser/OrchestrationTools.ts +++ b/src/main/services/agent-browser/OrchestrationTools.ts @@ -1,8 +1,9 @@ import type { OrchestrationCommandHandler, OrchestrationRequest } from "./orchestration-protocol.ts"; +import { selectedAccountId } from "../accountHomeIsolation.ts"; import { orchestrationBridgeError } from "./orchestration-protocol.ts"; import type { AgentProviderId, ProviderId, SessionRole } from "../../../shared/contracts.ts"; import { launchEffortProblem, launchModelProblem } from "../../../shared/launchModel.ts"; -import { PromptNotDeliveredError, type AgentControlService, type SpawnAgentRequest } from "../AgentControlService.ts"; +import { PromptNotDeliveredError, type AgentControlService, type AgentResult, type AgentReviewResult, type SpawnAgentRequest } from "../AgentControlService.ts"; import { LaunchRefusal } from "../launchRefusal.ts"; import type { PluginAgentTools } from "../PluginAgentTools.ts"; import { @@ -15,6 +16,24 @@ import { import { AGENT_PROVIDERS } from "../../../shared/contracts.ts"; import { listProviderDirectory, type ProviderDirectorySources } from "../providerDirectory.ts"; import type { McpToolDefinition } from "../../../agent-browser/orchestration-catalog.mjs"; +import type { OrchestrationBudgetService } from "../OrchestrationBudgetService.ts"; +import type { OrchestrationTaskBoard, OrchestrationTaskPatch } from "../OrchestrationTaskBoard.ts"; +import type { OrchestrationTemplateService } from "../OrchestrationTemplateService.ts"; + +import type { SecretGrantService } from "../SecretGrantService.ts"; + +const SECRET_TOOL_NAMES = new Set(["request_secret", "run_secret_request"]); + +const TASK_TOOL_NAMES = new Set(["list_tasks", "claim_task", "update_task", "complete_task"]); +const ORCHESTRATOR_ONLY_ADDITIONAL_TOOLS = new Set(["retry_agent"]); + +export interface ScopedOrchestrationIntegrations { + taskBoard?: OrchestrationTaskBoard; + budget?: Pick; + templates?: OrchestrationTemplateService; + /** Main-process grant manager; approval/revocation APIs are never exposed through agent tools. */ + secretGrants?: Pick; +} /** * The only bridge between the orchestration MCP surface and session control. @@ -26,22 +45,34 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { private readonly control: AgentControlService; private readonly plugins: Pick | null; private readonly providers: ProviderDirectorySources; + private readonly integrations: ScopedOrchestrationIntegrations; constructor( control: AgentControlService, plugins: Pick | null = null, - providers: ProviderDirectorySources = { cli: () => null, limits: () => null } + providers: ProviderDirectorySources = { cli: () => null, limits: () => null }, + integrations: ScopedOrchestrationIntegrations = {} ) { this.control = control; this.plugins = plugins; this.providers = providers; + this.integrations = integrations; } - /** Orchestrators see the core tools; every role sees the plugin tools that list it (EP-6). */ + /** Orchestrators see core tools; agents also see task-board tools scoped to their own root. */ listTools(sessionId: string): McpToolDefinition[] { + if (this.control.isReadOnlyReviewer(sessionId)) return []; const session = this.control.status(sessionId); + const core = ORCHESTRATION_TOOL_DEFINITIONS.filter((tool) => { + if (SECRET_TOOL_NAMES.has(tool.name)) return Boolean(this.integrations.secretGrants) && session.provider !== "terminal"; + if (TASK_TOOL_NAMES.has(tool.name)) return Boolean(this.integrations.taskBoard); + if (tool.name === "get_task_budget") return Boolean(this.integrations.budget); + if (tool.name === "list_orchestration_templates" || tool.name === "apply_orchestration_template") return Boolean(this.integrations.templates); + if (ORCHESTRATOR_ONLY_ADDITIONAL_TOOLS.has(tool.name)) return session.role === "orchestrator"; + return session.role === "orchestrator"; + }); return [ - ...(session.role === "orchestrator" ? ORCHESTRATION_TOOL_DEFINITIONS : []), + ...core, ...(this.plugins?.list(session.role, session.provider) ?? []) ]; } @@ -50,7 +81,21 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { try { if (signal?.aborted) throw canceledError(); const session = this.control.status(sessionId); + if (this.control.isReadOnlyReviewer(sessionId)) { + throw orchestrationBridgeError("INVALID_REQUEST", "Read-only reviewers cannot call agent or plugin tools.", false); + } + if (SECRET_TOOL_NAMES.has(request.tool)) return await this.secretTool(sessionId, request.tool, request.arguments, signal); if (isPluginOrchestrationTool(request.tool)) return await this.plugin(sessionId, session, request); + if (TASK_TOOL_NAMES.has(request.tool)) { + if (session.provider === "terminal") throw orchestrationBridgeError("INVALID_REQUEST", "Plain terminals cannot use the orchestration task board.", false); + return await this.taskTool(sessionId, request.tool, request.arguments); + } + if (request.tool === "get_task_budget") { + const budget = this.control.taskBudget(sessionId); + return budget ? ({ budget } as Record) : { available: false, reason: "No task budget is configured." }; + } + if (request.tool === "list_orchestration_templates") return await this.listTemplates(sessionId); + if (request.tool === "apply_orchestration_template") return await this.applyTemplate(sessionId, request.arguments); // Plugin tools may reach other roles' sessions through the same bridge; the core tools never do. if (session.role !== "orchestrator") { throw orchestrationBridgeError("INVALID_REQUEST", "Only orchestrator sessions can use CanvasTTY's agent tools.", false); @@ -67,11 +112,13 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { case "observe_agent": return 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": return this.list(sessionId); + case "retry_agent": + return await this.retry(sessionId, request.arguments, signal); default: throw orchestrationBridgeError("INVALID_REQUEST", "Unsupported orchestration tool.", false); } @@ -89,6 +136,40 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { } } + private async secretTool(sessionId: string, tool: string, args: Record, signal?: AbortSignal): Promise> { + const service = this.integrations.secretGrants; + if (!service) throw orchestrationBridgeError("INVALID_REQUEST", "Secret-grant controls are unavailable.", false); + if (this.control.status(sessionId).provider === "terminal") { + throw orchestrationBridgeError("INVALID_REQUEST", "Provider secrets are available to agent sessions only.", false); + } + try { + this.control.assertInputAllowed(sessionId); + if (tool === "request_secret") { + const request = service.requestSecret( + sessionId, + args.secretId as string, + this.control.maskText(args.reason as string, 1_000) + ); + return { pendingApproval: true, request }; + } + if (tool === "run_secret_request") { + const result = await service.runSecretRequest(sessionId, { + secretId: args.secretId as string, + ...(typeof args.apiProfileId === "string" ? { apiProfileId: args.apiProfileId } : {}), + method: args.method as string, + path: args.path as string, + ...(args.body && typeof args.body === "object" && !Array.isArray(args.body) ? { body: args.body as Record } : {}), + ...(typeof args.timeoutMs === "number" ? { timeoutMs: args.timeoutMs } : {}) + }, signal); + return { ...result }; + } + throw new Error("Unsupported secret operation."); + } catch (error) { + if (signal?.aborted) throw canceledError(); + throw orchestrationBridgeError("INVALID_REQUEST", error instanceof Error ? error.message : "Secret operation failed.", false); + } + } + private async plugin( sessionId: string, session: { role: SessionRole; provider: ProviderId }, @@ -98,6 +179,7 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { throw orchestrationBridgeError("INVALID_REQUEST", "That plugin tool is not available to this session.", false); } try { + this.control.assertInputAllowed(sessionId); const result = await this.plugins.call(sessionId, session.role, request.tool, request.arguments); return { pluginTool: true, text: result.content, isError: result.isError }; } catch (error) { @@ -110,6 +192,74 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { return listProviderDirectory(this.providers, pluginTools) as unknown as Record; } + private async taskTool(sessionId: string, tool: string, args: Record): Promise> { + const board = this.integrations.taskBoard; + if (!board) throw orchestrationBridgeError("INVALID_REQUEST", "The shared task board is not configured.", false); + const lineage = this.control.lineage(sessionId); + const root = this.control.taskRoot(sessionId); + const actor = lineage[0]!; + const authorityId=actor.role==="orchestrator" && !actor.parentSessionId ? root.id : sessionId; + try { + switch (tool) { + case "list_tasks": + return await board.listTasks(root.cwd, root.id); + case "claim_task": + return { task: await board.claimTask(root.cwd, root.id, sessionId, this.control.maskText(actor.title,65_536), args.taskId as string) }; + case "update_task": { + const patch: OrchestrationTaskPatch = {}; + for (const key of ["title", "description", "progress", "status", "ownerSessionId", "ownerName", "dependencies"]) { + if (Object.prototype.hasOwnProperty.call(args, key)) Object.assign(patch, { [key]: args[key] }); + } + if(patch.ownerSessionId && this.control.taskRoot(patch.ownerSessionId).id!==root.id)throw new Error("Task owner must belong to this orchestration tree."); + for(const key of ["title","description","progress","ownerName"] as const)if(typeof patch[key]==="string")patch[key]=this.control.maskText(patch[key]!,65_536); + return { task: await board.updateTask(root.cwd, root.id, authorityId, args.taskId as string, patch) }; + } + case "complete_task": + return { task: await board.completeTask(root.cwd, root.id, authorityId, args.taskId as string, this.control.maskText(args.result as string,65_536)) }; + default: + throw orchestrationBridgeError("INVALID_REQUEST", "Unsupported task-board tool.", false); + } + } catch (error) { + if (error && typeof error === "object" && "bridgeError" in error) throw error; + throw orchestrationBridgeError("INVALID_REQUEST", error instanceof Error ? error.message : "Task-board operation failed.", false); + } + } + + private async listTemplates(sessionId: string): Promise> { + const service = this.integrations.templates; + if (!service) throw orchestrationBridgeError("INVALID_REQUEST", "Orchestration templates are not configured.", false); + const root = this.control.taskRoot(sessionId); + const listing = await service.list(root.cwd); + return { ...listing, templates: listing.templates.filter(template => template.trusted === true) } as unknown as Record; + } + + private async applyTemplate(sessionId: string, args: Record): Promise> { + const service = this.integrations.templates; + if (!service) throw orchestrationBridgeError("INVALID_REQUEST", "Orchestration templates are not configured.", false); + const root = this.control.taskRoot(sessionId); + const available = await service.list(root.cwd); + const template = available.templates.find((item) => item.id === args.templateId); + if (!template) throw orchestrationBridgeError("INVALID_REQUEST", `Template "${String(args.templateId).slice(0, 80)}" is not available. Call list_orchestration_templates for the current choices.`, false); + return { + templateId: template.id, + name: template.name, + expectedSubagents: template.expectedSubagents, + instructions: service.instructions(template).replace("{{TASK}}", args.task as string), + ...(available.errors.length > 0 ? { templateErrors: available.errors } : {}) + }; + } + + private async retry(orchestratorId: string, args: Record, signal?: AbortSignal): Promise> { + const target = args.sessionId as string; + if (target === orchestratorId) throw orchestrationBridgeError("INVALID_REQUEST", "retry_agent retries a subagent, not this session.", false); + this.requireOwned(orchestratorId, target); + if (signal?.aborted) throw canceledError(); + const created = await this.control.retry(target, args.reason as string | undefined, signal); + // Retry owns cancellation cleanup and retains this same card's diagnostic history. + return { sessionId: created.id, provider: created.provider, title: created.title, profile: created.profile, + ...(created.model !== undefined ? { model: created.model } : {}), ...(created.effort !== undefined ? { effort: created.effort } : {}) }; + } + private async wait(orchestratorId: string, args: Record, signal?: AbortSignal): Promise> { const target = args.sessionId as string; // Its own session is in its subtree, but waiting on itself would only ever time out. @@ -118,12 +268,17 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { } this.requireOwned(orchestratorId, target); const seconds = typeof args.timeoutSeconds === "number" ? args.timeoutSeconds : DEFAULT_AGENT_WAIT_SECONDS; + // OpenCode's MCP client can impose a 60-second request deadline, even when the helper allows longer. + const clientLimitMs = this.control.status(orchestratorId).provider === "opencode" ? 50_000 : 100_000; try { const result = await this.control.waitFor(target, { - timeoutMs: Math.min(MAX_AGENT_WAIT_SECONDS, Math.max(1, seconds)) * 1_000, + timeoutMs: Math.min(clientLimitMs, Math.min(MAX_AGENT_WAIT_SECONDS, Math.max(1, seconds)) * 1_000), + deferReview: true, ...(signal ? { signal } : {}) }); - return { ...result }; + if (result.reason === "closed") return { ...result }; + return { ...result, ...(result.reason === "timeout" || result.review?.status === "pending" + ? { message: "Still running. Call wait_for_agent again for progress." } : {}) }; } catch (error) { if (signal?.aborted || (error instanceof Error && error.name === "AbortError")) throw canceledError(); throw error; @@ -135,16 +290,50 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { throw orchestrationBridgeError("INVALID_REQUEST", unknownProviderMessage(args.provider), false); } const provider = args.provider as AgentProviderId; - const problem = (args.model !== undefined ? launchModelProblem(provider, args.model) : null) + const hasExplicitModel = args.model !== undefined; + const problem = (hasExplicitModel ? launchModelProblem(provider, args.model) : null) ?? (args.effort !== undefined ? launchEffortProblem(provider, args.effort) : null); if (problem) throw orchestrationBridgeError("INVALID_REQUEST", `${problem} Call list_providers for what ${provider} takes.`, false); const profile = this.control.profileFor(orchestratorId, provider, args.profile); if ("error" in profile) throw orchestrationBridgeError("INVALID_REQUEST", profile.error, false); - if (args.model !== undefined) { + if (hasExplicitModel) { let unknown: string | null = null; try { unknown = await this.providers.checkModel?.(provider, args.model as string) ?? null; } catch { unknown = null; } if (unknown) throw orchestrationBridgeError("INVALID_REQUEST", unknown, false); } + if (args.reviewModel !== undefined) { + if (args.review !== true) throw orchestrationBridgeError("INVALID_REQUEST", "reviewModel can be set only when review:true.", false); + const reviewProblem = launchModelProblem(provider, args.reviewModel); + if (reviewProblem) throw orchestrationBridgeError("INVALID_REQUEST", `${reviewProblem} Call list_providers for the valid reviewer models.`, false); + const unknownReviewModel = await this.providers.checkModel?.(provider, args.reviewModel as string) ?? null; + if (unknownReviewModel) throw orchestrationBridgeError("INVALID_REQUEST", unknownReviewModel, false); + if (hasExplicitModel && args.reviewModel === args.model) { + throw orchestrationBridgeError("INVALID_REQUEST", "The review model must differ from the worker model.", false); + } + } + let model = hasExplicitModel ? args.model as string : undefined; + let effort = args.effort as SpawnAgentRequest["effort"] | undefined; + if (!hasExplicitModel && this.control.taskBudget(orchestratorId)?.paused) { + throw orchestrationBridgeError("INVALID_REQUEST", this.control.taskBudget(orchestratorId)?.reason ?? "This task's budget is exhausted.", false); + } + // A spawn without an account runs on the orchestrator's account (never silently on the CLI's default model). + let launchOptions = args.launchOptions as SpawnAgentRequest["launchOptions"]; + let accountSource: "inherited" | "explicit" | "none" = "none"; + if (typeof this.control.subagentAccount === "function") { + try { + const account = this.control.subagentAccount(orchestratorId, provider, launchOptions); + launchOptions = account.launchOptions; + accountSource = account.source; + } catch (error) { + throw orchestrationBridgeError("INVALID_REQUEST", error instanceof Error ? error.message : String(error), false); + } + } + const accountId = selectedAccountId(launchOptions as Record | undefined); + // A model account supplies its model and remains visible in the launch result. + const accountChosen = accountId !== "default"; + if (args.reviewModel !== undefined && model === args.reviewModel) { + throw orchestrationBridgeError("INVALID_REQUEST", "The review model must differ from the selected worker model.", false); + } if (signal?.aborted) throw canceledError(); const created = await this.control.spawn({ parentSessionId: orchestratorId, @@ -152,9 +341,12 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { 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"] } : {}), - ...(args.model !== undefined ? { model: args.model as string } : {}), - ...(args.effort !== undefined ? { effort: args.effort as SpawnAgentRequest["effort"] } : {}), + ...(launchOptions !== undefined ? { launchOptions } : {}), + ...(model !== undefined ? { model } : {}), + ...(effort !== undefined ? { effort } : {}), + ...(args.isolate === "worktree" ? { isolate: "worktree" as const } : {}), + ...(args.review === true ? { review: true } : {}), + ...(typeof args.reviewModel === "string" ? { reviewModel: args.reviewModel } : {}), profile: profile.profile }, signal).catch((error: unknown) => { if (signal?.aborted) throw canceledError(); @@ -169,6 +361,12 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { } throw canceledError(); } + const servedBy = { + provider, + account: accountChosen ? accountId : null, + accountSource: accountChosen ? accountSource : "none", + model: created.model ?? (accountChosen ? `set by model account ${accountId.slice(0, 40)}` : defaultModelText(provider)) + }; return { sessionId: created.id, provider: created.provider, @@ -177,7 +375,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 } : {}), + servedBy, + ...(args.review === true ? { reviewRequested: true } : {}), + ...(args.isolate === "worktree" ? { isolationRequested: "worktree" } : {}), }; } @@ -213,9 +414,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.resultWithReview(args.sessionId as string, { deferReview: true }); return { sessionId: result.sessionId, state: result.state, @@ -223,7 +424,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.review ? { review: result.review } : {}) }; } @@ -260,3 +462,10 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { function canceledError(): Error { return orchestrationBridgeError("CANCELED", "Orchestration command was canceled.", true); } + +/** What serves a subagent that names neither a model nor a model account. */ +function defaultModelText(provider: AgentProviderId): string { + return provider === "opencode" + ? "OpenCode's default model (its own configuration decides; with none set OpenCode uses its free OpenCode Zen model on opencode.ai)" + : `${provider}'s default model`; +} diff --git a/src/main/services/agent-runtime/AgentRuntimeBridge.ts b/src/main/services/agent-runtime/AgentRuntimeBridge.ts index 18b6ef2d..6a85950d 100644 --- a/src/main/services/agent-runtime/AgentRuntimeBridge.ts +++ b/src/main/services/agent-runtime/AgentRuntimeBridge.ts @@ -1,4 +1,6 @@ +import { randomUUID } from "node:crypto"; import type { ProviderId } from "../../../shared/contracts.ts"; +import { dirname, isAbsolute, join } from "node:path"; import { AGENT_RUNTIME_ENV, CAPTURE_ANSWER_ENV, @@ -38,10 +40,13 @@ export interface PreparedAgentRuntimePtyLaunch { export interface AgentRuntimeLaunchCoordinator { prepareLaunch(input: PrepareAgentRuntimeLaunchInput): PreparedAgentRuntimePtyLaunch; currentStatus(terminalSessionId: string): RuntimeLifecycleState | null; + readableRuntimePaths?(): readonly string[]; } export interface AgentRuntimeBridgeOptions extends ProviderRuntimeLaunchOptions { recoverOnStart?: boolean; + /** Host-only invalidation of turn-scoped authority on launch replacement, cleanup or hook revocation. */ + onTurnAuthorityChanged?(sessionId: string): void; coreHooksEnabled?: boolean; /** Whether a launch of this agent needs the decision hook (base protection on, or a decision plugin applies). */ wantsDecisions?(provider: Exclude): boolean; @@ -52,37 +57,52 @@ export interface AgentRuntimeBridgeOptions extends ProviderRuntimeLaunchOptions } export class AgentRuntimeBridge implements AgentRuntimeLaunchCoordinator { + private readonly runtimeReadPaths: readonly string[]; private readonly gateway: RuntimeGateway; private readonly providers: ProviderRuntimeLaunchAdapters; /** Running sessions and whether each got the decision hook. */ /** Live launches by card id; the entry object is the launch identity a late cleanup checks against. */ - private readonly activeSessions = new Map(); + private readonly activeSessions = new Map(); private coreHooksEnabled: boolean; + private readonly onTurnAuthorityChanged: AgentRuntimeBridgeOptions["onTurnAuthorityChanged"]; private readonly wantsDecisions: AgentRuntimeBridgeOptions["wantsDecisions"]; private readonly decisionBudgetMs: AgentRuntimeBridgeOptions["decisionBudgetMs"]; private readonly claudeHttpHooks: AgentRuntimeBridgeOptions["claudeHttpHooks"]; constructor(gateway: RuntimeGateway, options: AgentRuntimeBridgeOptions) { this.gateway = gateway; + this.onTurnAuthorityChanged = options.onTurnAuthorityChanged; this.wantsDecisions = options.wantsDecisions; this.decisionBudgetMs = options.decisionBudgetMs; this.claudeHttpHooks = options.claudeHttpHooks; this.providers = new ProviderRuntimeLaunchAdapters(options); + // File grants, never the containing source tree: a reviewer may review CanvasTTY itself. + const helpers = [options.helper, ...(options.permissionGate ? [options.permissionGate] : []), + ...(options.pluginHooks ? [options.pluginHooks.runner] : [])]; + const runtimeFiles = ["hook-helper.mjs", "permission-gate.mjs", "runtime-client.mjs", "runtime-protocol.mjs", + "ndjson.mjs", "path-inside.mjs", "opencode-plugin.mjs", "opencode-final-answer.mjs", "opencode-decisions.mjs", "omp-extension.mjs", + "plugin-hook-runner.mjs", "plugin-hook-dispatch.mjs"]; + this.runtimeReadPaths = Object.freeze([...new Set([ + ...helpers.flatMap(helper => [helper.command, ...helper.args.filter(isAbsolute)]), + ...runtimeFiles.map(file => join(dirname(options.openCodePluginPath), file))])]); this.coreHooksEnabled = options.coreHooksEnabled !== false; if (options.recoverOnStart) this.providers.recoverConfigurations(); } + readableRuntimePaths(): readonly string[] { return this.runtimeReadPaths; } + prepareLaunch(input: PrepareAgentRuntimeLaunchInput): PreparedAgentRuntimePtyLaunch { // The decision hook talks to the gateway over its own capability, with or without agent status hooks. const decisions = (input.decisions ?? this.wantsDecisions?.(input.provider) === true) && this.providers.decisionsSupported(input.provider); let budgetMs: number | undefined; try { budgetMs = decisions ? this.decisionBudgetMs?.(input.provider) : undefined; } catch { budgetMs = undefined; } - const capability = this.coreHooksEnabled || decisions + const captureResult = input.captureResult === true; + const capability = this.coreHooksEnabled || decisions || captureResult ? this.gateway.registerSession( input.terminalSessionId, input.provider, - input.captureResult === true, + captureResult, isLiveGrant(input.answerCaptureGrantExpiresAt) ? input.answerCaptureGrantExpiresAt : undefined, decisions, budgetMs @@ -92,13 +112,15 @@ export class AgentRuntimeBridge implements AgentRuntimeLaunchCoordinator { let prepared; try { prepared = this.providers.prepare(input.provider, input.terminalSessionId, this.coreHooksEnabled, decisions, budgetMs, - httpHookBase ?? undefined); + httpHookBase ?? undefined, captureResult); } catch (error) { if (capability) this.gateway.revokeTerminalSession(input.terminalSessionId, capability.capabilityToken); + this.turnAuthorityChanged(input.terminalSessionId); // Registration already replaced any preceding lease. throw error; } - const launch = { decisions }; + const launch = { decisions, captureResult, turnCompletion: prepared.turnCompletion === true, identity: randomUUID() }; this.activeSessions.set(input.terminalSessionId, launch); + this.turnAuthorityChanged(input.terminalSessionId); let cleaned = false; return { args: prepared.args, @@ -122,7 +144,10 @@ export class AgentRuntimeBridge implements AgentRuntimeLaunchCoordinator { if (cleaned) return; cleaned = true; // A relaunch under the same card id owns the entry and the lease now; leave both to it. - if (this.activeSessions.get(input.terminalSessionId) === launch) this.activeSessions.delete(input.terminalSessionId); + if (this.activeSessions.get(input.terminalSessionId) === launch) { + this.activeSessions.delete(input.terminalSessionId); + this.turnAuthorityChanged(input.terminalSessionId); + } try { prepared.releaseConfiguration(); } finally { @@ -148,14 +173,28 @@ export class AgentRuntimeBridge implements AgentRuntimeLaunchCoordinator { return this.coreHooksEnabled ? this.gateway.currentStatus(terminalSessionId) : null; } + /** An epoch alone is insufficient: permission hooks may open one without any completion transport. */ + currentTurnIdentity(sessionId: string): string | null { + const launch = this.activeSessions.get(sessionId); + if (!this.coreHooksEnabled || !launch?.turnCompletion) return null; + const epoch = this.gateway.currentTurnEpoch(sessionId); + return epoch === null ? null : `${launch.identity}:${epoch}`; + } + + private turnAuthorityChanged(sessionId: string): void { + try { this.onTurnAuthorityChanged?.(sessionId); } catch { /* observers cannot break launch cleanup */ } + } + setCoreHooksEnabled(enabled: boolean): void { const next = Boolean(enabled); if (this.coreHooksEnabled === next) return; this.coreHooksEnabled = next; if (next) return; - // A session with the decision hook keeps its lease: its protection must not silently stop. - for (const [terminalSessionId, { decisions }] of this.activeSessions) { - if (!decisions) this.gateway.revokeTerminalSession(terminalSessionId); + // Decisions and explicitly requested result capture are independent of lifecycle UI updates. + for (const [terminalSessionId, launch] of this.activeSessions) { + launch.turnCompletion = false; // Enabling UI later cannot install hooks in an already running launch. + this.turnAuthorityChanged(terminalSessionId); + if (!launch.decisions && !launch.captureResult) this.gateway.revokeTerminalSession(terminalSessionId); } } } diff --git a/src/main/services/agent-runtime/ClaudeHttpHooks.ts b/src/main/services/agent-runtime/ClaudeHttpHooks.ts index fb01c0a2..c9990da2 100644 --- a/src/main/services/agent-runtime/ClaudeHttpHooks.ts +++ b/src/main/services/agent-runtime/ClaudeHttpHooks.ts @@ -3,6 +3,7 @@ import { readFileSync, readdirSync, realpathSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { basename, dirname, posix, win32 } from "node:path"; import { CLAUDE_HTTP_HOOK } from "../../../agent-runtime/runtime-protocol.mjs"; +import type { AgentNetworkMode } from "../isolation/networkPolicy.ts"; /** * When Claude Code's lifecycle hooks may go over HTTP to the gateway's loopback listener instead of through the @@ -14,6 +15,7 @@ import { CLAUDE_HTTP_HOOK } from "../../../agent-runtime/runtime-protocol.mjs"; * - `HTTP_PROXY` and friends route them through that proxy; * - `allowedHttpHookUrls` blocks them, and `httpHookAllowedEnvVars` empties the headers that carry the capability; * - a plugin environment (container, remote host) has another 127.0.0.1 and none of CanvasTTY's variables; + * - Linux strict-network isolation cannot reach the host's loopback HTTP listener (the helper's Unix socket still works); * - Windows keeps its current-user named pipe; older Claude versions are untested. * * Otherwise the command helper runs exactly as before. @@ -29,6 +31,8 @@ export interface ClaudeHttpLaunchFacts { /** Claude's arguments (inline `--settings` values are read). */ args: readonly string[]; cwd: string; + /** Strict network modes may isolate loopback from the host gateway on Linux. */ + networkMode?: AgentNetworkMode; } export type ClaudeHttpVerdict = { ok: true } | { ok: false; reason: string }; @@ -68,6 +72,9 @@ export class ClaudeHttpHookPolicy { verdict(facts: ClaudeHttpLaunchFacts): ClaudeHttpVerdict { if (this.platform === "win32") return { ok: false, reason: "Windows keeps the named-pipe helper" }; + if (this.platform === "linux" && facts.networkMode !== undefined && facts.networkMode !== "open") { + return { ok: false, reason: "Linux strict network isolation cannot reach the loopback HTTP hook listener" }; + } if (facts.environmentWrapped) return { ok: false, reason: "a plugin environment runs the agent" }; if (facts.profile === "auto") return { ok: false, reason: "Claude's sandbox (auto profile) proxies HTTP hooks" }; const version = this.version(facts.executable); diff --git a/src/main/services/agent-runtime/ProviderRuntimeLaunch.ts b/src/main/services/agent-runtime/ProviderRuntimeLaunch.ts index 66387516..4fb71533 100644 --- a/src/main/services/agent-runtime/ProviderRuntimeLaunch.ts +++ b/src/main/services/agent-runtime/ProviderRuntimeLaunch.ts @@ -96,6 +96,8 @@ export interface ProviderRuntimeLaunchOptions { } export interface PreparedProviderRuntimeLaunch { + /** This prepared configuration installs a per-turn completion event (not merely session end). */ + turnCompletion?: boolean; args: string[]; environment: Record; releaseConfiguration(): void; @@ -182,7 +184,8 @@ export class ProviderRuntimeLaunchAdapters { coreHooksEnabled = true, decisions = false, decisionBudgetMs?: number, - claudeHttpHookBase?: string + claudeHttpHookBase?: string, + captureResult = false ): PreparedProviderRuntimeLaunch { const pluginRegistrations = this.options.pluginHooks?.list(provider) ?? []; const gate = decisions && this.decisionsSupported(provider); @@ -193,8 +196,9 @@ export class ProviderRuntimeLaunchAdapters { 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 resultHooks = captureResult && (provider === "codex" || provider === "opencode"); const hasHooks = HOOK_PROVIDERS.has(provider) - && (coreHooksEnabled || pluginCommands.length > 0 || (provider === "opencode" && (pluginRegistrations.length > 0 || openCodeDecisions))); + && (coreHooksEnabled || resultHooks || pluginCommands.length > 0 || (provider === "opencode" && (pluginRegistrations.length > 0 || openCodeDecisions))); const environment = hasHooks ? { ...(pluginRegistrations.length > 0 ? { @@ -207,10 +211,10 @@ export class ProviderRuntimeLaunchAdapters { return prepared([], environment); } 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), environment, undefined, coreHooksEnabled); } if (provider === "codex") { - return prepared(codexHookArgs(this.options.helper, this.platform, coreHooksEnabled, pluginCommands), environment); + return prepared(codexHookArgs(this.options.helper, this.platform, coreHooksEnabled, pluginCommands, resultHooks), environment, undefined, coreHooksEnabled); } if (provider === "qwen") { const path = createQwenHookSettings({ @@ -222,7 +226,7 @@ export class ProviderRuntimeLaunchAdapters { coreHooksEnabled, pluginCommands }); - return prepared([], { ...environment, [QWEN_SYSTEM_SETTINGS]: path }, () => unlinkIfExists(path)); + return prepared([], { ...environment, [QWEN_SYSTEM_SETTINGS]: path }, () => unlinkIfExists(path), coreHooksEnabled); } if (provider === "opencode") { const pluginEnvironment = this.openCodePluginEnvironment( @@ -237,20 +241,20 @@ export class ProviderRuntimeLaunchAdapters { this.environment[OPENCODE_CONFIG_CONTENT], this.options.openCodePluginPath ) - }); + }, undefined, coreHooksEnabled); } if (provider === "omp" || provider === "pi") { return prepared(coreHooksEnabled ? ["--extension", join(dirname(this.options.openCodePluginPath), "omp-extension.mjs")] - : [], environment); + : [], environment, undefined, coreHooksEnabled); } if (provider === "kimi") { - return prepared([], environment, this.acquireKimi(coreHooksEnabled, pluginCommands)); + return prepared([], environment, this.acquireKimi(coreHooksEnabled, pluginCommands), coreHooksEnabled); } if (provider === "hermes") { return prepared([], environment, this.acquireHermes(coreHooksEnabled, pluginCommands)); } - if (provider === "grok") return prepared([], environment, this.acquireGrok(coreHooksEnabled, pluginCommands)); + if (provider === "grok") return prepared([], environment, this.acquireGrok(coreHooksEnabled, pluginCommands), coreHooksEnabled); return prepared([], environment); } @@ -406,6 +410,7 @@ const CLAUDE_HOOKS: readonly HookMapping[] = [ { event: "UserPromptSubmit", state: "working" }, { event: "PermissionRequest", state: "needs_approval" }, { event: "PostToolUse", state: "working" }, + { event: "PostToolUseFailure", state: "working" }, { event: "Stop", state: "idle" }, { event: "StopFailure", state: "idle" }, { event: "SessionEnd", state: "idle" }, @@ -438,6 +443,8 @@ const KIMI_HOOKS: readonly HookMapping[] = [ { event: "TurnStarted", state: "working" }, { event: "PermissionRequest", state: "needs_approval" }, { event: "PermissionResult", state: "working" }, + { event: "PostToolUse", state: "working" }, + { event: "PostToolUseFailure", state: "working" }, { event: "Stop", state: "idle" }, { event: "StopFailure", state: "idle" }, { event: "Interrupt", state: "idle" }, @@ -449,6 +456,7 @@ const HERMES_HOOKS: readonly HookMapping[] = [ { event: "pre_llm_call", state: "working" }, { event: "pre_approval_request", state: "needs_approval" }, { event: "post_approval_response", state: "working" }, + { event: "post_tool_call", state: "working" }, { event: "on_session_end", state: "idle" } ]; @@ -641,11 +649,14 @@ function codexHookArgs( helper: RuntimeHookHelperLaunch, platform: NodeJS.Platform, coreHooksEnabled: boolean, - pluginCommands: readonly ProviderHookCommand[] + pluginCommands: readonly ProviderHookCommand[], + captureResult = false ): string[] { validateHelper(helper); + const hooks = coreHooksEnabled ? CODEX_HOOKS + : captureResult ? CODEX_HOOKS.filter(({ event }) => ["SessionStart", "UserPromptSubmit", "PermissionRequest", "PostToolUse", "Stop"].includes(event)) : []; const grouped = groupProviderHookMappings([ - ...(coreHooksEnabled ? lifecycleCommands(CODEX_HOOKS, helper, platform) : []), + ...lifecycleCommands(hooks, helper, platform), ...pluginCommands ]); const events = Object.entries(grouped).flatMap(([event, mappings]) => { @@ -1172,9 +1183,10 @@ function tomlString(value: string): string { function prepared( args: string[], environment: Record, - cleanup: () => void = () => undefined + cleanup: () => void = () => undefined, + turnCompletion = false ): PreparedProviderRuntimeLaunch { - return { args, environment, releaseConfiguration: once(cleanup) }; + return { args, environment, ...(turnCompletion ? { turnCompletion: true } : {}), releaseConfiguration: once(cleanup) }; } function once(action: () => void): () => void { diff --git a/src/main/services/agent-runtime/RuntimeGateway.ts b/src/main/services/agent-runtime/RuntimeGateway.ts index 7ad4f9d4..0ad765bd 100644 --- a/src/main/services/agent-runtime/RuntimeGateway.ts +++ b/src/main/services/agent-runtime/RuntimeGateway.ts @@ -16,7 +16,8 @@ import { PERMISSION_GATE, permissionGateTimings, RUNTIME_PROTOCOL_VERSION, - RUNTIME_STATES + RUNTIME_STATES, + toolOutcomeFromHook } from "../../../agent-runtime/runtime-protocol.mjs"; import { NdjsonLineReader } from "../../../agent-runtime/ndjson.mjs"; import { @@ -60,13 +61,27 @@ export interface RuntimeLifecycleSignal { state: RuntimeLifecycleState; event: string; turnId: string | null; + /** Host-owned turn generation; advances once per accepted start, including providers without turn ids. */ + turnEpoch: number; /** The provider's own conversation id (Codex thread, Claude or OpenCode session), as its hook reported it. */ threadId?: string; result?: { text: string; truncated: boolean }; lastAssistantMessage?: string; + /** Hash-only summary of an actual completed tool hook. */ + toolOutcome?: RuntimeToolOutcome; answerCaptureGrantExpiresAt?: number; } +export interface RuntimeToolOutcome { + toolName: string; + resultClass: "success" | "error" | "denied" | "unknown"; + normalizedActionHash?: string; + errorHash?: string; + /** Hash of the tool's output for non-error results; lets loop detection see repeated output. */ + outputHash?: string; + changedPathHashes: string[]; +} + export interface RuntimeSessionCapability { address: string; terminalSessionId: string; @@ -79,6 +94,8 @@ interface RuntimeLease { provider: Exclude; tokenDigest: Buffer; activeTurnId: string | null; + turnEpoch: number; + 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; @@ -89,6 +106,8 @@ interface RuntimeLease { /** How long a decision may take for this session (sized at launch from the decision services' budgets). */ gatewayMs: number; checks: Set; + lifecycleChecks: Set; + lifecycleRevision: number; } /** One decision hook call (permission-gate.mjs). The tool input is agent-influenced data, never instructions. */ @@ -103,6 +122,10 @@ export interface RuntimePermissionRequest { truncated: boolean; /** The agent's current folder as its CLI reported it. */ cwd: string | null; + /** Host-derived from the authenticated session's active lifecycle turn, never trusted from the permission packet. */ + turnId?: string; + /** Host-owned generation for providers whose lifecycle hooks omit turn ids. */ + turnEpoch: number; } /** `none`: no opinion, the CLI goes on as it would without CanvasTTY. */ @@ -121,6 +144,7 @@ interface ParsedLifecycleMessage { threadId?: string; result?: { text: string; truncated: boolean }; lastAssistantMessage?: string; + toolOutcome?: RuntimeToolOutcome; } export interface RuntimeGatewayOptions { @@ -130,6 +154,12 @@ export interface RuntimeGatewayOptions { windowsPipeHostFactory?: (options: WindowsPipeHostTransportOptions) => WindowsPipeHostTransport; /** A connection must send its one message within this time (default 5 s). */ firstMessageTimeoutMs?: number; + /** Captures a synchronous host freshness token, checked even if checkpoint cancellation settles later. */ + lifecycleGuard?(terminalSessionId: string, signal: RuntimeLifecycleSignal): () => boolean; + /** Barrier for authenticated working events only; UI delivery stays asynchronous. Failures/timeouts fail open. */ + beforeLifecycle?(terminalSessionId: string, signal: RuntimeLifecycleSignal, cancellation: AbortSignal): Promise | boolean | void; + /** Must remain below the unchanged 1 s JS/native hook deadline. Tests may shorten it. */ + lifecycleBarrierMs?: number; onSignal?(terminalSessionId: string, signal: RuntimeLifecycleSignal): void; onAnswerCaptureRevoked?(terminalSessionId: string): void; /** @@ -150,6 +180,9 @@ export class RuntimeGateway { private readonly requestedRuntimeDirectory: string | undefined; private readonly windowsHostPath: string | undefined; private readonly windowsPipeHostFactory: (options: WindowsPipeHostTransportOptions) => WindowsPipeHostTransport; + private readonly lifecycleGuard: RuntimeGatewayOptions["lifecycleGuard"]; + private readonly beforeLifecycle: RuntimeGatewayOptions["beforeLifecycle"]; + private readonly lifecycleBarrierMs: number; private readonly onSignal: RuntimeGatewayOptions["onSignal"]; private readonly onAnswerCaptureRevoked: RuntimeGatewayOptions["onAnswerCaptureRevoked"]; private readonly onPermissionRequest: RuntimeGatewayOptions["onPermissionRequest"]; @@ -157,6 +190,7 @@ export class RuntimeGateway { private readonly firstMessageTimeoutMs: number; private readonly checks = new Set(); private readonly leases = new Map(); + private nextTurnEpoch = 0; private readonly sockets = new Set(); private closed = false; private restartTimer: ReturnType | undefined; @@ -179,6 +213,9 @@ export class RuntimeGateway { ?? ((transportOptions) => new WindowsPipeHostTransport(transportOptions)); this.firstMessageTimeoutMs = options.firstMessageTimeoutMs ?? FIRST_MESSAGE_TIMEOUT_MS; this.onSignal = options.onSignal; + this.beforeLifecycle = options.beforeLifecycle; + this.lifecycleGuard = options.lifecycleGuard; + this.lifecycleBarrierMs = Math.max(1, Math.min(750, options.lifecycleBarrierMs ?? 750)); this.onAnswerCaptureRevoked = options.onAnswerCaptureRevoked; this.onPermissionRequest = options.onPermissionRequest; this.now = options.now ?? Date.now; @@ -276,6 +313,8 @@ export class RuntimeGateway { provider, tokenDigest: tokenDigest(capabilityToken), activeTurnId: null, + turnEpoch: 0, + turnOpen: false, seenTurnIds: new Set(), latest: null, captureResult: captureResultOrGrantExpiresAt === true, @@ -287,6 +326,7 @@ export class RuntimeGateway { : null, decisions, gatewayMs: permissionGateTimings(decisionBudgetMs).gatewayMs, + lifecycleChecks: new Set(), lifecycleRevision: 0, checks: new Set() }); return { address: this.endpoint, terminalSessionId, provider, capabilityToken }; @@ -296,6 +336,13 @@ export class RuntimeGateway { return this.leases.get(terminalSessionId)?.latest?.state ?? null; } + /** Current host-owned turn generation for a live hook/permission stream; null between turns or after revoke. */ + currentTurnEpoch(terminalSessionId: string): number | null { + const lease = this.leases.get(terminalSessionId); + if (!lease?.turnOpen) return null; + return lease.turnEpoch; + } + /** * Ends a session's lease. A launch's own cleanup passes its capability token, so a cleanup that runs * late (after the card was relaunched under the same id) cannot revoke the newer launch's lease. @@ -307,6 +354,7 @@ export class RuntimeGateway { lease.tokenDigest.fill(0); this.leases.delete(terminalSessionId); for (const check of lease.checks) check.abort(); + for (const check of lease.lifecycleChecks) check.abort(); if (lease.answerCaptureGrantExpiresAt !== null) { this.onAnswerCaptureRevoked?.(terminalSessionId); } @@ -342,6 +390,7 @@ export class RuntimeGateway { for (const lease of this.leases.values()) { lease.tokenDigest.fill(0); for (const check of lease.checks) check.abort(); + for (const check of lease.lifecycleChecks) check.abort(); if (lease.answerCaptureGrantExpiresAt !== null) { this.onAnswerCaptureRevoked?.(lease.terminalSessionId); } @@ -406,10 +455,18 @@ export class RuntimeGateway { answerCapture })}\n`, "utf8")); } else { - // The ack goes out before the app reacts: the hook (and the agent behind it) waits only for the check. const delivery = this.handleLifecycle(value); - socket.write(Buffer.from(`${JSON.stringify({ v: RUNTIME_PROTOCOL_VERSION, type: "ack" })}\n`, "utf8")); - this.deliverLater(delivery); + const acknowledge = (accepted: Delivery | null): void => { + socket.write(Buffer.from(`${JSON.stringify({ v: RUNTIME_PROTOCOL_VERSION, type: "ack" })}\n`, "utf8")); + this.deliverLater(accepted); + const timeout = setTimeout(close, 1_000); timeout.unref(); + }; + if (this.beforeLifecycle && delivery?.signal.state === "working") { + void this.waitBeforeLifecycle(delivery, socket).then(acknowledge).catch(close); + return; + } + acknowledge(delivery); + return; } const timeout = setTimeout(close, 1_000); timeout.unref(); @@ -469,40 +526,96 @@ 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. + // it must not make that newer turn's events look fresh. A duplicate start for the current open turn is inert. if (message.turnId !== lease.activeTurnId && lease.seenTurnIds.has(message.turnId)) return null; + if (message.turnId === lease.activeTurnId && !lease.turnOpen && lease.seenTurnIds.has(message.turnId)) return null; + if (message.turnId !== lease.activeTurnId) lease.turnEpoch = ++this.nextTurnEpoch; 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) { + } else if (message.turnId && lease.activeTurnId && message.turnId !== lease.activeTurnId) { + // A previously unseen id after an idle boundary can establish the current turn even if its start hook was + // unavailable; an old id or a conflicting id during an open turn is stale/out of order. + if (lease.turnOpen || lease.seenTurnIds.has(message.turnId)) return null; + lease.activeTurnId = message.turnId; + lease.turnEpoch = ++this.nextTurnEpoch; + lease.turnOpen = true; rememberTurn(lease, message.turnId); + } else if (message.turnId && !lease.activeTurnId) { + if (!lease.turnOpen) lease.turnEpoch = ++this.nextTurnEpoch; + lease.activeTurnId = message.turnId; + lease.turnOpen = true; + rememberTurn(lease, message.turnId); + } else if (!message.turnId && startsTurn) { + // With no provider id, only the first start while closed opens a generation. Approval/resume events and + // duplicate start hooks while a turn is open leave its identity unchanged. + if (!lease.turnOpen) { + lease.turnEpoch = ++this.nextTurnEpoch; + lease.activeTurnId = null; + } + lease.turnOpen = true; } + if (message.state === "idle") lease.turnOpen = false; const signal: RuntimeLifecycleSignal = { state: message.state, event: message.event, turnId: message.turnId, + turnEpoch: lease.turnEpoch, ...(message.threadId === undefined ? {} : { threadId: message.threadId }), ...(message.result === undefined ? {} : { result: message.result }), - ...(message.lastAssistantMessage === undefined ? {} : { lastAssistantMessage: message.lastAssistantMessage }) + ...(message.lastAssistantMessage === undefined ? {} : { lastAssistantMessage: message.lastAssistantMessage }), + ...(message.toolOutcome === undefined ? {} : { toolOutcome: message.toolOutcome }) }; if (message.lastAssistantMessage !== undefined && lease.answerCaptureGrantExpiresAt !== null) { signal.answerCaptureGrantExpiresAt = lease.answerCaptureGrantExpiresAt; } + // A newer accepted signal supersedes an in-flight pre-turn barrier, including its snapshot work. + for (const check of lease.lifecycleChecks) check.abort(); + lease.lifecycleRevision++; // Captured text is delivered once and never stored in the lifecycle lease. lease.latest = { state: signal.state, event: signal.event, turnId: signal.turnId, + turnEpoch: signal.turnEpoch, ...(signal.threadId === undefined ? {} : { threadId: signal.threadId }) }; - return { terminalSessionId: message.terminalSessionId, signal }; + return { terminalSessionId: message.terminalSessionId, signal, lease, revision: lease.lifecycleRevision }; + } + + private async waitBeforeLifecycle(delivery: Delivery, connection: { on(event: "close", listener: () => void): unknown }): Promise { + const controller = new AbortController(), { lease } = delivery; + const hostCurrent=this.lifecycleGuard?.(delivery.terminalSessionId,delivery.signal) ?? (()=>true); + const current = (): boolean => this.leases.get(delivery.terminalSessionId) === lease && lease.lifecycleRevision === delivery.revision && hostCurrent(); + delivery.current=()=>this.leases.get(delivery.terminalSessionId) === lease && hostCurrent(); + let listening = true; + const abort = (): void => { if (listening) controller.abort(); }; + connection.on("close", abort); + lease.lifecycleChecks.add(controller); + let cancel!: () => void; + const cancelled = new Promise(resolve => { cancel = resolve; }); + controller.signal.addEventListener("abort", cancel, { once: true }); + const timeout = setTimeout(abort, this.lifecycleBarrierMs); + timeout.unref(); + try { + if (!current()) return null; + const work = Promise.resolve().then(() => this.beforeLifecycle?.(delivery.terminalSessionId, delivery.signal, controller.signal)); + const accepted = await Promise.race([work, cancelled]); + // The callback must observe cancellation before publishing a checkpoint. A timeout remains fail-open. + if (controller.signal.aborted) console.warn("Pre-turn checkpoint unavailable: lifecycle barrier cancelled or timed out."); + return accepted !== false && current() ? delivery : null; + } catch (error) { + console.warn("Pre-turn checkpoint unavailable:", error instanceof Error ? error.message : String(error)); + return current() ? delivery : null; + } finally { + clearTimeout(timeout); + listening = false; + controller.signal.removeEventListener("abort", cancel); + lease.lifecycleChecks.delete(controller); + } } /** Runs the app's reaction after the current I/O callback, in arrival order; a failure there never reaches a hook. */ @@ -510,6 +623,7 @@ export class RuntimeGateway { const onSignal = this.onSignal; if (!delivery || !onSignal) return; setImmediate(() => { + if(delivery.current && !delivery.current())return; try { onSignal(delivery.terminalSessionId, delivery.signal); } catch (error) { @@ -605,16 +719,17 @@ export class RuntimeGateway { } catch { delivery = null; } - // An oversized body is still being sent: answer once it has arrived (read and dropped), so the connection is - // closed after it rather than reset under the sender, which would lose the answer (ECONNRESET). - if (oversized && !request.complete) { - request.resume(); - request.once("end", () => finish(200, true)); - request.once("close", () => finish(200, true)); - } else { - finish(200, oversized); - } - this.deliverLater(delivery); + const acknowledge = (accepted: Delivery | null): void => { + // Drain an oversized request before closing its connection, preserving the helper's fail-open contract. + if (oversized && !request.complete) { + request.resume(); + request.once("end", () => finish(200, true)); + request.once("close", () => finish(200, true)); + } else finish(200, oversized); + this.deliverLater(accepted); + }; + if (this.beforeLifecycle && delivery?.signal.state === "working") void this.waitBeforeLifecycle(delivery, response).then(acknowledge); + else acknowledge(delivery); }; if (oversized) return complete(); request.on("data", (chunk: Buffer) => { @@ -642,7 +757,7 @@ export class RuntimeGateway { * `unavailable`, so a fail-closed gate for a CLI that cannot ask denies it instead of letting the call run. */ private acceptPermission(socket: AgentGatewaySocket, value: Record, close: () => void): void { - let request: RuntimePermissionRequest & { terminalSessionId: string; capabilityToken: string }; + let request: Omit & { terminalSessionId: string; capabilityToken: string }; try { request = parsePermissionMessage(value); } catch { @@ -651,7 +766,18 @@ export class RuntimeGateway { const lease = this.leases.get(request.terminalSessionId); if (!lease || lease.provider !== request.provider) return close(); if (!tokenMatches(request.capabilityToken, lease.tokenDigest) || !lease.decisions) return close(); - const { terminalSessionId, capabilityToken: _token, ...forwarded } = request; + const { terminalSessionId, capabilityToken: _token, turnId: _untrustedTurnId, ...forwardedFields } = request; + if (!lease.turnOpen) { + // Some providers report only tool permissions. Their first real permission hook opens a host turn; an + // untrusted turn id in the permission packet is never used to create or identify that turn. + lease.turnEpoch = ++this.nextTurnEpoch; + lease.activeTurnId = null; + lease.turnOpen = true; + } + const turnId = lease.activeTurnId && /^[A-Za-z0-9._:-]{1,160}$/u.test(lease.activeTurnId) + ? lease.activeTurnId + : undefined; + const forwarded: RuntimePermissionRequest = { ...forwardedFields, turnEpoch: lease.turnEpoch, ...(turnId ? { turnId } : {}) }; let answered = false; const answer = (decision: RuntimePermissionDecision, unavailable = false): void => { if (answered) return; @@ -700,6 +826,9 @@ export class RuntimeGateway { } interface Delivery { + current?: () => boolean; + lease: RuntimeLease; + revision: number; terminalSessionId: string; signal: RuntimeLifecycleSignal; } @@ -743,6 +872,7 @@ function claudeLifecycleMessage( const text = boundedText(finalAnswer, MAX_RESULT_CHARS); result = { text, truncated: text.length < finalAnswer.length }; } + const toolOutcome = toolOutcomeFromHook("claude", event, record); return { terminalSessionId, provider: "claude", @@ -750,7 +880,8 @@ function claudeLifecycleMessage( event, turnId: turnId !== null && turnId.length <= 160 ? turnId : null, ...(threadId !== undefined ? { threadId } : {}), - ...(result === undefined ? {} : { result }) + ...(result === undefined ? {} : { result }), + ...(toolOutcome === undefined ? {} : { toolOutcome }) }; } @@ -793,8 +924,8 @@ const PERMISSION_KEYS = [ "toolInputPreview", "toolInputSha256", "toolName", "truncated", "type", "v" ].sort().join(","); -function parsePermissionMessage(value: Record): RuntimePermissionRequest & { terminalSessionId: string; capabilityToken: string } { - if (Object.keys(value).sort().join(",") !== PERMISSION_KEYS) throw new Error("Permission request has an invalid schema."); +function parsePermissionMessage(value: Record): Omit & { terminalSessionId: string; capabilityToken: string } { + if (Object.keys(value).filter((key) => key !== "turnId").sort().join(",") !== PERMISSION_KEYS) throw new Error("Permission request has an invalid schema."); if (value.v !== RUNTIME_PROTOCOL_VERSION || value.type !== "permission_request") throw new Error("Permission request version is unsupported."); if ( typeof value.terminalSessionId !== "string" || !value.terminalSessionId || value.terminalSessionId.length > 160 @@ -805,6 +936,7 @@ function parsePermissionMessage(value: Record): RuntimePermissi || typeof value.toolInputSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(value.toolInputSha256) || typeof value.truncated !== "boolean" || (value.cwd !== null && (typeof value.cwd !== "string" || !value.cwd || value.cwd.length > 4_096)) + || (value.turnId !== undefined && value.turnId !== null && (typeof value.turnId !== "string" || value.turnId.length > 160)) || (value.toolInputPreview !== null && (typeof value.toolInputPreview !== "string" || value.toolInputPreview.length > PERMISSION_GATE.toolInputPreviewChars)) ) throw new Error("Permission request fields are invalid."); // Cut input carries only its preview; whole input carries no preview and must match its hash. @@ -814,7 +946,10 @@ function parsePermissionMessage(value: Record): RuntimePermissi if (!value.truncated && createHash("sha256").update(JSON.stringify(value.toolInput), "utf8").digest("hex") !== value.toolInputSha256) { throw new Error("Permission request input does not match its hash."); } - return value as unknown as RuntimePermissionRequest & { terminalSessionId: string; capabilityToken: string }; + // A hook may claim a turn identifier, but only authenticated lifecycle messages update the lease. Drop this + // untrusted field here; acceptPermission supplies the host's active turn when it forwards the request. + const { turnId: _untrustedTurnId, ...trustedFields } = value; + return trustedFields as unknown as RuntimePermissionRequest & { terminalSessionId: string; capabilityToken: string }; } function isAnswerCaptureCheck(value: unknown): value is Record { @@ -834,6 +969,7 @@ function parseLifecycleMessage(value: unknown): ParsedLifecycleMessage { ]; if (value.result !== undefined) expected.push("result"); if (value.threadId !== undefined) expected.push("threadId"); + if (value.toolOutcome !== undefined) expected.push("toolOutcome"); expected.sort(); if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) { throw new Error("Runtime message has an invalid schema."); @@ -870,14 +1006,47 @@ function parseLifecycleMessage(value: unknown): ParsedLifecycleMessage { value.provider !== "codex" || value.event !== "Stop" || value.state !== "idle" || typeof value.lastAssistantMessage !== "string" || value.lastAssistantMessage.length > MAX_ANSWER_CHARS )) throw new Error("Runtime lastAssistantMessage is invalid."); + if (value.toolOutcome !== undefined) { + if (!isCompletedToolEvent(String(value.provider), String(value.event))) throw new Error("Runtime tool outcome event is invalid."); + parseToolOutcome(value.toolOutcome); + } return value as unknown as ParsedLifecycleMessage; } +function parseToolOutcome(value: unknown): RuntimeToolOutcome { + if (!isRecord(value)) throw new Error("Runtime tool outcome must be an object."); + const keys = Object.keys(value).sort(); + const expected = ["changedPathHashes", "resultClass", "toolName"]; + if (value.normalizedActionHash !== undefined) expected.push("normalizedActionHash"); + if (value.errorHash !== undefined) expected.push("errorHash"); + if (value.outputHash !== undefined) expected.push("outputHash"); + expected.sort(); + if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) { + throw new Error("Runtime tool outcome has an invalid schema."); + } + if (typeof value.toolName !== "string" || value.toolName.length === 0 || value.toolName.length > 80 + || !["success", "error", "denied", "unknown"].includes(String(value.resultClass)) + || !Array.isArray(value.changedPathHashes) || value.changedPathHashes.length > 16 + || !value.changedPathHashes.every((hash) => typeof hash === "string" && /^[a-f0-9]{64}$/u.test(hash)) + || (value.normalizedActionHash !== undefined && (typeof value.normalizedActionHash !== "string" || !/^[a-f0-9]{64}$/u.test(value.normalizedActionHash))) + || (value.errorHash !== undefined && (value.resultClass !== "error" || typeof value.errorHash !== "string" || !/^[a-f0-9]{64}$/u.test(value.errorHash))) + || (value.outputHash !== undefined && (value.resultClass === "error" || typeof value.outputHash !== "string" || !/^[a-f0-9]{64}$/u.test(value.outputHash))) + ) throw new Error("Runtime tool outcome fields are invalid."); + return value as unknown as RuntimeToolOutcome; +} + +function isCompletedToolEvent(provider: string, event: string): boolean { + return event === "PostToolUse" || ((provider === "claude" || provider === "kimi") && event === "PostToolUseFailure") + || (provider === "hermes" && event === "post_tool_call") + || (provider === "opencode" && (event === "tool.execute.after" || event === "message.part.updated")); +} + 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/browser/BrowserElectronSmoke.ts b/src/main/services/browser/BrowserElectronSmoke.ts index b114cbf4..e0a959ae 100644 --- a/src/main/services/browser/BrowserElectronSmoke.ts +++ b/src/main/services/browser/BrowserElectronSmoke.ts @@ -1,7 +1,8 @@ import { randomUUID } from "node:crypto"; import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { app, BrowserWindow, webContents } from "electron"; +import { app, BrowserWindow, View, webContents } from "electron"; +import type { NativeImage } from "electron"; import type { BrowserActor, BrowserCommand, BrowserElementRef, BrowserResult } from "../../../shared/contracts.ts"; import type { BrowserService } from "../BrowserService.ts"; import { BROWSER_CANVAS_WHEEL_IDLE_MS } from "./BrowserCanvasFreeze.ts"; @@ -17,6 +18,12 @@ const READY_TIMEOUT_MS = 12_000; const CLEANUP_TIMEOUT_MS = 1_000; const WHEEL_IDLE_SETTLE_MS = BROWSER_CANVAS_WHEEL_IDLE_MS * 2; const SENTINEL = "canvastty-secret-must-not-leak"; +const LAYERING_OVERLAY_ID = "canvastty-native-layering-smoke-overlay"; +// The layering fixture's geometry depends on the window, and the window's size otherwise comes from the +// display. This content size fits the smallest CI screen (GitHub's macOS runners: 1024 x 768) on every +// platform; see ensureLayeringWindowSize and zoomOutUntilBrowserClearsCanvasHud. +const LAYERING_WINDOW_CONTENT_SIZE = { width: 1020, height: 640 }; +const ZOOM_OUT_LABELS = ["Zoom out", "Отдалить"]; export async function runBrowserElectronSmoke( service: BrowserService, @@ -27,6 +34,12 @@ export async function runBrowserElectronSmoke( if (parsedOrigin.protocol !== "http:" || parsedOrigin.hostname !== "127.0.0.1") { throw new Error("Browser smoke fixture must be an HTTP loopback origin."); } + if (process.env.CANVASTTY_BROWSER_SMOKE_LAYERING_ONLY === "1") { + // The complete smoke additionally requires active physical desktop input. + // Keep that prerequisite and its checks intact in the default mode. + await assertNativeBrowserCanvasOverlayLayering(service, origin); + return; + } const uploadPath = join(userDataPath, "fixture-upload.txt"); await writeFile(uploadPath, "CanvasTTY upload fixture", { mode: 0o600 }); service.setViewport({ x: 0, y: 0, width: 820, height: 620, surface: "native", canvasScale: 1 }); @@ -225,11 +238,501 @@ export async function runBrowserElectronSmoke( if (stale.ok || stale.error?.code !== "STALE_REF") { throw new Error(`Old element reference was not rejected: ${JSON.stringify(stale)}`); } + + await assertNativeBrowserCanvasOverlayLayering(service, origin); } finally { service.core.agentDisconnected(actor); } } +/** + * Exercise the real React BrowserCard, its native WebContentsView and the + * renderer's persistent shortcut overlay together. The main renderer capture + * below only proves that the DOM overlay was painted into that renderer; the + * native view hierarchy and bounds assertions prove the separate native child + * is hidden whenever it would cover that DOM rectangle. + */ +async function assertNativeBrowserCanvasOverlayLayering(service: BrowserService, origin: string): Promise { + const owner = BrowserWindow.getAllWindows().find((candidate) => candidate.getParentWindow() === null); + if (!owner) throw new Error("Browser layering smoke could not resolve the application window."); + // This correctness fixture must paint even if the desktop occludes its test + // window. The production window policy is untouched outside this smoke. + owner.webContents.setBackgroundThrottling(false); + const fixtureUrl = `${origin}/`; + await ensureLayeringWindowSize(owner); + + // Seed the service with the loopback page before asking the app's launcher to + // mount BrowserCard. App.openBrowser() then reuses this tab, so no default + // external start page or provider account is touched by the UI path. + await service.open(fixtureUrl); + await waitUntil(async () => service.getState().tabs.some((tab) => tab.url === fixtureUrl && tab.status === "ready")); + await setShortcutHintsThroughSettingsUi(owner, false); + await waitUntil(async () => owner.webContents.executeJavaScript( + `Boolean(document.querySelector(".launcher-button--browser"))` + )); + + await owner.webContents.executeJavaScript( + `document.querySelector(".launcher-button--browser").click(); void 0` + ); + await zoomOutUntilBrowserClearsCanvasHud(owner); + // The service still holds the viewport the earlier steps set directly, and that view is visible, so the + // baseline is reached only once the BrowserCard's own report has placed the native page on its viewport. + const baselineStage = await waitForBrowserLayeringStage(owner, fixtureUrl, "baseline", ({ native, dom }) => ( + native?.effectiveVisible === true && dom.page !== null && dom.hints === null + && sameRectangle(native.pageBounds, dom.page, 2) + && dom.card !== null && dom.overlays.every((overlay) => !rectanglesOverlap(overlay, dom.card!)) + )); + + const page = webContents.getAllWebContents().find((candidate) => candidate.getURL() === fixtureUrl); + if (!page) throw new Error("Browser layering smoke could not find its loopback WebContents."); + await owner.webContents.executeJavaScript( + `document.querySelector(".browser-card__viewport").click(); void 0` + ); + const baselineNative = browserNativeViewState(owner, fixtureUrl); + const baselineDom = await browserLayeringDomState(owner); + if (!baselineNative?.effectiveVisible || !baselineDom.page || !baselineStage.native + || !sameRectangle(baselineNative.pageBounds, baselineStage.native.pageBounds, 2)) { + throw new Error("The local Browser page was not live before creating the overlay overlap."); + } + if (!sameRectangle(baselineNative.pageBounds, baselineDom.page, 2)) { + throw new Error(`Native Browser bounds do not match the BrowserCard viewport: ${JSON.stringify({ + native: baselineNative.pageBounds, + dom: baselineDom.page + })}.`); + } + const baselinePageBounds = baselineNative.pageBounds; + const baselinePageState = await page.executeJavaScript(`({ + url: location.href, + title: document.title, + scrollX: window.scrollX, + scrollY: window.scrollY + })`) as { url: string; title: string; scrollX: number; scrollY: number }; + + // Measure the actual DOM target while the app's real setting UI is on. The + // initial BrowserCard geometry already overlaps this hint rectangle, so no + // synthetic resize or forced native visibility change is needed. + await setShortcutHintsThroughSettingsUi(owner, true); + const measuredStage = await waitForBrowserLayeringStage(owner, fixtureUrl, "measured", ({ dom }) => ( + dom.hints !== null + )); + const targetHints = measuredStage.dom.hints; + if (!targetHints) throw new Error("Could not measure the real shortcut overlay target."); + const hiddenStage = await waitForBrowserLayeringStage(owner, fixtureUrl, "hidden", ({ native, dom }) => ( + native !== null && dom.page !== null && dom.hints !== null + && rectanglesOverlap(dom.page, dom.hints) + && rectanglesOverlap(native.paintBounds, dom.hints) + && !native.effectiveVisible + && dom.wheelOwner === "canvas" + )); + const overlapNative = hiddenStage.native; + const overlapDom = hiddenStage.dom; + if (!overlapNative || !overlapDom.page || !overlapDom.hints) { + throw new Error("Browser layering smoke lost the native view or DOM overlay during overlap."); + } + if (!sameRectangle(overlapNative.pageBounds, baselinePageBounds, 1) + || !rectanglesOverlap(overlapNative.paintBounds, targetHints) + || !rectanglesOverlap(overlapNative.paintBounds, overlapDom.hints)) { + throw new Error(`The hidden native view no longer occupies the measured DOM overlay region: ${JSON.stringify({ + native: overlapNative, + measuredOverlay: targetHints, + overlay: overlapDom.hints + })}.`); + } + + const marker = createLayeringOverlayMarker(overlapNative.paintBounds, overlapDom.page, overlapDom.hints); + if (!rectangleContains(overlapDom.hints, marker.bounds) + || !rectangleContains(overlapDom.page, marker.bounds) + || !rectangleContains(overlapNative.paintBounds, marker.bounds)) { + throw new Error(`The DOM overlay marker is outside the actual native page paint region: ${JSON.stringify({ + marker: marker.bounds, + page: overlapDom.page, + hints: overlapDom.hints, + nativePaintBounds: overlapNative.paintBounds + })}.`); + } + try { + const beforeMarker = await owner.webContents.capturePage(); + await installLayeringOverlayMarker(owner, marker.bounds); + await owner.webContents.executeJavaScript( + "new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))" + ); + const afterMarker = await owner.webContents.capturePage(); + const rendererSize = await owner.webContents.executeJavaScript( + "({ width: window.innerWidth, height: window.innerHeight })" + ) as { width: number; height: number }; + assertRendererCaptureShowsMarker(beforeMarker, afterMarker, marker.center, rendererSize); + const hiddenNative = browserNativeViewState(owner, fixtureUrl); + if (!hiddenNative || hiddenNative.effectiveVisible) { + throw new Error("The native Browser view became visible while the DOM overlay was active."); + } + } finally { + await owner.webContents.executeJavaScript(`document.getElementById(${JSON.stringify(LAYERING_OVERLAY_ID)})?.remove(); + void 0`).catch(() => undefined); + } + + await setShortcutHintsThroughSettingsUi(owner, false); + const restoredStage = await waitForBrowserLayeringStage(owner, fixtureUrl, "restored", ({ native, dom }) => ( + native?.effectiveVisible === true && dom.hints === null + && sameRectangle(native.pageBounds, baselinePageBounds, 1) + )); + const restoredNative = restoredStage.native; + const restoredPageState = await page.executeJavaScript(`({ + url: location.href, + title: document.title, + scrollX: window.scrollX, + scrollY: window.scrollY + })`) as { url: string; title: string; scrollX: number; scrollY: number }; + if (!restoredNative || !sameRectangle(restoredNative.pageBounds, baselinePageBounds, 1) + || JSON.stringify(restoredPageState) !== JSON.stringify(baselinePageState)) { + throw new Error(`The live Browser did not restore at the same bounds and page state: ${JSON.stringify({ + baseline: { bounds: baselinePageBounds, page: baselinePageState }, + restored: { bounds: restoredNative?.pageBounds, page: restoredPageState } + })}.`); + } + console.log("CANVASTTY_BROWSER_SMOKE_STEP native-dom-overlay-live-restore"); +} + +interface SmokeRectangle { + x: number; + y: number; + width: number; + height: number; +} + +interface BrowserNativeViewState { + pageBounds: SmokeRectangle; + paintBounds: SmokeRectangle; + effectiveVisible: boolean; +} + +interface BrowserLayeringDomState { + card: SmokeRectangle | null; + page: SmokeRectangle | null; + hints: SmokeRectangle | null; + wheelOwner: string | null; + /** Every other canvas HUD panel; any of them over the card correctly hides the native page. */ + overlays: SmokeRectangle[]; +} + +interface BrowserLayeringSnapshot { + native: BrowserNativeViewState | null; + dom: BrowserLayeringDomState; +} + +async function waitForBrowserLayeringStage( + owner: BrowserWindow, + url: string, + stage: "baseline" | "measured" | "hidden" | "restored", + check: (snapshot: BrowserLayeringSnapshot) => boolean +): Promise { + let snapshot: BrowserLayeringSnapshot = { + native: null, + dom: { card: null, page: null, hints: null, wheelOwner: null, overlays: [] } + }; + try { + await waitUntil(async () => { + snapshot = { + native: browserNativeViewState(owner, url), + dom: await browserLayeringDomState(owner) + }; + return check(snapshot); + }); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error(`Browser layering stage "${stage}" did not complete; last bounds/visibility snapshot: ${JSON.stringify(snapshot)}; ${reason}`); + } + console.log(`CANVASTTY_BROWSER_SMOKE_STEP native-dom-overlay-${stage} ${JSON.stringify(snapshot)}`); + return snapshot; +} + +/** + * Pins the window to one content size on every platform. The 1440 x 900 default window is clamped by a smaller + * display (macOS will not resize a window past its screen), and the fixture needs the card's place relative to + * the canvas HUD to be the same everywhere: under the bottom-right shortcut hints, clear of everything else. + */ +async function ensureLayeringWindowSize(owner: BrowserWindow): Promise { + const { width, height } = LAYERING_WINDOW_CONTENT_SIZE; + owner.setContentSize(width, height, false); + await waitUntil(async () => { + const inner = await owner.webContents.executeJavaScript( + "({ width: window.innerWidth, height: window.innerHeight })" + ) as { width: number; height: number }; + return inner.width === width && inner.height === height; + }); +} + +/** + * The card opens at a fixed focus zoom, 846 x 570 DIP on screen, which in a small window lies under the top-right + * minimap and the bottom-left canvas controls; the app then correctly keeps the native page hidden. Zoom out with + * the canvas's own control (about the viewport centre) until no HUD panel other than the shortcut hints, which + * are off here, overlaps the card (the app hides the page when any part of the card is under one). In the + * pinned window this leaves the page reaching under the hints' place. + */ +async function zoomOutUntilBrowserClearsCanvasHud(owner: BrowserWindow): Promise { + await waitUntil(async () => (await browserLayeringDomState(owner)).card !== null); + // Four steps of 0.82 from the focus zoom stay above the canvas summary zoom, where the page is never shown. + for (let step = 0; step < 4; step += 1) { + const before = await browserLayeringDomState(owner); + if (before.card && before.overlays.every((overlay) => !rectanglesOverlap(overlay, before.card!))) return; + const clicked = await owner.webContents.executeJavaScript(`(() => { + const button = [...document.querySelectorAll(".canvas-controls button")] + .find((candidate) => ${JSON.stringify(ZOOM_OUT_LABELS)}.includes(candidate.getAttribute("title") ?? "")); + button?.click(); + return Boolean(button); + })()`) as boolean; + if (!clicked) throw new Error("Could not find the canvas zoom-out control."); + await waitUntil(async () => { + const after = await browserLayeringDomState(owner); + return after.card !== null && before.card !== null && after.card.width < before.card.width - 1; + }); + } +} + +async function setShortcutHintsThroughSettingsUi(owner: BrowserWindow, visible: boolean): Promise { + await owner.webContents.executeJavaScript(`(() => { + if (document.querySelector(".settings-panel--open")) return; + const trigger = document.querySelector(".settings-button"); + if (!trigger) throw new Error("The HOME settings control is not mounted."); + trigger.click(); + })()`); + await waitUntil(async () => owner.webContents.executeJavaScript( + `Boolean(document.querySelector(".settings-panel--open"))` + )); + await owner.webContents.executeJavaScript( + `document.querySelector("#settings-tab-appearance")?.click(); void 0` + ); + await waitUntil(async () => owner.webContents.executeJavaScript( + `Boolean(document.querySelector("#settings-panel-appearance .setting-group"))` + )); + const desiredLabels = visible ? ["On", "Включено"] : ["Off", "Выключено"]; + const clickSetting = await owner.webContents.executeJavaScript(`(() => { + const panel = document.querySelector("#settings-panel-appearance"); + const group = [...(panel?.querySelectorAll(".setting-group") ?? [])].find((candidate) => { + const label = candidate.querySelector(".setting-group__copy h3")?.textContent?.trim(); + return label === "Shortcut hint" || label === "Подсказка горячих клавиш"; + }); + const option = [...(group?.querySelectorAll(".segmented__button") ?? [])].find((button) => + ${JSON.stringify(desiredLabels)}.includes(button.textContent?.trim() ?? "") + ); + if (!option) return false; + option.click(); + return true; + })()`); + if (!clickSetting) throw new Error("Could not find the shortcut hint option in the Appearance settings UI."); + + await waitUntil(async () => owner.webContents.executeJavaScript(`(() => { + const panel = document.querySelector("#settings-panel-appearance"); + const group = [...(panel?.querySelectorAll(".setting-group") ?? [])].find((candidate) => { + const label = candidate.querySelector(".setting-group__copy h3")?.textContent?.trim(); + return label === "Shortcut hint" || label === "Подсказка горячих клавиш"; + }); + const option = [...(group?.querySelectorAll(".segmented__button") ?? [])].find((button) => + ${JSON.stringify(desiredLabels)}.includes(button.textContent?.trim() ?? "") + ); + return option?.classList.contains("segmented__button--active") === true; + })()`)); + + await owner.webContents.executeJavaScript( + `document.querySelector(".settings-panel--open .settings-panel__close")?.click(); void 0` + ); + await waitUntil(async () => owner.webContents.executeJavaScript( + `!document.querySelector(".settings-panel--open")` + )); + await waitUntil(async () => { + const dom = await browserLayeringDomState(owner); + return visible ? dom.hints !== null : dom.hints === null; + }); +} + +function browserNativeViewState(owner: BrowserWindow, url: string): BrowserNativeViewState | null { + const findPath = (view: View, ancestors: View[] = []): View[] | null => { + const path = [...ancestors, view]; + const contents = (view as View & { webContents?: { getURL(): string } }).webContents; + if (contents?.getURL() === url) return path; + for (const child of view.children) { + const nested = findPath(child, path); + if (nested) return nested; + } + return null; + }; + let path: View[] | null = null; + for (const child of owner.contentView.children) { + path = findPath(child); + if (path) break; + } + if (!path) return null; + + let x = 0; + let y = 0; + let paintBounds: SmokeRectangle | null = null; + let effectiveVisible = true; + for (const view of path) { + const bounds = view.getBounds(); + x += bounds.x; + y += bounds.y; + const absolute = { x, y, width: bounds.width, height: bounds.height }; + paintBounds = paintBounds === null ? absolute : intersectRectangles(paintBounds, absolute); + effectiveVisible = effectiveVisible && view.getVisible(); + } + const pageBounds = path[path.length - 1]!.getBounds(); + // Convert the page's local bounds to the same main-window coordinate space as DOMClientRects. + const absolutePageBounds = path.reduce((rect, view) => { + const bounds = view.getBounds(); + return { ...rect, x: rect.x + bounds.x, y: rect.y + bounds.y }; + }, { x: 0, y: 0, width: pageBounds.width, height: pageBounds.height }); + return { pageBounds: absolutePageBounds, paintBounds: paintBounds ?? absolutePageBounds, effectiveVisible }; +} + +async function browserLayeringDomState(owner: BrowserWindow): Promise { + return owner.webContents.executeJavaScript(`(() => { + const rect = (selector) => { + const element = document.querySelector(selector); + if (!element) return null; + const value = element.getBoundingClientRect(); + return { x: value.left, y: value.top, width: value.width, height: value.height }; + }; + return { + card: rect(".browser-card"), + page: rect(".browser-card__viewport"), + hints: rect(".shortcut-hints"), + overlays: [...document.querySelectorAll(".canvas-overlay-slot > :not(.shortcut-hints)")] + .map((element) => element.getBoundingClientRect()) + .filter((value) => value.width > 0 && value.height > 0) + .map((value) => ({ x: value.left, y: value.top, width: value.width, height: value.height })), + wheelOwner: document.querySelector(".browser-card")?.getAttribute("data-browser-canvas-wheel-owner") ?? null + }; + })()`) as Promise; +} + +function intersectRectangles(left: SmokeRectangle, right: SmokeRectangle): SmokeRectangle { + const x = Math.max(left.x, right.x); + const y = Math.max(left.y, right.y); + const rightEdge = Math.min(left.x + left.width, right.x + right.width); + const bottomEdge = Math.min(left.y + left.height, right.y + right.height); + return { x, y, width: Math.max(0, rightEdge - x), height: Math.max(0, bottomEdge - y) }; +} + +function rectanglesOverlap(left: SmokeRectangle, right: SmokeRectangle): boolean { + return intersectRectangles(left, right).width > 0 && intersectRectangles(left, right).height > 0; +} + +function rectangleContains(outer: SmokeRectangle, inner: SmokeRectangle): boolean { + return inner.x >= outer.x && inner.y >= outer.y + && inner.x + inner.width <= outer.x + outer.width + && inner.y + inner.height <= outer.y + outer.height; +} + +function sameRectangle(left: SmokeRectangle, right: SmokeRectangle, tolerance = 0): boolean { + return Math.abs(left.x - right.x) <= tolerance && Math.abs(left.y - right.y) <= tolerance + && Math.abs(left.width - right.width) <= tolerance && Math.abs(left.height - right.height) <= tolerance; +} + +function createLayeringOverlayMarker( + nativePaintBounds: SmokeRectangle, + pageBounds: SmokeRectangle, + hintsBounds: SmokeRectangle +): { bounds: SmokeRectangle; center: { x: number; y: number } } { + const overlap = intersectRectangles(intersectRectangles(nativePaintBounds, pageBounds), hintsBounds); + if (overlap.width < 4 || overlap.height < 4) { + throw new Error(`The native Browser, its DOM viewport, and the shortcut overlay do not share a usable paint area: ${JSON.stringify({ + nativePaintBounds, + pageBounds, + hintsBounds, + overlap + })}.`); + } + const left = Math.ceil(overlap.x); + const top = Math.ceil(overlap.y); + const availableWidth = Math.floor(overlap.x + overlap.width) - left; + const availableHeight = Math.floor(overlap.y + overlap.height) - top; + if (availableWidth < 4 || availableHeight < 4) { + throw new Error(`The shared paint area has no integer-aligned four-pixel marker area: ${JSON.stringify({ overlap })}.`); + } + const width = Math.min(8, availableWidth); + const height = Math.min(8, availableHeight); + const bounds = { + x: left + Math.floor((availableWidth - width) / 2), + y: top + Math.floor((availableHeight - height) / 2), + width, + height + }; + return { + bounds, + center: { + x: bounds.x + Math.floor(bounds.width / 2), + y: bounds.y + Math.floor(bounds.height / 2) + } + }; +} + +async function installLayeringOverlayMarker(owner: BrowserWindow, bounds: SmokeRectangle): Promise { + await owner.webContents.executeJavaScript(`(() => { + const overlay = document.querySelector(".shortcut-hints"); + if (!overlay) throw new Error("The DOM shortcut overlay disappeared before capture."); + const marker = document.createElement("button"); + marker.id = ${JSON.stringify(LAYERING_OVERLAY_ID)}; + marker.type = "button"; + marker.setAttribute("aria-label", "Native layering smoke overlay"); + Object.assign(marker.style, { + position: "fixed", + left: ${JSON.stringify(bounds.x)} + "px", + top: ${JSON.stringify(bounds.y)} + "px", + width: ${JSON.stringify(bounds.width)} + "px", + height: ${JSON.stringify(bounds.height)} + "px", + padding: "0", + border: "0", + zIndex: "2147483647", + background: "rgb(255, 0, 255)", + color: "white", + pointerEvents: "auto" + }); + overlay.append(marker); + return true; + })()`); +} + +function assertRendererCaptureShowsMarker( + before: NativeImage, + after: NativeImage, + center: { x: number; y: number }, + rendererViewport: { width: number; height: number } +): void { + if (before.isEmpty() || after.isEmpty()) throw new Error("Renderer overlay capture was empty."); + const viewport = before.getSize(1); + const afterViewport = after.getSize(1); + if (viewport.width !== afterViewport.width || viewport.height !== afterViewport.height) { + throw new Error("Renderer overlay capture changed size during the layering smoke."); + } + if (!(rendererViewport.width > 0 && rendererViewport.height > 0)) { + throw new Error("Renderer overlay capture has invalid viewport dimensions."); + } + const width = viewport.width; + const height = viewport.height; + const beforePixels = before.toBitmap({ scaleFactor: 1 }); + const afterPixels = after.toBitmap({ scaleFactor: 1 }); + if (beforePixels.length !== width * height * 4 || afterPixels.length !== width * height * 4) { + throw new Error("Renderer overlay capture returned an unexpected bitmap layout."); + } + const pixelX = Math.floor(center.x * width / rendererViewport.width); + const pixelY = Math.floor(center.y * height / rendererViewport.height); + const index = (pixelY * width + pixelX) * 4; + if (index < 0 || index + 4 > afterPixels.length) { + throw new Error("The DOM overlay marker fell outside the renderer screenshot."); + } + const isMagenta = (pixels: Buffer): boolean => Math.abs(pixels[index]! - 255) <= 8 + && pixels[index + 1]! <= 8 + && Math.abs(pixels[index + 2]! - 255) <= 8 + && pixels[index + 3]! >= 240; + if (isMagenta(beforePixels)) { + throw new Error("The magenta DOM overlay marker was already present before installation."); + } + if (!isMagenta(afterPixels)) { + throw new Error(`The renderer screenshot did not capture the magenta DOM marker: ${JSON.stringify({ + rgba: Array.from(afterPixels.subarray(index, index + 4)) + })}.`); + } +} + async function assertFocusAwarePhysicalWheel(service: BrowserService, url: string): Promise { const contents = webContents.getAllWebContents().find((candidate) => candidate.getURL() === url); const owner = BrowserWindow.getAllWindows().find((candidate) => candidate.getParentWindow() === null); @@ -503,6 +1006,7 @@ async function assertOwnerWheelFreezesCrossingBrowser(service: BrowserService): }); void 0; `); + let wheelPulse: ReturnType | undefined; try { const initialViewport = { x: 500, @@ -517,13 +1021,17 @@ async function assertOwnerWheelFreezesCrossingBrowser(service: BrowserService): "globalThis.__canvasttyFreezeEvents?.some((event) => event.dataUrlLength > 0) === true" )); - owner.webContents.sendInputEvent({ + const sendWheel = (): void => owner.webContents.sendInputEvent({ type: "mouseWheel", x: 400, y: 300, deltaX: 8, deltaY: 12 }); + // Renderer round trips can exceed the idle boundary. Keep a real wheel stream active while + // checking surface transitions, then stop it to test the normal idle end separately. + sendWheel(); + wheelPulse = setInterval(sendWheel, Math.max(1, Math.floor(BROWSER_CANVAS_WHEEL_IDLE_MS / 5))); await new Promise((resolve) => setTimeout(resolve, 30)); service.setViewport({ ...initialViewport, x: 403 }); await waitUntil(async () => owner.webContents.executeJavaScript( @@ -543,6 +1051,8 @@ async function assertOwnerWheelFreezesCrossingBrowser(service: BrowserService): if (endedDuringPlaceholder) { throw new Error("Native/placeholder transition ended the active canvas wheel sequence."); } + clearInterval(wheelPulse); + wheelPulse = undefined; await waitUntil(async () => owner.webContents.executeJavaScript(` (() => { const events = globalThis.__canvasttyFreezeEvents ?? []; @@ -551,6 +1061,7 @@ async function assertOwnerWheelFreezesCrossingBrowser(service: BrowserService): })() `)); } finally { + clearInterval(wheelPulse); service.setViewport({ x: 0, y: 0, width: 820, height: 620, surface: "native", canvasScale: 1 }); await owner.webContents.executeJavaScript(` globalThis.__canvasttyFreezeOff?.(); diff --git a/src/main/services/companion/CompanionSessions.ts b/src/main/services/companion/CompanionSessions.ts index b10729f3..792337b9 100644 --- a/src/main/services/companion/CompanionSessions.ts +++ b/src/main/services/companion/CompanionSessions.ts @@ -27,6 +27,8 @@ export interface CompanionHost { providers?(): Record; read(sessionId: string): Promise; input(sessionId: string, data: string): boolean; + /** Called once after a successful structured text submission, never for keys or interrupts. */ + inputSubmitted?(sessionId: string): void; close(sessionId: string): void; rename(sessionId: string, title: string): CompanionSession; create(provider: ProviderId): CompanionSession; @@ -207,6 +209,7 @@ export class CompanionSessions { : `\x1b[200~${action.text}\x1b[201~\r`; if (!this.host.input(action.sessionId, data)) throw new CompanionError("unavailable"); + if (action.type === "session.input") this.host.inputSubmitted?.(action.sessionId); return { delivered: true, sessionId: action.sessionId }; } } diff --git a/src/main/services/companion/EvenG2Controller.ts b/src/main/services/companion/EvenG2Controller.ts index 1a913861..935eecb9 100644 --- a/src/main/services/companion/EvenG2Controller.ts +++ b/src/main/services/companion/EvenG2Controller.ts @@ -1,3 +1,4 @@ +import type { AttentionEvent, NotificationChannel } from "../../../shared/backlog.ts"; import { createServer, type Server, @@ -11,7 +12,7 @@ import { join, resolve, extname } from "node:path"; import { isPathInside } from "../../../agent-runtime/path-inside.mjs"; import { hostname } from "node:os"; import type { TerminalManager } from "../TerminalManager.ts"; -import type { LimitsSnapshot, ProviderId } from "../../../shared/contracts.ts"; +import type { LimitsSnapshot, ProviderId, SessionMetadata } from "../../../shared/contracts.ts"; import { CANVAS_LAUNCHER_ITEMS } from "../../../shared/providerCatalog.ts"; import { CompanionError, @@ -160,8 +161,12 @@ export class EvenG2Controller { private pairingDiagnostics: string[] = []; private diagnosticsWrite = Promise.resolve(); private limits: () => Promise; + private readonly loopWarningActive:(id:string)=>boolean; + private readonly notifications:(channel:NotificationChannel,id:string)=>AttentionEvent[]; constructor(options: { + loopWarningActive?: (sessionId:string)=>boolean; + notifications?: (channel:NotificationChannel,sessionId:string)=>AttentionEvent[]; userDataPath: string; terminals: Terminals; webRoot: string; @@ -194,6 +199,8 @@ export class EvenG2Controller { }) : null; this.terminals = options.terminals; + this.notifications=options.notifications ?? (()=>[]); + this.loopWarningActive=options.loopWarningActive ?? (()=>false); this.mobileRoot = options.mobileRoot ?? null; this.webRoot = options.webRoot; this.port = options.port ?? 3481; @@ -222,6 +229,7 @@ export class EvenG2Controller { if (written) this.presentation.pending(id); return written; }, + inputSubmitted: (id) => this.presentation.submitted(id), close: (id) => this.terminals.dispose(id), rename: (id, title) => safeSession(this.terminals.rename(id, title)), create: (provider) => { @@ -310,6 +318,20 @@ export class EvenG2Controller { } } } + /** History remains available; the glasses notice describes only a still-current condition. */ + private currentAttention(session:SessionMetadata,selectionSent:boolean):AttentionEvent|null { + return [...this.notifications("glasses",session.id)].reverse().find(event=>{ + if(event.sessionId!==session.id)return false; + switch(event.kind) { + case "approval": return session.status==="needs_approval" && !selectionSent; + case "response": return session.status==="idle" && !session.turnCompleted && this.presentation.canShowResponseAttention(session.id); + case "done": return session.status==="done" || session.status==="idle" && session.turnCompleted===true; + case "failed": return session.status==="failed"; + case "budget": return session.taskBudget?.warning===true || session.taskBudget?.paused===true; + case "loop": return session.exitCode===null && this.loopWarningActive(session.id); + } + }) ?? null; + } observe(channel: string, payload: unknown): void { if (this.config.enabled) this.presentation.observe(channel, payload); } @@ -1046,7 +1068,7 @@ export class EvenG2Controller { this.access.assertCurrent(grant); const session = this.terminals.listMetadata().find((s) => s.id === id); if (!session) return this.json(res, 404, { error: "not-found" }); - return this.json(res, 200, { session: safeSession(session), ...view }); + return this.json(res, 200, { session: {...safeSession(session),title:this.terminals.redactSecrets(session.title)}, ...view,attention:this.currentAttention(session,view.revision.startsWith("selected-")) }); } if (req.method !== "POST") return this.json(res, 404, { error: "not-found" }); @@ -1197,7 +1219,7 @@ export class EvenG2Controller { ) ) throw new Error("terminal-closed"); - this.presentation.pending(id); + this.presentation.submitted(id); return true; }, ), diff --git a/src/main/services/companion/TerminalPresentation.ts b/src/main/services/companion/TerminalPresentation.ts index 98238829..05a66eed 100644 --- a/src/main/services/companion/TerminalPresentation.ts +++ b/src/main/services/companion/TerminalPresentation.ts @@ -44,6 +44,9 @@ interface Screen { authoritative: boolean; sequence: number; turnPending: boolean; + inputPending: boolean; + observedStatus: SessionMetadata["status"] | null; + responseAvailable: boolean; busySeen: boolean; menuFingerprint: string | null; menuId: string | null; @@ -71,6 +74,9 @@ export class TerminalPresentation { authoritative: false, sequence: 0, turnPending: false, + inputPending: false, + observedStatus: null, + responseAvailable: false, busySeen: false, menuFingerprint: null, menuId: null, @@ -125,11 +131,7 @@ export class TerminalPresentation { } if (channel === IPC.terminalSession) { const session = (payload as { session: SessionMetadata }).session; - const screen = this.screen(session.id); - if (session.status === "working") { - screen.turnPending = true; - screen.busySeen = true; - } + this.observeStatus(this.screen(session.id),session); return; } if (channel !== IPC.terminalData) return; @@ -160,6 +162,21 @@ export class TerminalPresentation { new Promise((resolve) => terminal.write(data, resolve)), ); } + private observeStatus(screen:Screen,session:SessionMetadata):void { + const previous=screen.observedStatus; + screen.observedStatus=session.status; + if(session.status==="working") { + screen.turnPending=true;screen.busySeen=true;screen.responseAvailable=false; + } else if(session.status==="idle" && !session.turnCompleted) { + if(previous===null || previous==="working")screen.responseAvailable=true; + if(previous==="working")screen.inputPending=false; + } else screen.responseAvailable=false; + } + /** An old response is eligible again only after fresh progress/answer, not a failed-to-idle transition. */ + canShowResponseAttention(id:string):boolean { + const screen=this.screens.get(id); + return screen?.responseAvailable===true && !screen.inputPending; + } answer(id: string, text: string, turnId: string | null, expiresAt: number): void { if (!this.port.listMetadata().some((s) => s.id === id) || !text.trim()) return; @@ -172,6 +189,8 @@ export class TerminalPresentation { screen.authoritative = true; screen.sequence++; screen.turnPending = false; + screen.inputPending = false; + screen.responseAvailable = true; } clearAnswer(id: string): void { const screen = this.screens.get(id); @@ -187,6 +206,13 @@ export class TerminalPresentation { screen.turnPending = true; screen.busySeen = false; } + /** A confirmed text/voice submission acknowledges a response; raw terminal keys do not. */ + submitted(id:string):void { + this.pending(id); + this.screen(id).inputPending = true; + } + /** Successful device input resolves the prior response notice until fresh progress or an answer. */ + hasPendingInput(id:string):boolean { return this.screens.get(id)?.inputPending ?? false; } /** * What leaves for the companion device is masked like any text handed out: the whole screen at once, so a key * the terminal wrapped over two lines is still found (the registry tolerates the line break), before it is cut. @@ -220,8 +246,9 @@ export class TerminalPresentation { async read(id: string) { const session = this.port.listMetadata().find((s) => s.id === id); if (!session) throw new Error("Session unavailable"); - const screen = this.screen(id), - viewport = await this.text(id), + const screen = this.screen(id); + this.observeStatus(screen,session); + const viewport = await this.text(id), menu = session.provider === "codex" ? codexMenu(viewport) : null; if (screen.answerExpiresAt !== null && screen.answerExpiresAt <= Date.now()) { screen.lastAnswer = ""; @@ -267,8 +294,13 @@ export class TerminalPresentation { (session.provider !== "codex" || !screen.lastAnswer || (screen.turnPending && screen.busySeen)) - ) + ) { + if (reply !== screen.lastAnswer) { + screen.inputPending = false; + if(session.status==="idle" && !session.turnCompleted)screen.responseAvailable=true; + } screen.lastAnswer = reply; + } } const body = session.status === "needs_approval" diff --git a/src/main/services/configuredModel.ts b/src/main/services/configuredModel.ts new file mode 100644 index 00000000..3cc84d75 --- /dev/null +++ b/src/main/services/configuredModel.ts @@ -0,0 +1,20 @@ +import {readFileSync,statSync} from "node:fs"; +import {join} from "node:path"; +import type {AgentProviderId} from "../../shared/contracts.ts"; + +/** Reads a CLI's explicit default only. An absent/default alias is not guessed to be a catalog model. */ +export function configuredModel(provider:AgentProviderId,homes:{codex:string;claude:string;opencode:string}):string|null{ + try{ + let file:string; + switch(provider){ + case "codex":file=join(homes.codex,"config.toml");break; + case "claude":file=join(homes.claude,"settings.json");break; + case "opencode":file=join(homes.opencode,"opencode.json");break; + default:return null; + } + if(statSync(file).size>256_000)return null; + const text=readFileSync(file,"utf8"); + const model=provider==="codex" ? /^\s*model\s*=\s*["']([^"'\n]+)["']/mu.exec(text.split(/^\s*\[/mu)[0])?.[1] : JSON.parse(text).model; + return typeof model==="string" && /^[\w./:@+-]{1,200}$/u.test(model) ? model : null; + }catch{return null;} +} diff --git a/src/main/services/isolation/AgentIsolation.ts b/src/main/services/isolation/AgentIsolation.ts index 5d648c29..508af01c 100644 --- a/src/main/services/isolation/AgentIsolation.ts +++ b/src/main/services/isolation/AgentIsolation.ts @@ -1,7 +1,7 @@ import { spawnSync } from "node:child_process"; -import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, realpathSync, rmdirSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { chmodSync, closeSync, constants as fsConstants, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readSync, 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, relative } from "node:path"; import type { ProviderId, SessionIsolation } from "../../../shared/contracts.ts"; import { autoKind, PROFILE_RANK, type LaunchProfile } from "../../../shared/autoMode.ts"; import { LaunchRefusal } from "../launchRefusal.ts"; @@ -9,6 +9,10 @@ import { isolationPaths } from "./isolationPaths.ts"; import { seatbeltProfile } from "./seatbelt.ts"; import { bubblewrapArguments, projectHooks } from "./bubblewrap.ts"; import { LinuxHostPaths } from "./linuxHostPaths.ts"; +import type { AgentNetworkLaunch, AgentNetworkMode, AgentNetworkSummary, NetworkPolicyManager } from "./networkPolicy.ts"; +import { isPluginDataPath, worktreeGitAccess } from "./worktreeGitAccess.ts"; +import { validateSelectedAccountHome } from "../accountHomeIsolation.ts"; +import { prepareReviewerHome } from "./reviewerHome.ts"; export const SANDBOX_EXEC = "/usr/bin/sandbox-exec"; /** The prefix of a launch's own folder (its profile and TMPDIR) in the temporary folder. */ @@ -20,6 +24,133 @@ export const BUBBLEWRAP_USERNS_DOCS = "docs/installing-and-security.md#linux-whe /** A failed bubblewrap check is repeated after this long, so allowing it takes effect without a restart. */ const BUBBLEWRAP_PROBE_RETRY_MS = 60_000; export const ISOLATION_NOTE = "CanvasTTY agent isolation: files can be written only inside the project folder, $TMPDIR and this CLI's own folders; SSH/cloud keys, other agents' credentials and CanvasTTY's tokens cannot be read; other processes, apps and daemons are out of reach. \"Operation not permitted\" outside that is this rule: do the work inside the project, or tell the person what you need."; +export const WORKTREE_GIT_NOTE = "A plugin worktree's Git metadata stays read-only under agent isolation to protect the shared repository and sibling worktrees. You can edit project files; git add and git commit are unavailable here. The host environment collects your file edits into a review for the user to accept."; + +/** Restricted reviewers keep HOME, TMPDIR and their profile together below CanvasTTY's private run directory. */ +function reviewerRunRoot(userDataPath: string): string { + const root = join(realpathSync(userDataPath), "launch-runs"); + try { mkdirSync(root, { mode: 0o700 }); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; } + const rootInfo = lstatSync(root); + if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory() || realpathSync(root) !== root) { + throw new LaunchRefusal("The private launch-runs folder is not a safe directory. The reviewer was not started."); + } + chmodSync(root, 0o700); + return root; +} + +const REVIEWER_SYSTEM_READ_ROOTS = [ + "/bin", "/usr/bin", "/sbin", "/usr/sbin", "/usr/lib", "/usr/libexec", "/usr/share", + "/System/Library", "/System/Volumes/Preboot/Cryptexes/OS", "/dev", "/private/var/db/dyld" +]; +const MAX_REVIEWER_RUNTIME_FILES = 256; +const MAX_REVIEWER_RUNTIME_DEPTH = 16; + +/** Resolve only the selected runtime's exact Mach-O dependencies; never grant its containing directory. */ +function reviewerRuntimeDependencies(roots: readonly string[], env: Readonly>): string[] { + const runtimeFiles = new Set(); + const queue: Array<{ path: string; executablePath: string; runpaths: string[]; depth: number; selectedCommand: boolean }> = roots + .filter((path) => isAbsolute(path) && existsSync(path)) + .map((path, index) => ({ path, executablePath: realpathSync(path), runpaths: [], depth: 0, selectedCommand: index === 0 })); + const seen = new Set(); + const isSystemFile = (path: string): boolean => REVIEWER_SYSTEM_READ_ROOTS.some((root) => { + const rest = relative(root, path); + return rest === "" || (!rest.startsWith("..") && !isAbsolute(rest)); + }); + const resolveRunpath = (value: string, loader: string, executable: string): string | null => { + if (value.startsWith("@loader_path/")) return join(dirname(loader), value.slice("@loader_path/".length)); + if (value.startsWith("@executable_path/")) return join(dirname(executable), value.slice("@executable_path/".length)); + return isAbsolute(value) ? value : null; + }; + const runtimePaths = (file: string, executable: string): string[] => { + const result = spawnSync("/usr/bin/otool", ["-l", file], { encoding: "utf8", timeout: 5_000, maxBuffer: 1024 * 1024 }); + if (result.error || result.status !== 0) return []; + const lines = (result.stdout ?? "").split(/\r?\n/u); + const paths: string[] = []; + for (let index = 0; index < lines.length; index++) { + if (lines[index].trim() !== "cmd LC_RPATH") continue; + for (let next = index + 1; next < Math.min(lines.length, index + 5); next++) { + const match = /^\s*path\s+(.+?)\s+\(offset\s+\d+\)\s*$/u.exec(lines[next]); + if (!match) continue; + const path = resolveRunpath(match[1], file, executable); + if (path) paths.push(path); + break; + } + } + return paths; + }; + const shebangInterpreter = (file: string): string | null => { + const descriptor = openSync(file, "r"); + let firstLine: string; + try { + const buffer = Buffer.alloc(4096); + const bytesRead = readSync(descriptor, buffer, 0, buffer.length, 0); + firstLine = buffer.toString("utf8", 0, bytesRead).split(/\r?\n/u, 1)[0]; + } finally { + closeSync(descriptor); + } + if (!firstLine.startsWith("#!")) return null; + const words = firstLine.slice(2).trim().split(/\s+/u).filter(Boolean); + let interpreter = words.shift(); + if (!interpreter) throw new LaunchRefusal("The diff-only reviewer command has an invalid interpreter line."); + if (interpreter === "/usr/bin/env") { + if (words[0] === "-S") words.shift(); + if (words[0]?.startsWith("-")) throw new LaunchRefusal("The diff-only reviewer command uses an unsupported env interpreter option."); + const name = words[0]; + if (!name) throw new LaunchRefusal("The diff-only reviewer command has no env interpreter."); + const found = (env.PATH ?? "").split(delimiter).filter(Boolean).map((folder) => join(folder, name)).find((path) => existsSync(path)); + if (!found) throw new LaunchRefusal("The diff-only reviewer interpreter could not be resolved from PATH."); + interpreter = found; + } + if (!isAbsolute(interpreter) || !existsSync(interpreter)) throw new LaunchRefusal("The diff-only reviewer interpreter could not be verified."); + return realpathSync(interpreter); + }; + + while (queue.length > 0) { + const current = queue.shift()!; + const file = realpathSync(current.path); + if (isSystemFile(file)) continue; + if (seen.has(file)) continue; + if (seen.size >= MAX_REVIEWER_RUNTIME_FILES || current.depth > MAX_REVIEWER_RUNTIME_DEPTH) { + throw new LaunchRefusal("The diff-only reviewer runtime has too many linked libraries to verify safely."); + } + seen.add(file); + const stat = statSync(file); + if (!stat.isFile() || stat.nlink !== 1) throw new LaunchRefusal("A diff-only reviewer runtime file is not a single-link regular file."); + const linked = spawnSync("/usr/bin/otool", ["-L", file], { encoding: "utf8", timeout: 5_000, maxBuffer: 1024 * 1024 }); + if (linked.error || linked.status !== 0) { + if (!current.selectedCommand) continue; // AgentRuntimeBridge can also hand us exact text hooks and config files. + const interpreter = shebangInterpreter(file); + if (!interpreter) throw new LaunchRefusal("The diff-only reviewer command is not a verifiable macOS runtime."); + queue.push({ path: interpreter, executablePath: interpreter, runpaths: current.runpaths, depth: current.depth + 1, selectedCommand: false }); + continue; + } + const localRunpaths = runtimePaths(file, current.executablePath); + const availableRunpaths = [...new Set([...localRunpaths, ...current.runpaths])]; + const lines = (linked.stdout ?? "").split(/\r?\n/u).slice(1); + for (const line of lines) { + const entry = line.trim(); + if (!entry) continue; + const installName = entry.replace(/\s+\(compatibility version\s+.*$/u, "").replace(/\s+\(current version\s+.*$/u, ""); + const candidates = installName.startsWith("@rpath/") + ? availableRunpaths.map((path) => join(path, installName.slice("@rpath/".length))) + : [resolveRunpath(installName, file, current.executablePath)].filter((path): path is string => Boolean(path)); + const dependency = candidates.find((candidate) => existsSync(candidate)); + if (!dependency) { + if (installName.startsWith("@")) throw new LaunchRefusal("A diff-only reviewer runtime dependency could not be resolved safely."); + continue; // A Mach-O may list a dylib for another architecture that is not installed here. + } + const resolved = realpathSync(dependency); + if (isSystemFile(resolved)) continue; + const dependencyStat = statSync(resolved); + if (!dependencyStat.isFile() || dependencyStat.nlink !== 1) throw new LaunchRefusal("A diff-only reviewer runtime dependency is not a single-link regular file."); + runtimeFiles.add(dependency); + runtimeFiles.add(resolved); + queue.push({ path: resolved, executablePath: current.executablePath, runpaths: availableRunpaths, depth: current.depth + 1, selectedCommand: false }); + } + } + return [...runtimeFiles]; +} export interface AgentIsolationOptions { /** CanvasTTY's userData folder (its private data is never readable inside the layer). */ @@ -37,6 +168,12 @@ export interface AgentIsolationOptions { exists?: (path: string) => boolean; /** Linux: the host folders and placeholders bubblewrap needs (shared across launches); tests pass their own. */ linuxHostPaths?: LinuxHostPaths; + /** Optional per-project network policy store and host-side allowlist proxy. */ + networkPolicy?: NetworkPolicyManager; + /** Packaged native canvastty-helper; both strict Linux network modes require its Unix socket guard. */ + networkHelperPath?: string | null; + /** Linux: checks the native helper's Landlock boundary without starting an agent. */ + networkIsolationProbe?: (helper: string) => string | null; /** * Linux: runs bubblewrap once with the namespaces a launch uses and returns null when it works, or what it said. * bubblewrap can be installed and still unable to create an unprivileged user namespace (Ubuntu 24.04's AppArmor @@ -48,6 +185,8 @@ export interface AgentIsolationOptions { export interface IsolationDecisionInput { provider: ProviderId; + /** Project root used to resolve its network policy; omitted by older callers to use the global policy. */ + cwd?: string; profile: LaunchProfile; /** Not launched by the person: a subagent, or an agent a plugin started. */ delegated: boolean; @@ -70,11 +209,21 @@ export interface IsolationLaunch { sessionId: string; provider: ProviderId; cwd: string; + /** Original project root used for policy lookup when cwd is a plugin-provided worktree or container path. */ + networkProjectRoot?: string; command: string; args: readonly string[]; env: Record; /** Folders under CanvasTTY's private data this launch was handed (its control grant, its account home). */ grantedPrivate?: readonly string[]; + /** Host-derived candidate from the selected Accounts contribution; validated against userData at wrap time. */ + accountHome?: string; + /** Host-derived project/session roots hidden from the diff-only reviewer. */ + deniedReadPaths?: readonly string[]; + /** Model API hosts of the selected model account; allowed with the provider APIs in allowed-domains mode. */ + apiDomains?: readonly string[]; + restrictHomeReads?: boolean; + runtimeReadable?: readonly string[]; /** The launch profile: in plan the project is not writable. */ profile?: LaunchProfile; } @@ -83,6 +232,8 @@ export interface WrappedLaunch { command: string; args: string[]; env: Record; + /** Host-owned summary for a safe platform-specific restriction (shown on the session card). */ + isolationReason?: string; /** Removes the launch's own folder (profile and temporary files); call when its process exited or was replaced. */ cleanup(): void; } @@ -100,7 +251,7 @@ export class AgentIsolation { private bubblewrap: string | null | undefined; private readonly linuxHostPaths: LinuxHostPaths; /** The last bubblewrap check: a working one is kept, a failed one is repeated after BUBBLEWRAP_PROBE_RETRY_MS. */ - private bubblewrapCheck: { path: string; failure: string | null; at: number } | null = null; + private readonly isolationChecks = new Map(); constructor(options: AgentIsolationOptions) { this.options = options; @@ -121,7 +272,7 @@ export class AgentIsolation { if (this.platform === "linux") { if (this.bubblewrap === undefined) this.bubblewrap = findOnPath("bwrap", exists); if (!this.bubblewrap) return { reason: "bubblewrap (bwrap) is not installed; install it to isolate agents on Linux." }; - const failure = this.bubblewrapFailure(this.bubblewrap); + const failure = this.cachedProbe(`bwrap:${this.bubblewrap}`, () => (this.options.bubblewrapProbe ?? probeBubblewrap)(this.bubblewrap!)); return failure === null ? { layer: "bubblewrap" } : { reason: `bubblewrap (bwrap) is installed but cannot create its sandbox here (${failure}); Ubuntu 24.04 and later block unprivileged user namespaces through AppArmor. ${BUBBLEWRAP_USERNS_DOCS} says how to allow it.` }; @@ -139,12 +290,30 @@ export class AgentIsolation { const { provider, delegated } = input; let profile = input.profile; if (provider === "terminal") return { apply: false, profile }; - const wanted = delegated || profile !== "normal"; + let networkMode: AgentNetworkMode = "open"; + try { networkMode = this.options.networkPolicy?.getPolicy(input.cwd).mode ?? "open"; } + catch (error) { + return { apply: false, profile, refuse: `The saved agent network policy cannot be read: ${error instanceof Error ? error.message : String(error)} The agent was not started.` }; + } + const strictNetwork = networkMode !== "open"; + const wanted = delegated || profile !== "normal" || strictNetwork; if (!wanted) return { apply: false, profile }; const containedAuto = profile === "auto" && autoKind(provider) === "contained"; if (input.environment?.isolated) { + if (strictNetwork) return { apply: false, profile, refuse: `${networkMode} network mode cannot be enforced inside ${input.environment.label}. The agent was not started.` }; return { apply: false, profile, isolation: { state: "environment", reason: `Runs in ${input.environment.label}; the isolation layer of this computer does not apply there.` } }; } + if (strictNetwork) { + if (!this.enabled()) return { apply: false, profile, refuse: `${networkMode} network mode requires agent isolation to be on. The agent was not started.` }; + const available = this.availability(); + if ("reason" in available) return { apply: false, profile, refuse: `${networkMode} network mode cannot be enforced: ${available.reason} The agent was not started.` }; + const socketFailure = this.platform === "linux" ? this.networkIsolationFailure() : null; + if (socketFailure) return { apply: false, profile, refuse: `${networkMode} network mode cannot be enforced: ${socketFailure} The agent was not started.` }; + if (networkMode === "allowed-domains") { + const proxy = this.options.networkPolicy?.availability(); + if (!proxy?.available) return { apply: false, profile, refuse: `allowed-domains network mode cannot be enforced: ${proxy && "reason" in proxy ? proxy.reason : "the proxy manager is not configured"} The agent was not started.` }; + } + } const lower = (state: SessionIsolation["state"], why: string): IsolationDecision => { if (containedAuto && !delegated) { return { apply: false, profile, refuse: `${provider} has no auto mode of its own; its auto runs only inside CanvasTTY's agent isolation, and ${why}` }; @@ -165,59 +334,164 @@ export class AgentIsolation { return { apply: true, profile, isolation: { state: "on", layer: available.layer } }; } + networkPolicyFor(projectRoot: string | undefined, provider: ProviderId): AgentNetworkSummary | null { + return this.options.networkPolicy?.getEffectivePolicy(projectRoot, provider) ?? null; + } + /** Wraps one launch; throws a LaunchRefusal (fail closed) when the layer cannot be set up. */ wrap(launch: IsolationLaunch): WrappedLaunch { const available = this.availability(); if ("reason" in available) throw new LaunchRefusal(`agent isolation is not available: ${available.reason} The agent was not started without it.`); let folder: string | null = null; let releaseHostPaths: (() => void) | null = null; + let networkLaunch: AgentNetworkLaunch | null = null; const cleanup = (): void => { releaseHostPaths?.(); releaseHostPaths = null; + networkLaunch?.cleanup(); + networkLaunch = null; if (folder) rmSync(folder, { recursive: true, force: true }); folder = null; }; try { - const root = realpathSync(this.options.tempRoot ?? tmpdir()); + const root = launch.restrictHomeReads + ? reviewerRunRoot(this.options.userDataPath) + : realpathSync(this.options.tempRoot ?? tmpdir()); folder = mkdtempSync(join(root, ISOLATION_FOLDER_PREFIX)); chmodSync(folder, 0o700); const temp = join(folder, "tmp"); mkdirSync(temp, { mode: 0o700 }); + const requestedCwdInPluginData = isPluginDataPath(launch.cwd, this.options.userDataPath); const cwd = realpathSync(launch.cwd); + // Avoid resolving an executable through a chain of HOME symlinks from inside the reviewer sandbox. The host + // resolves only the selected executable; its file grant already covers this canonical spelling. + const launchCommand = launch.restrictHomeReads ? realpathSync(launch.command) : launch.command; + if (launch.restrictHomeReads && (launch.profile !== "plan" || !launch.deniedReadPaths?.length)) + throw new LaunchRefusal("Restricted reviewer reads require a Plan profile and verified project roots."); + const deniedReadPaths = launch.deniedReadPaths?.map((path) => { + if (!isAbsolute(path)) throw new LaunchRefusal("A reviewer isolation root is not absolute."); + const denied = realpathSync(path); + const within = (path: string, folder: string): boolean => { + const rest = relative(folder, path); + return rest === "" || (!rest.startsWith("..") && !isAbsolute(rest)); + }; + if (!statSync(denied).isDirectory() || denied === dirname(denied) + || within(cwd, denied) || within(denied, cwd)) { + throw new LaunchRefusal("A reviewer isolation root is missing or overlaps its temporary workspace."); + } + return denied; + }); + if (launch.deniedReadPaths && deniedReadPaths?.length !== launch.deniedReadPaths.length) { + throw new LaunchRefusal("A reviewer isolation root could not be verified."); + } + const trustedWorktree = worktreeGitAccess(cwd, launch.networkProjectRoot ?? cwd, this.options.userDataPath); + if ((requestedCwdInPluginData || isPluginDataPath(cwd, this.options.userDataPath)) && !trustedWorktree) { + throw new LaunchRefusal("The plugin environment worktree could not be verified against the task's original Git repository. The agent was not started."); + } + const launchEnvironment = { ...launch.env }; + const accountHome = launch.accountHome === undefined + ? undefined + : validateSelectedAccountHome(this.options.userDataPath, launch.provider, launch.accountHome); + // A reviewer on the worker's model account: a copy of the account's run file in the reviewer's own temp folder + // (CanvasTTY's launch-runs stays unreadable to it). + const accountRunSource = launch.restrictHomeReads && launch.provider === "opencode" && launch.apiDomains?.length + ? accountLaunchRunFile(launchEnvironment.OPENCODE_CONFIG, this.options.userDataPath) : undefined; + let accountRunConfig: string | undefined; + if (accountRunSource) { + accountRunConfig = join(temp, "account-opencode.json"); + copyFileSync(accountRunSource, accountRunConfig, fsConstants.COPYFILE_EXCL); + chmodSync(accountRunConfig, 0o600); + } + if (launch.restrictHomeReads) prepareReviewerHome(launchEnvironment, launch.provider, temp, launch.runtimeReadable ?? [], accountRunConfig); + const reviewerRuntimeFiles = launch.restrictHomeReads && this.platform === "darwin" + ? reviewerRuntimeDependencies([launchCommand, ...(launch.runtimeReadable ?? [])], launchEnvironment) + : []; + networkLaunch = this.options.networkPolicy?.prepareLaunch(launch.networkProjectRoot ?? cwd, launch.provider, launch.apiDomains) ?? null; + const networkMode = networkLaunch?.mode ?? "open"; + if (networkMode !== "open" && this.platform === "linux") { + const failure = this.networkIsolationFailure(); + if (failure) throw new LaunchRefusal(`${networkMode} network mode cannot be enforced: ${failure} The agent was not started.`); + } + if (networkMode !== "open") stripBrowserEnvironment(launchEnvironment); const paths = isolationPaths({ provider: launch.provider, cwd, sessionTemp: temp, - env: launch.env, + env: launchEnvironment, hostEnvironment: this.hostEnvironment, userDataPath: this.options.userDataPath, sessionId: launch.sessionId, - ...(launch.grantedPrivate ? { grantedPrivate: launch.grantedPrivate } : {}), + ...(launch.restrictHomeReads ? { privateRunDirectory: folder } : {}), + networkMode, + ...(deniedReadPaths ? { deniedReadPaths } : {}), + ...(launch.restrictHomeReads ? { restrictHomeReads: true, runtimeReadable: [...reviewerRuntimeFiles, launchCommand, + ...(networkMode !== "open" && this.platform === "linux" ? [this.options.networkHelperPath!] : []), ...(launch.runtimeReadable ?? [])] } : {}), + ...(trustedWorktree ? { worktreeGitAccess: trustedWorktree } : {}), + ...((launch.grantedPrivate || accountHome) ? { + grantedPrivate: [...(launch.grantedPrivate ?? []), ...(accountHome && !launch.restrictHomeReads ? [accountHome] : [])] + } : {}), ...(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 worktreeGitMetadataReadOnly = Boolean(trustedWorktree); + const isolationNote = worktreeGitMetadataReadOnly ? `${ISOLATION_NOTE} ${WORKTREE_GIT_NOTE}` : ISOLATION_NOTE; + const env: Record = { ...launchEnvironment, TMPDIR: `${temp}/`, TMP: temp, TEMP: temp, GIT_TEMPLATE_DIR: gitTemplate, [ISOLATION_ENV]: isolationNote }; + if (worktreeGitMetadataReadOnly) env.GIT_OPTIONAL_LOCKS = "0"; + if (networkMode !== "open") { + stripProxyEnvironment(env); + env.CANVASTTY_NETWORK_MODE = networkMode; + } + if (networkLaunch?.mode === "allowed-domains" && this.platform === "darwin") { + if (!networkLaunch.token || !networkLaunch.macProxyPort) throw new LaunchRefusal("The macOS allowlist proxy was not ready. The agent was not started."); + const proxy = `http://canvastty:${networkLaunch.token}@127.0.0.1:${networkLaunch.macProxyPort}`; + setProxyEnvironment(env, proxy); + } if (available.layer === "seatbelt") { const profilePath = join(folder, "profile.sb"); - writeFileSync(profilePath, seatbeltProfile(paths), { mode: 0o600, flag: "wx" }); - return { command: this.options.sandboxExecPath ?? SANDBOX_EXEC, args: ["-f", profilePath, launch.command, ...launch.args], env, cleanup }; + const network = networkLaunch && networkLaunch.mode !== "open" ? { + mode: networkLaunch.mode, + ...(networkLaunch.mode === "allowed-domains" && networkLaunch.macProxyPort ? { proxyPort: networkLaunch.macProxyPort } : {}), + loopbackPorts: claudeHookPorts(launch.provider, launch.args) + } as const : undefined; + writeFileSync(profilePath, seatbeltProfile(paths, network), { mode: 0o600, flag: "wx" }); + return { command: this.options.sandboxExecPath ?? SANDBOX_EXEC, args: ["-f", profilePath, launchCommand, ...launch.args], env, + ...(worktreeGitMetadataReadOnly ? { isolationReason: WORKTREE_GIT_NOTE } : {}), cleanup }; } // 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): "file" | "directory" | "socket" | null => { + 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); + let command = launchCommand; + let commandArgs = [...launch.args]; + if (networkLaunch && networkLaunch.mode !== "open") { + if (networkLaunch.mode === "allowed-domains" && (!networkLaunch.unixProxyPath || !networkLaunch.token)) { + throw new LaunchRefusal("allowed-domains network mode could not prepare its Linux socket bridge. The agent was not started."); + } + command = this.options.networkHelperPath!; + // These exact addresses come from core grants; no plugin-supplied CANVASTTY_* name grants a socket. + const gatewaySockets = [env.CANVASTTY_RUNTIME_ADDRESS, env.CANVASTTY_ORCHESTRATION_ADDRESS].filter((path): path is string => Boolean(path)); + commandArgs = ["network-bridge", ...(networkLaunch.mode === "offline" ? ["--offline"] : ["--socket", networkLaunch.unixProxyPath!, "--token", networkLaunch.token!]), + ...[...new Set(gatewaySockets)].flatMap((path) => ["--allow-socket", path]), "--", launchCommand, ...launch.args]; + } + const args = bubblewrapArguments(paths, { + command, args: commandArgs, cwd, + ...(launch.env.XDG_RUNTIME_DIR ? { runtimeDir: launch.env.XDG_RUNTIME_DIR } : {}), + ...(networkLaunch && networkLaunch.mode !== "open" ? { + network: { mode: networkLaunch.mode, ...(networkLaunch.unixProxyPath ? { proxySocketPath: networkLaunch.unixProxyPath } : {}) } + } : {}) + }, kind); const hooks = projectHooks(cwd); const mountPoint = kind(dirname(hooks)) === null && args.includes(hooks); return { command: this.bubblewrap!, args, env, + ...(worktreeGitMetadataReadOnly ? { isolationReason: WORKTREE_GIT_NOTE } : {}), cleanup: () => { cleanup(); // bwrap created an empty `.git/hooks` as the throwaway hooks mount point in a folder that had no repository: @@ -233,21 +507,75 @@ export class AgentIsolation { } /** Null when bubblewrap can start here; cached, so a launch costs one check at most once a minute. */ - private bubblewrapFailure(bwrap: string): string | null { + private cachedProbe(key: string, probe: () => string | null): string | null { const now = (this.options.now ?? Date.now)(); - const last = this.bubblewrapCheck; - if (last && last.path === bwrap && (last.failure === null || now - last.at < BUBBLEWRAP_PROBE_RETRY_MS)) return last.failure; + const last = this.isolationChecks.get(key); + if (last && (last.failure === null || now - last.at < BUBBLEWRAP_PROBE_RETRY_MS)) return last.failure; let failure: string | null; - try { failure = (this.options.bubblewrapProbe ?? probeBubblewrap)(bwrap); } catch (error) { failure = error instanceof Error ? error.message : String(error); } - this.bubblewrapCheck = { path: bwrap, failure, at: now }; + try { failure = probe(); } catch (error) { failure = error instanceof Error ? error.message : String(error); } + this.isolationChecks.set(key, { failure, at: now }); return failure; } + private networkIsolationFailure(): string | null { + const helper = this.options.networkHelperPath; + if (!helper) return "CanvasTTY's native Unix socket guard is missing on Linux."; + return this.cachedProbe(`network:${helper}`, () => (this.options.networkIsolationProbe ?? probeNetworkIsolation)(helper)); + } + private enabled(): boolean { try { return this.options.enabled() !== false; } catch { return true; } } } +function stripProxyEnvironment(env: Record): void { + for (const key of Object.keys(env)) if (/^(?:https?|all|no)_proxy$/iu.test(key)) delete env[key]; +} + +function stripBrowserEnvironment(env: Record): void { + for (const key of [ + "CANVASTTY_AGENT_BROWSER_ADDRESS", "CANVASTTY_AGENT_CAPABILITY", "CANVASTTY_AGENT_CONNECTION_ID", + "CANVASTTY_AGENT_ID", "CANVASTTY_AGENT_PROVIDER" + ]) delete env[key]; +} + +function setProxyEnvironment(env: Record, proxy: string): void { + env.HTTP_PROXY = proxy; + env.http_proxy = proxy; + env.HTTPS_PROXY = proxy; + env.https_proxy = proxy; + env.ALL_PROXY = proxy; + env.all_proxy = proxy; + // The seatbelt profile grants only an exact capability-bearing hook port on loopback; the network proxy itself + // rejects private addresses. This lets a generated Claude lifecycle hook reach that one local listener. + env.NO_PROXY = "127.0.0.1,localhost,::1"; + env.no_proxy = env.NO_PROXY; +} + +/** The only direct loopback HTTP request retained in strict mode: a capability-bearing Claude lifecycle hook. */ +function claudeHookPorts(provider: ProviderId, args: readonly string[]): number[] { + if (provider !== "claude") return []; + const found = new Set(); + const visit = (value: unknown): void => { + if (Array.isArray(value)) { for (const item of value) visit(item); return; } + if (!value || typeof value !== "object") return; + const record = value as Record; + if (record.type === "http" && typeof record.url === "string" && record.url.startsWith("http://127.0.0.1:")) { + const headers = record.headers && typeof record.headers === "object" ? record.headers as Record : {}; + const hasCapability = Object.values(headers).some((header) => typeof header === "string" && header.includes("CANVASTTY_RUNTIME_CAPABILITY")); + const url = /^http:\/\/127\.0\.0\.1:(\d{1,5})\/claude\/v1\//u.exec(record.url); + const port = url ? Number(url[1]) : 0; + if (hasCapability && Number.isInteger(port) && port > 0 && port <= 65_535) found.add(port); + } + for (const item of Object.values(record)) visit(item); + }; + for (const arg of args) { + if (!arg.trimStart().startsWith("{")) continue; + try { visit(JSON.parse(arg) as unknown); } catch { /* non-JSON provider argument */ } + } + return [...found]; +} + /** Removes `/.git` when all it holds is the empty `hooks` and `info` mount points. */ function removeMountPoint(hooks: string): void { const gitDir = dirname(hooks); @@ -261,7 +589,16 @@ function removeMountPoint(hooks: string): void { /** Starts `true` under bubblewrap with the namespaces a launch gets: null when it runs, else bubblewrap's first line. */ export function probeBubblewrap(bwrap: string): string | null { - const result = spawnSync(bwrap, ["--die-with-parent", "--unshare-pid", "--unshare-ipc", "--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", "true"], { + return probeIsolationCommand(bwrap, ["--die-with-parent", "--unshare-pid", "--unshare-ipc", "--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", "true"]); +} + +/** Tests the same kernel boundary every strict launch applies; old or disabled Landlock refuses before launch. */ +export function probeNetworkIsolation(helper: string): string | null { + return probeIsolationCommand(helper, ["network-bridge", "--probe"]); +} + +function probeIsolationCommand(command: string, args: string[]): string | null { + const result = spawnSync(command, args, { stdio: ["ignore", "ignore", "pipe"], encoding: "utf8", timeout: 5_000 @@ -290,3 +627,15 @@ export function controlGrantFolder(env: Readonly