diff --git a/CHANGELOG.md b/CHANGELOG.md index 078880c1..9e6e6557 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ ## Unreleased +- Added two launch points for account plugins. A launcher `select` may declare `"optionsFrom": "service"`: the launcher asks the service `canvastty.launch.options` (3 s) and lists up to 64 more choices after the declared ones, such as the plugin's own accounts; the saved value is then checked by the service when it prepares. Orchestrators may pass plugin launch options to `spawn_agent` as `launchOptions`, checked exactly like the launcher's. A plugin's inline Claude `--settings` is merged into CanvasTTY's own (Claude Code keeps only the last one, which dropped the lifecycle and decision hooks); approval and hook keys in it are refused. Example: `examples/plugins/launch-env` (Profile). +- Added plugin services (manifest apiVersion 2, `services`): bundled single-file JavaScript that runs as a supervised child process only after the separate per-plugin **Extension native code** confirmation in Settings → Agents (off by default, never granted by install, revoked by update, module change, disable, or a changed entry file). Services get a minimal environment without keys or CanvasTTY internals, speak JSON-RPC over stdio with 1 MB messages and 15 s timeouts, restart with backoff, stop on disable, uninstall, update and quit, and log to a bounded per-plugin log. Plugin surfaces call their own plugin's services through `host.service.request` and receive `host.service.onEvent`; services may call back `log`, own-plugin `storage` and `event`, and read their own plugin's secrets with `secrets.get` (needs `secrets`). Example: `examples/plugins/service-echo` (its service also reads a token the page saved). +- Added launch contributors (`launch:contribute`): a trusted plugin service can declare launcher options (boolean, select, text) shown under **Advanced** in the agent launcher. For launches where the person chose the plugin, and their restarts and restores, CanvasTTY asks the service to prepare the launch and adds its environment variables, secret variables resolved from the plugin's own secrets (masked in text other agents and the control CLI read), arguments and per-run files. Contributors merge in plugin-id order; a refusal, a 5 s timeout, a conflict, a reserved name or an approval/conversation argument refuses the launch with the reason on the card, and a restored card whose plugin is unavailable comes back stopped. The chosen values are saved with the session. A contributor may also declare `launch.policy`: it is then asked before every launch of its agents where the person did not choose it (`chosen: false`), may only refuse, and no answer refuses too. Examples: `examples/plugins/launch-env`, `examples/plugins/yolo-guard` (a launch policy). +- Added session environments (`environment:provide`): a trusted plugin service can offer places a card runs in (a git worktree, a container, a remote host), chosen under **Where** in the launcher's Advanced section; terminals get the same launcher while such an environment applies to them. The service prepares the place once, then wraps every start (validated: an absolute program path or a bare name resolved on PATH, never a shell string; launch-contributor env rules; plugin secrets masked) while CanvasTTY keeps spawning the PTY. The opaque ref is saved with the card; restore resumes environments first, then parents before children, and a missing, disabled or untrusted plugin, a stopped environment or a timeout brings the card back stopped with the reason, never run locally. Closing such a card asks once "Keep environment data?" and releases it accordingly. Launch contributors and policies are told the card's environment (`environment` in `canvastty.launch.prepare`). Example: `examples/plugins/env-worktree` (one git worktree per card). +- Added base protection and decision hooks. Base protection (Settings → Agents, on by default, the person can turn it off) refuses, before a local Claude Code, Codex, Qwen Code or OpenCode tool call runs (YOLO included), sudo and other elevation, curl | sh and download-and-run, disk and format commands, fork bombs, and writes or deletes outside the working folder (`/tmp` and the home folder included, and deleting the folder itself; the agent's own plan and memory folders excepted), telling the model what to do instead. A trusted plugin service can declare `decide` (`decision:provide`) and answer `canvastty.decide` with deny, ask or allow: base protection runs first, any deny wins, a timeout or error asks the person, and an allow counts only after a separate **May allow agent actions** confirmation. A service may declare `decide.timeoutMs` (1–60 s, 3 s by default): CanvasTTY waits that long, tells the service its `budgetMs`, and sizes each card's hook, helper and gateway deadlines at launch for the longest budget that applies (the default keeps today's deadlines). Example: `examples/plugins/deny-rm`. Every text one agent reads from another (`observe_agent`, `get_agent_result`, the control CLI's screen, result and failure details) is now masked for vault keys, launch secrets, values a service registers (`redaction.register`) or reads (`secrets.get`), keys wrapped over lines, and common key shapes. +- Reworked "Windows after restart" into one "Agent sessions after restart" model: **Don't save**, **Reopen windows** (new conversations), or **Continue conversations** (the old "on" migrates here). Claude Code and OpenCode now resume their own conversation by the id their lifecycle hook reported, as Codex does (`claude --resume`, `opencode --session`); two cards of one CLI in one folder no longer continue the same conversation. Finished agents come back stopped with Restart / Continue instead of rerunning, the card options menu has **Don't restore this card**, and session records (v2, read-compatible with v1) keep no scrollback, prompts or secrets. - Made the agent orchestration endpoint an explicit setting (Settings → Agents → "Agent orchestration endpoint", `agentControlEnabled`, off by default; `--agent-control` / `CANVASTTY_AGENT_CONTROL=1` still force it on for one launch) that starts and stops the endpoint at runtime, and added an **Orchestrator** role to the launch dialog next to the normal/YOLO profile: the session keeps the provider you opened the dialog for, gets `CANVASTTY_CONTROL_CONNECTION` and `CANVASTTY_CONTROL_CLI` in its environment so the bundled CLI works without setup, shows an "Orchestrator" badge, keeps its role across restore, and the dialog offers to enable the endpoint first when it is off instead of enabling anything silently. The endpoint's `create` now accepts every agent provider (`codex, claude, qwen, kimi, opencode, hermes, grok, omp, pi`) and reports `capabilities { result, menus }` per worker on `create` and `list`: both are `true` for Codex only; other providers' `screen` has no menu interaction, `choose`/`dismiss` fail with `NOT_SUPPORTED`, `send` relies on the idle status alone, and `result` completes as `no_result`. - Added a native Codex orchestration CLI (`agent-control/canvastty-control.mjs`, documented in `agent/orchestrator/SKILL.md`) behind `--agent-control` or `CANVASTTY_AGENT_CONTROL=1`: a local controller creates Codex sessions in a project directory, sends work, observes bounded terminal output against a screen revision, and collects the final answer. Each controller sees only the sessions it created, grants are bound to the session generation, mutation IDs are deduplicated, and only controlled sessions opt into authenticated Stop-hook result capture. No automatic approval or terminal deletion endpoint is included. - Added the opt-in Even G2 companion (Settings → Controls → Even G2, with the Even App companion under `integrations/even-g2`): Bonjour discovery, short-lived SRP-6a pairing with a six-digit code and explicit device approval, encrypted local requests and audio, per-session grants, bounded terminal presentation on the glasses HUD, local speech recognition through the pinned transcribe.cpp helper (bundled on macOS only), and session creation through the existing desktop launcher. The final answer of a Codex turn reaches the companion only for sessions spawned while the companion is enabled: the runtime hook reports it under a separate per-session grant, bounded to 4000 characters, and the gateway refuses it for any other session. diff --git a/CHANGELOG.ru.md b/CHANGELOG.ru.md index 2ed4b884..979e7945 100644 --- a/CHANGELOG.ru.md +++ b/CHANGELOG.ru.md @@ -4,6 +4,12 @@ ## Unreleased +- Добавлены две точки запуска для плагинов учётных записей. Список (`select`) в параметрах запуска может объявить `"optionsFrom": "service"`: окно запуска спрашивает сервис `canvastty.launch.options` (3 с) и показывает до 64 дополнительных вариантов после объявленных, например учётные записи самого плагина; сохранённое значение проверяет сервис при подготовке запуска. Оркестраторы могут передать параметры запуска плагинов в `spawn_agent` как `launchOptions`; они проверяются так же, как в окне запуска. Встроенный `--settings` плагина для Claude сливается с собственным JSON CanvasTTY (Claude Code применяет только последний, из-за чего пропадали хуки состояния и решений); ключи подтверждений и хуков в нём отклоняются. Пример: `examples/plugins/launch-env` (Profile). +- Добавлены сервисы плагинов (манифест apiVersion 2, `services`): собранный одним файлом JavaScript, который запускается отдельным дочерним процессом под надзором хоста только после отдельного подтверждения **Нативный код расширений** для плагина в Настройки → Агенты (по умолчанию выключено, установка его не даёт, обновление, смена модулей, выключение или изменённый файл entry его снимают). Сервис получает минимальное окружение без ключей и внутренних переменных CanvasTTY, общается по JSON-RPC через stdio (сообщения до 1 МБ, таймаут 15 с), перезапускается с паузами, останавливается при выключении, удалении, обновлении и выходе и пишет в ограниченный журнал плагина. Поверхности плагина обращаются к сервисам своего плагина через `host.service.request` и получают `host.service.onEvent`; сервис может вызывать `log`, `storage` своего плагина и `event` и читать секреты своего плагина через `secrets.get` (нужно `secrets`). Пример: `examples/plugins/service-echo` (его сервис ещё и читает токен, сохранённый страницей). +- Добавлены launch contributors (`launch:contribute`): доверенный сервис плагина может объявить параметры запуска (флажок, список, текст), которые показываются в разделе **Дополнительно** окна запуска агента. Для запусков, где человек выбрал плагин, а также их перезапусков и восстановления, CanvasTTY просит сервис подготовить запуск и добавляет его переменные окружения, секретные переменные из собственных секретов плагина (маскируются в тексте, который читают другие агенты и control CLI), аргументы и файлы на время запуска. Вклады объединяются в порядке id плагинов; отказ, таймаут 5 с, конфликт, зарезервированное имя или аргумент подтверждений/выбора разговора отклоняют запуск с причиной в окне, а восстановленное окно с недоступным плагином возвращается остановленным. Выбранные значения сохраняются с сессией. Contributor может также объявить `launch.policy`: тогда его спрашивают и перед каждым запуском его агентов, где человек его не выбрал (`chosen: false`); он может только отказать, а отсутствие ответа тоже отказ. Примеры: `examples/plugins/launch-env`, `examples/plugins/yolo-guard` (политика запуска). +- Добавлены среды сессий (`environment:provide`): доверенный сервис плагина может предлагать места, где работает окно (git worktree, контейнер, удалённый хост); их выбирают в **Где запустить** в разделе «Дополнительно» лаунчера, а терминал получает тот же лаунчер, пока такая среда к нему применима. Сервис один раз готовит место и оборачивает каждый запуск (с проверкой: абсолютный путь к программе или простое имя из PATH, никогда строка оболочки; правила окружения launch contributors; секреты плагина маскируются), а PTY по-прежнему создаёт CanvasTTY. Непрозрачная ссылка хранится с окном; восстановление сначала возобновляет среды, затем родителей, потом дочерние окна, а отсутствующий, выключенный или недоверенный плагин, остановленная среда или таймаут возвращают окно остановленным с причиной, без локального запуска. При закрытии такого окна один раз спрашивается «Сохранить данные среды?», и среда освобождается по ответу. Launch contributors и политики запуска получают среду окна (`environment` в `canvastty.launch.prepare`). Пример: `examples/plugins/env-worktree` (git worktree на каждое окно). +- Добавлены базовая защита и хуки решений. Базовая защита (Настройки → Агенты, включена по умолчанию, человек может её выключить) до выполнения вызова инструмента локальным Claude Code, Codex, Qwen Code или OpenCode (включая YOLO) запрещает sudo и другое повышение прав, curl | sh и запуск скачанного, команды для дисков и форматирования, форк-бомбы, а также запись и удаление вне рабочей папки (включая `/tmp`, домашнюю папку и удаление самой папки; кроме собственных папок планов и памяти агента) и говорит модели, что сделать вместо этого. Доверенный сервис плагина может объявить `decide` (`decision:provide`) и отвечать на `canvastty.decide` запретом, вопросом или разрешением: базовая защита работает первой, любой запрет побеждает, таймаут или ошибка спрашивают человека, а разрешение учитывается только после отдельного подтверждения **Может разрешать действия агентов**. Сервис может объявить `decide.timeoutMs` (1–60 с, по умолчанию 3 с): CanvasTTY ждёт столько, сообщает сервису его `budgetMs` и при запуске настраивает сроки хука, помощника и шлюза каждого окна под самый долгий действующий бюджет (по умолчанию сроки прежние). Пример: `examples/plugins/deny-rm`. Весь текст, который один агент читает у другого (`observe_agent`, `get_agent_result`, экран, результат и детали ошибки в control CLI), теперь маскируется: ключи из хранилища, секреты запуска, значения, зарегистрированные (`redaction.register`) или прочитанные (`secrets.get`) сервисом, ключи, перенесённые на несколько строк, и типичные формы ключей. +- «Окна после перезапуска» стали единой моделью «Сессии агентов после перезапуска»: **Не сохранять**, **Открыть окна** (новые разговоры) или **Продолжить разговоры** (прежнее «включено» переходит сюда). Claude Code и OpenCode теперь, как и Codex, продолжают свой разговор по id, который сообщил их lifecycle hook (`claude --resume`, `opencode --session`); две карточки одного CLI в одной папке больше не продолжают один и тот же разговор. Завершённые агенты возвращаются остановленными с кнопками «Перезапустить» / «Продолжить», в меню карточки есть **Не восстанавливать это окно**, а записи сессий (v2, совместимы с v1 при чтении) не хранят буфер, промпты и секреты. - Эндпоинт оркестрации агентов стал явной настройкой (Настройки → Агенты → «Эндпоинт оркестрации агентов», `agentControlEnabled`, по умолчанию выключен; `--agent-control` / `CANVASTTY_AGENT_CONTROL=1` по-прежнему принудительно включают его на один запуск), которая запускает и останавливает эндпоинт на лету, а в диалог запуска рядом с профилем normal/YOLO добавлена роль **Оркестратор**: сессия сохраняет провайдера, для которого открыт диалог, получает в окружении `CANVASTTY_CONTROL_CONNECTION` и `CANVASTTY_CONTROL_CLI`, чтобы встроенный CLI работал без настройки, показывает бейдж «Оркестратор», сохраняет роль при восстановлении, а при выключенном эндпоинте диалог предлагает сначала включить его, ничего не включая молча. `create` эндпоинта теперь принимает любого провайдера-агента (`codex, claude, qwen, kimi, opencode, hermes, grok, omp, pi`) и в ответах `create` и `list` сообщает `capabilities { result, menus }` для каждого воркера: оба значения `true` только для Codex; у остальных провайдеров `screen` не содержит взаимодействия с меню, `choose`/`dismiss` завершаются ошибкой `NOT_SUPPORTED`, `send` опирается только на статус idle, а `result` завершается как `no_result`. - Добавлен нативный CLI оркестрации Codex (`agent-control/canvastty-control.mjs`, описан в `agent/orchestrator/SKILL.md`), включаемый флагом `--agent-control` или `CANVASTTY_AGENT_CONTROL=1`: локальный контроллер создаёт сессии Codex в каталоге проекта, отправляет задачи, наблюдает ограниченный вывод терминала относительно ревизии экрана и получает итоговый ответ. Контроллер видит только созданные им сессии, гранты привязаны к поколению сессии, идентификаторы мутаций дедуплицируются, и только управляемые сессии включают аутентифицированный захват результата через Stop-hook. Автоматического одобрения и удаления терминалов нет. - Добавлен опциональный компаньон Even G2 (Настройки → Управление → Even G2, приложение-компаньон в `integrations/even-g2`): обнаружение через Bonjour, короткоживущее сопряжение SRP-6a с шестизначным кодом и явным подтверждением устройства, шифрованные локальные запросы и аудио, гранты на сессию, ограниченное отображение терминала на HUD очков, локальное распознавание речи через закреплённый helper transcribe.cpp (поставляется только для macOS) и создание сессий через обычный лаунчер. Итоговый ответ хода Codex попадает в компаньон только для сессий, запущенных при включённом компаньоне: runtime-hook передаёт его по отдельному гранту сессии с ограничением 4000 символов, а gateway отклоняет его для любой другой сессии. diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index b92a94a2..945234f4 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -4,6 +4,12 @@ ## Unreleased +- 新增两个供账户插件使用的启动扩展点。启动选项中的 `select` 可以声明 `"optionsFrom": "service"`:启动器向服务发送 `canvastty.launch.options`(3 秒),并在声明的选项之后列出最多 64 个额外选项,例如插件自己的账户;保存的值由服务在准备启动时检查。编排器可以把插件启动选项作为 `launchOptions` 传给 `spawn_agent`,校验方式与启动器相同。插件为 Claude 提供的内联 `--settings` 会合并进 CanvasTTY 自己的 JSON(Claude Code 只保留最后一个,此前会丢失生命周期和决策 hook);其中的审批和 hook 键会被拒绝。示例:`examples/plugins/launch-env`(Profile)。 +- 新增插件服务(manifest apiVersion 2,`services`):打包为单文件的 JavaScript,仅在 设置 → Agents 中为该插件单独确认 **Extension native code** 后才作为受监管的子进程运行(默认关闭,安装不会授予;更新、更换模块、禁用或 entry 文件被修改都会撤销)。服务获得不含密钥和 CanvasTTY 内部变量的最小环境,通过 stdio 使用 JSON-RPC(消息上限 1 MB,超时 15 秒),退避重启,在禁用、卸载、更新和退出时停止,并写入有界的插件日志。插件界面通过 `host.service.request` 调用自身插件的服务,并通过 `host.service.onEvent` 接收事件;服务可回调 `log`、自身插件的 `storage` 和 `event`,并可用 `secrets.get` 读取自身插件的机密(需要 `secrets`)。示例:`examples/plugins/service-echo`(其服务还会读取页面保存的令牌)。 +- 新增启动贡献者(`launch:contribute`):受信任的插件服务可以声明启动选项(布尔、选择、文本),显示在智能体启动对话框的 **Advanced(高级)** 部分。对于用户选择了该插件的启动及其重启和恢复,CanvasTTY 请服务准备启动,并加入其环境变量、从插件自身机密解析的机密变量(在其他智能体和控制 CLI 读取的文本中被遮蔽)、参数和本次运行的文件。贡献按插件 id 顺序合并;拒绝、5 秒超时、冲突、保留名称或审批/会话参数都会拒绝启动并在卡片上显示原因,插件不可用的恢复卡片以停止状态返回。所选值随会话保存。贡献者还可以声明 `launch.policy`:此后在其智能体的每次启动中,只要用户没有选择它,也会询问它(`chosen: false`);它只能拒绝,无回答同样视为拒绝。示例:`examples/plugins/launch-env`、`examples/plugins/yolo-guard`(启动策略)。 +- 新增会话环境(`environment:provide`):受信任的插件服务可以提供卡片的运行位置(git worktree、容器、远程主机),在启动器 Advanced 部分的 **Where** 中选择;只要此类环境适用于终端,终端也会使用同一启动器。服务只准备一次运行位置,之后包装每次启动(经过校验:程序的绝对路径或在 PATH 中解析的纯程序名,绝不接受 shell 字符串;遵循启动贡献者的环境变量规则;插件机密被遮蔽),PTY 仍由 CanvasTTY 创建。不透明引用随卡片保存;恢复时先恢复环境,再先父后子启动卡片;插件缺失、被禁用或不受信任、环境已停止或超时时,卡片以停止状态返回并显示原因,绝不在本地运行。关闭此类卡片时只询问一次“Keep environment data?”并据此释放环境。启动贡献者和启动策略会收到卡片的环境(`canvastty.launch.prepare` 中的 `environment`)。示例:`examples/plugins/env-worktree`(每张卡片一个 git worktree)。 +- 新增基础保护和决策 hook。基础保护(设置 → Agents,默认开启,用户可以关闭)在本地 Claude Code、Codex、Qwen Code 或 OpenCode 的工具调用运行之前(包括 YOLO)拒绝 sudo 及其他提权、curl | sh 和下载后运行、磁盘与格式化命令、fork 炸弹,以及在工作文件夹之外写入或删除(包括 `/tmp`、主目录和删除文件夹本身;agent 自己的计划和记忆文件夹除外),并告诉模型应当改做什么。受信任的插件服务可以声明 `decide`(`decision:provide`),以拒绝、询问或允许回答 `canvastty.decide`:基础保护最先运行,任何拒绝优先,超时或错误会询问用户,允许只有在单独确认 **May allow agent actions** 之后才算数。服务可以声明 `decide.timeoutMs`(1–60 秒,默认 3 秒):CanvasTTY 会等待这么久,把 `budgetMs` 告知服务,并在启动时按适用的最长预算设置每张卡片的 hook、helper 和网关期限(默认保持现有期限)。示例:`examples/plugins/deny-rm`。一个 agent 从另一个 agent 读取的所有文本(`observe_agent`、`get_agent_result`、control CLI 的屏幕、结果和失败详情)现在都会遮蔽:密钥库中的密钥、启动机密、服务注册(`redaction.register`)或读取(`secrets.get`)的值、被折行拆开的密钥以及常见密钥形式。 +- 将“重启后的窗口”改为统一的“重启后的智能体会话”模型:**不保存**、**重新打开窗口**(新会话)或 **继续会话**(原先的“开启”迁移到此项)。Claude Code 和 OpenCode 现在与 Codex 一样,按其 lifecycle hook 报告的 id 继续自己的会话(`claude --resume`、`opencode --session`);同一文件夹中同一 CLI 的两张卡片不再继续同一个会话。已结束的智能体恢复为停止状态,提供“重启”/“继续”,卡片选项菜单提供 **不恢复此卡片**,会话记录(v2,可读取 v1)不保存 scrollback、提示词或密钥。 - 将代理编排端点改为显式设置(设置 → 代理 → “代理编排端点”,`agentControlEnabled`,默认关闭;`--agent-control` / `CANVASTTY_AGENT_CONTROL=1` 仍可为单次启动强制开启),并在运行时按设置启动和停止端点;启动对话框在 normal/YOLO 配置旁新增 **Orchestrator(编排器)** 角色:会话保留打开对话框时的提供方,环境中携带 `CANVASTTY_CONTROL_CONNECTION` 和 `CANVASTTY_CONTROL_CLI`,使内置 CLI 无需配置即可工作,卡片显示 “Orchestrator” 徽标,恢复会话时保留角色;端点关闭时对话框会先提示并提供开启按钮,而不会静默开启任何内容。端点的 `create` 现在接受所有代理提供方(`codex, claude, qwen, kimi, opencode, hermes, grok, omp, pi`),并在 `create` 和 `list` 响应中为每个工作会话报告 `capabilities { result, menus }`:仅 Codex 两者都为 `true`;其他提供方的 `screen` 没有菜单交互,`choose`/`dismiss` 返回 `NOT_SUPPORTED`,`send` 仅依据 idle 状态,`result` 以 `no_result` 结束。 - 新增原生 Codex 编排 CLI(`agent-control/canvastty-control.mjs`,文档见 `agent/orchestrator/SKILL.md`),通过 `--agent-control` 或 `CANVASTTY_AGENT_CONTROL=1` 启用:本地控制器在项目目录中创建 Codex 会话、发送任务、按屏幕修订号观察有界的终端输出并收集最终回答。每个控制器只能看到自己创建的会话,授权绑定到会话代次,变更 ID 去重,且只有受控会话会启用经过认证的 Stop-hook 结果捕获。不包含自动批准或删除终端的端点。 - 新增可选的 Even G2 伴侣(设置 → 控制 → Even G2,伴侣应用位于 `integrations/even-g2`):Bonjour 发现、带六位码和显式设备批准的短期 SRP-6a 配对、加密的本地请求与音频、按会话授权、眼镜 HUD 上的有界终端展示、通过固定版本的 transcribe.cpp helper 进行本地语音识别(仅 macOS 随包提供),以及通过现有桌面启动器创建会话。Codex 一轮的最终回答只会送达在伴侣启用期间启动的会话:runtime hook 以单独的会话授权上报,限制为 4000 个字符,gateway 会拒绝任何其他会话的该字段。 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f5b2400a..bf8673d5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -19,6 +19,10 @@ Electron main process ├── TerminalManager → node-pty lifecycle, bounded scrollback, and output batching ├── LimitsService → sanitized provider-limit adapters and cache ├── PluginManager → GitHub install, manifest validation, assets, permissions, storage, hook trust registry + ├── PluginServiceSupervisor → trusted plugin services as child processes, JSON-RPC over stdio + ├── LaunchPipeline → asks chosen plugins' launch services, merges env/args/files or refuses + ├── EnvironmentRegistry → plugin session environments: prepare, wrap, resume, release, describe + ├── DecisionHooks → base protection (safety/*) then plugin canvastty.decide for PreToolUse / OpenCode tool calls ├── PluginSecretsService → OS-backed encrypted plugin credentials with fail-closed availability ├── PluginMediaService → user-granted music folders, ranged audio streams, playlist files ├── HermesHudService → permission-gated Hermes Desktop HUD lifecycle through a fixed control contract @@ -35,10 +39,14 @@ Electron main process - `src/preload/index.ts` exposes only the typed capabilities the renderer needs. Node integration stays disabled; context isolation and sandbox stay enabled. - Terminal file drops resolve native `File` objects through preload's `webUtils.getPathForFile`, format paths for the host's default shell, and paste through xterm without submitting. File contents are not read and no new main-process IPC is exposed. - `src/main/ipc/registerIpc.ts` owns native side effects and validates access to persisted media. -- `src/main/services/TerminalManager.ts` is the source of truth for live session state and PTY buffers. It keeps scrollback in a bounded chunk buffer and coalesces PTY data into 16ms IPC batches so clear/redraw sequences reach xterm together. A plain terminal starts `idle`; an agent stays `unavailable` until its provider emits a machine-readable lifecycle signal. Codex, Claude Code, Qwen Code, Kimi Code, OpenCode, Hermes, and Grok Build then transition through `idle`, `working`, and `needs_approval` from provider hooks; exact Claude/Qwen OSC 0/2 markers remain a compatibility fallback. Human-readable terminal text and PTY existence are never treated as activity. Process exit provides only `done` or `failed`. An exited PTY may be restarted under the same session ID while preserving its card, bounds, title, and scrollback. Optional restart persistence writes only provider/profile/title/cwd/bounds descriptors through `TerminalSessionStore`; it never writes PTY scrollback, child environment, or capabilities. Restored agents use each provider's native project-scoped continue mode, while plain terminals reopen as fresh shells in their saved folder. +- `src/main/services/TerminalManager.ts` is the source of truth for live session state and PTY buffers. It keeps scrollback in a bounded chunk buffer and coalesces PTY data into 16ms IPC batches so clear/redraw sequences reach xterm together. A plain terminal starts `idle`; an agent stays `unavailable` until its provider emits a machine-readable lifecycle signal. Codex, Claude Code, Qwen Code, Kimi Code, OpenCode, Hermes, and Grok Build then transition through `idle`, `working`, and `needs_approval` from provider hooks; exact Claude/Qwen OSC 0/2 markers remain a compatibility fallback. Human-readable terminal text and PTY existence are never treated as activity. Process exit provides only `done` or `failed`. An exited PTY may be restarted under the same session ID while preserving its card, bounds, title, and scrollback. Optional restart persistence (Settings → General, "Agent sessions after restart": Don't save / Reopen windows / Continue conversations) writes session records v2 through `TerminalSessionStore`: the card descriptor, the state at quit or exit, the provider conversation id a lifecycle hook reported (`threadId`: Codex thread or Claude session UUID, OpenCode `ses_` id), the per-card restore flag, and two opaque plugin slots (launch options and an environment reference, at most 4 KB each). It never writes PTY scrollback, prompts, child environment, secrets, or capabilities. Plain terminals reopen as fresh shells in their saved folder. - `src/main/services/LimitsService.ts` reads Codex through the installed CLI's app-server protocol and Claude, Kimi, OpenCode Go, and Grok Build through their provider usage or billing endpoints. Qwen Code is multi-provider and exposes no provider-neutral read-only quota protocol, so its adapter reports `cli-not-found` or `unsupported-protocol` and never invents percentages. Provider credentials are read only inside the trusted main process, sent only to the matching provider over HTTPS, and never logged or exposed over IPC. The service owns timeout, structural normalization, caching, stale fallback, and subprocess cleanup; raw provider responses never cross IPC. - `src/main/services/SettingsStore.ts` normalizes every update and persists through a serialized atomic write. Canvas regions and sticky notes have independent persistence gates: disabling one keeps its live objects for the current process but omits that collection from the disk snapshot and therefore from the next launch. The configurable canvas launcher and UI scale use the same boundary; transient window stacking does not. - `src/main/services/PluginManager.ts` installs ready-to-run repositories without executing package scripts during install/update, rejects symlinks and oversized packages, persists the enabled registry, serves only contained package files, and enforces per-plugin permissions/storage quotas. Optional native agent-hook entries remain off by default; explicit per-hook trust is persisted in the plugin registry and compiled into a separate private atomic runtime registry. Update, module replacement, plugin disable, and uninstall revoke that trust before executable files change. +- `src/main/services/PluginServiceSupervisor.ts` runs the `services` of an apiVersion 2 plugin only after the separate per-plugin "native code" confirmation, which pins each entry's SHA-256 and is revoked like hook trust. Each service is a `process.execPath` + `ELECTRON_RUN_AS_NODE` child in the plugin folder with an allow-listed environment (no provider keys, tokens, `NODE_OPTIONS`, or `CANVASTTY_*`), newline-delimited JSON-RPC 2.0 over stdio with 1 MB messages and 15 s request timeouts, restart with backoff (at most 5 in 10 minutes), and a bounded per-plugin log. Its host API is `log`, own-plugin `storage.*` behind the `storage` permission, and `event` to the plugin's own surfaces; surfaces reach only their own plugin's services (`service.request`). +- `src/main/services/LaunchPipeline.ts` runs before a card with plugin launch options is spawned (create, restart, restore). It sends `canvastty.launch.prepare` to each chosen plugin's trusted launch service (5 s budget), validates the answers, merges them in plugin-id order, resolves `secretEnv` from the plugin's own secrets in main (the values are masked by `TerminalManager.redactSecrets` in agent-readable text), writes per-run files, and refuses the launch on any refusal, timeout, error, conflict, reserved name or core-owned argument (`coreOwnedLaunchArgument`). `TerminalManager` keeps such a card waiting until the answer arrives and never launches it without the contribution; restore holds it stopped when the plugin is unavailable. +- `src/main/services/EnvironmentRegistry.ts` talks to trusted services that declare `environments` (`environment:provide`). `TerminalManager` calls `canvastty.environment.prepare` once for a card started in an environment and saves the opaque ref (≤4 KB) in the session record; before every start it calls `wrap` and validates the answer (an absolute executable or a bare name resolved on PATH, never a shell string; launch-contributor env rules; `secretEnv` resolved in main and masked) and still spawns the PTY itself. Restore resumes all saved environments first (`resume`), then plans parents before children; an unavailable plugin, a `stopped` answer or a timeout holds the card stopped with the reason and never runs it locally. Closing a card releases its environment with the person's "Keep environment data?" answer; quitting releases nothing unless saving is off (`keepData: true`). +- `src/main/services/DecisionHooks.ts` answers the decision hook (`src/agent-runtime/permission-gate.mjs` as Claude Code/Codex/Qwen Code `PreToolUse`, `opencode-decisions.mjs` in OpenCode's `tool.execute.before`), which reaches `RuntimeGateway` over the session's runtime capability. Base protection (`safety/baseProtection.ts`, `commandFacts.ts`, `shellParse.ts`: deny-only local rules, Settings `baseProtectionEnabled`) runs first; then trusted services that declare `decide` answer `canvastty.decide` in parallel (3 s). Any deny wins, a timeout or error is ask, an allow counts only with the plugin's separate `decisionsMayAllow`. `safety/SecretRedaction.ts` is the redaction registry (vault values, launch `secretEnv`, `redaction.register`, generic shapes) that `TerminalManager.redactSecrets` applies to every text one agent reads from another. - `src/main/services/PluginSecretsService.ts` serializes per-plugin secret writes, encrypts the complete bounded payload through Electron `safeStorage`, rejects plaintext-only backends, and removes each encrypted file on uninstall. `ProviderSecretsService.ts` applies the same architecture to provider API keys for BYOK-capable CLIs: values stay in the main process, and the renderer contract exposes only per-key `configured` flags plus set/clear actions. `ApiProfile` settings entries name model backends (protocol, HTTPS base URL, secret reference) for the same BYOK runtimes; they are not agent providers, and the settings normalizer drops invalid profiles instead of repairing them. - `src/main/services/PluginMediaService.ts` persists per-plugin grants only after a native folder choice, hides absolute paths, skips symlinks, and serves contained audio with HTTP Range semantics. Playlist reads stay inside granted libraries; writes are bounded and atomic under the library's `Playlists/` directory. - `src/main/services/HermesHudService.ts` is the only plugin-facing native application controller. It resolves the installed Hermes CLI through the immutable provider registry, sends only the fixed `--hud`/`--quit` control commands, and derives visible state from Hermes Desktop's validated live runtime record. It never accepts executable paths, arguments, PIDs, or arbitrary commands from plugin code. @@ -52,7 +60,7 @@ Electron main process The primary `BrowserWindow` is created and shown with a lightweight local startup page before settings, plugins, media, and IPC services initialize. Successful initialization replaces that page with the trusted renderer; bootstrap failures replace it with a visible error page and retain a native-dialog fallback. The main process holds Electron's single-instance lock; a rejected second launch raises the running window through the `second-instance` handler so the app never appears to ignore a launch, while background plugin and browser requests never restore, show, or focus an existing window. Native browser contents are focused programmatically only while their owner `BrowserWindow` is already focused; explicit user pointer input remains the only cross-surface focus route. -Runtime plugin code is never imported into main or the trusted renderer bundle. HOME widgets and canvas apps run in sandboxed iframes with an opaque origin. Separate plugin windows use a dedicated narrow preload which forwards the same message SDK through an IPC handler that verifies the actual `canvastty-plugin:///` sender URL. Explicitly trusted agent hooks run only in isolated child processes, not in either trusted JavaScript context; they are privileged OS code rather than sandboxed web contributions. Arbitrary native OS windows are not embedded. +Runtime plugin code is never imported into main or the trusted renderer bundle. HOME widgets and canvas apps run in sandboxed iframes with an opaque origin. Separate plugin windows use a dedicated narrow preload which forwards the same message SDK through an IPC handler that verifies the actual `canvastty-plugin:///` sender URL. Explicitly trusted agent hooks and plugin services run only in isolated child processes, not in either trusted JavaScript context; they are privileged OS code rather than sandboxed web contributions. Arbitrary native OS windows are not embedded. Plugin music access is capability-based rather than generic filesystem access. Media scans return library IDs, relative paths, metadata, and `canvastty-media://` stream URLs; raw playlist text remains the only format-neutral file content exposed. A media URL is resolved only for the owning enabled plugin and only beneath a previously selected library root. Removing a plugin revokes its persisted folder grants. @@ -81,6 +89,7 @@ App ├── AgentLaunchDialog fixed provider + folder + profile + launch └── SettingsPanel two-pane icon-sidebar modal for General, Appearance, Agents, Controls, Browser, Plugins, and About ├── AgentHooksSettings built-in status revocation and explicit plugin-hook trust + ├── PluginServicesSettings per-plugin native-code trust, service state and log ├── AboutSettings app identity and expandable hook/data/security FAQ └── PluginSettingsSection install preview, permissions, registry, and contributions ``` @@ -91,7 +100,7 @@ Keep domain decisions in pure selectors such as `homeModel.ts`, orchestration in ## Session flow -When terminal restore is enabled, startup loads validated window descriptors before the renderer and relaunches each saved agent through its provider's native continue mode. Stable CanvasTTY session IDs preserve card identity, while region membership remains spatial and requires the complete card bounds to be inside the region at region-drag start. Grok restoration still waits for the renderer-measured xterm grid before spawning. Turning restore off clears the descriptor store immediately; it remains off by default. +When session restore is on, startup loads validated records before the renderer and restores parents before children (`sessionRestorePlan.ts`). "Continue conversations" resumes the conversation id the card's lifecycle hook reported (`codex resume `, `claude --resume `, `opencode --session `); without one Codex opens its own resume picker, and other CLIs use their "latest in this folder" flag only when that CLI has exactly one card in the folder, and otherwise start fresh and say so on the card. A plain Restart starts a new conversation and forgets the id; Continue on a stopped card resumes it. Cards that had already exited come back stopped with Restart / Continue, cards marked "Don't restore this card" do not come back, and a card placed in an environment that is unavailable comes back stopped with the reason and is never started locally. Stable CanvasTTY session IDs preserve card identity, while region membership remains spatial and requires the complete card bounds to be inside the region at region-drag start. Grok restoration still waits for the renderer-measured xterm grid before spawning. Turning restore off clears the descriptor store immediately; it remains off by default. 1. Home requests a terminal or opens a provider-specific launch card. 2. `App` sends a typed `terminal:create` request. @@ -126,6 +135,6 @@ Session counters, progress bars, and statuses must always derive from actual `Se - Add a provider in `ProviderId`, `providers.ts`, `TerminalManager.resolveLaunch`, the official provider asset map, and an optional safe limit adapter. - Add a persisted setting to `AppSettings`, defaults/normalization in `SettingsStore`, and the owning feature only. Settings owns user-facing canvas controls and shortcuts; camera math and snapping geometry remain pure renderer concerns. - Add a canvas entity as a separate feature component with an explicit position and callbacks; keep camera ownership in `WorkspaceCanvas`. -- Publish a runtime extension with `canvastty.plugin.json` API v1 and static HTML/CSS/JS entries. Contribution kinds are `home-widget`, `canvas-app`, and `window`; capability access is restricted to declared permissions. See [Runtime plugins](plugins.md). +- Publish a runtime extension with `canvastty.plugin.json` API v1 (or v2 for `services`) and static HTML/CSS/JS entries. Contribution kinds are `home-widget`, `canvas-app`, and `window`; capability access is restricted to declared permissions. See [Runtime plugins](plugins.md). Every extension should pass `npm run typecheck`, `npm run build`, and a real Electron interaction check. diff --git a/docs/agent-orchestration.md b/docs/agent-orchestration.md index f5c9753d..d7527491 100644 --- a/docs/agent-orchestration.md +++ b/docs/agent-orchestration.md @@ -29,7 +29,7 @@ node scripts/canvastty-control.mjs interrupt SESSION_ID Output is JSON; `--json` is accepted explicitly too. Save the returned session ID. For each send, use its returned `resultRevisionBefore` as `result --after`, rather than repeatedly using zero. -`create` defaults to **YOLO** and passes Codex's real `--dangerously-bypass-approvals-and-sandbox` flag. Workers can edit files and run tests. Their global settings are unchanged. Specify `--profile normal` to use the ordinary provider configuration instead. Scope tasks and external actions explicitly: full access is not filesystem isolation. A control API with no deletion command does not prevent a full-access model from deleting files. +`create` defaults to **YOLO** and passes Codex's real `--dangerously-bypass-approvals-and-sandbox` flag. Workers can edit files and run tests. Their global settings are unchanged. Specify `--profile normal` to use the ordinary provider configuration instead. Scope tasks and external actions explicitly: full access is not filesystem isolation. A control API with no deletion command does not prevent a full-access model from deleting files. Base protection (Settings → Agents, on by default) still refuses elevation, pipes into a shell, disk commands, and writes or deletes outside the working folder before the tool call runs, YOLO included; it is a guard through the agent's own hook, not a sandbox. Screens, results and failure details returned to a controller are masked for known keys and common key shapes. The backend uses `TerminalManager` and the existing native provider path. `cwd`, title and profile apply at creation. YOLO is retained by ordinary native restart/restore. Creation does not activate a canvas window or request UI focus. diff --git a/docs/canvastty-plugin.schema.json b/docs/canvastty-plugin.schema.json index 9cbde4f2..09889ea0 100644 --- a/docs/canvastty-plugin.schema.json +++ b/docs/canvastty-plugin.schema.json @@ -1,16 +1,19 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/howdeploy/CanvasTTY/blob/main/docs/canvastty-plugin.schema.json", - "title": "CanvasTTY plugin manifest v1", + "title": "CanvasTTY plugin manifest (apiVersion 1 and 2)", "type": "object", "additionalProperties": false, "required": ["apiVersion", "id", "name", "version", "description", "permissions", "contributions"], "anyOf": [ { "properties": { "contributions": { "minItems": 1 } } }, - { "required": ["hooks"], "properties": { "hooks": { "minItems": 1 } } } + { "required": ["hooks"], "properties": { "hooks": { "minItems": 1 } } }, + { "required": ["services"], "properties": { "services": { "minItems": 1 } } } ], + "if": { "properties": { "apiVersion": { "const": 1 } } }, + "then": { "not": { "required": ["services"] } }, "properties": { - "apiVersion": { "const": 1 }, + "apiVersion": { "enum": [1, 2] }, "id": { "type": "string", "minLength": 3, "maxLength": 80, "pattern": "^(?!.*\\.\\.)[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$" }, "name": { "type": "string", "minLength": 1, "maxLength": 80 }, "version": { "type": "string", "maxLength": 40, "pattern": "^\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?$" }, @@ -34,7 +37,7 @@ "type": "array", "maxItems": 12, "uniqueItems": true, - "items": { "enum": ["storage", "secrets", "sessions:read", "limits:read", "launcher:open", "external:open", "browser:open", "media:library", "playlists:read", "playlists:write", "hermes:hud", "network"] } + "items": { "enum": ["storage", "secrets", "sessions:read", "limits:read", "launcher:open", "external:open", "browser:open", "media:library", "playlists:read", "playlists:write", "hermes:hud", "network", "launch:contribute", "environment:provide", "decision:provide"] } }, "contributions": { "type": "array", @@ -47,6 +50,13 @@ "minItems": 1, "maxItems": 16, "items": { "$ref": "#/$defs/agentHook" } + }, + "services": { + "description": "apiVersion 2 only: bundled single-file services run as separate processes after the user trusts the plugin's native code.", + "type": "array", + "minItems": 1, + "maxItems": 8, + "items": { "$ref": "#/$defs/service" } } }, "$defs": { @@ -73,7 +83,7 @@ "type": "array", "maxItems": 12, "uniqueItems": true, - "items": { "enum": ["storage", "secrets", "sessions:read", "limits:read", "launcher:open", "external:open", "browser:open", "media:library", "playlists:read", "playlists:write", "hermes:hud", "network"] } + "items": { "enum": ["storage", "secrets", "sessions:read", "limits:read", "launcher:open", "external:open", "browser:open", "media:library", "playlists:read", "playlists:write", "hermes:hud", "network", "launch:contribute", "environment:provide", "decision:provide"] } }, "files": { "type": "array", "minItems": 1, "maxItems": 500, "items": { "$ref": "#/$defs/moduleAsset" } } } @@ -102,6 +112,90 @@ "module": { "type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$" } } }, + "service": { + "type": "object", + "additionalProperties": false, + "required": ["id", "title", "entry"], + "properties": { + "id": { "type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$" }, + "title": { "type": "string", "minLength": 1, "maxLength": 80 }, + "description": { "type": "string", "minLength": 1, "maxLength": 240 }, + "entry": { "type": "string", "minLength": 1, "maxLength": 180, "pattern": "^(?!/)(?!.*\\\\)(?!\\.\\.?/)(?!.*(?:/\\.\\.?/|/\\.\\.?$|//)).+\\.(?:js|mjs|cjs)$" }, + "module": { "type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$" }, + "launch": { "$ref": "#/$defs/serviceLaunch" }, + "environments": { + "description": "Session environments (needs environment:provide; at most 8 kinds per plugin, unique across its services): kinds offered under Where in the launcher; the host calls canvastty.environment.prepare/wrap/resume/release/describe.", + "type": "array", "minItems": 1, "maxItems": 8, + "items": { "$ref": "#/$defs/environmentKind" } + }, + "decide": { + "description": "Decision hooks (needs decision:provide; at most one service per plugin): the host calls canvastty.decide before local agents' shell and file-writing tool calls run; answers deny, ask or allow (allow counts only after a second confirmation).", + "type": "object", + "additionalProperties": false, + "required": ["events"], + "properties": { + "events": { "type": "array", "minItems": 1, "items": { "enum": ["pre-tool"] } }, + "timeoutMs": { "description": "How long the host waits for an answer (the agent's call waits as long); 3000 when omitted.", "type": "integer", "minimum": 1000, "maximum": 60000 }, + "appliesTo": { + "type": "array", "minItems": 1, "uniqueItems": true, + "items": { "enum": ["codex", "claude", "qwen", "kimi", "opencode", "hermes", "grok", "omp", "pi", "cursor", "minimax", "devin", "antigravity"] } + } + } + } + } + }, + "serviceLaunch": { + "description": "Launch contributor (needs launch:contribute; at most one service per plugin): options shown in the agent launcher under Advanced; the host calls canvastty.launch.prepare for launches that chose this plugin, and with policy for every agent launch (chosen: false, refusal only).", + "type": "object", + "additionalProperties": false, + "required": ["fields"], + "properties": { + "appliesTo": { + "type": "array", "minItems": 1, "uniqueItems": true, + "items": { "enum": ["codex", "claude", "qwen", "kimi", "opencode", "hermes", "grok", "omp", "pi", "cursor", "minimax", "devin", "antigravity"] } + }, + "fields": { "type": "array", "maxItems": 8, "items": { "$ref": "#/$defs/launchField" } }, + "policy": { "description": "Also ask before every launch of these agents where the person did not choose the plugin; that answer may only refuse, and no answer refuses too.", "type": "boolean" } + } + }, + "environmentKind": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "label"], + "properties": { + "kind": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]{0,31}$" }, + "label": { "type": "string", "minLength": 1, "maxLength": 80 }, + "description": { "type": "string", "minLength": 1, "maxLength": 240 }, + "appliesTo": { + "type": "array", "minItems": 1, "uniqueItems": true, + "items": { "enum": ["terminal", "codex", "claude", "qwen", "kimi", "opencode", "hermes", "grok", "omp", "pi", "cursor", "minimax", "devin", "antigravity"] } + }, + "fields": { "type": "array", "maxItems": 8, "items": { "$ref": "#/$defs/launchField" } } + } + }, + "launchField": { + "type": "object", + "additionalProperties": false, + "required": ["key", "label", "kind"], + "properties": { + "key": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_]{0,39}$" }, + "label": { "type": "string", "minLength": 1, "maxLength": 80 }, + "kind": { "enum": ["boolean", "select", "text"] }, + "options": { + "type": "array", "minItems": 1, "maxItems": 16, + "items": { + "type": "object", "additionalProperties": false, "required": ["value", "label"], + "properties": { + "value": { "type": "string", "minLength": 1, "maxLength": 200 }, + "label": { "type": "string", "minLength": 1, "maxLength": 80 } + } + } + }, + "optionsFrom": { "const": "service", "description": "select only: the launcher also asks the service (canvastty.launch.options) for up to 64 more choices." }, + "default": { "type": ["boolean", "string"] }, + "maxLength": { "type": "integer", "minimum": 1, "maximum": 200 } + } + }, "baseContribution": { "type": "object", "additionalProperties": false, diff --git a/docs/plugin-api.d.ts b/docs/plugin-api.d.ts index 66237995..85b525b5 100644 --- a/docs/plugin-api.d.ts +++ b/docs/plugin-api.d.ts @@ -29,6 +29,7 @@ export interface CanvasTTYPluginHost { request(method: "secrets.get", params: { key: string }): Promise; request(method: "secrets.set", params: { key: string; value: string }): Promise; request(method: "secrets.delete", params: { key: string }): Promise; + request(method: "service.request", params: { serviceId: string; method: string; params?: unknown }): Promise; request(method: string, params?: Record): Promise; storage: { get(key: string): Promise; @@ -58,6 +59,12 @@ export interface CanvasTTYPluginHost { open(): Promise; close(): Promise; }; + /** Talks to this plugin's own services only (apiVersion 2 `services`). */ + service: { + /** Rejects when the service is not running (native code not trusted, disabled, restarting, failed) or after 15 s. */ + request(serviceId: string, method: string, params?: unknown): Promise; + onEvent(listener: (event: CanvasTTYPluginServiceEvent) => void): () => void; + }; onContext(listener: (context: CanvasTTYPluginContext) => void): () => void; onStorageChange(listener: (key: string, value: unknown) => void): () => void; } @@ -68,7 +75,7 @@ export interface CanvasTTYPluginContext { id: string; name: string; version: string; - permissions: Array<"storage" | "secrets" | "sessions:read" | "limits:read" | "launcher:open" | "external:open" | "browser:open" | "media:library" | "playlists:read" | "playlists:write" | "hermes:hud" | "network">; + permissions: Array<"storage" | "secrets" | "sessions:read" | "limits:read" | "launcher:open" | "external:open" | "browser:open" | "media:library" | "playlists:read" | "playlists:write" | "hermes:hud" | "network" | "launch:contribute" | "environment:provide" | "decision:provide">; modules: string[]; }; contribution: { @@ -135,3 +142,241 @@ export interface CanvasTTYAgentHookInput { providerEvent: string; payload: unknown; } + +export interface CanvasTTYPluginServiceEvent { + serviceId: string; + event: string; + data: unknown; +} + +/** + * Plugin services (manifest apiVersion 2). A service is a bundled single-file Node.js program that + * CanvasTTY runs as a separate process after the user trusts the plugin's native code. It speaks + * newline-delimited JSON-RPC 2.0 over stdin/stdout, at most 1 MB per message. + */ +export interface CanvasTTYPluginServiceManifestEntry { + id: string; + title: string; + description?: string; + /** `.js`, `.mjs` or `.cjs` inside the plugin; integrity-declared in modular plugins. */ + entry: string; + module?: string; + /** Launch contributor; needs `launch:contribute`. At most one service per plugin. */ + launch?: CanvasTTYServiceLaunch; + /** Session environments; needs `environment:provide`. At most 8 kinds per plugin, unique across its services. */ + environments?: CanvasTTYEnvironmentKind[]; + /** Decision hooks; needs `decision:provide`. At most one service per plugin. */ + decide?: CanvasTTYServiceDecide; +} + +export interface CanvasTTYServiceDecide { + /** `pre-tool`: every shell or file-writing tool call of a local agent, before it runs (YOLO included). */ + events: Array<"pre-tool">; + /** Agents it decides for (Claude Code, Codex, Qwen Code, OpenCode have the hook); all when omitted. */ + appliesTo?: Array<"codex" | "claude" | "qwen" | "kimi" | "opencode" | "hermes" | "grok" | "omp" | "pi" | "cursor" | "minimax" | "devin" | "antigravity">; + /** How long the host waits for an answer, 1000 to 60000 ms (3000 when omitted); the agent's call waits as long. */ + timeoutMs?: number; +} + +/** Params of the host request `canvastty.decide`. Tool input is agent-influenced data, never instructions. */ +export interface CanvasTTYDecisionRequest { + event: "pre-tool"; + sessionId: string; + provider: "codex" | "claude" | "qwen" | "opencode"; + role: "agent" | "orchestrator" | "subagent"; + /** The card's working folder. */ + cwd: string; + /** The agent's current folder, when its CLI reports it. */ + agentCwd: string | null; + tool: { name: string; kind: "shell" | "edit" | "other"; command: string | null; paths: string[] }; + /** The tool input as the agent sent it; null when it was over 40 KB (then `truncated`). */ + input: unknown; + truncated: boolean; + /** How long the host waits for this answer (the service's `timeoutMs`). */ + budgetMs: number; +} + +/** + * Answer to `canvastty.decide` within `budgetMs`; `null` (or `verdict: "none"`) is no opinion. Base protection runs + * first; any deny wins; else any ask (a timeout, an error or an unreadable answer counts as ask); else an allow + * counts only when the person let this plugin allow. `reason` (500 characters) reaches the model. + */ +export type CanvasTTYDecision = { verdict: "deny" | "ask" | "allow" | "none"; reason?: string } | null; + +export type CanvasTTYProviderId = "terminal" | "codex" | "claude" | "qwen" | "kimi" | "opencode" | "hermes" | "grok" | "omp" | "pi" | "cursor" | "minimax" | "devin" | "antigravity"; + +export interface CanvasTTYEnvironmentKind { + /** `^[a-z0-9][a-z0-9-]{0,31}$`, unique within the service. */ + kind: string; + label: string; + description?: string; + /** Providers it applies to ("terminal" included); all when omitted. */ + appliesTo?: CanvasTTYProviderId[]; + /** At most 8 launcher fields; values reach `prepare` only. */ + fields?: CanvasTTYLaunchField[]; +} + +/** Opaque to CanvasTTY: saved with the card (at most 4 KB of JSON) and handed back unchanged. */ +export type CanvasTTYEnvironmentRef = unknown; + +/** `canvastty.environment.prepare` (15 s): create the place once, when the card first starts. */ +export interface CanvasTTYEnvironmentPrepareParams { + sessionId: string; + kind: string; + provider: CanvasTTYProviderId; + cwd: string; + options: Record; +} +export type CanvasTTYEnvironmentPrepareResult = + /** `label` is the card badge (80 characters); `cwd` (an existing absolute folder) becomes the card's folder. */ + | { ref: CanvasTTYEnvironmentRef; label: string; cwd?: string } + | { refuse: { reason: string } }; + +/** `canvastty.environment.wrap` (5 s): before every start; the host still spawns the PTY. */ +export interface CanvasTTYEnvironmentWrapParams { + sessionId: string; + kind: string; + ref: CanvasTTYEnvironmentRef; + provider: CanvasTTYProviderId; + command: string; + args: string[]; + /** The launch's own variables, without reserved `CANVASTTY_*` names and without secret values. */ + env: Record; + /** Names whose values the spawned process gets from the host (forward them by name). */ + secretEnvNames: string[]; + cwd: string; +} +export type CanvasTTYEnvironmentWrapResult = + | { + /** An absolute path to an executable, or a bare program name resolved on PATH. Never a shell string. */ + command: string; + /** At most 256, 8 KB each, no NUL. */ + args: string[]; + /** Launch-contributor rules: no reserved names, no names this launch already sets. */ + env?: Record; + /** Env name -> the plugin's own secret key (needs `secrets`); set and masked by the host. */ + secretEnv?: Record; + /** An existing absolute folder; the launch's folder when omitted. */ + cwd?: string; + } + | { refuse: { reason: string } }; + +/** `canvastty.environment.resume` (10 s): on restore, and before restarting a card from an earlier run. */ +export type CanvasTTYEnvironmentResumeResult = { ok: true } | { stopped: { reason: string } }; + +/** `canvastty.environment.release` (10 s): the card was closed, or the app quit with saving off. */ +export interface CanvasTTYEnvironmentReleaseParams { + sessionId: string; + kind: string; + ref: CanvasTTYEnvironmentRef; + /** The person's answer to "Keep environment data?"; always true when quitting. */ + keepData: boolean; + reason: "closed" | "quit"; +} + +/** `canvastty.environment.describe` (3 s): the card badge and its tooltip. */ +export interface CanvasTTYEnvironmentDescribeResult { + label: string; + detail?: string; +} + +export interface CanvasTTYServiceLaunch { + /** Agent providers the options apply to; every agent when omitted. */ + appliesTo?: Array<"codex" | "claude" | "qwen" | "kimi" | "opencode" | "hermes" | "grok" | "omp" | "pi" | "cursor" | "minimax" | "devin" | "antigravity">; + /** At most 8, shown in the agent launcher under Advanced (a policy with none is not shown). */ + fields: CanvasTTYLaunchField[]; + /** Also asked before every launch of these agents where the person did not choose the plugin (`chosen: false`); + * such an answer may only refuse, and no answer refuses too. */ + policy?: boolean; +} + +export type CanvasTTYLaunchField = + | { key: string; label: string; kind: "boolean"; default?: boolean } + | { + key: string; label: string; kind: "select"; options: Array<{ value: string; label: string }>; default?: string; + /** The launcher also asks `canvastty.launch.options` (3 s) for up to 64 more choices; the saved value is then any + * text up to 200 characters, which `canvastty.launch.prepare` must check. */ + optionsFrom?: "service"; + } + | { key: string; label: string; kind: "text"; maxLength?: number; default?: string }; + +/** Params of the host request `canvastty.launch.prepare`: launches that chose this plugin, and with `policy` every agent launch. */ +export interface CanvasTTYLaunchContext { + sessionId: string; + provider: "terminal" | "codex" | "claude" | "qwen" | "kimi" | "opencode" | "hermes" | "grok" | "omp" | "pi" | "cursor" | "minimax" | "devin" | "antigravity"; + profile: "normal" | "yolo"; + role: "agent" | "orchestrator" | "subagent"; + cwd: string; + parentSessionId?: string; + /** The app is bringing back a saved card. */ + restoring: boolean; + /** The agent continues an earlier conversation. */ + resume: boolean; + /** This plugin's values for this card, checked against its fields; empty when `chosen` is false. */ + options: Record; + /** The person chose this plugin for the launch; false for a policy check, whose answer may only refuse. */ + chosen: boolean; + /** Where the card runs: the chosen or saved environment, or null on this computer. */ + environment: { pluginId: string; kind: string } | null; +} + +/** + * Answer to `canvastty.launch.prepare` (or `null`). Answer within 5 s: a timeout, error or invalid + * answer refuses the launch. Conflicting names between plugins, reserved names and approval or + * conversation arguments refuse it as well. + */ +export interface CanvasTTYLaunchContribution { + /** At most 32; values up to 8 KB. `{launchFiles}` becomes this run's folder of `files`. */ + env?: Record; + /** Env name -> the plugin's own secret key (needs `secrets`); the host sets the value and masks it. */ + secretEnv?: Record; + /** At most 32, appended after CanvasTTY's own arguments. */ + args?: string[]; + /** At most 16 files, 256 KB, removed when the process exits. */ + files?: Array<{ relPath: string; content: string }>; + refuse?: { reason: string }; +} + +/** Params of the first host notification, `canvastty.initialize`. */ +export interface CanvasTTYServiceContext { + apiVersion: 2; + pluginId: string; + serviceId: string; + /** `/plugin-data/`: created before start, removed on uninstall. */ + dataDir: string; + locale: string; + hostVersion: string; +} + +/** Notifications the host sends to a service. */ +export type CanvasTTYServiceHostNotification = + | { jsonrpc: "2.0"; method: "canvastty.initialize"; params: CanvasTTYServiceContext } + | { jsonrpc: "2.0"; method: "canvastty.shutdown"; params: Record }; + +/** Requests the host sends to a service, which plugin surfaces cannot send. */ +export type CanvasTTYServiceHostRequest = + | { jsonrpc: "2.0"; id: number; method: "canvastty.launch.prepare"; params: CanvasTTYLaunchContext } + /** Answer `{ "": [{ value, label }] }` (at most 64 per field) within 3 s. */ + | { jsonrpc: "2.0"; id: number; method: "canvastty.launch.options"; params: { provider: CanvasTTYLaunchContext["provider"]; fields: string[] } } + | { jsonrpc: "2.0"; id: number; method: "canvastty.environment.prepare"; params: CanvasTTYEnvironmentPrepareParams } + | { jsonrpc: "2.0"; id: number; method: "canvastty.environment.wrap"; params: CanvasTTYEnvironmentWrapParams } + | { jsonrpc: "2.0"; id: number; method: "canvastty.environment.resume"; params: { sessionId: string; kind: string; ref: CanvasTTYEnvironmentRef } } + | { jsonrpc: "2.0"; id: number; method: "canvastty.environment.release"; params: CanvasTTYEnvironmentReleaseParams } + | { jsonrpc: "2.0"; id: number; method: "canvastty.environment.describe"; params: { sessionId: string; kind: string; ref: CanvasTTYEnvironmentRef } } + | { jsonrpc: "2.0"; id: number; method: "canvastty.decide"; params: CanvasTTYDecisionRequest }; + +/** Methods a service may call on the host. Every other method is answered with error -32601. */ +export interface CanvasTTYServiceHostApi { + /** Request or notification. */ + log(params: { level?: "info" | "warn" | "error"; message: string }): null; + /** Needs the `storage` permission. */ + "storage.get"(params: { key: string }): unknown; + /** Needs the `storage` permission. */ + "storage.set"(params: { key: string; value: unknown }): null; + /** Notification only: delivered to this plugin's surfaces through `host.service.onEvent`. */ + event(params: { event: string; data?: unknown }): void; + /** Up to 32 values (8 to 4096 characters) masked in every text one agent reads from another; memory only. */ + "redaction.register"(params: { values: string[] }): null; + /** Needs the `secrets` permission: the plugin's own secret, or null; the value is masked for agents from then on. */ + "secrets.get"(params: { key: string }): string | null; +} diff --git a/docs/plugins.md b/docs/plugins.md index 6be85806..6cf366c6 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -2,7 +2,7 @@ [English](plugins.md) · [Русский](plugins.ru.md) · [简体中文](plugins.zh-CN.md) · [Docs home](README.md) -CanvasTTY runtime plugins are installed from an HTTPS GitHub repository. A plugin can contribute sandboxed web surfaces and can optionally declare agent hook scripts. Web contributions run without Node.js. Agent hooks are a separate, explicit trust boundary and stay disabled until the user enables each hook in **Settings → Agents → Hooks**. +CanvasTTY runtime plugins are installed from an HTTPS GitHub repository. A plugin can contribute sandboxed web surfaces and can optionally declare agent hook scripts and long-lived services. Web contributions run without Node.js. Agent hooks and services are native code: a separate, explicit trust boundary that stays off until the user enables each hook in **Settings → Agents → Hooks** and each plugin's services in **Settings → Agents → Extension native code**. ## Trust model @@ -15,6 +15,7 @@ Installing a plugin is equivalent to allowing third-party browser code to run lo - Every privileged SDK method is gated by a manifest permission. Permissions are shown before the user confirms installation. - Sandboxed web contributions never receive provider credentials, PTY buffers, working directories, raw provider responses, or filesystem access. - Disabling or uninstalling a plugin immediately stops serving its assets and closes its separate windows. +- Declared services follow the same rule as hooks, per plugin: install never starts them, and update, module changes, disabling, or a changed entry file revokes the confirmation. Services run out of process; no plugin code runs in the CanvasTTY main process. - Declared agent hooks are never enabled by install, update, or module changes. Enabling one is equivalent to running that repository's JavaScript as a native application with the current user's OS privileges, access to the provider event payload, and potential access to user-readable configuration or credentials. Updating the plugin, replacing modules, or disabling the plugin revokes every enabled hook so changed code must be trusted again. CanvasTTY does not embed arbitrary native OS windows. A `window` contribution is a sandboxed CanvasTTY-owned `BrowserWindow`. Native reparenting is not portable or reliable across Wayland, macOS, Windows, DPI modes, popups, and GPU surfaces. @@ -35,7 +36,7 @@ windows/focus.js hooks/audit.mjs ``` -An end-to-end sandboxed web-surface example (without a privileged hook) lives in [`examples/plugins/studio-kit`](../examples/plugins/studio-kit). +An end-to-end sandboxed web-surface example (without a privileged hook) lives in [`examples/plugins/studio-kit`](../examples/plugins/studio-kit). A minimal service with a canvas app that calls it lives in [`examples/plugins/service-echo`](../examples/plugins/service-echo). A launch contributor lives in [`examples/plugins/launch-env`](../examples/plugins/launch-env), and a launch policy in [`examples/plugins/yolo-guard`](../examples/plugins/yolo-guard). A session environment (git worktree) lives in [`examples/plugins/env-worktree`](../examples/plugins/env-worktree). A decision service lives in [`examples/plugins/deny-rm`](../examples/plugins/deny-rm). Editor tooling can use the [manifest JSON Schema](canvastty-plugin.schema.json) and [SDK TypeScript declarations](plugin-api.d.ts). ## Manifest v1 @@ -121,6 +122,190 @@ interface CanvasTTYAgentHookInput { Hook stdout/stderr is discarded, execution is time-bounded, and CanvasTTY's internal runtime/browser capability tokens are removed from the child environment. This is isolation from host internals, not a sandbox: the hook still has the user's normal filesystem and process privileges. +### Services (apiVersion 2) + +A manifest with `"apiVersion": 2` may declare up to 8 `services`. Version 1 manifests stay valid; only `services` needs version 2. + +```json +"services": [ + { "id": "echo", "title": "Echo", "description": "Echoes requests.", "entry": "services/echo.mjs" } +] +``` + +A service has a stable `id`, a `title`, an optional `description` and `module`, and an `entry` ending in `.js`, `.mjs`, or `.cjs`. The entry must be a bundled single file (for example built with esbuild): the installer runs no build and no `npm install`, and Electron and node-pty are not available to it. In a modular plugin the entry must be integrity-declared by its `module`, or by `coreFiles` when it has none, exactly like hook entries. When the user trusts the plugin's native code, CanvasTTY records the entry's SHA-256 and checks it again before every start; a changed file is never run and the confirmation is revoked on the next launch. + +Lifecycle: every service of an enabled, trusted plugin runs as its own process (`process.execPath` with `ELECTRON_RUN_AS_NODE=1`) with the plugin folder as its working directory. The environment is minimal: `PATH`, `HOME`, user, shell, locale, temp and XDG folders, `SSH_AUTH_SOCK`, and the Windows system folders. Provider keys, tokens, `NODE_OPTIONS`, and every `CANVASTTY_*` variable are removed. A service that exits unexpectedly restarts after 1, 2, 4, 8, then 16 s; after more than 5 unexpected exits in 10 minutes it stays failed until its trust is confirmed again. Disabling, uninstalling, updating, changing modules, revoking trust, or quitting CanvasTTY stops it: first a `canvastty.shutdown` notification and closed stdin, then `SIGTERM`, then `SIGKILL`. `/plugin-data/` is created for the service and removed on uninstall. Its stderr, non-protocol stdout, `log` calls, and lifecycle events go to a bounded per-plugin log (the last 300 entries) shown under the plugin in **Settings → Agents → Extension native code**. + +Protocol: newline-delimited JSON-RPC 2.0 over stdin/stdout, at most 1 MB per message in each direction. A larger message from the host is refused; a larger line from the service is dropped and logged. The host first sends a notification: + +```json +{"jsonrpc":"2.0","method":"canvastty.initialize","params":{"apiVersion":2,"pluginId":"com.example.service-echo","serviceId":"echo","dataDir":"…/plugin-data/com.example.service-echo","locale":"en","hostVersion":"1.5.2"}} +``` + +Requests from the plugin's own surfaces arrive with the method and params chosen by the surface; method names starting with `canvastty.` are reserved for the host. Answer with `{"jsonrpc":"2.0","id":…,"result":…}` or `{"jsonrpc":"2.0","id":…,"error":{"code":-32000,"message":"…"}}`. A request unanswered within 15 s fails with a timeout error, as does a request while the service is stopped, restarting, or failed; at most 64 requests wait at once per service. + +A service may call back this host API (the base that later extension points add to; anything else is answered with error `-32601`): + +| Method | Kind | Gate | Result | +|:--|:--|:--|:--| +| `log` `{ level?: "info" \| "warn" \| "error", message }` | request or notification | none | Adds a line to the plugin log | +| `storage.get` `{ key }` | request | `storage` permission | The same isolated 64 KB storage as `host.storage.get` | +| `storage.set` `{ key, value }` | request | `storage` permission | Writes it and notifies the plugin's surfaces | +| `event` `{ event, data }` | notification | none | Delivered to this plugin's live surfaces through `host.service.onEvent` | +| `redaction.register` `{ values }` | request | none | Up to 32 strings (4096 characters each, 8 or more to count) that CanvasTTY masks in every text one agent reads from another; kept in memory only | +| `secrets.get` `{ key }` | request | `secrets` permission | The plugin's own secret (the same store as `host.secrets`), or `null`. The value is then masked like `redaction.register` values. For keys a service needs itself (an API key for a model it calls); never send one back to a surface | + +The host binds every call to the service's own plugin; a service cannot name another plugin, read another plugin's secrets, or reach sessions. The example [`service-echo`](../examples/plugins/service-echo) saves a token from its page with `host.secrets.set` and its service reads it with `secrets.get`, answering only whether one is set. + +UI channel: sandboxed surfaces call their own plugin's services, and only those: + +```js +const reply = await host.service.request("echo", "echo", { text: "hi" }); +host.service.onEvent(({ serviceId, event, data }) => { /* … */ }); +``` + +The permission is implicit when the plugin declares a service. The host relays opaque JSON and never adds credentials. A request to a service that is not running (not trusted yet, disabled, restarting, failed) or that times out rejects with an error. + +### Launch contributors (`launch:contribute`) + +One service per plugin may add a `launch` block. Its fields appear in the agent launcher under **Advanced** once the plugin's native code is trusted; the person turns the plugin on for one launch with **Use _plugin name_** and sets its fields. Only launches where the person chose the plugin, and restarts and restores of those cards, ask the plugin anything. + +```json +"permissions": ["launch:contribute"], +"services": [{ + "id": "launcher", "title": "Launch env", "entry": "services/launcher.mjs", + "launch": { + "appliesTo": ["claude"], + "fields": [ + { "key": "enabled", "label": "Add the variable", "kind": "boolean", "default": true }, + { "key": "greeting", "label": "Value", "kind": "text", "default": "hello", "maxLength": 60 }, + { "key": "mode", "label": "Mode", "kind": "select", "default": "plain", + "options": [{ "value": "plain", "label": "Plain" }, { "value": "loud", "label": "Loud" }] } + ] + } +}] +``` + +Up to 8 fields; `kind` is `boolean`, `select` (1–16 options) or `text` (at most 200 characters, or `maxLength`). `appliesTo` lists agent providers; omitted means every agent. The chosen values are checked against the fields, saved in the card's session record (at most 4 KB per plugin), and reused on restart and restore. They are not secret: put keys in the plugin's `secrets`, never in a field. + +A `select` with `"optionsFrom": "service"` also lists choices the service offers, such as its own accounts. When the launcher opens, CanvasTTY asks the service `canvastty.launch.options` `{ provider, fields: [keys] }` and waits at most 3 s; the answer `{ "": [{ value, label }] }` adds up to 64 choices per field after the declared ones (which stay required and are all the launcher shows when the service does not answer). Because such a list can change after a card was saved, its value is accepted as any text up to 200 characters without control characters, and `canvastty.launch.prepare` must check it and refuse a value it no longer knows. + +Orchestrators pass the same values to `spawn_agent` as `launchOptions` (`{ "": { "": value } }`), checked exactly like the launcher's; a plugin tool can hand them out (for example the account it picked). + +Before the agent starts, the host sends the service a `canvastty.launch.prepare` request, which surfaces cannot send: + +```json +{"sessionId":"…","provider":"claude","profile":"normal","role":"agent","cwd":"/project","restoring":false,"resume":false,"options":{"enabled":true,"greeting":"hello","mode":"plain"}} +``` + +The answer is `null` (nothing to add) or an object with any of: + +| Key | Limit | Effect | +|:--|:--|:--| +| `env` `{ NAME: value }` | 32 names, 8 KB per value | Added to the agent's environment | +| `secretEnv` `{ NAME: secretKey }` | 16 names; needs `secrets` | The host reads the plugin's own secret in the main process and sets it. The value never reaches the service or any UI, and is masked as `` in text other agents and the control CLI read from this card (observe, result, screen, failure details) | +| `args` `[string]` | 32, 1024 characters each, no control characters | Appended after CanvasTTY's own arguments, before the resume selection | +| `files` `[{ relPath, content }]` | 16 files, 256 KB, plain relative paths | Written to a private folder for this run, removed when the process exits; `{launchFiles}` in `env` values and `args` becomes that folder | +| `refuse` `{ reason }` | 240 characters | The card is not started and shows the reason | + +Rules the host enforces, none of which is ever skipped: + +- Several chosen plugins are asked side by side and merged in plugin-id order. Two plugins setting the same name, or a plugin setting a name CanvasTTY sets for this launch, refuses the launch and names them. Names starting with `CANVASTTY_`, `ELECTRON_`, `DYLD_` or `LD_`, and `NODE_OPTIONS`, `PATH`, `TERM`, `COLORTERM`, are reserved. +- Arguments that bypass approvals or pick a conversation (every provider's YOLO flag, `--permission-mode`, `--sandbox`, `--resume`, `--continue`, `--session`, and the like) are refused: the profile and the restore rules stay the person's and the core's. This is not a sandbox; trusted native code already runs as you. +- Claude Code applies only its last `--settings`, so a plugin's inline `--settings` JSON is merged into CanvasTTY's own (objects such as `env` key by key, hook lists appended); one that sets `permissions`, `hooks`, `disableAllHooks`, `sandbox`, `defaultMode` or `apiKeyHelper` is refused. +- No answer within 5 s, an error, an invalid answer, a missing secret, or a plugin that is disabled, removed or no longer trusted refuses the launch with the reason on the card. The agent is never started without a contribution the person chose. A restored card whose plugin is unavailable comes back stopped with that reason and keeps its record until the plugin returns or the card is closed. +- A plain terminal takes no launch options. + +**Launch policies.** With `"policy": true` the service is also asked before every launch of the agents it applies to (create, restart, restore) where the person did not choose it, with `"chosen": false` and empty `options`. Such an answer may only be `null` or `refuse`; anything else, no answer within 5 s, or an error refuses the launch, so a policy never lets a launch through by failing. Every `canvastty.launch.prepare` also carries `"environment"`: `{ pluginId, kind }` of the card's environment, or `null` on this computer. A policy with no `fields` is not shown in the launcher. Revoking the plugin's native code trust removes its policy. + +```json +"launch": { "policy": true, "fields": [] } +``` + +The full examples are [`examples/plugins/launch-env`](../examples/plugins/launch-env) (options) and [`examples/plugins/yolo-guard`](../examples/plugins/yolo-guard) (a policy that refuses YOLO outside an environment). + +### Session environments (`environment:provide`) + +An environment is where a card runs: a git worktree, a container, a remote host. A plugin may list up to 8 `environments` kinds, on one service or split over several (for example one service per module); each kind is unique in the plugin and answered by the service that lists it. Once the plugin's native code is trusted, the launcher's **Advanced** section shows **Where** (default **This computer**) with each kind that applies to the provider, and its optional `fields` (same kinds and limits as launch fields). While a kind applies to terminals, **Open terminal** opens the same launcher (folder and Where) instead of opening at once. + +```json +"permissions": ["environment:provide"], +"services": [{ + "id": "worktree", "title": "Git worktree", "entry": "services/worktree.mjs", + "environments": [{ + "kind": "worktree", "label": "Git worktree", + "description": "A branch in its own folder", + "appliesTo": ["terminal", "claude"], + "fields": [{ "key": "branch", "label": "Branch", "kind": "text", "default": "", "maxLength": 80 }] + }] +}] +``` + +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 | +|:--|:--|:--|:--| +| `canvastty.environment.prepare` | `sessionId, kind, provider, cwd, options` | `{ ref, label, cwd? }` or `{ refuse: { reason } }`. `ref` is opaque JSON of at most 4 KB saved with the card; `label` (80 characters) is the badge; `cwd` (an existing absolute folder) becomes the card's folder | 15 s | +| `canvastty.environment.wrap` | `sessionId, kind, ref, provider, command, args, env, secretEnvNames, cwd` | `{ command, args, env?, secretEnv?, cwd? }` or `{ refuse }` | 5 s | +| `canvastty.environment.resume` | `sessionId, kind, ref` | `{ ok: true }` or `{ stopped: { reason } }` | 10 s | +| `canvastty.environment.release` | `sessionId, kind, ref, keepData, reason` (`closed` or `quit`) | ignored | 10 s | +| `canvastty.environment.describe` | `sessionId, kind, ref` | `{ label, detail? }` for the card badge and its tooltip | 3 s | + +- `prepare` runs once, when the card first starts. `wrap` runs before every start (create, restart, restore) and turns what the host would spawn into what runs inside the environment, for example `ssh -tt host …`, `docker exec -it …`, or the same program in another folder. The host still spawns it with node-pty, so scrollback, status and orchestration work unchanged. +- `wrap` output is checked: `command` must be an absolute path to an executable file or a bare program name that the host resolves on `PATH`; a command line, a relative path or shell syntax is refused, and nothing runs through a shell. `args` is an array (256 items, 8 KB each, no NUL). `env` and `secretEnv` follow the launch-contributor rules: reserved names are refused, and so is any name CanvasTTY or a launch option already sets for this launch. `secretEnv` values come from the plugin's own secrets (needs `secrets`) and are masked like launch secrets. +- `wrap` receives the launch's own variables (from CanvasTTY and chosen launch options) without reserved `CANVASTTY_*` names and without secret values; `secretEnvNames` lists names whose values the spawned process gets from the host, so a wrapper can forward them by name (`docker exec -e NAME`). +- Restore resumes every saved environment first, then starts parents before children. If the plugin is disabled, removed or untrusted, or `resume` answers `stopped`, the card comes back stopped with the reason and keeps its record; Restart asks `resume` again. A card is never started locally instead, and a timeout or error refuses, never falls back. +- Closing a card in an environment asks once, "Keep environment data?", then calls `release` with the answer. Quitting releases nothing (the environment comes back with the card); with saving off, quitting calls `release` with `keepData: true` and `reason: "quit"` so compute can stop. The plugin keeps no session list of its own and has no restore logic. + +The full example is [`examples/plugins/env-worktree`](../examples/plugins/env-worktree): `prepare` runs `git worktree add` in a folder under the plugin's data directory, `wrap` sets the folder, `resume` checks it still exists, `describe` shows the current branch, and `release` removes the worktree (and the branch it created) unless you keep it. + +### Decision hooks (`decision:provide`) + +Before a local agent's shell command or file write runs, CanvasTTY can ask a plugin: deny, ask the person, or allow. One service per plugin may declare `decide`: + +```json +"permissions": ["decision:provide"], +"services": [{ + "id": "guard", "title": "rm -rf guard", "entry": "services/guard.mjs", + "decide": { "events": ["pre-tool"], "appliesTo": ["claude", "codex"], "timeoutMs": 3000 } +}] +``` + +`pre-tool` is every shell and file-writing tool call, before it runs and in every permission mode, YOLO included: Claude Code, Codex and Qwen Code through their `PreToolUse` hook, OpenCode through CanvasTTY's OpenCode plugin (`tool.execute.before`). `appliesTo` limits the agents; all four when omitted. The host sends `canvastty.decide` (host-only) and waits at most `timeoutMs` (1000 to 60000; 3000 when omitted). The agent's call waits as long, so ask for more only when the answer needs it (for example a local model reading the command); CanvasTTY sizes each card's hook for the longest budget of the services that apply when the card starts, and a service trusted later gets no more than its card allows. The request carries the budget as `budgetMs`: + +```ts +interface DecisionRequest { + event: "pre-tool"; + sessionId: string; provider: string; role: "agent" | "orchestrator" | "subagent"; + cwd: string; // the card's working folder + agentCwd: string | null; // the agent's current folder, when its CLI reports it + tool: { name: string; kind: "shell" | "edit" | "other"; command: string | null; paths: string[] }; + input: unknown; // the tool input as the agent sent it; null when over 40 KB (truncated) + truncated: boolean; + budgetMs: number; // how long CanvasTTY waits for this answer +} +// answer: { verdict: "deny" | "ask" | "allow", reason?: string } or null for no opinion +``` + +How answers combine, in this order: + +1. **Base protection** (below) runs first; its deny is final and plugins are not asked. +2. Any plugin's `deny` wins. The model reads `CanvasTTY plugin "" blocked this tool call ()`, so write the reason as what to do instead. +3. Else any `ask`: Claude Code asks the person for this call, whatever its permission mode. A timeout, an error, a stopped service or an unreadable answer counts as `ask`, never as allow. +4. Else an `allow` counts only from a plugin the person let allow: a second confirmation, **May allow agent actions**, under the plugin in **Settings → Agents → Extension native code**, revoked with its native code trust. Claude Code then runs the call without its own prompt; OpenCode's prompt for that call is answered `once`. An allow never applies to input that was too large to send whole. +5. Else nothing: the agent goes on exactly as it would without CanvasTTY. + +Codex and Qwen Code take only a deny from this hook: for them `ask` and `allow` leave the decision to the CLI's own permission mode. Remote and container sessions are not covered (their hook cannot reach this computer). The hook is installed for agents started while base protection is on or a decision plugin applies, so a plugin trusted later covers new cards only. A CLI runs the call when its hook crashes, so this is a guard, not a sandbox. + +The full example is [`examples/plugins/deny-rm`](../examples/plugins/deny-rm): it denies `rm -rf` of anything at the top of the working folder (`rm -rf *`, `rm -rf src`) and has no opinion on everything else. It declares `timeoutMs: 5000` to show the field; it answers at once. + +### Base protection and redaction (core) + +Two safety parts are built in and need no plugin: + +- **Base protection** (Settings → Agents, on by default; the person can turn it off) denies, through the same hook, sudo and other elevation, piping downloaded or generated text into a shell, download-and-run, disk and format commands, fork bombs, and writing or deleting outside the working folder: the home folder, other projects and `/tmp` included, and deleting the working folder itself. An agent's own plan and memory folders (`~/.claude/plans`, `~/.claude/projects//memory`, and the same inside the run's `CLAUDE_CONFIG_DIR`) are not "outside". It only ever denies; each reason tells the model what to do instead (a write to `/tmp` suggests a scratch folder inside the project). +- **Secret redaction**: every text CanvasTTY hands from one agent to another (`observe_agent`, `get_agent_result`, the control CLI's `screen`, `result` and failure details) is masked: provider keys CanvasTTY holds, launch `secretEnv` values, values a service registered with `redaction.register`, also when the terminal wrapped them over lines, plus common key shapes (`sk-…`, GitHub, Slack, AWS, Google, JWT, `Bearer …`, `"apiKey": "…"`, PEM private keys, long random runs). + host.onStorageChange(listener) notifies every live contribution of the same plugin — canvases, HOME widgets, and separate windows — of writes made through host.storage.set, avoiding polling when a plugin coordinates several surfaces. ## Permissions @@ -128,8 +313,11 @@ host.onStorageChange(listener) notifies every live contribution of the same plug | Permission | SDK capability | Data boundary | |:--|:--|:--| | `storage` | `storage.get`, `storage.set` | Isolated JSON storage, 64 KB per plugin | -| `secrets` | `secrets.get`, `secrets.set`, `secrets.delete` | String secrets encrypted with Electron `safeStorage`; fails closed when protected OS storage is unavailable | +| `secrets` | `secrets.get`, `secrets.set`, `secrets.delete`; a service's `secrets.get` | String secrets encrypted with Electron `safeStorage`; fails closed when protected OS storage is unavailable. A trusted service reads its own plugin's secrets only | | `sessions:read` | `sessions.list` | ID, provider, title, status, start time, exit code only | +| `launch:contribute` | A service's `launch` block and `canvastty.launch.prepare` | Can add environment variables, arguments and files to agents the person starts with its option; with `policy`, can refuse any agent launch | +| `environment:provide` | A service's `environments` and `canvastty.environment.*` | Can create a place for cards the person starts in its environment and change the command, arguments, variables and folder they run with there | +| `decision:provide` | A service's `decide` and `canvastty.decide` | Sees agents' commands and file writes (with their input) before they run and can block them or ask the person; allowing needs a second confirmation | | `limits:read` | `limits.get` | The same sanitized `LimitsSnapshot` used by HOME | | `launcher:open` | `launcher.open` | Opens the built-in provider Focus Card or terminal action; it does not bypass user launch choices | | `external:open` | `external.open` | Opens only an explicit HTTP(S) URL through the OS | @@ -185,7 +373,7 @@ if (library) { } ``` -Supported methods are `host.getContext`, `storage.*`, `secrets.*`, `sessions.list`, `limits.get`, `launcher.open`, `canvas.open`, `external.open`, `browser.open`, `window.open`, `media.*`, `playlists.*`, and `hermesHud.*`. `canvas.open` opens or focuses a `canvas-app` contribution from the same plugin, placing it beside the requesting canvas card when possible. `browser.open` completes only after the workspace creates or focuses its Browser card and navigates it once; it accepts normalized HTTP(S) URLs only (not free-text searches, `file:`, `data:`, `javascript:`, `about:`, or credentialed URLs). `window.open` may target only a `window` contribution declared by the same plugin. `hermesHud.open` and `hermesHud.close` use a fixed Hermes control contract; plugins cannot choose an executable, arguments, or PID. +Supported methods are `host.getContext`, `storage.*`, `secrets.*`, `sessions.list`, `limits.get`, `launcher.open`, `canvas.open`, `external.open`, `browser.open`, `window.open`, `media.*`, `playlists.*`, `hermesHud.*`, and `service.request` (see [Services](#services-apiversion-2)). `canvas.open` opens or focuses a `canvas-app` contribution from the same plugin, placing it beside the requesting canvas card when possible. `browser.open` completes only after the workspace creates or focuses its Browser card and navigates it once; it accepts normalized HTTP(S) URLs only (not free-text searches, `file:`, `data:`, `javascript:`, `about:`, or credentialed URLs). `window.open` may target only a `window` contribution declared by the same plugin. `hermesHud.open` and `hermesHud.close` use a fixed Hermes control contract; plugins cannot choose an executable, arguments, or PID. Use `storage` for non-sensitive JSON preferences and `secrets` only for credentials such as OAuth tokens or API keys. Secrets are string-only, limited to 32 keys / 16 KB per value / 64 KB per plugin, removed on uninstall, and never fall back to plaintext storage. A secret call fails explicitly when the operating system cannot provide protected encryption. diff --git a/docs/plugins.ru.md b/docs/plugins.ru.md index cee62344..d017055c 100644 --- a/docs/plugins.ru.md +++ b/docs/plugins.ru.md @@ -2,7 +2,7 @@ [English](plugins.md) · [Русский](plugins.ru.md) · [简体中文](plugins.zh-CN.md) · [Документация](README.ru.md) -Runtime-плагин CanvasTTY устанавливается из HTTPS GitHub-репозитория. Он может добавить sandboxed web-поверхности и опционально объявить scripts хуков агентов. Web-contributions работают без Node.js; каждый hook script остаётся выключенным, пока пользователь отдельно не включит его в **Настройки → Агенты → Хуки**. +Runtime-плагин CanvasTTY устанавливается из HTTPS GitHub-репозитория. Он может добавить sandboxed web-поверхности и опционально объявить scripts хуков агентов. Он также может объявить долгоживущие сервисы. Web-contributions работают без Node.js. Хуки и сервисы — нативный код: каждый hook script остаётся выключенным, пока пользователь отдельно не включит его в **Настройки → Агенты → Хуки**, а сервисы плагина — пока он не подтвердит их в **Настройки → Агенты → Нативный код расширений**. ## Модель доверия @@ -15,6 +15,7 @@ Runtime-плагин CanvasTTY устанавливается из HTTPS GitHub- - Каждый привилегированный SDK-метод требует permission из manifest. Полный список разрешений показывается до подтверждения установки. - Sandboxed web-contributions не получают учётные данные провайдеров, PTY buffer, рабочие каталоги, сырые ответы API или доступ к файловой системе. - Выключение или удаление плагина сразу прекращает отдачу его ресурсов и закрывает отдельные окна. +- Сервисы подчиняются тому же правилу, что и хуки, для плагина целиком: установка их не запускает, а обновление, смена modules, выключение или изменённый файл entry снимают подтверждение. Сервисы работают вне процесса; код плагинов не выполняется в main-процессе CanvasTTY. - Agent hooks никогда не включаются автоматически. Включённый hook script эквивалентен нативному приложению: он получает payload события агента, выполняется с правами учётной записи пользователя и потенциально видит доступные ей конфиги или credentials. Обновление, смена modules или выключение плагина отзывает все такие разрешения. CanvasTTY не встраивает произвольные нативные окна ОС. Contribution `window` — это sandboxed `BrowserWindow`, которым владеет CanvasTTY. Native reparenting ненадёжен и непереносим между Wayland, macOS, Windows, разными DPI, popup и GPU surfaces. @@ -35,7 +36,7 @@ windows/focus.js hooks/audit.mjs ``` -Рабочий пример sandboxed web-поверхностей без привилегированного хука: [`examples/plugins/studio-kit`](../examples/plugins/studio-kit). +Рабочий пример sandboxed web-поверхностей без привилегированного хука: [`examples/plugins/studio-kit`](../examples/plugins/studio-kit). Минимальный сервис с canvas-приложением, которое его вызывает: [`examples/plugins/service-echo`](../examples/plugins/service-echo). Launch contributor: [`examples/plugins/launch-env`](../examples/plugins/launch-env), политика запуска: [`examples/plugins/yolo-guard`](../examples/plugins/yolo-guard). Среда сессии (git worktree): [`examples/plugins/env-worktree`](../examples/plugins/env-worktree). Сервис решений: [`examples/plugins/deny-rm`](../examples/plugins/deny-rm). Для IDE доступны [JSON Schema manifest](canvastty-plugin.schema.json) и [TypeScript declarations SDK](plugin-api.d.ts). ## Manifest v1 @@ -106,6 +107,186 @@ Hook-only plugin использует пустой массив `contributions` Script запускается отдельным процессом из каталога плагина и получает JSON через stdin с полями `apiVersion`, `pluginId`, `hookId`, `terminalSessionId`, `provider`, `event`, `providerEvent`, `payload`. Stdout/stderr отбрасываются, время выполнения ограничено, внутренние capability-токены CanvasTTY удаляются из environment. Это защита host internals, а не sandbox: script всё ещё может читать/менять файлы и запускать процессы с обычными правами пользователя. +### Сервисы (apiVersion 2) + +Манифест с `"apiVersion": 2` может объявить до 8 `services`. Манифесты версии 1 остаются валидными; версия 2 нужна только для `services`. + +```json +"services": [ + { "id": "echo", "title": "Echo", "description": "Отвечает эхом.", "entry": "services/echo.mjs" } +] +``` + +У сервиса стабильный `id`, `title`, необязательные `description` и `module`, и `entry` с расширением `.js`, `.mjs` или `.cjs`. Entry должен быть собранным одним файлом (например, esbuild): установщик ничего не собирает и не запускает `npm install`, Electron и node-pty сервису недоступны. В модульном плагине entry должен быть объявлен с хешем в своём `module` (или в `coreFiles`), как entry хуков. Когда пользователь доверяет нативному коду плагина, CanvasTTY запоминает SHA-256 entry и проверяет его перед каждым запуском; изменённый файл не запускается, а доверие снимается при следующем старте. + +Жизненный цикл: каждый сервис включённого и доверенного плагина работает отдельным процессом (`process.execPath` с `ELECTRON_RUN_AS_NODE=1`), рабочая папка — папка плагина. Окружение минимальное: `PATH`, `HOME`, пользователь, shell, локаль, временные и XDG-папки, `SSH_AUTH_SOCK` и системные папки Windows. Ключи провайдеров, токены, `NODE_OPTIONS` и все `CANVASTTY_*` удаляются. Неожиданно завершившийся сервис перезапускается через 1, 2, 4, 8, затем 16 с; после более чем 5 неожиданных завершений за 10 минут он остаётся в ошибке, пока доверие не подтвердят заново. Выключение, удаление, обновление, смена модулей, снятие доверия или выход из CanvasTTY останавливают его: сначала уведомление `canvastty.shutdown` и закрытый stdin, затем `SIGTERM`, затем `SIGKILL`. Для сервиса создаётся `/plugin-data/`, при удалении плагина папка удаляется. stderr, stdout вне протокола, вызовы `log` и события жизненного цикла пишутся в ограниченный журнал плагина (последние 300 записей) в **Настройки → Агенты → Нативный код расширений**. + +Протокол: JSON-RPC 2.0 построчно через stdin/stdout, не больше 1 МБ на сообщение в каждую сторону. Более крупный запрос хоста отклоняется, более длинная строка сервиса отбрасывается и попадает в журнал. Первым хост отправляет уведомление `canvastty.initialize` с `{ apiVersion: 2, pluginId, serviceId, dataDir, locale, hostVersion }`. + +Запросы от собственных поверхностей плагина приходят с методом и параметрами, которые выбрала поверхность; методы с префиксом `canvastty.` зарезервированы за хостом. Отвечайте `{"jsonrpc":"2.0","id":…,"result":…}` или `{"jsonrpc":"2.0","id":…,"error":{"code":-32000,"message":"…"}}`. Запрос без ответа за 15 с завершается ошибкой таймаута, как и запрос к остановленному, перезапускающемуся или упавшему сервису; одновременно ждут не больше 64 запросов на сервис. + +Сервис может вызывать API хоста (это основа, которую расширят следующие точки расширения; всё остальное получает ошибку `-32601`): + +| Метод | Вид | Условие | Результат | +|:--|:--|:--|:--| +| `log` `{ level?: "info" \| "warn" \| "error", message }` | запрос или уведомление | нет | Строка в журнале плагина | +| `storage.get` `{ key }` | запрос | разрешение `storage` | То же изолированное хранилище 64 КБ, что `host.storage.get` | +| `storage.set` `{ key, value }` | запрос | разрешение `storage` | Запись и уведомление поверхностей плагина | +| `event` `{ event, data }` | уведомление | нет | Доставляется открытым поверхностям плагина через `host.service.onEvent` | +| `redaction.register` `{ values }` | запрос | нет | До 32 строк (до 4096 символов, учитываются от 8), которые CanvasTTY маскирует во всём тексте, который один агент читает у другого; хранятся только в памяти | +| `secrets.get` `{ key }` | запрос | разрешение `secrets` | Собственный секрет плагина (то же хранилище, что `host.secrets`) или `null`. Значение затем маскируется, как значения `redaction.register`. Для ключей, которые нужны самому сервису (API-ключ модели, которую он вызывает); никогда не отправляйте его обратно на страницу | + +Хост привязывает каждый вызов к плагину самого сервиса: сервис не может назвать другой плагин, прочитать секреты другого плагина или получить доступ к сессиям. Пример [`service-echo`](../examples/plugins/service-echo) сохраняет токен со своей страницы через `host.secrets.set`, а его сервис читает его через `secrets.get` и отвечает только, задан ли он. + +Канал UI: sandboxed-поверхности обращаются только к сервисам своего плагина: + +```js +const reply = await host.service.request("echo", "echo", { text: "hi" }); +host.service.onEvent(({ serviceId, event, data }) => { /* … */ }); +``` + +Разрешение неявное, если плагин объявил сервис. Хост передаёт непрозрачный JSON и никогда не добавляет credentials. Запрос к неработающему сервису (ещё не доверен, выключен, перезапускается, упал) или по таймауту завершается ошибкой. + +### Launch contributors (`launch:contribute`) + +Один сервис плагина может объявить блок `launch`. Его поля появляются в окне запуска агента в разделе **Дополнительно**, когда нативному коду плагина доверяют; человек включает плагин для одного запуска флажком **Использовать _имя плагина_** и задаёт поля. Плагин спрашивают только о запусках, где его выбрали, и о перезапусках и восстановлении этих окон. + +```json +"permissions": ["launch:contribute"], +"services": [{ + "id": "launcher", "title": "Launch env", "entry": "services/launcher.mjs", + "launch": { + "appliesTo": ["claude"], + "fields": [ + { "key": "enabled", "label": "Add the variable", "kind": "boolean", "default": true }, + { "key": "greeting", "label": "Value", "kind": "text", "default": "hello", "maxLength": 60 }, + { "key": "mode", "label": "Mode", "kind": "select", "default": "plain", + "options": [{ "value": "plain", "label": "Plain" }, { "value": "loud", "label": "Loud" }] } + ] + } +}] +``` + +До 8 полей; `kind`: `boolean`, `select` (1–16 вариантов) или `text` (до 200 символов или `maxLength`). `appliesTo` перечисляет провайдеров-агентов; без него плагин доступен всем агентам. Выбранные значения проверяются по полям, сохраняются в записи сессии окна (до 4 КБ на плагин) и используются при перезапуске и восстановлении. Они не секретны: ключи хранят в `secrets` плагина, а не в поле. + +`select` с `"optionsFrom": "service"` дополнительно показывает варианты, которые предлагает сервис, например его собственные учётные записи. При открытии окна запуска CanvasTTY спрашивает сервис `canvastty.launch.options` `{ provider, fields: [ключи] }` и ждёт не более 3 с; ответ `{ "<ключ>": [{ value, label }] }` добавляет до 64 вариантов на поле после объявленных (они по-прежнему обязательны, и только они видны, если сервис не ответил). Такой список может измениться после сохранения окна, поэтому значение принимается как любой текст до 200 символов без управляющих символов, а `canvastty.launch.prepare` обязан его проверить и отказать, если такого варианта больше нет. + +Оркестраторы передают те же значения в `spawn_agent` как `launchOptions` (`{ "": { "<ключ>": значение } }`); они проверяются так же, как значения окна запуска. Их может выдать инструмент плагина (например, выбранную им учётную запись). + +Перед запуском агента хост отправляет сервису запрос `canvastty.launch.prepare` (поверхности его отправить не могут): + +```json +{"sessionId":"…","provider":"claude","profile":"normal","role":"agent","cwd":"/project","restoring":false,"resume":false,"options":{"enabled":true,"greeting":"hello","mode":"plain"}} +``` + +Ответ: `null` (ничего не добавлять) или объект с любыми из ключей: + +| Ключ | Предел | Действие | +|:--|:--|:--| +| `env` `{ NAME: value }` | 32 имени, 8 КБ на значение | Добавляется в окружение агента | +| `secretEnv` `{ NAME: secretKey }` | 16 имён; нужно `secrets` | Хост читает собственный секрет плагина в main-процессе и подставляет его. Значение не попадает ни в сервис, ни в UI и маскируется как `` в тексте этого окна, который читают другие агенты и control CLI (observe, result, screen, причина ошибки) | +| `args` `[string]` | 32, до 1024 символов, без управляющих символов | Добавляются после аргументов CanvasTTY, перед выбором разговора | +| `files` `[{ relPath, content }]` | 16 файлов, 256 КБ, простые относительные пути | Пишутся в личную папку этого запуска и удаляются при выходе процесса; `{launchFiles}` в `env` и `args` заменяется на эту папку | +| `refuse` `{ reason }` | 240 символов | Окно не запускается и показывает причину | + +Правила хоста, которые никогда не пропускаются: + +- Выбранные плагины опрашиваются параллельно, результаты объединяются в порядке id плагинов. Если два плагина задают одно имя или плагин задаёт имя, которое CanvasTTY задаёт для этого запуска, запуск отклоняется с их названиями. Имена с префиксами `CANVASTTY_`, `ELECTRON_`, `DYLD_`, `LD_`, а также `NODE_OPTIONS`, `PATH`, `TERM`, `COLORTERM` зарезервированы. +- Аргументы, которые отключают подтверждения или выбирают разговор (YOLO-флаги всех провайдеров, `--permission-mode`, `--sandbox`, `--resume`, `--continue`, `--session` и подобные), отклоняются: профиль остаётся за человеком, правила восстановления за ядром. Это не песочница: доверенный нативный код и так работает от вашего имени. +- Claude Code применяет только последний `--settings`, поэтому встроенный JSON `--settings` плагина сливается с собственным JSON CanvasTTY (объекты вроде `env` по ключам, списки хуков дописываются); если он задаёт `permissions`, `hooks`, `disableAllHooks`, `sandbox`, `defaultMode` или `apiKeyHelper`, запуск отклоняется. +- Нет ответа за 5 с, ошибка, неверный ответ, отсутствующий секрет или выключенный, удалённый либо переставший быть доверенным плагин отклоняют запуск, и причина видна в окне. Агент никогда не запускается без выбранного вклада. Восстановленное окно с недоступным плагином возвращается остановленным с этой причиной и сохраняет запись, пока плагин не вернётся или окно не закроют. +- Обычный терминал параметров запуска не принимает. + +**Политики запуска.** С `"policy": true` сервис спрашивают ещё и перед каждым запуском агентов, к которым он относится (создание, перезапуск, восстановление), где человек его не выбрал, с `"chosen": false` и пустыми `options`. Такой ответ может быть только `null` или `refuse`; всё остальное, нет ответа за 5 с или ошибка — отказ в запуске, так что политика никогда не пропускает запуск из-за сбоя. Каждый `canvastty.launch.prepare` несёт и `"environment"`: `{ pluginId, kind }` среды окна или `null` на этом компьютере. Политика без `fields` в запускателе не показывается. Отзыв доверия к нативному коду плагина убирает его политику. + +```json +"launch": { "policy": true, "fields": [] } +``` + +Полные примеры: [`examples/plugins/launch-env`](../examples/plugins/launch-env) (параметры) и [`examples/plugins/yolo-guard`](../examples/plugins/yolo-guard) (политика, которая не пускает YOLO вне среды). + +### Среды сессий (`environment:provide`) + +Среда — это место, где работает окно: git worktree, контейнер, удалённый хост. Плагин может перечислить до 8 видов в `environments` — в одном сервисе или в нескольких (например, по сервису на модуль); каждый вид уникален в плагине, и на него отвечает сервис, который его перечислил. После доверия нативному коду в разделе **Дополнительно** лаунчера появляется **Где запустить** (по умолчанию **Этот компьютер**) с видами, подходящими провайдеру, и их необязательными `fields` (те же виды и лимиты, что у полей запуска). Пока какой-то вид подходит терминалам, **Открыть терминал** открывает тот же лаунчер (папка и «Где запустить»), а не терминал сразу. + +```json +"permissions": ["environment:provide"], +"services": [{ + "id": "worktree", "title": "Git worktree", "entry": "services/worktree.mjs", + "environments": [{ + "kind": "worktree", "label": "Git worktree", + "description": "A branch in its own folder", + "appliesTo": ["terminal", "claude"], + "fields": [{ "key": "branch", "label": "Branch", "kind": "text", "default": "", "maxLength": 80 }] + }] +}] +``` + +CanvasTTY владеет окном, PTY, сохранённой записью и порядком восстановления; сервис отвечает на пять запросов, которые может отправить только хост: + +| Запрос | Параметры | Ответ | Лимит | +|:--|:--|:--|:--| +| `canvastty.environment.prepare` | `sessionId, kind, provider, cwd, options` | `{ ref, label, cwd? }` или `{ refuse: { reason } }`. `ref` — непрозрачный JSON до 4 КБ, хранится с окном; `label` (80 символов) — бейдж; `cwd` (существующая абсолютная папка) становится папкой окна | 15 с | +| `canvastty.environment.wrap` | `sessionId, kind, ref, provider, command, args, env, secretEnvNames, cwd` | `{ command, args, env?, secretEnv?, cwd? }` или `{ refuse }` | 5 с | +| `canvastty.environment.resume` | `sessionId, kind, ref` | `{ ok: true }` или `{ stopped: { reason } }` | 10 с | +| `canvastty.environment.release` | `sessionId, kind, ref, keepData, reason` (`closed` или `quit`) | игнорируется | 10 с | +| `canvastty.environment.describe` | `sessionId, kind, ref` | `{ label, detail? }` для бейджа окна и подсказки | 3 с | + +- `prepare` вызывается один раз, при первом запуске окна. `wrap` вызывается перед каждым запуском (создание, перезапуск, восстановление) и превращает то, что хост запустил бы, в то, что работает внутри среды: `ssh -tt host …`, `docker exec -it …` или та же программа в другой папке. PTY по-прежнему создаёт хост через node-pty, поэтому прокрутка, статус и оркестрация работают как раньше. +- Ответ `wrap` проверяется: `command` — абсолютный путь к исполняемому файлу или простое имя программы, которое хост находит в `PATH`; командная строка, относительный путь или синтаксис оболочки отклоняются, ничего не запускается через оболочку. `args` — массив (256 элементов, до 8 КБ, без NUL). `env` и `secretEnv` подчиняются правилам launch contributors: зарезервированные имена и имена, которые CanvasTTY или параметр запуска уже задают для этого запуска, отклоняются. Значения `secretEnv` берутся из собственных секретов плагина (нужно `secrets`) и маскируются так же, как секреты запуска. +- `wrap` получает переменные самого запуска (от CanvasTTY и выбранных параметров запуска) без зарезервированных имён `CANVASTTY_*` и без значений секретов; `secretEnvNames` перечисляет имена, значения которых процесс получит от хоста, чтобы обёртка могла пробросить их по имени (`docker exec -e NAME`). +- При восстановлении сначала возобновляются все сохранённые среды, затем запускаются родители, потом дочерние окна. Если плагин выключен, удалён или не доверен либо `resume` ответил `stopped`, окно возвращается остановленным с причиной и сохраняет запись; «Перезапуск» снова вызывает `resume`. Окно никогда не запускается локально вместо среды, а таймаут или ошибка отклоняют запуск без запасного варианта. +- При закрытии окна в среде один раз спрашивается «Сохранить данные среды?», затем вызывается `release` с ответом. Выход из приложения ничего не освобождает (среда вернётся вместе с окном); если сохранение выключено, выход вызывает `release` с `keepData: true` и `reason: "quit"`, чтобы остановить вычисления. Плагин не ведёт своего списка сессий и не содержит логики восстановления. + +Полный пример: [`examples/plugins/env-worktree`](../examples/plugins/env-worktree): `prepare` выполняет `git worktree add` в папке внутри каталога данных плагина, `wrap` задаёт папку, `resume` проверяет, что она существует, `describe` показывает текущую ветку, `release` удаляет worktree (и созданную им ветку), если вы не решили её сохранить. + +### Решения по действиям агентов (`decision:provide`) + +Перед тем как локальный агент выполнит shell-команду или запись файла, CanvasTTY может спросить плагин: запретить, спросить человека или разрешить. Один сервис плагина может объявить `decide`: + +```json +"permissions": ["decision:provide"], +"services": [{ + "id": "guard", "title": "rm -rf guard", "entry": "services/guard.mjs", + "decide": { "events": ["pre-tool"], "appliesTo": ["claude", "codex"], "timeoutMs": 3000 } +}] +``` + +`pre-tool` — каждый вызов shell-инструмента и записи файла до выполнения, в любом режиме разрешений, включая YOLO: у Claude Code, Codex и Qwen Code через их хук `PreToolUse`, у OpenCode через плагин CanvasTTY для OpenCode (`tool.execute.before`). `appliesTo` ограничивает агентов; без него — все четыре. Хост отправляет `canvastty.decide` (только хост) и ждёт не больше `timeoutMs` (от 1000 до 60000; без него 3000). Вызов агента ждёт столько же, поэтому просите больше, только когда ответу это нужно (например, локальная модель читает команду); CanvasTTY настраивает хук каждого окна под самый долгий бюджет сервисов, которые к нему относятся на момент запуска, а сервис, которому доверились позже, получает не больше, чем позволяет окно. Бюджет приходит в запросе как `budgetMs`: + +```ts +interface DecisionRequest { + event: "pre-tool"; + sessionId: string; provider: string; role: "agent" | "orchestrator" | "subagent"; + cwd: string; // рабочая папка окна + agentCwd: string | null; // текущая папка агента, если CLI её сообщает + tool: { name: string; kind: "shell" | "edit" | "other"; command: string | null; paths: string[] }; + input: unknown; // ввод инструмента как есть; null, если больше 40 КБ (truncated) + truncated: boolean; + budgetMs: number; // сколько CanvasTTY ждёт этот ответ +} +// ответ: { verdict: "deny" | "ask" | "allow", reason?: string } или null — нет мнения +``` + +Как объединяются ответы, по порядку: + +1. Сначала работает **базовая защита** (ниже); её запрет окончательный, плагины не спрашиваются. +2. Побеждает любой `deny` плагина. Модель читает `CanvasTTY plugin "" blocked this tool call ()`, поэтому пишите в причине, что сделать вместо этого. +3. Иначе любой `ask`: Claude Code спрашивает человека об этом вызове при любом режиме разрешений. Таймаут, ошибка, остановленный сервис или нечитаемый ответ считаются `ask`, никогда не разрешением. +4. Иначе `allow` учитывается только от плагина, которому человек это доверил: второе подтверждение **Может разрешать действия агентов** под плагином в **Настройки → Агенты → Нативный код расширений**, снимается вместе с доверием к нативному коду. Тогда Claude Code выполняет вызов без своего вопроса, а на вопрос OpenCode об этом вызове отвечается `once`. Разрешение никогда не действует для ввода, который был слишком велик, чтобы отправить его целиком. +5. Иначе ничего: агент продолжает так же, как без CanvasTTY. + +Codex и Qwen Code принимают от этого хука только запрет: для них `ask` и `allow` оставляют решение собственному режиму разрешений CLI. Удалённые и контейнерные сессии не покрываются (их хук не достаёт до этого компьютера). Хук ставится агентам, запущенным, пока включена базовая защита или применяется плагин решений, поэтому доверенный позже плагин действует только для новых окон. CLI выполняет вызов, если его хук упал, так что это страховка, а не песочница. + +Полный пример: [`examples/plugins/deny-rm`](../examples/plugins/deny-rm): запрещает `rm -rf` чего-либо на верхнем уровне рабочей папки (`rm -rf *`, `rm -rf src`) и не имеет мнения обо всём остальном. Он объявляет `timeoutMs: 5000`, чтобы показать поле; отвечает сразу. + +### Базовая защита и скрытие секретов (ядро) + +Две части безопасности встроены и не требуют плагина: + +- **Базовая защита** (Настройки → Агенты, включена по умолчанию; человек может её выключить) через тот же хук запрещает sudo и другое повышение прав, передачу скачанного или сгенерированного текста в shell, скачивание с запуском, команды для дисков и форматирования, форк-бомбы, а также запись и удаление вне рабочей папки — включая домашнюю папку, другие проекты и `/tmp` — и удаление самой рабочей папки. Собственные папки планов и памяти агента (`~/.claude/plans`, `~/.claude/projects//memory` и то же внутри `CLAUDE_CONFIG_DIR` запуска) не считаются «вне». Она только запрещает; каждая причина говорит модели, что сделать вместо этого (для записи в `/tmp` — завести временную папку внутри проекта). +- **Скрытие секретов**: весь текст, который CanvasTTY передаёт от одного агента другому (`observe_agent`, `get_agent_result`, `screen`, `result` и детали ошибок в control CLI), маскируется: ключи провайдеров, которые хранит CanvasTTY, значения `secretEnv` запуска, значения, зарегистрированные сервисом через `redaction.register`, в том числе перенесённые терминалом на несколько строк, а также типичные формы ключей (`sk-…`, GitHub, Slack, AWS, Google, JWT, `Bearer …`, `"apiKey": "…"`, приватные ключи PEM, длинные случайные строки). + host.onStorageChange(listener) сообщает всем открытым поверхностям того же плагина — canvas cards, HOME widgets и отдельным окнам — об изменениях через host.storage.set, поэтому нескольким поверхностям не требуется постоянный polling. ## Permissions @@ -113,8 +294,11 @@ host.onStorageChange(listener) сообщает всем открытым пов | Permission | Возможность SDK | Граница данных | |:--|:--|:--| | `storage` | `storage.get`, `storage.set` | Изолированное JSON-хранилище, 64 КБ на плагин | -| `secrets` | `secrets.get`, `secrets.set`, `secrets.delete` | Строковые секреты, зашифрованные через Electron `safeStorage`; без защищённого хранилища ОС вызов завершается ошибкой | +| `secrets` | `secrets.get`, `secrets.set`, `secrets.delete`; `secrets.get` сервиса | Строковые секреты, зашифрованные через Electron `safeStorage`; без защищённого хранилища ОС вызов завершается ошибкой. Доверенный сервис читает только секреты своего плагина | | `sessions:read` | `sessions.list` | Только ID, provider, title, status, startedAt и exitCode | +| `launch:contribute` | Блок `launch` сервиса и `canvastty.launch.prepare` | Может добавлять переменные окружения, аргументы и файлы агентам, запущенным с его опцией; с `policy` может отказать любому запуску агента | +| `environment:provide` | `environments` сервиса и `canvastty.environment.*` | Может создавать место для окон, запущенных в его среде, и менять команду, аргументы, переменные и папку, с которыми они там работают | +| `decision:provide` | `decide` сервиса и `canvastty.decide` | Видит команды и записи файлов агентов (с их вводом) до выполнения и может запрещать их или спрашивать человека; для разрешения нужно второе подтверждение | | `limits:read` | `limits.get` | Тот же очищенный `LimitsSnapshot`, который использует HOME | | `launcher:open` | `launcher.open` | Открывает штатную Focus Card или запуск терминала; не обходит пользовательский выбор | | `external:open` | `external.open` | Передаёт ОС только явную HTTP(S)-ссылку | diff --git a/docs/plugins.zh-CN.md b/docs/plugins.zh-CN.md index 748b268f..c47b74a9 100644 --- a/docs/plugins.zh-CN.md +++ b/docs/plugins.zh-CN.md @@ -2,7 +2,7 @@ [English](plugins.md) · [Русский](plugins.ru.md) · [简体中文](plugins.zh-CN.md) · [文档首页](README.zh-CN.md) -CanvasTTY 运行时插件从 HTTPS GitHub 仓库安装。插件可以提供 sandboxed web contribution,也可以声明可选的 agent hook 脚本。Web contribution 不具备 Node.js 能力;每个 hook 在用户于 **设置 → Agents → Hooks** 中单独确认信任前始终关闭。 +CanvasTTY 运行时插件从 HTTPS GitHub 仓库安装。插件可以提供 sandboxed web contribution,也可以声明可选的 agent hook 脚本和长期运行的服务。Web contribution 不具备 Node.js 能力。Hook 和服务属于原生代码:每个 hook 在用户于 **设置 → Agents → Hooks** 中单独确认信任前始终关闭,插件的服务在 **设置 → Agents → Extension native code** 中确认前不会运行。 ## 信任模型 @@ -15,6 +15,7 @@ CanvasTTY 运行时插件从 HTTPS GitHub 仓库安装。插件可以提供 sand - 每个特权 SDK 方法都由 manifest 中的权限把关。权限会在用户确认安装之前展示。 - Sandboxed web contribution 不会收到服务商凭据、PTY 缓冲区、工作目录、原始服务商响应或文件系统访问权限。 - 禁用或卸载插件会立即停止提供其资源,并关闭其独立窗口。 +- 服务按插件整体遵循与 hook 相同的规则:安装不会启动服务,更新、更换 module、禁用插件或 entry 文件被修改都会撤销确认。服务在进程外运行;插件代码不会在 CanvasTTY 主进程中执行。 - Agent hook 不会随安装自动启用。启用后,该脚本等同于原生应用:它会接收 agent 事件 payload、以当前用户权限运行,并可能访问该用户可读的配置或凭据;更新插件、更换 module 或禁用插件都会撤销全部 hook 信任。 CanvasTTY 不嵌入任意的原生操作系统窗口。`window` 贡献是一个由 CanvasTTY 持有的 sandboxed `BrowserWindow`。原生 reparenting 在 Wayland、macOS、Windows、不同 DPI 模式、弹窗和 GPU surface 之间既不可移植也不可靠。 @@ -35,7 +36,7 @@ windows/focus.js hooks/audit.mjs ``` -不包含特权 hook 的 sandboxed web surface 端到端示例见 [`examples/plugins/studio-kit`](../examples/plugins/studio-kit)。 +不包含特权 hook 的 sandboxed web surface 端到端示例见 [`examples/plugins/studio-kit`](../examples/plugins/studio-kit)。调用自身服务的最小 canvas 应用示例见 [`examples/plugins/service-echo`](../examples/plugins/service-echo)。启动贡献者示例见 [`examples/plugins/launch-env`](../examples/plugins/launch-env),启动策略示例见 [`examples/plugins/yolo-guard`](../examples/plugins/yolo-guard)。会话环境(git worktree)示例见 [`examples/plugins/env-worktree`](../examples/plugins/env-worktree)。决策服务示例见 [`examples/plugins/deny-rm`](../examples/plugins/deny-rm)。 编辑器工具可以使用 [manifest JSON Schema](canvastty-plugin.schema.json) 和 [SDK TypeScript 声明](plugin-api.d.ts)。 ## Manifest v1 @@ -104,6 +105,186 @@ Hook-only 插件使用空的 `contributions` 与非空的 `hooks`。安装只复 脚本在独立进程中运行,并通过 stdin 接收包含 `apiVersion`、`pluginId`、`hookId`、`terminalSessionId`、`provider`、`event`、`providerEvent` 与 `payload` 的 JSON。Stdout/stderr 会被丢弃,执行时间受限,CanvasTTY 内部 capability token 会从子进程环境中移除。这不是 sandbox:脚本仍以当前用户权限读写文件或启动进程。 +### 服务(apiVersion 2) + +`"apiVersion": 2` 的 manifest 最多可声明 8 个 `services`。版本 1 的 manifest 仍然有效;只有 `services` 需要版本 2。 + +```json +"services": [ + { "id": "echo", "title": "Echo", "description": "回显请求。", "entry": "services/echo.mjs" } +] +``` + +服务包含稳定的 `id`、`title`、可选的 `description` 和 `module`,以及以 `.js`、`.mjs` 或 `.cjs` 结尾的 `entry`。entry 必须是打包好的单文件(例如用 esbuild 构建):安装器不执行构建也不运行 `npm install`,服务无法使用 Electron 和 node-pty。在模块化插件中,entry 必须像 hook entry 一样由其 `module`(或 `coreFiles`)声明完整性。用户信任插件的原生代码时,CanvasTTY 记录 entry 的 SHA-256,并在每次启动前重新校验;被修改的文件不会运行,信任会在下次启动时撤销。 + +生命周期:已启用且受信任插件的每个服务都作为独立进程运行(`process.execPath` 加 `ELECTRON_RUN_AS_NODE=1`),工作目录为插件目录。环境变量最小化:`PATH`、`HOME`、用户、shell、语言区域、临时目录与 XDG 目录、`SSH_AUTH_SOCK` 以及 Windows 系统目录;provider 密钥、令牌、`NODE_OPTIONS` 和所有 `CANVASTTY_*` 变量都会被移除。意外退出的服务会在 1、2、4、8、16 秒后重启;10 分钟内意外退出超过 5 次后保持失败状态,直到重新确认信任。禁用、卸载、更新、更换模块、撤销信任或退出 CanvasTTY 都会停止服务:先发送 `canvastty.shutdown` 通知并关闭 stdin,然后 `SIGTERM`,最后 `SIGKILL`。服务会获得 `/plugin-data/` 目录,卸载时删除。stderr、协议之外的 stdout、`log` 调用和生命周期事件写入每个插件的有界日志(最近 300 条),显示在 **设置 → Agents → Extension native code**。 + +协议:通过 stdin/stdout 的逐行 JSON-RPC 2.0,每个方向单条消息最多 1 MB。更大的宿主请求会被拒绝,服务输出的超长行会被丢弃并记录。宿主首先发送 `canvastty.initialize` 通知,参数为 `{ apiVersion: 2, pluginId, serviceId, dataDir, locale, hostVersion }`。 + +来自插件自身界面的请求使用界面选择的方法和参数;以 `canvastty.` 开头的方法名保留给宿主。用 `{"jsonrpc":"2.0","id":…,"result":…}` 或 `{"jsonrpc":"2.0","id":…,"error":{"code":-32000,"message":"…"}}` 应答。15 秒内未应答的请求以超时错误结束;服务已停止、正在重启或失败时的请求同样返回错误;每个服务同时最多等待 64 个请求。 + +服务可以回调以下宿主 API(后续扩展点在此基础上扩展;其他方法返回错误 `-32601`): + +| 方法 | 类型 | 条件 | 结果 | +|:--|:--|:--|:--| +| `log` `{ level?: "info" \| "warn" \| "error", message }` | 请求或通知 | 无 | 写入插件日志 | +| `storage.get` `{ key }` | 请求 | `storage` 权限 | 与 `host.storage.get` 相同的隔离 64 KB 存储 | +| `storage.set` `{ key, value }` | 请求 | `storage` 权限 | 写入并通知插件界面 | +| `event` `{ event, data }` | 通知 | 无 | 通过 `host.service.onEvent` 发送给该插件的活动界面 | +| `redaction.register` `{ values }` | 请求 | 无 | 最多 32 个字符串(每个最多 4096 字符,8 字符以上才生效),CanvasTTY 会在一个 agent 读取另一个 agent 的所有文本中遮蔽它们;只保存在内存中 | +| `secrets.get` `{ key }` | 请求 | `secrets` 权限 | 插件自己的机密(与 `host.secrets` 同一存储),或 `null`。之后该值会像 `redaction.register` 的值一样被遮蔽。用于服务自身需要的密钥(例如它调用的模型的 API 密钥);绝不要把它发回界面 | + +宿主把每次调用绑定到服务自身的插件:服务无法指定其他插件、读取其他插件的机密或访问会话。示例 [`service-echo`](../examples/plugins/service-echo) 在其页面用 `host.secrets.set` 保存令牌,其服务用 `secrets.get` 读取,只回答是否已设置。 + +UI 通道:sandboxed 界面只能调用自身插件的服务: + +```js +const reply = await host.service.request("echo", "echo", { text: "hi" }); +host.service.onEvent(({ serviceId, event, data }) => { /* … */ }); +``` + +插件声明服务即隐含该权限。宿主只转发不透明的 JSON,从不附加凭据。对未运行(尚未信任、已禁用、重启中、失败)的服务的请求或超时请求会返回错误。 + +### 启动贡献者(`launch:contribute`) + +每个插件最多一个服务可以声明 `launch` 块。插件的原生代码被信任后,其字段出现在智能体启动对话框的 **Advanced(高级)** 部分;用户通过 **Use _插件名_** 为一次启动启用该插件并填写字段。只有选择了该插件的启动,以及这些卡片的重启和恢复,才会询问插件。 + +```json +"permissions": ["launch:contribute"], +"services": [{ + "id": "launcher", "title": "Launch env", "entry": "services/launcher.mjs", + "launch": { + "appliesTo": ["claude"], + "fields": [ + { "key": "enabled", "label": "Add the variable", "kind": "boolean", "default": true }, + { "key": "greeting", "label": "Value", "kind": "text", "default": "hello", "maxLength": 60 }, + { "key": "mode", "label": "Mode", "kind": "select", "default": "plain", + "options": [{ "value": "plain", "label": "Plain" }, { "value": "loud", "label": "Loud" }] } + ] + } +}] +``` + +最多 8 个字段;`kind` 为 `boolean`、`select`(1–16 个选项)或 `text`(最多 200 个字符或 `maxLength`)。`appliesTo` 列出适用的智能体服务商,省略表示所有智能体。所选值按字段校验,保存在卡片的会话记录中(每个插件最多 4 KB),并在重启和恢复时复用。它们不是机密:密钥应放在插件的 `secrets` 中,而不是字段里。 + +带 `"optionsFrom": "service"` 的 `select` 还会列出服务提供的选项,例如插件自己的账户。启动器打开时,CanvasTTY 向服务发送 `canvastty.launch.options` `{ provider, fields: [键] }`,最多等待 3 秒;回答 `{ "<键>": [{ value, label }] }` 在声明的选项之后为每个字段追加最多 64 个选项(声明的选项仍然必需,服务未回答时启动器只显示它们)。由于此类列表可能在卡片保存后变化,其值接受为不含控制字符、最多 200 个字符的任意文本,`canvastty.launch.prepare` 必须检查该值,并拒绝已不存在的选项。 + +编排器可以把同样的值作为 `launchOptions`(`{ "": { "<键>": 值 } }`)传给 `spawn_agent`,校验方式与启动器相同;插件工具可以给出这些值(例如它选定的账户)。 + +智能体启动前,宿主向服务发送 `canvastty.launch.prepare` 请求(界面无法发送): + +```json +{"sessionId":"…","provider":"claude","profile":"normal","role":"agent","cwd":"/project","restoring":false,"resume":false,"options":{"enabled":true,"greeting":"hello","mode":"plain"}} +``` + +应答为 `null`(不添加任何内容)或包含以下任意键的对象: + +| 键 | 限制 | 作用 | +|:--|:--|:--| +| `env` `{ NAME: value }` | 32 个名称,每个值 8 KB | 加入智能体的环境变量 | +| `secretEnv` `{ NAME: secretKey }` | 16 个名称;需要 `secrets` | 宿主在主进程中读取插件自身的机密并设置。该值不会到达服务或任何 UI,并在其他智能体和控制 CLI 从该卡片读取的文本中(observe、result、screen、失败详情)显示为 `` | +| `args` `[string]` | 32 个,每个 1024 字符,无控制字符 | 追加在 CanvasTTY 自身参数之后、会话选择之前 | +| `files` `[{ relPath, content }]` | 16 个文件,256 KB,普通相对路径 | 写入本次运行的私有文件夹,进程退出时删除;`env` 值和 `args` 中的 `{launchFiles}` 替换为该文件夹 | +| `refuse` `{ reason }` | 240 字符 | 卡片不启动并显示原因 | + +宿主强制执行、从不跳过的规则: + +- 多个被选插件并行询问,并按插件 id 顺序合并。两个插件设置同一名称,或插件设置 CanvasTTY 为此次启动设置的名称,会拒绝启动并指明它们。以 `CANVASTTY_`、`ELECTRON_`、`DYLD_`、`LD_` 开头的名称以及 `NODE_OPTIONS`、`PATH`、`TERM`、`COLORTERM` 为保留名称。 +- 绕过审批或选择会话的参数(各服务商的 YOLO 标志、`--permission-mode`、`--sandbox`、`--resume`、`--continue`、`--session` 等)会被拒绝:配置档由用户决定,恢复规则由核心决定。这不是沙箱:受信任的原生代码本来就以你的身份运行。 +- Claude Code 只应用最后一个 `--settings`,因此插件的内联 `--settings` JSON 会合并进 CanvasTTY 自己的 JSON(`env` 等对象按键合并,hook 列表追加);若其中设置了 `permissions`、`hooks`、`disableAllHooks`、`sandbox`、`defaultMode` 或 `apiKeyHelper`,启动会被拒绝。 +- 5 秒内无应答、出错、应答无效、缺少机密,或插件被禁用、删除或不再受信任,都会拒绝启动并在卡片上显示原因。智能体绝不会在缺少用户所选贡献的情况下启动。插件不可用的恢复卡片以停止状态返回并显示该原因,记录保留到插件恢复或卡片被关闭。 +- 普通终端不接受启动选项。 + +**启动策略。** 设置 `"policy": true` 后,在该服务适用的智能体每次启动(创建、重启、恢复)而用户没有选择它时,也会以 `"chosen": false` 和空的 `options` 询问它。这样的回答只能是 `null` 或 `refuse`;其他任何回答、5 秒内无回答或出错都会拒绝启动,因此策略绝不会因失败而放行。每个 `canvastty.launch.prepare` 还带有 `"environment"`:卡片环境的 `{ pluginId, kind }`,在本机运行时为 `null`。没有 `fields` 的策略不会显示在启动器中。撤销插件的原生代码信任即移除其策略。 + +```json +"launch": { "policy": true, "fields": [] } +``` + +完整示例见 [`examples/plugins/launch-env`](../examples/plugins/launch-env)(选项)和 [`examples/plugins/yolo-guard`](../examples/plugins/yolo-guard)(拒绝环境之外的 YOLO 的策略)。 + +### 会话环境(`environment:provide`) + +环境是卡片运行的位置:git worktree、容器或远程主机。每个插件最多可以在 `environments` 中列出 8 种类型,可以放在一个服务中,也可以分布在多个服务中(例如每个模块一个服务);每种类型在插件内唯一,由列出它的服务应答。信任插件原生代码后,启动器的 **Advanced** 部分会显示 **Where**(默认 **This computer**),列出适用于该服务商的类型及其可选 `fields`(种类和限制与启动字段相同)。只要有类型适用于终端,**Open terminal** 就会打开同一个启动器(文件夹和 Where),而不是立即打开终端。 + +```json +"permissions": ["environment:provide"], +"services": [{ + "id": "worktree", "title": "Git worktree", "entry": "services/worktree.mjs", + "environments": [{ + "kind": "worktree", "label": "Git worktree", + "description": "A branch in its own folder", + "appliesTo": ["terminal", "claude"], + "fields": [{ "key": "branch", "label": "Branch", "kind": "text", "default": "", "maxLength": 80 }] + }] +}] +``` + +CanvasTTY 负责卡片、PTY、保存的记录和恢复顺序;服务回答五个只有宿主能发送的请求: + +| 请求 | 参数 | 应答 | 时限 | +|:--|:--|:--|:--| +| `canvastty.environment.prepare` | `sessionId, kind, provider, cwd, options` | `{ ref, label, cwd? }` 或 `{ refuse: { reason } }`。`ref` 是不超过 4 KB 的不透明 JSON,随卡片保存;`label`(80 字符)是徽标;`cwd`(已存在的绝对路径文件夹)成为卡片的文件夹 | 15 秒 | +| `canvastty.environment.wrap` | `sessionId, kind, ref, provider, command, args, env, secretEnvNames, cwd` | `{ command, args, env?, secretEnv?, cwd? }` 或 `{ refuse }` | 5 秒 | +| `canvastty.environment.resume` | `sessionId, kind, ref` | `{ ok: true }` 或 `{ stopped: { reason } }` | 10 秒 | +| `canvastty.environment.release` | `sessionId, kind, ref, keepData, reason`(`closed` 或 `quit`) | 忽略 | 10 秒 | +| `canvastty.environment.describe` | `sessionId, kind, ref` | `{ label, detail? }`,用于卡片徽标及其提示 | 3 秒 | + +- `prepare` 只在卡片首次启动时调用一次。`wrap` 在每次启动(创建、重启、恢复)前调用,把宿主原本要启动的命令变成在环境中运行的命令,例如 `ssh -tt host …`、`docker exec -it …`,或在另一个文件夹中运行同一程序。PTY 仍由宿主通过 node-pty 创建,因此回滚、状态和编排照常工作。 +- `wrap` 的输出会被检查:`command` 必须是可执行文件的绝对路径,或由宿主在 `PATH` 中解析的纯程序名;命令行、相对路径或 shell 语法会被拒绝,任何内容都不经过 shell 运行。`args` 是数组(256 项,每项 8 KB,不含 NUL)。`env` 和 `secretEnv` 遵循启动贡献者的规则:保留名称以及 CanvasTTY 或启动选项已为此次启动设置的名称会被拒绝。`secretEnv` 的值来自插件自己的机密(需要 `secrets`),并像启动机密一样被遮蔽。 +- `wrap` 收到此次启动自身的变量(来自 CanvasTTY 和所选启动选项),不含保留的 `CANVASTTY_*` 名称,也不含机密值;`secretEnvNames` 列出进程将从宿主获得值的名称,包装器可以按名称转发它们(`docker exec -e NAME`)。 +- 恢复时先恢复所有保存的环境,再先启动父卡片、后启动子卡片。如果插件被禁用、删除或不受信任,或 `resume` 应答 `stopped`,卡片以停止状态返回并显示原因,记录保留;重启会再次调用 `resume`。卡片绝不会改为在本地启动,超时或错误会拒绝启动,不会回退。 +- 关闭环境中的卡片时只询问一次“Keep environment data?”,然后带着答案调用 `release`。退出应用不会释放任何环境(环境随卡片一起恢复);关闭保存时,退出会以 `keepData: true` 和 `reason: "quit"` 调用 `release`,以便停止计算。插件不保存自己的会话列表,也没有恢复逻辑。 + +完整示例见 [`examples/plugins/env-worktree`](../examples/plugins/env-worktree):`prepare` 在插件数据目录下的文件夹中运行 `git worktree add`,`wrap` 设置文件夹,`resume` 检查它仍然存在,`describe` 显示当前分支,`release` 删除该 worktree(以及它创建的分支),除非你选择保留。 + +### 决策 hook(`decision:provide`) + +在本地 agent 运行 shell 命令或写入文件之前,CanvasTTY 可以询问插件:拒绝、询问用户或允许。每个插件最多一个服务可以声明 `decide`: + +```json +"permissions": ["decision:provide"], +"services": [{ + "id": "guard", "title": "rm -rf guard", "entry": "services/guard.mjs", + "decide": { "events": ["pre-tool"], "appliesTo": ["claude", "codex"], "timeoutMs": 3000 } +}] +``` + +`pre-tool` 指每一次 shell 和写文件的工具调用,在运行之前,并且适用于所有权限模式(包括 YOLO):Claude Code、Codex 和 Qwen Code 通过它们的 `PreToolUse` hook,OpenCode 通过 CanvasTTY 的 OpenCode 插件(`tool.execute.before`)。`appliesTo` 限定 agent;省略时为全部四个。主机发送 `canvastty.decide`(仅主机可发),最多等待 `timeoutMs`(1000 到 60000;省略时 3000)。agent 的调用会等待同样长的时间,所以只有在回答确实需要时才申请更长时间(例如让本地模型阅读命令);CanvasTTY 在卡片启动时按适用服务中最长的预算设置该卡片的 hook,之后才被信任的服务所得时间不超过卡片允许的范围。请求以 `budgetMs` 携带该预算: + +```ts +interface DecisionRequest { + event: "pre-tool"; + sessionId: string; provider: string; role: "agent" | "orchestrator" | "subagent"; + cwd: string; // 卡片的工作文件夹 + agentCwd: string | null; // agent 当前所在文件夹(CLI 报告时) + tool: { name: string; kind: "shell" | "edit" | "other"; command: string | null; paths: string[] }; + input: unknown; // agent 发送的原始工具输入;超过 40 KB 时为 null(truncated) + truncated: boolean; + budgetMs: number; // CanvasTTY 等待这个回答的时长 +} +// 回答:{ verdict: "deny" | "ask" | "allow", reason?: string },或 null 表示没有意见 +``` + +回答按以下顺序合并: + +1. 先运行**基础保护**(见下文);它的拒绝是最终结果,不再询问插件。 +2. 任何插件的 `deny` 优先。模型读到 `CanvasTTY plugin "" blocked this tool call ()`,所以请在原因中写明应当改做什么。 +3. 否则任何 `ask`:无论权限模式如何,Claude Code 都会就此调用询问用户。超时、错误、服务已停止或无法读取的回答都算 `ask`,绝不算允许。 +4. 否则只有用户授权可以允许的插件,其 `allow` 才算数:在**设置 → Agents → Extension native code**中该插件下的第二个确认**May allow agent actions**,随原生代码信任一起撤销。之后 Claude Code 不经自身提示直接运行该调用;OpenCode 对该调用的询问以 `once` 回答。输入过大、无法完整发送时,允许永不生效。 +5. 否则什么都不做:agent 照常继续,就像没有 CanvasTTY 一样。 + +Codex 和 Qwen Code 只接受此 hook 的拒绝:对它们来说,`ask` 和 `allow` 把决定留给 CLI 自身的权限模式。远程和容器会话不在覆盖范围内(它们的 hook 无法连回本机)。只有在基础保护开启或有决策插件适用时启动的 agent 才会安装此 hook,因此之后才信任的插件只对新卡片生效。hook 崩溃时 CLI 会照常运行该调用,所以这是一道防护,而不是沙箱。 + +完整示例见 [`examples/plugins/deny-rm`](../examples/plugins/deny-rm):它拒绝对工作文件夹顶层任何内容执行 `rm -rf`(`rm -rf *`、`rm -rf src`),对其他一切不表态。它声明了 `timeoutMs: 5000` 以展示该字段;它会立即回答。 + +### 基础保护与密钥遮蔽(核心) + +两项安全功能内置,无需插件: + +- **基础保护**(设置 → Agents → Base protection,默认开启;用户可以关闭)通过同一个 hook 拒绝:sudo 及其他提权、把下载或生成的文本管道给 shell、下载后直接运行、磁盘和格式化命令、fork 炸弹,以及在工作文件夹之外写入或删除(包括主目录、其他项目和 `/tmp`),以及删除工作文件夹本身。agent 自己的计划和记忆文件夹(`~/.claude/plans`、`~/.claude/projects//memory`,以及本次运行 `CLAUDE_CONFIG_DIR` 中的相同位置)不算"外部"。它只会拒绝;每条原因都告诉模型应当改做什么(写入 `/tmp` 时建议在项目内建立临时文件夹)。 +- **密钥遮蔽**:CanvasTTY 从一个 agent 交给另一个 agent 的所有文本(`observe_agent`、`get_agent_result`,以及 control CLI 的 `screen`、`result` 和失败详情)都会被遮蔽:CanvasTTY 保存的服务商密钥、启动时的 `secretEnv` 值、服务通过 `redaction.register` 注册的值(包括被终端折行拆开的情况),以及常见密钥形式(`sk-…`、GitHub、Slack、AWS、Google、JWT、`Bearer …`、`"apiKey": "…"`、PEM 私钥、长随机串)。 + host.onStorageChange(listener) 会把 host.storage.set 的写入通知给同一插件的所有活动界面——画布卡片、HOME 小组件和独立窗口——从而避免轮询。 ## 权限 @@ -111,8 +292,11 @@ host.onStorageChange(listener) 会把 host.storage.set 的写入通知给同一 | 权限 | SDK 能力 | 数据边界 | |:--|:--|:--| | `storage` | `storage.get`、`storage.set` | 隔离的 JSON 存储,每个插件 64 KB | -| `secrets` | `secrets.get`、`secrets.set`、`secrets.delete` | 通过 Electron `safeStorage` 加密的字符串机密;操作系统没有受保护存储时会明确失败 | +| `secrets` | `secrets.get`、`secrets.set`、`secrets.delete`;服务的 `secrets.get` | 通过 Electron `safeStorage` 加密的字符串机密;操作系统没有受保护存储时会明确失败。受信任的服务只能读取自身插件的机密 | | `sessions:read` | `sessions.list` | 仅限 ID、服务商、标题、状态、开始时间、退出码 | +| `launch:contribute` | 服务的 `launch` 块和 `canvastty.launch.prepare` | 可以为用户以其选项启动的智能体添加环境变量、参数和文件;设置 `policy` 后可以拒绝任何智能体启动 | +| `environment:provide` | 服务的 `environments` 和 `canvastty.environment.*` | 可以为用户在其环境中启动的卡片创建运行位置,并更改它们在那里运行的命令、参数、变量和文件夹 | +| `decision:provide` | 服务的 `decide` 和 `canvastty.decide` | 在 agent 的命令和文件写入运行之前看到它们(含输入),可以拒绝或询问用户;允许需要第二次确认 | | `limits:read` | `limits.get` | 与 HOME 使用的同一个脱敏 `LimitsSnapshot` | | `launcher:open` | `launcher.open` | 打开内置服务商的 Focus Card 或终端动作;不会绕过用户的启动选择 | | `external:open` | `external.open` | 仅通过操作系统打开明确的 HTTP(S) URL | diff --git a/examples/plugins/deny-rm/canvastty.plugin.json b/examples/plugins/deny-rm/canvastty.plugin.json new file mode 100644 index 00000000..ad179434 --- /dev/null +++ b/examples/plugins/deny-rm/canvastty.plugin.json @@ -0,0 +1,19 @@ +{ + "apiVersion": 2, + "id": "com.example.deny-rm", + "name": "Deny rm -rf", + "version": "1.0.0", + "description": "Minimal decision service: blocks rm -rf of anything at the top of the working folder (rm -rf *, rm -rf src). Everything else it leaves alone.", + "author": "CanvasTTY contributors", + "permissions": ["decision:provide"], + "services": [ + { + "id": "guard", + "title": "rm -rf guard", + "description": "Answers deny for rm -rf at the top of the working folder.", + "entry": "services/guard.mjs", + "decide": { "events": ["pre-tool"], "timeoutMs": 5000 } + } + ], + "contributions": [] +} diff --git a/examples/plugins/deny-rm/services/guard.mjs b/examples/plugins/deny-rm/services/guard.mjs new file mode 100644 index 00000000..8c9fe281 --- /dev/null +++ b/examples/plugins/deny-rm/services/guard.mjs @@ -0,0 +1,50 @@ +// A CanvasTTY decision service: newline-delimited JSON-RPC 2.0 over stdin/stdout. +// Before an agent's shell command or file write runs, CanvasTTY calls canvastty.decide and waits +// at most 3 s. Answer { verdict: "deny" | "ask" | "allow", reason } or null for no opinion. +import { createInterface } from "node:readline"; +import { dirname, resolve } from "node:path"; + +const send = (message) => process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", ...message })}\n`); + +/** Denies `rm` with -r and -f when a target is the working folder itself or directly inside it. */ +function decide({ tool, cwd, agentCwd }) { + if (tool.kind !== "shell" || typeof tool.command !== "string") return null; + const here = agentCwd ?? cwd; + for (const part of tool.command.split(/&&|\|\||[;|\n]/u)) { + const words = part.trim().split(/\s+/u).filter(Boolean); + const at = words.findIndex((word) => word === "rm" || word.endsWith("/rm")); + if (at < 0) continue; + const args = words.slice(at + 1); + const short = args.filter((word) => /^-[^-]/u.test(word)).join(""); + const recursive = /[rR]/u.test(short) || args.includes("--recursive"); + const force = /f/u.test(short) || args.includes("--force"); + if (!recursive || !force) continue; + const topLevel = args.filter((word) => !word.startsWith("-")).some((target) => { + const path = resolve(here, target.replace(/[*?].*$/u, "") || "."); + return path === resolve(cwd) || dirname(path) === resolve(cwd); + }); + if (topLevel) { + return { verdict: "deny", reason: "rm -rf at the top of the working folder is blocked by the Deny rm -rf plugin; delete specific files instead" }; + } + } + return null; +} + +createInterface({ input: process.stdin }).on("line", (line) => { + let message; + try { + message = JSON.parse(line); + } catch { + return; + } + if (message.method === "canvastty.shutdown") process.exit(0); + if (message.method === "canvastty.decide" && message.id !== undefined) { + try { + send({ id: message.id, result: decide(message.params) }); + } catch (error) { + send({ id: message.id, error: { code: -32000, message: error.message } }); + } + } else if (typeof message.method === "string" && message.id !== undefined) { + send({ id: message.id, error: { code: -32601, message: `Unknown method: ${message.method}` } }); + } +}).on("close", () => process.exit(0)); diff --git a/examples/plugins/env-worktree/canvastty.plugin.json b/examples/plugins/env-worktree/canvastty.plugin.json new file mode 100644 index 00000000..16dc9c49 --- /dev/null +++ b/examples/plugins/env-worktree/canvastty.plugin.json @@ -0,0 +1,28 @@ +{ + "apiVersion": 2, + "id": "com.example.env-worktree", + "name": "Worktree Env", + "version": "1.0.0", + "description": "Minimal session environment: runs a card in its own git worktree, created in the plugin's data folder and removed on close unless you keep it.", + "author": "CanvasTTY contributors", + "permissions": ["environment:provide"], + "services": [ + { + "id": "worktree", + "title": "Git worktree", + "description": "Creates, reopens and removes git worktrees for cards started in this environment.", + "entry": "services/worktree.mjs", + "environments": [ + { + "kind": "worktree", + "label": "Git worktree", + "description": "A branch in its own folder; the project folder stays untouched.", + "fields": [ + { "key": "branch", "label": "Branch (empty: canvastty/)", "kind": "text", "default": "", "maxLength": 80 } + ] + } + ] + } + ], + "contributions": [] +} diff --git a/examples/plugins/env-worktree/services/worktree.mjs b/examples/plugins/env-worktree/services/worktree.mjs new file mode 100644 index 00000000..598bbd6c --- /dev/null +++ b/examples/plugins/env-worktree/services/worktree.mjs @@ -0,0 +1,100 @@ +// A CanvasTTY session environment: newline-delimited JSON-RPC 2.0 over stdin/stdout. +// Each card started in "Git worktree" gets `git worktree add` in this plugin's data folder. +// CanvasTTY keeps the ref and the PTY; this service only prepares, wraps (sets the folder), +// resumes, describes and releases. +import { execFile } from "node:child_process"; +import { existsSync, realpathSync } from "node:fs"; +import { basename, join, relative, resolve, sep } from "node:path"; +import { createInterface } from "node:readline"; +import { promisify } from "node:util"; + +const run = promisify(execFile); +const git = async (cwd, ...args) => (await run("git", ["-C", cwd, ...args], { timeout: 10_000 })).stdout.trim(); +const send = (message) => process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", ...message })}\n`); +let worktreesRoot = null; + +/** Only folders this plugin created are ever touched, whatever a saved ref says. */ +function ownedRef(ref) { + const dir = typeof ref?.dir === "string" ? resolve(ref.dir) : ""; + if (!worktreesRoot || !dir.startsWith(worktreesRoot + sep)) throw new Error("This worktree does not belong to the plugin."); + return { ...ref, dir }; +} + +async function prepare({ sessionId, cwd, options }) { + const repo = await git(cwd, "rev-parse", "--show-toplevel").catch(() => null); + if (!repo) return { refuse: { reason: `${cwd} is not inside a git repository.` } }; + const branch = options.branch?.trim() || `canvastty/${sessionId.slice(0, 8)}`; + if (!await git(repo, "check-ref-format", "--branch", branch).catch(() => null)) { + return { refuse: { reason: `${branch} is not a valid branch name.` } }; + } + const dir = join(worktreesRoot, `${basename(repo)}-${sessionId.slice(0, 8)}`); + const exists = await git(repo, "rev-parse", "--verify", "--quiet", `refs/heads/${branch}`).then(() => true, () => false); + try { + await git(repo, "worktree", "add", ...(exists ? [dir, branch] : ["-b", branch, dir])); + } catch (error) { + return { refuse: { reason: `git worktree add failed: ${String(error.stderr || error.message).trim().slice(0, 200)}` } }; + } + // The card's folder inside the repository (git reports real paths, so compare real paths). + const inside = relative(repo, realpathSync(cwd)); + const sub = inside.startsWith("..") ? "" : inside; + return { ref: { repo, dir, branch, createdBranch: !exists, sub }, label: `worktree ${branch}`, cwd: join(dir, sub) }; +} + +async function resume({ ref }) { + const { dir } = ownedRef(ref); + if (!existsSync(dir)) return { stopped: { reason: `The worktree folder ${dir} no longer exists.` } }; + const inside = await git(dir, "rev-parse", "--is-inside-work-tree").catch(() => ""); + return inside === "true" ? { ok: true } : { stopped: { reason: `${dir} is no longer a git worktree.` } }; +} + +function wrap({ ref, command, args }) { + const { dir, sub } = ownedRef(ref); + // Same program and arguments; only the folder changes. + return { command, args, cwd: join(dir, sub ?? "") }; +} + +async function release({ ref, keepData }) { + if (keepData) return {}; + const { repo, dir, branch, createdBranch } = ownedRef(ref); + await git(repo, "worktree", "remove", "--force", dir); + if (createdBranch) await git(repo, "branch", "-D", branch).catch(() => undefined); + return {}; +} + +async function describe({ ref }) { + const { dir } = ownedRef(ref); + const branch = await git(dir, "rev-parse", "--abbrev-ref", "HEAD").catch(() => ref.branch); + return { label: `worktree ${branch}`, detail: dir }; +} + +const methods = { + "canvastty.environment.prepare": prepare, + "canvastty.environment.resume": resume, + "canvastty.environment.wrap": wrap, + "canvastty.environment.release": release, + "canvastty.environment.describe": describe +}; + +createInterface({ input: process.stdin }).on("line", (line) => { + let message; + try { + message = JSON.parse(line); + } catch { + return; + } + if (message.method === "canvastty.initialize") { + worktreesRoot = resolve(message.params.dataDir, "worktrees"); + return; + } + if (message.method === "canvastty.shutdown") process.exit(0); + if (typeof message.method !== "string" || message.id === undefined) return; + const method = methods[message.method]; + if (!method) { + send({ id: message.id, error: { code: -32601, message: `Unknown method: ${message.method}` } }); + return; + } + Promise.resolve().then(() => method(message.params)).then( + (result) => send({ id: message.id, result }), + (error) => send({ id: message.id, error: { code: -32000, message: error.message } }) + ); +}).on("close", () => process.exit(0)); diff --git a/examples/plugins/launch-env/canvastty.plugin.json b/examples/plugins/launch-env/canvastty.plugin.json new file mode 100644 index 00000000..d3101991 --- /dev/null +++ b/examples/plugins/launch-env/canvastty.plugin.json @@ -0,0 +1,29 @@ +{ + "apiVersion": 2, + "id": "com.example.launch-env", + "name": "Launch Env", + "version": "1.0.0", + "description": "Minimal launch contributor: when its launcher option is on, the agent starts with an extra environment variable, a file and one argument. Its Profile choices come from the service.", + "author": "CanvasTTY contributors", + "permissions": ["launch:contribute"], + "services": [ + { + "id": "launcher", + "title": "Launch env", + "description": "Prepares launches of agents started with this plugin's option.", + "entry": "services/launcher.mjs", + "launch": { + "appliesTo": ["claude"], + "fields": [ + { "key": "enabled", "label": "Set CTTY_LAUNCH_EXAMPLE and add --verbose", "kind": "boolean", "default": true }, + { "key": "greeting", "label": "Value", "kind": "text", "default": "hello", "maxLength": 60 }, + { "key": "mode", "label": "Mode", "kind": "select", "default": "plain", + "options": [{ "value": "plain", "label": "Plain" }, { "value": "loud", "label": "Loud" }] }, + { "key": "profile", "label": "Profile", "kind": "select", "default": "none", "optionsFrom": "service", + "options": [{ "value": "none", "label": "No profile" }] } + ] + } + } + ], + "contributions": [] +} diff --git a/examples/plugins/launch-env/services/launcher.mjs b/examples/plugins/launch-env/services/launcher.mjs new file mode 100644 index 00000000..38995f33 --- /dev/null +++ b/examples/plugins/launch-env/services/launcher.mjs @@ -0,0 +1,52 @@ +// A CanvasTTY launch contributor: newline-delimited JSON-RPC 2.0 over stdin/stdout. +// CanvasTTY calls canvastty.launch.prepare only for launches where the person chose this +// plugin in the launcher's Advanced section, and waits at most 5 s for the answer. The launcher +// asks canvastty.launch.options for the choices of "optionsFrom": "service" selects (3 s). +import { createInterface } from "node:readline"; + +const send = (message) => process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", ...message })}\n`); + +// Choices a real plugin would read from its own storage (its accounts, say). +const PROFILES = [{ value: "work", label: "Work" }, { value: "home", label: "Home" }]; + +function prepare({ provider, restoring, options }) { + if (!options.enabled) return {}; + // A service-provided value may be stale by the time the card starts or is restored: check it here. + const profile = options.profile ?? "none"; + if (profile !== "none" && !PROFILES.some((entry) => entry.value === profile)) { + return { refuse: { reason: `Profile ${profile} no longer exists; choose another one.` } }; + } + const value = options.mode === "loud" ? options.greeting.toUpperCase() : options.greeting; + if (!value) return { refuse: { reason: "Value is empty; type one in the launcher or turn the option off." } }; + return { + env: { + CTTY_LAUNCH_EXAMPLE: value, + ...(profile !== "none" ? { CTTY_LAUNCH_PROFILE: profile } : {}), + // {launchFiles} becomes this plugin's folder of files for this run. + CTTY_LAUNCH_EXAMPLE_FILE: "{launchFiles}/note.txt" + }, + files: [{ relPath: "note.txt", content: `${provider} ${restoring ? "restored" : "started"}\n` }], + args: ["--verbose"] + }; +} + +createInterface({ input: process.stdin }).on("line", (line) => { + let message; + try { + message = JSON.parse(line); + } catch { + return; + } + if (message.method === "canvastty.shutdown") process.exit(0); + if (message.method === "canvastty.launch.options" && message.id !== undefined) { + send({ id: message.id, result: { profile: PROFILES } }); + } else if (message.method === "canvastty.launch.prepare" && message.id !== undefined) { + try { + send({ id: message.id, result: prepare(message.params) }); + } catch (error) { + send({ id: message.id, error: { code: -32000, message: error.message } }); + } + } else if (typeof message.method === "string" && message.id !== undefined) { + send({ id: message.id, error: { code: -32601, message: `Unknown method: ${message.method}` } }); + } +}).on("close", () => process.exit(0)); diff --git a/examples/plugins/service-echo/apps/echo.css b/examples/plugins/service-echo/apps/echo.css new file mode 100644 index 00000000..20c51067 --- /dev/null +++ b/examples/plugins/service-echo/apps/echo.css @@ -0,0 +1,5 @@ +body { margin: 0; font: 600 14px/1.5 system-ui, sans-serif; color: #eef0f6; background: #353442; } +.echo { display: grid; gap: 10px; padding: 16px; } +.echo input { padding: 8px 10px; border: 1px solid rgba(255,255,255,.15); border-radius: 10px; color: inherit; background: rgba(255,255,255,.06); font: inherit; } +.echo button { min-height: 38px; border: 0; border-radius: 10px; color: #30313d; background: #b9d4a8; cursor: pointer; font-weight: 800; } +.echo output { min-height: 22px; overflow-wrap: anywhere; } diff --git a/examples/plugins/service-echo/apps/echo.html b/examples/plugins/service-echo/apps/echo.html new file mode 100644 index 00000000..be6d6909 --- /dev/null +++ b/examples/plugins/service-echo/apps/echo.html @@ -0,0 +1,21 @@ + + + + + + + Service echo + + +
+ + + Not called yet. + + + +
+ + + + diff --git a/examples/plugins/service-echo/apps/echo.js b/examples/plugins/service-echo/apps/echo.js new file mode 100644 index 00000000..864fe240 --- /dev/null +++ b/examples/plugins/service-echo/apps/echo.js @@ -0,0 +1,35 @@ +const host = window.CanvasTTYPlugin; +const text = document.querySelector("#text"); +const send = document.querySelector("#send"); +const result = document.querySelector("#result"); + +send.addEventListener("click", async () => { + result.textContent = "Calling…"; + try { + const reply = await host.service.request("echo", "echo", { text: text.value }); + result.textContent = `Echo #${reply.count}: ${reply.echo.text}`; + } catch (error) { + // Not trusted yet, disabled, crashed or timed out: the host returns an error, never a reply. + result.textContent = `Error: ${error instanceof Error ? error.message : String(error)}`; + } +}); + +// Write-only: the page saves the token into the plugin's secrets and asks the service whether it can read it. +document.querySelector("#save-token").addEventListener("click", async () => { + const token = document.querySelector("#token"); + await host.secrets.set("token", token.value); + token.value = ""; + result.textContent = "Token saved."; +}); +document.querySelector("#check-token").addEventListener("click", async () => { + try { + const reply = await host.service.request("echo", "token"); + result.textContent = reply.set ? "The service sees a token." : "No token is set."; + } catch (error) { + result.textContent = `Error: ${error instanceof Error ? error.message : String(error)}`; + } +}); + +host.service.onEvent(({ serviceId, event, data }) => { + document.body.dataset.lastEvent = `${serviceId}:${event}:${data?.count ?? ""}`; +}); diff --git a/examples/plugins/service-echo/canvastty.plugin.json b/examples/plugins/service-echo/canvastty.plugin.json new file mode 100644 index 00000000..cc4baa99 --- /dev/null +++ b/examples/plugins/service-echo/canvastty.plugin.json @@ -0,0 +1,27 @@ +{ + "apiVersion": 2, + "id": "com.example.service-echo", + "name": "Service Echo", + "version": "1.0.0", + "description": "Minimal plugin service: a canvas app button that calls its own service, which echoes the text back. The app can also save a token into the plugin's secrets, and the service reads it with secrets.get (it answers only whether one is set).", + "author": "CanvasTTY contributors", + "permissions": ["storage", "secrets"], + "services": [ + { + "id": "echo", + "title": "Echo", + "description": "Echoes requests and counts them in plugin storage.", + "entry": "services/echo.mjs" + } + ], + "contributions": [ + { + "id": "echo", + "kind": "canvas-app", + "title": "Service echo", + "description": "Sends text to the plugin's own service and shows the reply.", + "entry": "apps/echo.html", + "defaultSize": { "width": 420, "height": 260 } + } + ] +} diff --git a/examples/plugins/service-echo/services/echo.mjs b/examples/plugins/service-echo/services/echo.mjs new file mode 100644 index 00000000..57af670f --- /dev/null +++ b/examples/plugins/service-echo/services/echo.mjs @@ -0,0 +1,57 @@ +// A CanvasTTY plugin service: newline-delimited JSON-RPC 2.0 over stdin/stdout. +// Bundled single file, no dependencies. Anything written to stderr ends up in the plugin log. +import { createInterface } from "node:readline"; + +const pending = new Map(); +let nextId = 1; +let context = null; + +const send = (message) => process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", ...message })}\n`); +const callHost = (method, params) => new Promise((resolve, reject) => { + const id = nextId++; + pending.set(id, { resolve, reject }); + send({ id, method, params }); +}); + +async function handle(method, params) { + if (method === "echo") { + const count = ((await callHost("storage.get", { key: "count" })) ?? 0) + 1; + await callHost("storage.set", { key: "count", value: count }); + send({ method: "event", params: { event: "echoed", data: { count } } }); + return { echo: params, count, serviceId: context?.serviceId ?? null }; + } + if (method === "token") { + // The service reads the plugin's own secret (needs the secrets permission); CanvasTTY masks the value in + // everything agents read. Never send it back to a page: answer only whether it is set. + const token = await callHost("secrets.get", { key: "token" }); + return { set: typeof token === "string" && token.length > 0 }; + } + throw new Error(`Unknown method: ${method}`); +} + +createInterface({ input: process.stdin }).on("line", (line) => { + let message; + try { + message = JSON.parse(line); + } catch { + return; + } + if (message.method === "canvastty.initialize") { + context = message.params; + send({ method: "log", params: { level: "info", message: `echo ready for ${context.pluginId}` } }); + return; + } + if (message.method === "canvastty.shutdown") process.exit(0); + if (typeof message.method === "string" && message.id !== undefined) { + handle(message.method, message.params).then( + (result) => send({ id: message.id, result }), + (error) => send({ id: message.id, error: { code: -32000, message: error.message } }) + ); + return; + } + const waiter = pending.get(message.id); + if (!waiter) return; + pending.delete(message.id); + if (message.error) waiter.reject(new Error(message.error.message)); + else waiter.resolve(message.result); +}).on("close", () => process.exit(0)); diff --git a/examples/plugins/yolo-guard/canvastty.plugin.json b/examples/plugins/yolo-guard/canvastty.plugin.json new file mode 100644 index 00000000..d3b94769 --- /dev/null +++ b/examples/plugins/yolo-guard/canvastty.plugin.json @@ -0,0 +1,19 @@ +{ + "apiVersion": 2, + "id": "com.example.yolo-guard", + "name": "YOLO Guard", + "version": "1.0.0", + "description": "Minimal launch policy: refuses every YOLO agent launch that is not in a plugin environment (a worktree, a container, a server). It has no launcher options; CanvasTTY asks it before every agent launch.", + "author": "CanvasTTY contributors", + "permissions": ["launch:contribute"], + "services": [ + { + "id": "guard", + "title": "YOLO guard", + "description": "Answers the launch policy for every agent launch.", + "entry": "services/guard.mjs", + "launch": { "policy": true, "fields": [] } + } + ], + "contributions": [] +} diff --git a/examples/plugins/yolo-guard/services/guard.mjs b/examples/plugins/yolo-guard/services/guard.mjs new file mode 100644 index 00000000..ba753b12 --- /dev/null +++ b/examples/plugins/yolo-guard/services/guard.mjs @@ -0,0 +1,29 @@ +// A CanvasTTY launch policy: newline-delimited JSON-RPC 2.0 over stdin/stdout. +// With `launch.policy: true` CanvasTTY sends canvastty.launch.prepare before every agent launch, with `chosen: false` +// when the person did not pick this plugin's options (this one has none). Such an answer may only refuse; no answer +// within 5 s refuses the launch too. +import { createInterface } from "node:readline"; + +const send = (message) => process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", ...message })}\n`); + +function prepare({ profile, environment }) { + if (profile === "yolo" && !environment) { + return { refuse: { reason: "YOLO runs only in an isolated environment: pick one under Advanced → Where, or start without YOLO." } }; + } + return null; +} + +createInterface({ input: process.stdin }).on("line", (line) => { + let message; + try { + message = JSON.parse(line); + } catch { + return; + } + if (message.method === "canvastty.shutdown") process.exit(0); + if (message.method === "canvastty.launch.prepare" && message.id !== undefined) { + send({ id: message.id, result: prepare(message.params) }); + } else if (typeof message.method === "string" && message.id !== undefined) { + send({ id: message.id, error: { code: -32601, message: `Unknown method: ${message.method}` } }); + } +}).on("close", () => process.exit(0)); diff --git a/src/agent-browser/orchestration-catalog.mjs b/src/agent-browser/orchestration-catalog.mjs index 7887d149..f2bdba67 100644 --- a/src/agent-browser/orchestration-catalog.mjs +++ b/src/agent-browser/orchestration-catalog.mjs @@ -12,6 +12,14 @@ const object = (properties, required = []) => ({ }); const sessionId = string({ minLength: 1, maxLength: 128 }); +// Plugin launch options, `{ "": { "": value } }`, as a plugin tool hands them out; the launch +// checks them against each plugin's declared fields exactly like the launcher's. +const MAX_LAUNCH_OPTIONS_BYTES = 16 * 1024; +const launchOptions = { + type: "object", + maxProperties: 16, + additionalProperties: { type: "object" } +}; const prompt = string({ minLength: 1, maxLength: 65_536 }); const title = string({ minLength: 1, maxLength: 80 }); @@ -26,12 +34,13 @@ function tool(name, description, properties = {}, required = []) { 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.", + "Launch another provider's agent as a CanvasTTY subagent of this session and optionally deliver a first prompt. Returns the new session id. launchOptions passes plugin launch options exactly as a plugin tool gives them (for example the account a plugin picked).", { provider: string({ minLength: 1, maxLength: 32 }), cwd: string({ minLength: 1, maxLength: 4_096 }), prompt, - title + title, + launchOptions }, ["provider", "cwd"] ), @@ -107,6 +116,15 @@ export function validateOrchestrationArguments(toolName, args) { } else if (property.type === "boolean") { if (typeof candidate !== "boolean") errors.push(`${key} must be a boolean.`); else value[key] = candidate; + } else if (property === launchOptions) { + const plain = (entry) => entry !== null && typeof entry === "object" && !Array.isArray(entry); + if (!plain(candidate) || Object.keys(candidate).length > property.maxProperties + || !Object.values(candidate).every((values) => plain(values) + && Object.values(values).every((item) => typeof item === "string" || typeof item === "boolean"))) { + errors.push(`${key} must map plugin ids to objects of text or true/false values.`); + } else if (canonicalStringify(candidate).length > MAX_LAUNCH_OPTIONS_BYTES) { + errors.push(`${key} is too large.`); + } else value[key] = candidate; } else if (property.type === "integer") { if (!Number.isInteger(candidate)) errors.push(`${key} must be an integer.`); else if (property.minimum !== undefined && candidate < property.minimum) errors.push(`${key} is below the minimum.`); diff --git a/src/agent-runtime/hook-helper.mjs b/src/agent-runtime/hook-helper.mjs index a60a655e..024a1376 100644 --- a/src/agent-runtime/hook-helper.mjs +++ b/src/agent-runtime/hook-helper.mjs @@ -40,6 +40,15 @@ const turnId = firstString( input?.prompt_id, input?.promptId ); +// The provider's own conversation id; runtime-client keeps it only in a shape that provider issues. +const threadId = firstString( + input?.session_id, + input?.sessionId, + input?.thread_id, + input?.threadId, + input?.conversation_id, + input?.conversationId +); const finalAnswer = state === "idle" && event === "Stop" && typeof input?.last_assistant_message === "string" ? input.last_assistant_message : null; @@ -55,6 +64,7 @@ await reportLifecycle({ state, event, turnId, + ...(threadId ? { threadId } : {}), ...(result === undefined ? {} : { result }), ...(lastAssistantMessage === undefined ? {} : { lastAssistantMessage }) }); diff --git a/src/agent-runtime/opencode-decisions.mjs b/src/agent-runtime/opencode-decisions.mjs new file mode 100644 index 00000000..a86b0482 --- /dev/null +++ b/src/agent-runtime/opencode-decisions.mjs @@ -0,0 +1,113 @@ +import { buildRequest, exchange, identityFrom } from "./permission-gate.mjs"; +import { OPENCODE_DECISIONS_ENV, helperDeadlineMs } from "./runtime-protocol.mjs"; + +/** + * Decision hooks for OpenCode, used by opencode-plugin.mjs while `CANVASTTY_RUNTIME_DECISIONS` is "1". + * + * `guard` runs in `tool.execute.before` (before OpenCode's own permission check, in every mode, YOLO included) + * and sends each shell and file-writing call (bash, write, edit, multiedit, apply_patch/patch) to CanvasTTY the way + * the Claude/Codex/Qwen PreToolUse hook does. A deny throws, which fails that tool call with the reason as its error + * text for the model. An allow (only from plugins the person let allow) is remembered by call id: when OpenCode then + * asks for that call (`permission.asked`), `permissionAsked` answers `once` through OpenCode's own API + * (POST /permission/{requestID}/reply), never `always`. Anything else (ask, no verdict, an error) leaves OpenCode's + * own flow as it is. + */ + +const MAX_ALLOWED = 64; + +/** @param {{ client?: any, env?: Record, send?: typeof exchange }} [options] */ +export function createOpenCodeDecisions({ client, env = process.env, send = exchange } = {}) { + const identity = identityFrom(env); + const enabled = env[OPENCODE_DECISIONS_ENV] === "1" && identity?.provider === "opencode"; + /** callIDs a plugin allowed; their permission request is answered `once`. */ + const allowed = new Set(); + + async function guard(input, output) { + if (!enabled) return; + let decision = null; + try { + const call = guardedCall(stringOf(input?.tool), output && typeof output === "object" ? output.args : null); + if (!call) return; + const message = buildRequest({ tool_name: call.toolName, tool_input: call.toolInput }, identity); + if (!message) return; + decision = await send(identity.address, message, helperDeadlineMs(env)); + } catch { + return; + } + if (decision?.behavior === "deny") { + throw new Error(decision.message || "CanvasTTY blocked this tool call. Ask the person how to proceed."); + } + const callID = stringOf(input?.callID); + if (decision?.behavior === "allow" && callID) { + allowed.add(callID); + while (allowed.size > MAX_ALLOWED) allowed.delete(allowed.values().next().value); + } + } + + /** `permission.asked`: answers `once` for a call a plugin allowed; false when it left the request alone. */ + async function permissionAsked(properties) { + if (!enabled || !properties || typeof properties !== "object") return false; + const requestID = stringOf(properties.id); + const callID = stringOf(properties.tool?.callID); + if (!requestID || !callID || !allowed.delete(callID)) return false; + return reply(client, requestID, "once"); + } + + return { enabled, guard, permissionAsked }; +} + +/** + * An OpenCode tool call in the shape the decision hook reads (the Claude/Codex hook tool names), or null for a tool + * that neither runs commands nor writes files. OpenCode's own names: bash {command, workdir}, write {filePath, + * content}, edit {filePath, oldString, newString}, multiedit {filePath, edits}, apply_patch {patchText}. + */ +export function guardedCall(tool, args) { + if (!tool || !args || typeof args !== "object") return null; + if (tool === "bash") { + if (typeof args.command !== "string" || args.command.length === 0) return null; + const workdir = typeof args.workdir === "string" && args.workdir.length > 0 ? args.workdir : null; + return { toolName: "bash", toolInput: { command: args.command, ...(workdir ? { workdir } : {}) } }; + } + if (tool === "write" || tool === "edit" || tool === "multiedit") { + const filePath = typeof args.filePath === "string" && args.filePath.length > 0 ? args.filePath + : typeof args.file_path === "string" && args.file_path.length > 0 ? args.file_path : null; + if (!filePath) return null; + const content = typeof args.content === "string" ? args.content : typeof args.newString === "string" ? args.newString : null; + return { toolName: "edit", toolInput: { file_path: filePath, ...(content !== null ? { content } : {}) } }; + } + if (tool === "apply_patch" || tool === "patch") { + const patch = typeof args.patchText === "string" ? args.patchText : typeof args.patch === "string" ? args.patch : null; + return patch ? { toolName: "apply_patch", toolInput: { patch } } : null; + } + return null; +} + +/** POST /permission/{requestID}/reply with `once`. False on any failure (OpenCode's prompt stays for the person). */ +async function reply(client, requestID, value) { + try { + if (typeof client?.permission?.reply === "function") { + return accepted(await client.permission.reply({ requestID, reply: value })); + } + const raw = client?._client; + if (typeof raw?.post === "function") { + return accepted(await raw.post({ + url: "/permission/{requestID}/reply", + path: { requestID }, + body: { reply: value }, + headers: { "Content-Type": "application/json" } + })); + } + } catch { /* the person answers */ } + return false; +} + +function accepted(result) { + if (!result || typeof result !== "object") return result === true; + if (result.error) return false; + if (result.response && typeof result.response.ok === "boolean") return result.response.ok; + return true; +} + +function stringOf(value) { + return typeof value === "string" && value.length > 0 && value.length <= 400 ? value : null; +} diff --git a/src/agent-runtime/opencode-plugin.mjs b/src/agent-runtime/opencode-plugin.mjs index 73ca8978..e0bda1c8 100644 --- a/src/agent-runtime/opencode-plugin.mjs +++ b/src/agent-runtime/opencode-plugin.mjs @@ -1,4 +1,5 @@ import { reportLifecycle } from "./runtime-client.mjs"; +import { createOpenCodeDecisions } from "./opencode-decisions.mjs"; import { spawn } from "node:child_process"; let rootSessionId = null; @@ -10,76 +11,88 @@ const pluginHookRunner = process.env.CANVASTTY_PLUGIN_HOOK_RUNNER ?? ""; const pluginHookTerminalSessionId = process.env.CANVASTTY_PLUGIN_HOOK_TERMINAL_SESSION_ID ?? ""; const pluginHooks = parsePluginHooks(process.env.CANVASTTY_PLUGIN_HOOK_SESSION); -export const CanvasTTYLifecycle = async () => ({ - event: async ({ event }) => { - if (!event || typeof event !== "object") return; - const properties = event.properties && typeof event.properties === "object" - ? event.properties - : {}; - const info = properties.info && typeof properties.info === "object" ? properties.info : null; - const sessionId = stringField(properties.sessionID, properties.sessionId, properties.id, info?.id); +export const CanvasTTYLifecycle = async (input) => { + // Decision hooks: only for a session launched with them; otherwise nothing below changes. A deny throws, which + // 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) } : {}), + event: async ({ event }) => lifecycleEvent(event, decisions) + }; +}; - if (event.type === "session.created") { - const session = info ?? properties; - if (session.parentID || session.parentId) return; - rootSessionId = stringField(session.id, sessionId); - rootWorking = false; - if (!rootSessionId) return; - if (lifecycleEnabled) await reportLifecycle({ state: "idle", event: event.type, turnId: rootSessionId }); - runPluginHooks("session-start", event.type, event); - return; - } - if (rootSessionId && sessionId && sessionId !== rootSessionId) return; +async function lifecycleEvent(event, decisions) { + if (!event || typeof event !== "object") return; + const properties = event.properties && typeof event.properties === "object" + ? event.properties + : {}; + const info = properties.info && typeof properties.info === "object" ? properties.info : null; + const sessionId = stringField(properties.sessionID, properties.sessionId, properties.id, info?.id); + // A call a plugin allowed is answered for every session of this OpenCode (subagents included). + if (event.type === "permission.asked" && await decisions.permissionAsked(properties)) return; + + if (event.type === "session.created") { + const session = info ?? properties; + if (session.parentID || session.parentId) return; + rootSessionId = stringField(session.id, sessionId); + rootWorking = false; if (!rootSessionId) return; + if (lifecycleEnabled) { + await reportLifecycle({ state: "idle", event: event.type, turnId: rootSessionId, threadId: rootSessionId }); + } + runPluginHooks("session-start", event.type, event); + return; + } + if (rootSessionId && sessionId && sessionId !== rootSessionId) return; + if (!rootSessionId) return; - if (event.type === "session.status") { - const statusValue = properties.status; - const status = typeof statusValue === "string" - ? statusValue - : statusValue && typeof statusValue === "object" - ? statusValue.type - : null; - if (status === "busy" || status === "retry") { - if (lifecycleEnabled) { - await reportLifecycle({ state: "working", event: `session.status:${status}`, turnId: rootSessionId }); - } - if (status === "busy" && !rootWorking) { - 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 }); + if (event.type === "session.status") { + const statusValue = properties.status; + const status = typeof statusValue === "string" + ? statusValue + : statusValue && typeof statusValue === "object" + ? statusValue.type + : null; + if (status === "busy" || status === "retry") { + if (lifecycleEnabled) { + await reportLifecycle({ state: "working", event: `session.status:${status}`, turnId: rootSessionId }); } - return; - } - if (event.type === "session.idle") { - rootWorking = false; - if (lifecycleEnabled) await reportLifecycle({ state: "idle", event: event.type, turnId: rootSessionId }); - runPluginHooks("stop", event.type, event); - } else if (event.type === "permission.asked") { - if (lifecycleEnabled) await reportLifecycle({ state: "needs_approval", event: event.type, turnId: rootSessionId }); - runPluginHooks("permission-request", event.type, event); - } else if (event.type === "permission.replied") { - if (lifecycleEnabled) await reportLifecycle({ state: "working", event: event.type, turnId: rootSessionId }); - runPluginHooks("permission-result", event.type, event); - } else if (event.type === "question.asked") { - if (lifecycleEnabled) await reportLifecycle({ state: "needs_approval", event: event.type, turnId: rootSessionId }); - } else if (event.type === "question.replied" || event.type === "question.rejected") { - if (lifecycleEnabled) await reportLifecycle({ state: "working", event: event.type, turnId: rootSessionId }); - } else if (event.type === "session.error") { - rootWorking = false; - if (lifecycleEnabled) await reportLifecycle({ state: "idle", event: event.type, turnId: rootSessionId }); - runPluginHooks("stop", event.type, event); - } else if (event.type === "session.deleted") { - runPluginHooks("session-end", event.type, event); + if (status === "busy" && !rootWorking) { + runPluginHooks("prompt-submit", `session.status:${status}`, event); + } + rootWorking = true; + } else if (status === "idle") { rootWorking = false; - rootSessionId = null; - } else if (event.type === "tool.execute.after") { - runPluginHooks("after-tool", event.type, event); + if (lifecycleEnabled) await reportLifecycle({ state: "idle", event: "session.status:idle", turnId: rootSessionId }); } + return; } -}); + if (event.type === "session.idle") { + rootWorking = false; + if (lifecycleEnabled) await reportLifecycle({ state: "idle", event: event.type, turnId: rootSessionId }); + runPluginHooks("stop", event.type, event); + } else if (event.type === "permission.asked") { + if (lifecycleEnabled) await reportLifecycle({ state: "needs_approval", event: event.type, turnId: rootSessionId }); + runPluginHooks("permission-request", event.type, event); + } else if (event.type === "permission.replied") { + if (lifecycleEnabled) await reportLifecycle({ state: "working", event: event.type, turnId: rootSessionId }); + runPluginHooks("permission-result", event.type, event); + } else if (event.type === "question.asked") { + if (lifecycleEnabled) await reportLifecycle({ state: "needs_approval", event: event.type, turnId: rootSessionId }); + } else if (event.type === "question.replied" || event.type === "question.rejected") { + if (lifecycleEnabled) await reportLifecycle({ state: "working", event: event.type, turnId: rootSessionId }); + } else if (event.type === "session.error") { + rootWorking = false; + if (lifecycleEnabled) await reportLifecycle({ state: "idle", event: event.type, turnId: rootSessionId }); + 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); + } +} function stringField(...values) { return values.find((value) => typeof value === "string" && value.length > 0) ?? null; diff --git a/src/agent-runtime/permission-gate.mjs b/src/agent-runtime/permission-gate.mjs new file mode 100644 index 00000000..d043a572 --- /dev/null +++ b/src/agent-runtime/permission-gate.mjs @@ -0,0 +1,186 @@ +#!/usr/bin/env node +import { createHash, randomUUID } from "node:crypto"; +import { createConnection } from "node:net"; +import { realpathSync } from "node:fs"; +import { pathToFileURL } from "node:url"; +import { + AGENT_RUNTIME_ENV, + MAX_HOOK_INPUT_BYTES, + MAX_RUNTIME_MESSAGE_BYTES, + PERMISSION_GATE, + helperDeadlineMs, + RUNTIME_PROTOCOL_VERSION +} from "./runtime-protocol.mjs"; + +/** + * The decision hook for Claude Code, Codex and Qwen Code (PreToolUse; it fires before every matched tool call, + * YOLO / bypass included). It sends the call to CanvasTTY over the capability-authenticated runtime socket, where + * base protection and the plugins' decision services answer, and prints what the CLI reads: + * + * - deny (every CLI): the call does not run, and the reason goes to the model as what to do instead; + * - ask (Claude Code): Claude asks the person for this call, whatever its permission mode; + * - allow (Claude Code): the call runs without Claude's own prompt (only from plugins the person let allow); + * - no verdict, or anything Codex and Qwen Code cannot take: nothing, and the CLI goes on as it would without us. + * + * Exit code is always 0 (exit 2 means something else for some CLIs). A CLI runs the call when this hook crashes or + * times out, so this is a guard, not a sandbox. + */ + +if (invokedDirectly() && process.argv[2] === "pretool") { + await run().catch(() => undefined); +} + +function invokedDirectly() { + try { + return typeof process.argv[1] === "string" && pathToFileURL(realpathSync(process.argv[1])).href === import.meta.url; + } catch { + return false; + } +} + +async function run() { + const identity = identityFrom(process.env); + const raw = await readInput(); + if (!identity || raw === null) return; + let input; + try { input = JSON.parse(raw); } catch { return; } + const message = buildRequest(input, identity); + if (!message) return; + const decision = await exchange(identity.address, message, helperDeadlineMs(process.env)); + const output = hookOutput(identity.provider, decision); + if (output) await writeOut(`${JSON.stringify(output)}\n`); +} + +export function identityFrom(env) { + const identity = { + address: env[AGENT_RUNTIME_ENV.address], + terminalSessionId: env[AGENT_RUNTIME_ENV.terminalSessionId], + provider: env[AGENT_RUNTIME_ENV.provider], + capabilityToken: env[AGENT_RUNTIME_ENV.capabilityToken] + }; + return identity.address && identity.terminalSessionId && identity.provider && identity.capabilityToken ? identity : null; +} + +/** Reads the whole hook input; null when it is over the bound (the CLI then goes on as usual). */ +async function readInput() { + const chunks = []; + let size = 0; + for await (const chunk of process.stdin) { + const bytes = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk; + size += bytes.length; + if (size > MAX_HOOK_INPUT_BYTES) return null; + chunks.push(bytes); + } + return Buffer.concat(chunks).toString("utf8"); +} + +/** + * The permission_request line. The tool input goes whole when its JSON fits the bound; otherwise only a bounded + * preview and the sha256 of the whole input go, marked truncated, and main never allows it. + */ +export function buildRequest(input, identity) { + if (!input || typeof input !== "object" || Array.isArray(input)) return null; + const toolName = typeof input.tool_name === "string" && input.tool_name.length > 0 + ? input.tool_name.slice(0, PERMISSION_GATE.toolNameChars) : null; + if (!toolName) return null; + const toolInput = input.tool_input === undefined ? null : input.tool_input; + const json = JSON.stringify(toolInput) ?? "null"; + const base = { + v: RUNTIME_PROTOCOL_VERSION, + type: "permission_request", + terminalSessionId: identity.terminalSessionId, + provider: identity.provider, + capabilityToken: identity.capabilityToken, + requestId: randomUUID(), + toolName, + toolInputSha256: createHash("sha256").update(json, "utf8").digest("hex"), + // The agent's current folder, as the CLI reports it: relative paths in the call are resolved against it. + cwd: typeof input.cwd === "string" && input.cwd.length > 0 && input.cwd.length <= 4_096 ? input.cwd : null + }; + const message = Buffer.byteLength(json, "utf8") <= PERMISSION_GATE.toolInputBytes + ? { ...base, toolInput, toolInputPreview: null, truncated: false } + : { ...base, toolInput: null, toolInputPreview: boundedText(json, PERMISSION_GATE.toolInputPreviewChars), truncated: true }; + if (Buffer.byteLength(`${JSON.stringify(message)}\n`, "utf8") <= MAX_RUNTIME_MESSAGE_BYTES) return message; + // Multi-byte text can outgrow the wire cap: send the smaller, truncated form. + return { ...base, toolInput: null, toolInputPreview: boundedText(json, 2_048), truncated: true }; +} + +/** Sends one line and waits for the matching decision; null on anything else (close, timeout, garbage). */ +export function exchange(address, message, deadlineMs) { + return new Promise((resolve) => { + const payload = Buffer.from(`${JSON.stringify(message)}\n`, "utf8"); + const socket = createConnection(address); + let settled = false; + let response = Buffer.alloc(0); + const finish = (value) => { + if (settled) return; + settled = true; + clearTimeout(timer); + socket.destroy(); + resolve(value); + }; + const timer = setTimeout(() => finish(null), deadlineMs); + socket.on("connect", () => socket.write(payload)); + socket.on("data", (chunk) => { + response = Buffer.concat([response, typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk]); + if (response.length > MAX_RUNTIME_MESSAGE_BYTES) return finish(null); + const newline = response.indexOf(0x0a); + if (newline < 0) return; + try { + finish(parseDecision(JSON.parse(response.subarray(0, newline).toString("utf8")), message.requestId)); + } catch { + finish(null); + } + }); + socket.on("error", () => finish(null)); + socket.on("close", () => finish(null)); + }); +} + +export function parseDecision(value, requestId) { + if (!value || typeof value !== "object" || value.v !== RUNTIME_PROTOCOL_VERSION + || value.type !== "permission_decision" || value.requestId !== requestId) return null; + if (value.behavior !== "allow" && value.behavior !== "deny" && value.behavior !== "ask") return null; + const message = typeof value.message === "string" ? cleanMessage(value.message) : ""; + return { behavior: value.behavior, message }; +} + +/** What the CLI reads on stdout, or null to print nothing. Only Claude Code takes ask and allow from a hook. */ +export function hookOutput(provider, decision) { + if (!decision) return null; + if (decision.behavior === "deny") { + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: decision.message || "CanvasTTY blocked this tool call. Ask the person how to proceed." + } + }; + } + if (provider !== "claude") return null; + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: decision.behavior, + permissionDecisionReason: decision.message || (decision.behavior === "ask" + ? "CanvasTTY asks the person about this tool call." + : "Allowed by a CanvasTTY plugin the person trusts to allow.") + } + }; +} + +function cleanMessage(value) { + // eslint-disable-next-line no-control-regex + return boundedText(value.replace(/[\u0000-\u0008\u000B-\u001F\u007F]/gu, " "), PERMISSION_GATE.messageChars); +} + +function boundedText(value, limit) { + const text = value.slice(0, limit); + return /[\uD800-\uDBFF]$/u.test(text) ? text.slice(0, -1) : text; +} + +function writeOut(text) { + return new Promise((resolve) => { + process.stdout.write(text, () => resolve()); + }); +} diff --git a/src/agent-runtime/runtime-client.mjs b/src/agent-runtime/runtime-client.mjs index aae729c9..cc441733 100644 --- a/src/agent-runtime/runtime-client.mjs +++ b/src/agent-runtime/runtime-client.mjs @@ -5,13 +5,14 @@ import { CAPTURE_ANSWER_EXPIRES_AT_ENV, MAX_ANSWER_CHARS, MAX_RUNTIME_MESSAGE_BYTES, + normalizeThreadId, RUNTIME_PROTOCOL_VERSION, RUNTIME_STATES } from "./runtime-protocol.mjs"; const CONNECT_TIMEOUT_MS = 1_000; -export async function reportLifecycle({ state, event, turnId = null, result, lastAssistantMessage }) { +export async function reportLifecycle({ state, event, turnId = null, threadId, result, lastAssistantMessage }) { 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]; @@ -20,6 +21,8 @@ export async function reportLifecycle({ state, event, turnId = null, result, las const capabilityToken = process.env[AGENT_RUNTIME_ENV.capabilityToken]; if (!address || !terminalSessionId || !provider || !capabilityToken) return false; + const validThreadId = normalizeThreadId(provider, threadId); + const message = { v: RUNTIME_PROTOCOL_VERSION, type: "lifecycle", @@ -29,6 +32,7 @@ export async function reportLifecycle({ state, event, turnId = null, result, las state, event, turnId: normalizedId(turnId), + ...(validThreadId !== undefined ? { threadId: validThreadId } : {}), ...(result === undefined ? {} : { result }) }; const answerCaptureExpiresAt = Number(process.env[CAPTURE_ANSWER_EXPIRES_AT_ENV]); diff --git a/src/agent-runtime/runtime-protocol.d.mts b/src/agent-runtime/runtime-protocol.d.mts index 03fda4cd..84659d0a 100644 --- a/src/agent-runtime/runtime-protocol.d.mts +++ b/src/agent-runtime/runtime-protocol.d.mts @@ -13,3 +13,21 @@ export const AGENT_RUNTIME_ENV: Readonly<{ capabilityToken: "CANVASTTY_RUNTIME_CAPABILITY"; }>; export const RUNTIME_STATES: readonly ["idle", "working", "needs_approval"]; +/** The provider's conversation id in the one form it may be stored or passed to its CLI, or undefined. */ +export function normalizeThreadId(provider: string, value: unknown): string | undefined; +export const PERMISSION_GATE: Readonly<{ + helperMs: number; + gatewayMs: number; + hookSeconds: number; + toolInputBytes: number; + toolInputPreviewChars: number; + toolNameChars: number; + messageChars: number; +}>; +export const OPENCODE_DECISIONS_ENV: "CANVASTTY_RUNTIME_DECISIONS"; +export const DECISION_BUDGET_ENV: "CANVASTTY_RUNTIME_DECISION_MS"; +export const DEFAULT_DECIDE_TIMEOUT_MS: number; +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; diff --git a/src/agent-runtime/runtime-protocol.mjs b/src/agent-runtime/runtime-protocol.mjs index 45de46d4..114cdf2c 100644 --- a/src/agent-runtime/runtime-protocol.mjs +++ b/src/agent-runtime/runtime-protocol.mjs @@ -19,3 +19,58 @@ export const AGENT_RUNTIME_ENV = Object.freeze({ }); export const RUNTIME_STATES = Object.freeze(["idle", "working", "needs_approval"]); + +// A provider's own conversation id, as its lifecycle hook reports it, lets a restored +// card resume exactly that conversation. It ends up in the provider's argv, so only +// the shapes those CLIs issue are accepted: canonical UUIDs for Codex threads and +// Claude sessions (lower-cased), `ses_` tokens for OpenCode sessions. +const CANONICAL_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const OPENCODE_SESSION_RE = /^ses_[A-Za-z0-9]{1,120}$/; + +export function normalizeThreadId(provider, value) { + if (typeof value !== "string") return undefined; + if (provider === "codex" || provider === "claude") { + return CANONICAL_UUID_RE.test(value) ? value.toLowerCase() : undefined; + } + if (provider === "opencode") return OPENCODE_SESSION_RE.test(value) ? value : undefined; + return undefined; +} + +// Decision hooks (permission-gate.mjs): before a matched tool call runs, the agent's PreToolUse hook (Claude Code, +// Codex, Qwen Code) or OpenCode's CanvasTTY plugin asks CanvasTTY over this socket. The answer is base protection's +// deny or the plugins' merged verdict. The helper waits at most `helperMs`; the gateway answers within `gatewayMs`. +export const PERMISSION_GATE = Object.freeze({ + helperMs: 12_000, + gatewayMs: 10_000, + hookSeconds: 15, + // The tool input travels whole up to this many bytes of JSON; beyond that only a preview and its sha256. + toolInputBytes: 40 * 1024, + toolInputPreviewChars: 8 * 1024, + toolNameChars: 200, + messageChars: 1_000 +}); +// Set to "1" for an OpenCode session launched with decision hooks: its plugin then checks each shell and file call. +export const OPENCODE_DECISIONS_ENV = "CANVASTTY_RUNTIME_DECISIONS"; +// A decision service may ask for more time than the default 3 s (`decide.timeoutMs`, 1-60 s), for example to ask a +// local model. The session's hook, helper and gateway deadlines are set at launch from the longest such budget and +// passed to the helper in this variable; without it the defaults above hold. +export const DECISION_BUDGET_ENV = "CANVASTTY_RUNTIME_DECISION_MS"; +export const DEFAULT_DECIDE_TIMEOUT_MS = 3_000; +export const MIN_DECIDE_TIMEOUT_MS = 1_000; +export const MAX_DECIDE_TIMEOUT_MS = 60_000; + +/** The gate's deadlines for a decision budget: never below PERMISSION_GATE, each a little longer than the one inside it. */ +export function permissionGateTimings(budgetMs) { + const budget = Number.isInteger(budgetMs) + ? Math.min(MAX_DECIDE_TIMEOUT_MS, Math.max(DEFAULT_DECIDE_TIMEOUT_MS, budgetMs)) + : DEFAULT_DECIDE_TIMEOUT_MS; + const gatewayMs = Math.max(PERMISSION_GATE.gatewayMs, budget + 2_000); + const helperMs = gatewayMs + (PERMISSION_GATE.helperMs - PERMISSION_GATE.gatewayMs); + return { budgetMs: budget, gatewayMs, helperMs, hookSeconds: Math.ceil(helperMs / 1_000) + 3 }; +} + +/** The helper's wait from its environment: the launch's budget, or the default. */ +export function helperDeadlineMs(env) { + const raw = env?.[DECISION_BUDGET_ENV]; + return permissionGateTimings(typeof raw === "string" && /^\d{1,6}$/.test(raw) ? Number(raw) : undefined).helperMs; +} diff --git a/src/main/index.ts b/src/main/index.ts index ee3eb3a3..91a65595 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -9,6 +9,7 @@ import { IPC, type LocaleId, type PluginCanvasRequest, + type PluginServiceEvent, type SessionStatus, type UpdaterState, type UpdaterStateEvent @@ -25,6 +26,11 @@ import { type ProviderCliRegistry } from "./services/providerCliRegistry"; import { PluginManager } from "./services/PluginManager"; +import { PluginServiceSupervisor } from "./services/PluginServiceSupervisor"; +import { LaunchPipeline } from "./services/LaunchPipeline"; +import { EnvironmentRegistry } from "./services/EnvironmentRegistry"; +import { DecisionHooks } from "./services/DecisionHooks"; +import { SecretRedactionRegistry } from "./services/safety/SecretRedaction"; import { GithubAuthService } from "./services/GithubAuthService"; import { PluginMediaService } from "./services/PluginMediaService"; import { PluginSecretsService } from "./services/PluginSecretsService"; @@ -122,6 +128,7 @@ let terminalManager: TerminalManager | null = null; let agentControl: AgentControlGateway | null = null; let limitsService: LimitsService | null = null; let pluginManager: PluginManager | null = null; +let pluginServices: PluginServiceSupervisor | null = null; let githubAuth: GithubAuthService | null = null; let pluginMediaService: PluginMediaService | null = null; let pluginSecretsService: PluginSecretsService | null = null; @@ -267,6 +274,47 @@ async function initializeServices(): Promise { await settings.load(); pluginManager = new PluginManager(userDataPath); await pluginManager.load(); + // Secrets this app knows are masked in every text one agent reads from another (EP-8). + const redaction = new SecretRedactionRegistry(); + // Trusted plugin services run as separate processes, started the way plugin hooks are. + pluginServices = new PluginServiceSupervisor({ + command: process.execPath, + hostVersion: app.getVersion(), + locale: () => settings.get().locale, + host: { + storageGet: (pluginId, key) => pluginManager!.storageGet(pluginId, key), + storageSet: async (pluginId, key, value) => { + await pluginManager!.storageSet(pluginId, key, value); + broadcastPluginStorageChange(pluginId, key, value); + }, + emit: (pluginId, serviceId, event, data) => broadcastPluginServiceEvent({ pluginId, serviceId, event, data }), + registerSecrets: (pluginId, values) => redaction.add(`plugin:${pluginId}`, values), + secretGet: (pluginId, key) => { + if (!pluginSecretsService) throw new Error("Plugin secrets are not ready yet."); + return pluginSecretsService.get(pluginId, key); + } + } + }); + // Base protection runs first; then trusted plugin decision services (EP-5). + const decisionHooks = new DecisionHooks({ + baseProtection: () => settings.get().baseProtectionEnabled, + services: () => pluginManager!.decisionServices(), + call: (pluginId, serviceId, method, params, timeoutMs) => pluginServices!.hostCall(pluginId, serviceId, method, params, timeoutMs), + session: (sessionId) => terminalManager?.decisionContext(sessionId) ?? null + }); + pluginManager.setServiceObserver((specs) => pluginServices!.sync(specs)); + const pluginServicesStarted = pluginServices.sync(pluginManager.trustedServiceSpecs()); + // Created before sessions are restored: launch services may resolve the plugin's own secrets. + pluginSecretsService = new PluginSecretsService( + app.getPath("userData"), + (pluginId, permission) => pluginManager!.assertPermission(pluginId, permission), + { + isAvailable: securePluginStorageAvailable, + encrypt: (value) => safeStorage.encryptString(value), + decrypt: (value) => safeStorage.decryptString(value) + } + ); + await pluginSecretsService.load(); canvasNavigationInput = new CanvasNavigationInputController( { @@ -341,7 +389,8 @@ async function initializeServices(): Promise { terminalManager?.applyProviderSignal(terminalSessionId, { kind: "lifecycle", state: signal.state, - ...(signal.turnId ? { requestId: signal.turnId } : {}) + ...(signal.turnId ? { requestId: signal.turnId } : {}), + ...(signal.threadId ? { threadId: signal.threadId } : {}) }); agentControl?.onSignal(terminalSessionId, signal); if (signal.lastAssistantMessage !== undefined && signal.answerCaptureGrantExpiresAt !== undefined) { @@ -353,7 +402,8 @@ async function initializeServices(): Promise { ); } }, - onAnswerCaptureRevoked: (terminalSessionId) => evenG2?.clearAnswer(terminalSessionId) + onAnswerCaptureRevoked: (terminalSessionId) => evenG2?.clearAnswer(terminalSessionId), + onPermissionRequest: (terminalSessionId, request, signal) => decisionHooks.decide(terminalSessionId, request, signal) }); await runtimeGateway.start(); const runtimeHelperPath = app.isPackaged @@ -365,6 +415,9 @@ async function initializeServices(): Promise { const pluginHookRunnerPath = app.isPackaged ? join(process.resourcesPath, "agent-runtime", "plugin-hook-runner.mjs") : join(app.getAppPath(), "src", "agent-runtime", "plugin-hook-runner.mjs"); + const permissionGatePath = app.isPackaged + ? join(process.resourcesPath, "agent-runtime", "permission-gate.mjs") + : join(app.getAppPath(), "src", "agent-runtime", "permission-gate.mjs"); agentRuntimeHelper = { command: process.execPath, args: [runtimeHelperPath], @@ -378,6 +431,9 @@ async function initializeServices(): Promise { kimiHomeDirectory, recoverOnStart: true, coreHooksEnabled: settings.get().agentLifecycleHooksEnabled, + permissionGate: { command: process.execPath, args: [permissionGatePath], env: { ELECTRON_RUN_AS_NODE: "1" } }, + wantsDecisions: (provider) => decisionHooks.wanted(provider), + decisionBudgetMs: (provider) => decisionHooks.budgetMs(provider), pluginHooks: { runner: { command: process.execPath, @@ -429,8 +485,9 @@ async function initializeServices(): Promise { notifiedAttentionStatus.delete(payload.id); } }, providerClis, agentBrowserBridge ?? undefined, agentRuntimeBridge ?? undefined, settings.get().agentLifecycleHooksEnabled); + terminalManager.configureRedaction(redaction); const terminalSessionStore = new TerminalSessionStore(userDataPath); - terminalManager.configureSessionPersistence(terminalSessionStore, settings.get().restoreTerminalSessions); + terminalManager.configureSessionPersistence(terminalSessionStore, settings.get().sessionRestoreMode); // The orchestration bridge exists only for sessions explicitly launched with // the orchestrator role; interactive sessions never receive capabilities. @@ -441,6 +498,21 @@ async function initializeServices(): Promise { await orchestrationGateway.start(); terminalManager.configureOrchestration(new OrchestrationBridge(orchestrationGateway)); + const launchPipeline = new LaunchPipeline({ + contributors: () => pluginManager!.launchContributors(), + call: (pluginId, serviceId, method, params, timeoutMs) => pluginServices!.hostCall(pluginId, serviceId, method, params, timeoutMs), + secret: (pluginId, key) => pluginSecretsService!.get(pluginId, key), + runsRoot: join(userDataPath, "launch-runs") + }); + await launchPipeline.clearRuns().catch(() => undefined); + terminalManager.configureLaunchPipeline(launchPipeline); + 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) + })); + // Restored cards with launch options or an environment ask their plugin's service, so start services first. + await pluginServicesStarted.catch(() => undefined); 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) @@ -513,21 +585,11 @@ async function initializeServices(): Promise { (pluginId, permission) => pluginManager!.assertPermission(pluginId, permission) ); await pluginMediaService.load(); - pluginSecretsService = new PluginSecretsService( - app.getPath("userData"), - (pluginId, permission) => pluginManager!.assertPermission(pluginId, permission), - { - isAvailable: securePluginStorageAvailable, - encrypt: (value) => safeStorage.encryptString(value), - decrypt: (value) => safeStorage.decryptString(value) - } - ); - await pluginSecretsService.load(); providerSecretsService = new ProviderSecretsService(app.getPath("userData"), { isAvailable: securePluginStorageAvailable, encrypt: (value) => safeStorage.encryptString(value), decrypt: (value) => safeStorage.decryptString(value) - }); + }, (values) => redaction.add("vault", values)); await providerSecretsService.load(); protocol.handle("canvastty-plugin", (request) => pluginManager!.protocolResponse(request.url)); protocol.handle("canvastty-media", (request) => pluginMediaService!.protocolResponse(request)); @@ -545,12 +607,14 @@ async function initializeServices(): Promise { terminals: terminalManager, limits: limitsService, plugins: pluginManager, + pluginServices, pluginMedia: pluginMediaService, pluginSecrets: pluginSecretsService, providerSecrets: providerSecretsService!, browser: browserService, githubAuth: githubAuth!, hermesHud: hermesHudService, + launchFieldOptions: (pluginId, provider) => launchPipeline.fieldOptions(pluginId, provider), getMainWindow: () => mainWindow, applyBrowserSettings: async (next) => { agentRuntimeBridge?.setCoreHooksEnabled(next.agentLifecycleHooksEnabled); @@ -566,7 +630,7 @@ async function initializeServices(): Promise { wheelBinding: activeCanvasWheelBinding(next.canvasWheelCaptureMode, next.canvasWheelOverride), navigationBinding: next.canvasNavigationOverride }); - await terminalManager?.setSessionPersistenceEnabled(next.restoreTerminalSessions); + await terminalManager?.setSessionRestoreMode(next.sessionRestoreMode); }, setCanvasNavigationShortcutCapture: (active) => { if (active) browserService?.cancelCanvasNavigationGesture(); @@ -920,6 +984,7 @@ async function shutdownServices(): Promise { 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()]); } @@ -984,6 +1049,16 @@ function broadcastPluginStorageChange(pluginId: string, key: string, value: unkn } } +function broadcastPluginServiceEvent(event: PluginServiceEvent): void { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send(IPC.pluginsServiceEvent, event); + } + for (const [window, ownerPluginId] of pluginWindows) { + if (ownerPluginId !== event.pluginId || window.isDestroyed()) continue; + window.webContents.send(IPC.pluginsServiceEvent, event); + } +} + function securePluginStorageAvailable(): boolean { if (!safeStorage.isEncryptionAvailable()) return false; return process.platform !== "linux" || safeStorage.getSelectedStorageBackend() !== "basic_text"; diff --git a/src/main/ipc/registerIpc.ts b/src/main/ipc/registerIpc.ts index 1fb3ca10..5a0002b3 100644 --- a/src/main/ipc/registerIpc.ts +++ b/src/main/ipc/registerIpc.ts @@ -10,6 +10,7 @@ import type { CreateSessionRequest, PluginBrowserOpenResponse, PluginCanvasRequest, + PluginLaunchFieldOptions, ProviderId, ProviderSecretId, SessionBounds @@ -22,6 +23,7 @@ import { providerCliAvailability, type ProviderCliRegistry } from "../services/p import type { TerminalManager } from "../services/TerminalManager"; import type { LimitsService } from "../services/LimitsService"; import type { PluginManager } from "../services/PluginManager"; +import type { PluginServiceSupervisor } from "../services/PluginServiceSupervisor"; import type { PluginMediaService } from "../services/PluginMediaService"; import type { PluginSecretsService } from "../services/PluginSecretsService"; import type { ProviderSecretsService } from "../services/ProviderSecretsService"; @@ -48,12 +50,14 @@ interface Dependencies { terminals: TerminalManager; limits: LimitsService; plugins: PluginManager; + pluginServices: PluginServiceSupervisor; pluginMedia: PluginMediaService; pluginSecrets: PluginSecretsService; providerSecrets: ProviderSecretsService; browser: BrowserService; githubAuth: GithubAuthService; hermesHud: HermesHudService; + launchFieldOptions(pluginId: string, provider: ProviderId): Promise; getMainWindow(): BrowserWindow | null; applyBrowserSettings(settings: AppSettings): Promise | void; setCanvasNavigationShortcutCapture(active: boolean): void; @@ -80,12 +84,14 @@ export function registerIpc({ terminals, limits, plugins, + pluginServices, pluginMedia, pluginSecrets, providerSecrets, browser, githubAuth, hermesHud, + launchFieldOptions, getMainWindow, applyBrowserSettings, setCanvasNavigationShortcutCapture, @@ -98,6 +104,13 @@ export function registerIpc({ updater }: Dependencies): (window: BrowserWindow | null) => void { const pluginBrowserOpenBroker = new PluginBrowserOpenBroker(getMainWindow); + // A surface reaches only its own plugin's services: the caller's plugin id is bound by the + // renderer frame host or by the identity-checked plugin window, never taken from plugin code. + const requestPluginService = (pluginId: string, values: Record): Promise => { + const serviceId = stringValue(values.serviceId, "serviceId"); + plugins.assertService(pluginId, serviceId); + return pluginServices.request(pluginId, serviceId, stringValue(values.method, "method"), values.params); + }; const requestPluginBrowserOpen = async (pluginId: string, value: unknown): Promise => { plugins.assertPermission(pluginId, "browser:open"); await pluginBrowserOpenBroker.request(pluginId, normalizePluginBrowserUrl(value)); @@ -268,11 +281,43 @@ export function registerIpc({ } return plugins.setHookEnabled(pluginId, hookId, enabled); }); + ipcMain.handle(IPC.pluginsSetNativeCodeTrusted, (event, pluginId: string, trusted: boolean) => { + assertMainRenderer(event, getMainWindow); + if (typeof pluginId !== "string" || typeof trusted !== "boolean") throw new Error("Plugin native code state is invalid."); + return plugins.setNativeCodeTrusted(pluginId, trusted); + }); + ipcMain.handle(IPC.pluginsSetDecisionsMayAllow, (event, pluginId: string, allowed: boolean) => { + assertMainRenderer(event, getMainWindow); + if (typeof pluginId !== "string" || typeof allowed !== "boolean") throw new Error("Plugin decision state is invalid."); + return plugins.setDecisionsMayAllow(pluginId, allowed); + }); + ipcMain.handle(IPC.pluginsServiceReport, (event, pluginId: string) => { + assertMainRenderer(event, getMainWindow); + if (typeof pluginId !== "string") throw new Error("Plugin identifier is required."); + return pluginServices.report(pluginId); + }); + ipcMain.handle(IPC.pluginsServiceRequest, ( + event, + pluginId: string, + serviceId: string, + method: string, + params: unknown + ) => { + assertMainRenderer(event, getMainWindow); + return requestPluginService(pluginId, { serviceId, method, params }); + }); + ipcMain.handle(IPC.pluginsLaunchFieldOptions, (event, pluginId: unknown, provider: unknown) => { + // Only the app's own launcher asks; plugin surfaces cannot reach this channel. + assertMainRenderer(event, getMainWindow); + if (typeof pluginId !== "string" || typeof provider !== "string") throw new Error("Launch option request is invalid."); + return launchFieldOptions(pluginId, provider as ProviderId); + }); ipcMain.handle(IPC.pluginsUninstall, async (_event, pluginId: string) => { closePluginWindows(pluginId); await pluginSecrets.revokeAll(pluginId); await pluginMedia.revokeAll(pluginId); await plugins.uninstall(pluginId); + pluginServices.forget(pluginId); }); ipcMain.handle(IPC.pluginsOpenCanvas, ( _event, @@ -486,6 +531,7 @@ export function registerIpc({ playlistContent(values.content) ); } + if (method === "service.request") return requestPluginService(pluginId, values); if (method === "window.open") { const targetId = stringValue(values.contributionId, "contributionId"); const target = plugins.contribution(pluginId, targetId); @@ -617,14 +663,20 @@ export function registerIpc({ return terminals.readBuffer(id); }); ipcMain.handle(IPC.terminalCreate, (_event, request: CreateSessionRequest) => terminals.create(request)); - ipcMain.handle(IPC.terminalRestart, (_event, id: string) => terminals.restart(id)); + ipcMain.handle(IPC.terminalRestart, (_event, id: string, options?: { resume?: unknown }) => ( + terminals.restart(id, { resume: options?.resume === true }) + )); ipcMain.on(IPC.terminalInput, (_event, id: string, data: string) => terminals.input(id, data)); ipcMain.on(IPC.terminalResize, (_event, id: string, cols: number, rows: number) => { terminals.resize(id, cols, rows); }); ipcMain.on(IPC.terminalBounds, (_event, id: string, bounds: SessionBounds) => terminals.setBounds(id, bounds)); ipcMain.handle(IPC.terminalRename, (_event, id: string, title: string) => terminals.rename(id, title)); - ipcMain.handle(IPC.terminalDispose, (_event, id: string) => terminals.dispose(id)); + ipcMain.handle(IPC.terminalSetRestore, (_event, id: string, restore: boolean) => terminals.setRestore(id, restore)); + ipcMain.handle(IPC.terminalDispose, (_event, id: string, options?: { keepEnvironmentData?: unknown }) => ( + // Environment data is kept unless the person explicitly chose Remove. + terminals.dispose(id, { keepEnvironmentData: options?.keepEnvironmentData !== false }) + )); // Fire-and-forget, like the other stream-reporting channels: a malformed // report is ignored rather than rejecting into the renderer. ipcMain.on(IPC.terminalSetVisible, (_event, id: unknown, visible: unknown) => { diff --git a/src/main/services/AgentControlService.ts b/src/main/services/AgentControlService.ts index e85b1f37..e9db773a 100644 --- a/src/main/services/AgentControlService.ts +++ b/src/main/services/AgentControlService.ts @@ -1,5 +1,6 @@ import type { AgentProviderId, + CreateSessionRequest, LaunchProfileId, SessionSnapshot } from "../../shared/contracts.ts"; @@ -21,6 +22,8 @@ export interface SpawnAgentRequest { title?: string; /** Prompt written into the new agent's PTY immediately after launch. */ initialPrompt?: string; + /** Plugin launch options, checked by the launch exactly like the launcher's. */ + launchOptions?: CreateSessionRequest["launchOptions"]; } export interface AgentObservation { @@ -69,7 +72,8 @@ export class AgentControlService { }, ...(request.title !== undefined ? { title: request.title } : {}), role: "subagent", - parentSessionId: parent.id + parentSessionId: parent.id, + ...(request.launchOptions !== undefined ? { launchOptions: request.launchOptions } : {}) }); if (request.initialPrompt !== undefined && request.initialPrompt.length > 0) { this.send(created.id, request.initialPrompt); @@ -121,7 +125,7 @@ export class AgentControlService { return { sessionId: session.id, status: session.status, - output: tail(this.terminals.readBuffer(sessionId).buffer, maxChars) + output: this.redact(tail(this.terminals.readBuffer(sessionId).buffer, maxChars)) }; } @@ -141,7 +145,7 @@ export class AgentControlService { ? "running" : session.exitCode === 0 ? "done" : "failed", exitCode: session.exitCode, - output: tail(buffer, MAX_OBSERVE_CHARS) + output: this.redact(tail(buffer, MAX_OBSERVE_CHARS)) }; } @@ -150,6 +154,11 @@ export class AgentControlService { this.terminals.dispose(sessionId); } + /** Plugin launch secrets never reach another agent through observed output. */ + private redact(text: string): string { + return typeof this.terminals.redactSecrets === "function" ? this.terminals.redactSecrets(text) : text; + } + private requireSession(sessionId: string): SessionSnapshot { if (typeof sessionId !== "string" || sessionId.length === 0) { throw new Error("A session id is required."); diff --git a/src/main/services/DecisionHooks.ts b/src/main/services/DecisionHooks.ts new file mode 100644 index 00000000..e78bc9bd --- /dev/null +++ b/src/main/services/DecisionHooks.ts @@ -0,0 +1,198 @@ +import { join } from "node:path"; +import { homedir } from "node:os"; +import type { AgentProviderId, SessionRole } from "../../shared/contracts.ts"; +import type { RuntimePermissionDecision, RuntimePermissionRequest } from "./agent-runtime/RuntimeGateway.ts"; +import { actionFromHook, checkBaseProtection } from "./safety/baseProtection.ts"; +import { DEFAULT_DECIDE_TIMEOUT_MS } from "../../agent-runtime/runtime-protocol.mjs"; + +/** A trusted plugin service that declared `decide` (PluginManager.decisionServices). */ +export interface DecisionService { + pluginId: string; + pluginName: string; + serviceId: string; + /** Agents it decides for; all when omitted. */ + appliesTo?: AgentProviderId[]; + /** The person separately let this plugin allow tool calls. Without it an allow counts as no opinion. */ + mayAllow: boolean; + /** Its `decide.timeoutMs`: how long it may take (1-60 s); 3 s when omitted. */ + timeoutMs?: number; +} + +/** What the core knows about the session a hook call comes from. */ +export interface DecisionSession { + provider: AgentProviderId; + role: SessionRole; + /** The session's working folder: "outside" is measured from here. */ + cwd: string; + /** The agent's own config folders (CLAUDE_CONFIG_DIR of this run), whose plans and memory are not "outside". */ + configDirs: string[]; +} + +export interface DecisionHooksDependencies { + baseProtection(): boolean; + services(): DecisionService[]; + call(pluginId: string, serviceId: string, method: "canvastty.decide", params: unknown, timeoutMs: number): Promise; + session(sessionId: string): DecisionSession | null; + home?: string; + timeoutMs?: number; +} + +/** What a decision service receives (`canvastty.decide`). */ +export interface DecisionRequest { + event: "pre-tool"; + sessionId: string; + provider: AgentProviderId; + role: SessionRole; + cwd: string; + /** The agent's current folder as its CLI reported it, when it did. */ + agentCwd: string | null; + tool: { name: string; kind: "shell" | "edit" | "other"; command: string | null; paths: string[] }; + /** The tool input as the agent sent it; null when it was over 40 KB (then `truncated`). */ + input: unknown; + truncated: boolean; + /** How long CanvasTTY waits for this answer (the service's `decide.timeoutMs`, capped by the session's gate). */ + budgetMs: number; +} + +type Verdict = "deny" | "ask" | "allow"; +interface Answer { verdict: Verdict | null; reason: string; service: DecisionService } + +export const DECIDE_TIMEOUT_MS = DEFAULT_DECIDE_TIMEOUT_MS; +const MAX_REASON = 500; +const MAX_SERVICES = 8; + +/** + * Decision hooks (EP-5). For every shell or file-writing tool call an agent's hook reports, base protection runs + * first and its deny is final. Then every trusted decision service that applies answers deny, ask, allow or + * nothing, in parallel, within 3 s. Any deny wins; else any ask (a timeout, an error or an unreadable answer is an + * ask); else an allow counts only from a plugin the person separately let allow; else no verdict and the agent goes + * on as it would without CanvasTTY. Nothing here ever turns a failure into an allow. + */ +export class DecisionHooks { + private readonly deps: DecisionHooksDependencies; + + constructor(deps: DecisionHooksDependencies) { + this.deps = deps; + } + + /** Whether a launch of this agent needs the decision hook at all. */ + wanted(provider: AgentProviderId): boolean { + return this.protects() || this.applicable(provider).length > 0; + } + + /** The longest wait a decision service of this agent asked for: the session's gate is sized for it at launch. */ + budgetMs(provider: AgentProviderId): number { + return Math.max(DECIDE_TIMEOUT_MS, ...this.applicable(provider).map((service) => this.timeoutFor(service))); + } + + private timeoutFor(service: DecisionService): number { + return this.deps.timeoutMs ?? service.timeoutMs ?? DECIDE_TIMEOUT_MS; + } + + async decide(sessionId: string, request: RuntimePermissionRequest, signal: AbortSignal): Promise { + const session = this.deps.session(sessionId); + if (!session) return { behavior: "none" }; + if (this.protects()) { + const home = this.deps.home ?? homedir(); + const base = checkBaseProtection({ + toolName: request.toolName, + toolInput: request.toolInput, + preview: request.toolInputPreview, + root: session.cwd, + commandCwd: request.cwd, + home, + agentRoots: [join(home, ".claude"), ...session.configDirs] + }); + if (base) return { behavior: "deny", message: base.message }; + } + const services = this.applicable(session.provider); + if (services.length === 0) return { behavior: "none" }; + const action = actionFromHook(request.toolName, request.toolInput, request.toolInputPreview); + const params: Omit = { + event: "pre-tool", + sessionId, + provider: session.provider, + role: session.role, + cwd: session.cwd, + agentCwd: request.cwd, + tool: { name: request.toolName, kind: action.kind ?? "other", command: action.command, paths: action.paths }, + input: request.toolInput, + truncated: request.truncated + }; + // A service trusted after this card started gets no more time than the card's gate allows; the signal ends it. + const answers = await Promise.all(services.map((service) => { + const timeoutMs = this.timeoutFor(service); + return this.ask(service, { ...params, budgetMs: timeoutMs }, timeoutMs, signal); + })); + return mergeDecisions(answers, request.truncated); + } + + private protects(): boolean { + // A settings read that fails counts as on. + try { return this.deps.baseProtection() !== false; } catch { return true; } + } + + private applicable(provider: AgentProviderId): DecisionService[] { + let services: DecisionService[]; + try { services = this.deps.services(); } catch { return []; } + return services + .filter((service) => !service.appliesTo || service.appliesTo.includes(provider)) + .slice(0, MAX_SERVICES); + } + + private async ask(service: DecisionService, params: DecisionRequest, timeoutMs: number, signal: AbortSignal): Promise { + const late = (reason: string): Answer => ({ verdict: "ask", reason, service }); + if (signal.aborted) return late("it did not answer in time"); + let timer: NodeJS.Timeout | undefined; + let onAbort: (() => void) | undefined; + try { + const result = await Promise.race([ + this.deps.call(service.pluginId, service.serviceId, "canvastty.decide", params, timeoutMs), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("did not answer in time")), timeoutMs); + onAbort = () => reject(new Error("did not answer in time")); + signal.addEventListener("abort", onAbort, { once: true }); + }) + ]); + return parseAnswer(result, service); + } catch (error) { + const message = error instanceof Error && /in time/iu.test(error.message) ? "it did not answer in time" : "it could not answer"; + return late(message); + } finally { + clearTimeout(timer); + if (onAbort) signal.removeEventListener("abort", onAbort); + } + } +} + +/** `null`, `{}` or `{ verdict: "none" }` is no opinion; anything unreadable is an ask. */ +function parseAnswer(value: unknown, service: DecisionService): Answer { + if (value === null || value === undefined) return { verdict: null, reason: "", service }; + if (typeof value !== "object" || Array.isArray(value)) return { verdict: "ask", reason: "its answer could not be read", service }; + const record = value as Record; + const reason = typeof record.reason === "string" ? clean(record.reason) : ""; + if (record.verdict === undefined || record.verdict === "none") return { verdict: null, reason, service }; + if (record.verdict === "deny" || record.verdict === "ask" || record.verdict === "allow") return { verdict: record.verdict, reason, service }; + return { verdict: "ask", reason: "its answer could not be read", service }; +} + +/** Any deny wins; else any ask; else an allow from a plugin the person let allow; else no verdict. */ +export function mergeDecisions(answers: readonly Answer[], truncated: boolean): RuntimePermissionDecision { + const because = (answer: Answer): string => answer.reason ? ` (${answer.reason.replace(/[.!?\s]+$/u, "")})` : ""; + const deny = answers.find((answer) => answer.verdict === "deny"); + if (deny) { + return { behavior: "deny", message: `CanvasTTY plugin "${deny.service.pluginName}" blocked this tool call${because(deny)}. If it is needed, ask the person.` }; + } + const ask = answers.find((answer) => answer.verdict === "ask"); + if (ask) return { behavior: "ask", message: `CanvasTTY plugin "${ask.service.pluginName}" asks the person about this tool call${because(ask)}.` }; + const allow = answers.find((answer) => answer.verdict === "allow" && answer.service.mayAllow); + // Cut input is never allowed: the plugin did not see all of it. + if (allow && truncated) return { behavior: "ask", message: "The tool input was too large to check in full." }; + if (allow) return { behavior: "allow", message: `Allowed by CanvasTTY plugin "${allow.service.pluginName}"${because(allow)}.` }; + return { behavior: "none" }; +} + +function clean(value: string): string { + // eslint-disable-next-line no-control-regex + return value.replace(/[\u0000-\u001F\u007F]+/gu, " ").trim().slice(0, MAX_REASON); +} diff --git a/src/main/services/EnvironmentRegistry.ts b/src/main/services/EnvironmentRegistry.ts new file mode 100644 index 00000000..6dc78fd0 --- /dev/null +++ b/src/main/services/EnvironmentRegistry.ts @@ -0,0 +1,368 @@ +import { accessSync, constants, statSync } from "node:fs"; +import { delimiter, isAbsolute, join } from "node:path"; +import type { + PluginEnvironmentKind, + PluginLaunchValues, + ProviderId, + SessionEnvironmentChoice +} from "../../shared/contracts.ts"; +import { errorText, isRecord, MAX_ENV, MAX_ENV_VALUE_BYTES, MAX_SECRET_ENV, stringMap } from "./LaunchPipeline.ts"; +import { MAX_PLUGIN_SLOT_BYTES, type PersistedEnvironmentRef } from "./TerminalSessionStore.ts"; + +/** A trusted plugin service that provides session environments (PluginManager.environmentProviders). */ +export interface EnvironmentProvider { + pluginId: string; + pluginName: string; + serviceId: string; + kinds: PluginEnvironmentKind[]; + /** The plugin holds the `secrets` permission, so `wrap` may name its secrets in `secretEnv`. */ + secrets: boolean; +} + +export type EnvironmentStep = "prepare" | "wrap" | "resume" | "release" | "describe"; +export type EnvironmentMethod = `canvastty.environment.${EnvironmentStep}`; + +export interface EnvironmentRegistryDependencies { + providers(): EnvironmentProvider[]; + call(pluginId: string, serviceId: string, method: EnvironmentMethod, params: unknown, timeoutMs: number): Promise; + /** Reads one of the plugin's own secrets in this process; the value never reaches plugin code or UI. */ + secret(pluginId: string, key: string): Promise; + timeouts?: Partial>; + platform?: NodeJS.Platform; +} + +/** The core never waits longer and never falls back to a local launch when a step runs out. */ +export const ENVIRONMENT_TIMEOUTS: Record = { + prepare: 15_000, + wrap: 5_000, + resume: 10_000, + release: 10_000, + describe: 3_000 +}; + +/** What the host would spawn without an environment; `wrap` returns its replacement. */ +export interface EnvironmentLaunch { + command: string; + args: string[]; + env: Record; + cwd: string; +} + +export type PreparedEnvironment = + | { ok: true; environment: PersistedEnvironmentRef; cwd?: string } + | { ok: false; reason: string }; + +export type WrappedLaunch = + | { ok: true; command: string; args: string[]; env: Record; cwd: string; secrets: string[] } + | { ok: false; reason: string }; + +const MAX_LABEL = 80; +const MAX_DETAIL = 240; +const MAX_REASON = 240; +const MAX_WRAP_ARGS = 256; +const MAX_WRAP_ARG_BYTES = 8 * 1024; +const BARE_COMMAND = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$/; + +export class EnvironmentRegistry { + private readonly dependencies: EnvironmentRegistryDependencies; + private readonly timeouts: Record; + + constructor(dependencies: EnvironmentRegistryDependencies) { + this.dependencies = dependencies; + this.timeouts = { ...ENVIRONMENT_TIMEOUTS, ...dependencies.timeouts }; + } + + /** True when the plugin named by a saved ref can serve that kind now. */ + available(environment: Pick): boolean { + return Boolean(this.lookup(environment.pluginId, environment.kind)); + } + + unavailableReason(environment: Pick): string { + return `Needs plugin ${environment.pluginId} (${environment.label}); it is disabled, removed, or its native code is not trusted. It was not started locally.`; + } + + /** + * Checks a launcher choice against the provider's declared kinds and fields and fills defaults. + * Throws with a person-readable reason; returns undefined for "this computer". + */ + normalizeChoice(provider: ProviderId, candidate: unknown): SessionEnvironmentChoice | undefined { + if (candidate === undefined || candidate === null) return undefined; + if (!isRecord(candidate) || typeof candidate.pluginId !== "string" || typeof candidate.kind !== "string") { + throw new Error("Environment choice is invalid."); + } + const found = this.lookup(candidate.pluginId, candidate.kind); + if (!found) throw new Error(`Environment ${candidate.kind.slice(0, 32)} from plugin ${candidate.pluginId.slice(0, 80)} is not available.`); + const { provider: owner, kind } = found; + if (kind.appliesTo && !kind.appliesTo.includes(provider)) throw new Error(`${kind.label} does not apply to ${provider}.`); + const raw = candidate.options ?? {}; + if (!isRecord(raw)) throw new Error(`${kind.label} options are invalid.`); + const fields = kind.fields ?? []; + const known = new Set(fields.map((field) => field.key)); + const unknown = Object.keys(raw).find((key) => !known.has(key)); + if (unknown) throw new Error(`${kind.label} has no option ${unknown.slice(0, 40)}.`); + const options: PluginLaunchValues = {}; + for (const field of fields) { + const value = raw[field.key] ?? field.default + ?? (field.kind === "boolean" ? false : field.kind === "select" ? field.options?.[0]?.value ?? "" : ""); + const valid = field.kind === "boolean" + ? typeof value === "boolean" + : field.kind === "select" + ? typeof value === "string" && Boolean(field.options?.some((option) => option.value === value)) + : typeof value === "string" && value.length <= (field.maxLength ?? 200) && !/[\u0000-\u001f\u007f]/.test(value); + if (!valid) throw new Error(`${kind.label} option ${field.label} is invalid.`); + options[field.key] = value as boolean | string; + } + return { pluginId: owner.pluginId, kind: kind.kind, ...(fields.length ? { options } : {}) }; + } + + /** `canvastty.environment.prepare`: creates the place (a worktree, a container) and returns its ref. */ + async prepare(request: { + sessionId: string; + provider: ProviderId; + cwd: string; + choice: SessionEnvironmentChoice; + }): 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.` }; + const answer = await this.ask(found.provider, "prepare", { + sessionId: request.sessionId, + kind: request.choice.kind, + provider: request.provider, + cwd: request.cwd, + options: request.choice.options ?? {} + }); + if (!answer.ok) return answer; + const value = answer.value; + const name = found.provider.pluginName; + if (isRecord(value) && value.refuse !== undefined) return { ok: false, reason: `${name}: ${refusal(value.refuse)}` }; + if (!isRecord(value)) return { ok: false, reason: `${name} answered with an invalid environment: not an object` }; + const unknown = Object.keys(value).find((key) => !["ref", "label", "cwd"].includes(key)); + if (unknown) return { ok: false, reason: `${name} answered with an invalid environment: unknown key ${unknown.slice(0, 40)}` }; + if (value.ref === undefined || !fitsSlot(value.ref)) { + return { ok: false, reason: `${name} answered with an invalid environment: ref must be JSON of at most 4 KB` }; + } + const label = plainText(value.label, MAX_LABEL); + if (!label) return { ok: false, reason: `${name} answered with an invalid environment: label is required` }; + if (value.cwd !== undefined && !isDirectory(value.cwd)) { + return { ok: false, reason: `${name} answered with an invalid environment: cwd must be an existing absolute folder` }; + } + return { + ok: true, + environment: { pluginId: found.provider.pluginId, kind: request.choice.kind, ref: structuredClone(value.ref), label }, + ...(typeof value.cwd === "string" ? { cwd: value.cwd } : {}) + }; + } + + /** `canvastty.environment.resume`: on restore and before relaunching a card from an earlier run. */ + async resume(environment: PersistedEnvironmentRef, sessionId: string): Promise<{ ok: true } | { ok: false; reason: string }> { + const found = this.lookup(environment.pluginId, environment.kind); + if (!found) return { ok: false, reason: this.unavailableReason(environment) }; + const answer = await this.ask(found.provider, "resume", refParams(environment, sessionId)); + if (!answer.ok) return answer; + const value = answer.value; + const name = found.provider.pluginName; + if (isRecord(value) && value.ok === true && Object.keys(value).length === 1) return { ok: true }; + if (isRecord(value) && value.stopped !== undefined) return { ok: false, reason: `${name}: ${refusal(value.stopped)}` }; + return { ok: false, reason: `${name} answered resume with neither ok nor stopped.` }; + } + + /** + * `canvastty.environment.wrap`: turns the host's launch into the one that runs inside the environment. + * The host still spawns the PTY; the answer is validated and merged under the launch-contributor rules. + */ + async wrap(environment: PersistedEnvironmentRef, request: { + sessionId: string; + provider: ProviderId; + launch: EnvironmentLaunch; + /** Names the host sets for this launch whose values the environment never sees (secrets). */ + secretEnvNames: string[]; + /** Names CanvasTTY or a launch contributor sets for this launch; the environment may not set them. */ + takenEnv: ReadonlySet; + /** PATH used to resolve a bare command name. */ + path: string | undefined; + }): Promise { + const found = this.lookup(environment.pluginId, environment.kind); + if (!found) return { ok: false, reason: this.unavailableReason(environment) }; + const { provider } = found; + const name = provider.pluginName; + const answer = await this.ask(provider, "wrap", { + ...refParams(environment, request.sessionId), + provider: request.provider, + command: request.launch.command, + args: request.launch.args, + env: request.launch.env, + secretEnvNames: request.secretEnvNames, + cwd: request.launch.cwd + }); + if (!answer.ok) return answer; + const invalid = (problem: string): WrappedLaunch => ({ ok: false, reason: `${name} answered with an invalid launch: ${problem}` }); + const value = answer.value; + if (isRecord(value) && value.refuse !== undefined) return { ok: false, reason: `${name}: ${refusal(value.refuse)}` }; + if (!isRecord(value)) return invalid("not an object"); + const unknown = Object.keys(value).find((key) => !["command", "args", "env", "secretEnv", "cwd"].includes(key)); + if (unknown) return invalid(`unknown key ${unknown.slice(0, 40)}`); + if (typeof value.command !== "string" || value.command.length === 0 || value.command.length > 1_024) return invalid("command is required"); + const command = resolveCommand(value.command, request.path, this.dependencies.platform ?? process.platform); + if (!command) { + return invalid(`command ${value.command.slice(0, 80)} must be an absolute path to a program or a bare program name on PATH; CanvasTTY runs no shell string`); + } + const args = value.args ?? []; + if (!Array.isArray(args) || args.length > MAX_WRAP_ARGS) return invalid(`args must be an array of at most ${MAX_WRAP_ARGS}`); + for (const argument of args) { + if (typeof argument !== "string" || argument.includes("\u0000") || Buffer.byteLength(argument, "utf8") > MAX_WRAP_ARG_BYTES) { + return invalid("every arg must be text without NUL, at most 8 KB"); + } + } + if (value.cwd !== undefined && !isDirectory(value.cwd)) return invalid("cwd must be an existing absolute folder"); + const env = stringMap(value.env, MAX_ENV, "env"); + if (typeof env === "string") return invalid(env); + for (const [key, entry] of Object.entries(env)) { + if (entry.includes("\u0000") || Buffer.byteLength(entry, "utf8") > MAX_ENV_VALUE_BYTES) return invalid(`env ${key} value is invalid or larger than 8 KB`); + } + const secretEnv = stringMap(value.secretEnv, MAX_SECRET_ENV, "secretEnv"); + if (typeof secretEnv === "string") return invalid(secretEnv); + const merged: Record = {}; + const secrets: string[] = []; + for (const key of [...Object.keys(env), ...Object.keys(secretEnv)]) { + if (request.takenEnv.has(key)) return { ok: false, reason: `${name} sets ${key}, which CanvasTTY or a launch option already sets for this launch.` }; + if (key in merged) return { ok: false, reason: `${name} sets ${key} twice.` }; + merged[key] = env[key] ?? ""; + } + for (const [key, secretKey] of Object.entries(secretEnv)) { + if (!/^[A-Za-z0-9._-]{1,80}$/.test(secretKey)) return invalid(`secretEnv ${key} must name a plugin secret key`); + if (!provider.secrets) return { ok: false, reason: `${name} asked for a secret without the secrets permission.` }; + const secret = await this.dependencies.secret(provider.pluginId, secretKey).catch(() => null); + if (typeof secret !== "string" || secret.length === 0) return { ok: false, reason: `${name}: its secret ${secretKey} is not set.` }; + if (secret.includes("\u0000")) return { ok: false, reason: `${name}: its secret ${secretKey} cannot be passed in the environment.` }; + merged[key] = secret; + secrets.push(secret); + } + return { + ok: true, + command, + args: args as string[], + env: merged, + cwd: typeof value.cwd === "string" ? value.cwd : request.launch.cwd, + secrets + }; + } + + /** `canvastty.environment.release`: the card was closed (or the app quit with saving off). Never throws. */ + async release(environment: PersistedEnvironmentRef, sessionId: string, options: { keepData: boolean; reason: "closed" | "quit"; timeoutMs?: number }): Promise { + const found = this.lookup(environment.pluginId, environment.kind); + if (!found) return; + const answer = await this.ask(found.provider, "release", { + ...refParams(environment, sessionId), + keepData: options.keepData, + reason: options.reason + }, options.timeoutMs); + if (!answer.ok) console.warn(`CanvasTTY environment ${environment.label} could not be released: ${answer.reason}`); + } + + /** `canvastty.environment.describe`: the card badge text; null when the plugin gave none. */ + async describe(environment: PersistedEnvironmentRef, sessionId: string): Promise<{ label: string; detail?: string } | null> { + const found = this.lookup(environment.pluginId, environment.kind); + if (!found) return null; + const answer = await this.ask(found.provider, "describe", refParams(environment, sessionId)); + if (!answer.ok || !isRecord(answer.value)) return null; + const label = plainText(answer.value.label, MAX_LABEL); + if (!label) return null; + const detail = plainText(answer.value.detail, MAX_DETAIL); + return { label, ...(detail ? { detail } : {}) }; + } + + private lookup(pluginId: string, kindId: string): { provider: EnvironmentProvider; kind: PluginEnvironmentKind } | null { + // Kinds are unique within a plugin, whichever of its services lists them. + const provider = this.dependencies.providers() + .find((candidate) => candidate.pluginId === pluginId && candidate.kinds.some((kind) => kind.kind === kindId)); + const kind = provider?.kinds.find((candidate) => candidate.kind === kindId); + return provider && kind ? { provider, kind } : null; + } + + private async ask( + provider: EnvironmentProvider, + step: EnvironmentStep, + params: unknown, + timeoutMs = this.timeouts[step] + ): Promise<{ ok: true; value: unknown } | { ok: false; reason: string }> { + let timer: NodeJS.Timeout | undefined; + try { + const value = await Promise.race([ + this.dependencies.call(provider.pluginId, provider.serviceId, `canvastty.environment.${step}`, params, timeoutMs), + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error("timed out")), timeoutMs); + }) + ]); + return { ok: true, value }; + } catch (error) { + const text = errorText(error); + if (/timed out/i.test(text)) { + return { ok: false, reason: `${provider.pluginName} did not answer ${step} within ${Number((timeoutMs / 1000).toFixed(1))} s; nothing was started locally.` }; + } + return { ok: false, reason: `${provider.pluginName} could not ${step} the environment: ${text}` }; + } finally { + if (timer) clearTimeout(timer); + } + } +} + +function refParams(environment: PersistedEnvironmentRef, sessionId: string): Record { + return { sessionId, kind: environment.kind, ref: structuredClone(environment.ref) }; +} + +function refusal(value: unknown): string { + const reason = isRecord(value) ? value.reason : value; + return plainText(reason, MAX_REASON) || "no reason given"; +} + +function plainText(value: unknown, limit: number): string { + return typeof value === "string" ? value.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, limit) : ""; +} + +function fitsSlot(value: unknown): boolean { + try { + const json = JSON.stringify(value); + return typeof json === "string" && Buffer.byteLength(json, "utf8") <= MAX_PLUGIN_SLOT_BYTES; + } catch { + return false; + } +} + +function isDirectory(value: unknown): value is string { + if (typeof value !== "string" || !isAbsolute(value) || value.includes("\u0000")) return false; + try { + return statSync(value).isDirectory(); + } catch { + return false; + } +} + +/** + * An absolute path to an executable file, or a bare program name found on PATH. Anything else + * (a relative path, a command line with spaces or shell syntax) is refused: the host spawns the + * program directly with an argv and never through a shell. + */ +export function resolveCommand(command: string, path: string | undefined, platform: NodeJS.Platform = process.platform): string | null { + if (command.includes("\u0000")) return null; + if (isAbsolute(command)) return isExecutable(command, platform) ? command : null; + if (!BARE_COMMAND.test(command)) return null; + const extensions = platform === "win32" ? [".exe", ".com"] : [""]; + for (const directory of (path ?? "").split(platform === "win32" ? ";" : delimiter)) { + if (!directory || !isAbsolute(directory)) continue; + for (const extension of extensions) { + const candidate = join(directory, `${command}${extension}`); + if (isExecutable(candidate, platform)) return candidate; + } + } + return null; +} + +function isExecutable(path: string, platform: NodeJS.Platform): boolean { + try { + if (!statSync(path).isFile()) return false; + if (platform !== "win32") accessSync(path, constants.X_OK); + return true; + } catch { + return false; + } +} diff --git a/src/main/services/LaunchPipeline.ts b/src/main/services/LaunchPipeline.ts new file mode 100644 index 00000000..904b26e0 --- /dev/null +++ b/src/main/services/LaunchPipeline.ts @@ -0,0 +1,410 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import type { + LaunchProfileId, + PluginLaunchFieldOptions, + PluginLaunchValues, + PluginServiceLaunch, + ProviderId, + SessionRole +} from "../../shared/contracts.ts"; +import { coreOwnedLaunchArgument } from "./terminalLaunch.ts"; +import { MAX_PLUGIN_SLOT_BYTES } from "./TerminalSessionStore.ts"; + +/** A trusted plugin service that declared launch options (PluginManager.launchContributors). */ +export interface LaunchContributor { + pluginId: string; + pluginName: string; + serviceId: string; + launch: PluginServiceLaunch; + /** The plugin holds the `secrets` permission, so `secretEnv` may name its secrets. */ + secrets: boolean; +} + +export interface LaunchPipelineDependencies { + contributors(): LaunchContributor[]; + call(pluginId: string, serviceId: string, method: "canvastty.launch.prepare" | "canvastty.launch.options", params: unknown, timeoutMs: number): Promise; + /** Reads one of the plugin's own secrets in this process; the value never reaches plugin code or UI. */ + secret(pluginId: string, key: string): Promise; + /** Per-run file folders live below this directory; it is emptied at startup. */ + runsRoot: string; + timeoutMs?: number; +} + +/** What a launch service receives (`canvastty.launch.prepare`). */ +export interface LaunchContext { + sessionId: string; + provider: ProviderId; + profile: LaunchProfileId; + role: SessionRole; + cwd: string; + parentSessionId?: string; + /** True when the app is bringing back a saved session. */ + restoring: boolean; + /** True when the provider continues an earlier conversation. */ + resume: boolean; + /** This plugin's saved option values for this session; empty when `chosen` is false. */ + options: PluginLaunchValues; + /** The person chose this plugin for the launch; false for a launch policy check, whose answer may only refuse. */ + chosen: boolean; + /** Where the card runs (the chosen or saved environment), or null on this computer. */ + environment: { pluginId: string; kind: string } | null; +} + +export type LaunchSessionContext = Omit & { options: Record }; + +export type PreparedLaunch = + | { + ok: true; + env: Record; + args: string[]; + /** Values of `secretEnv`, masked in every agent-readable text for the session's lifetime. */ + secrets: string[]; + /** Env name -> plugin name, to name the plugin when a core variable collides. */ + envSources: Record; + cleanup(): Promise; + } + | { ok: false; reason: string }; + +export const LAUNCH_PREPARE_TIMEOUT_MS = 5_000; +/** How long the launcher waits for a service's extra select choices before showing the declared ones only. */ +export const LAUNCH_OPTIONS_TIMEOUT_MS = 3_000; +const MAX_SERVICE_OPTIONS = 64; +/** Replaced in env values and args with the plugin's folder of written files for this run. */ +export const LAUNCH_FILES_TOKEN = "{launchFiles}"; +const MAX_OPTION_PLUGINS = 16; +export const MAX_ENV = 32; +export const MAX_ENV_VALUE_BYTES = 8 * 1024; +export const MAX_SECRET_ENV = 16; +const MAX_ARGS = 32; +const MAX_ARG_LENGTH = 1_024; +const MAX_FILES = 16; +const MAX_FILES_BYTES = 256 * 1024; +const MAX_REASON = 240; +const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/; +/** Names that steer CanvasTTY itself or the process loader, never a plugin's to set. */ +export const RESERVED_ENV = /^(?:CANVASTTY_|ELECTRON_|DYLD_|LD_)|^(?:NODE_OPTIONS|PATH|TERM|COLORTERM)$/i; + +export class LaunchPipeline { + private readonly dependencies: LaunchPipelineDependencies; + private readonly timeoutMs: number; + + constructor(dependencies: LaunchPipelineDependencies) { + this.dependencies = dependencies; + this.timeoutMs = dependencies.timeoutMs ?? LAUNCH_PREPARE_TIMEOUT_MS; + } + + /** Removes file folders left by a previous run of the app. */ + clearRuns(): Promise { + return rm(this.dependencies.runsRoot, { recursive: true, force: true }); + } + + /** Removes a closed session's file folders. */ + forgetSession(sessionId: string): Promise { + return rm(join(this.dependencies.runsRoot, safeSegment(sessionId)), { recursive: true, force: true }); + } + + /** Launch policies that apply to this agent: every launch of it asks them, chosen or not. */ + hasPolicy(provider: ProviderId): boolean { + return provider !== "terminal" && this.policies(provider, []).length > 0; + } + + private policies(provider: ProviderId, selected: readonly string[]): LaunchContributor[] { + if (provider === "terminal") return []; + return this.dependencies.contributors().filter((contributor) => contributor.launch.policy === true + && !selected.includes(contributor.pluginId) + && (!contributor.launch.appliesTo || contributor.launch.appliesTo.includes(provider as never))); + } + + /** Plugin ids among saved options that cannot prepare a launch now. */ + unavailable(options: Record): string[] { + const available = new Set(this.dependencies.contributors().map((contributor) => contributor.pluginId)); + return Object.keys(options).filter((pluginId) => !available.has(pluginId)).sort(); + } + + /** + * 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): Record | undefined { + if (candidate === undefined) return undefined; + if (!isRecord(candidate) || Object.keys(candidate).length > MAX_OPTION_PLUGINS) throw new Error("Launch options are invalid."); + if (Object.keys(candidate).length === 0) return undefined; + if (provider === "terminal") throw new Error("A plain terminal takes no launch options."); + const contributors = new Map(this.dependencies.contributors().map((contributor) => [contributor.pluginId, contributor])); + const options: Record = {}; + for (const [pluginId, raw] of Object.entries(candidate)) { + const contributor = contributors.get(pluginId); + if (!contributor) throw new Error(unavailableReason(pluginId)); + const { appliesTo, fields } = contributor.launch; + if (appliesTo && !appliesTo.includes(provider as never)) { + throw new Error(`${contributor.pluginName} launch options do not apply to ${provider}.`); + } + if (!isRecord(raw)) throw new Error(`${contributor.pluginName} launch options are invalid.`); + const known = new Set(fields.map((field) => field.key)); + const unknown = Object.keys(raw).find((key) => !known.has(key)); + if (unknown) throw new Error(`${contributor.pluginName} has no launch option ${unknown.slice(0, 40)}.`); + const values: PluginLaunchValues = {}; + for (const field of fields) { + const value = raw[field.key] ?? field.default + ?? (field.kind === "boolean" ? false : field.kind === "select" ? field.options?.[0]?.value ?? "" : ""); + const plainText = (limit: number): boolean => typeof value === "string" && value.length <= limit && !/[\u0000-\u001f\u007f]/.test(value); + // A service-provided choice may have changed since the launcher showed it: the service checks it when it prepares. + const valid = field.kind === "boolean" + ? typeof value === "boolean" + : field.kind === "select" + ? typeof value === "string" && (Boolean(field.options?.some((option) => option.value === value)) || (field.optionsFrom === "service" && plainText(200))) + : plainText(field.maxLength ?? 200); + if (!valid) throw new Error(`${contributor.pluginName} launch option ${field.label} is invalid.`); + values[field.key] = value as boolean | string; + } + if (Buffer.byteLength(JSON.stringify(values), "utf8") > MAX_PLUGIN_SLOT_BYTES) { + throw new Error(`${contributor.pluginName} launch options exceed 4 KB.`); + } + options[pluginId] = values; + } + return options; + } + + /** + * The extra choices a plugin's service offers for its `optionsFrom: "service"` selects, for the launcher. Any + * failure (no such fields, not running, timeout, invalid answer) leaves only the declared choices. + */ + async fieldOptions(pluginId: string, provider: ProviderId): Promise { + const contributor = this.dependencies.contributors().find((candidate) => candidate.pluginId === pluginId); + if (!contributor || provider === "terminal") return {}; + if (contributor.launch.appliesTo && !contributor.launch.appliesTo.includes(provider as never)) return {}; + const fields = contributor.launch.fields.filter((field) => field.kind === "select" && field.optionsFrom === "service"); + if (fields.length === 0) return {}; + let timer: NodeJS.Timeout | undefined; + let answer: unknown; + try { + answer = await Promise.race([ + this.dependencies.call(pluginId, contributor.serviceId, "canvastty.launch.options", + { provider, fields: fields.map((field) => field.key) }, LAUNCH_OPTIONS_TIMEOUT_MS), + new Promise((_resolve, reject) => { timer = setTimeout(() => reject(new TimeoutError()), LAUNCH_OPTIONS_TIMEOUT_MS); }) + ]); + } catch { + return {}; + } finally { + if (timer) clearTimeout(timer); + } + if (!isRecord(answer)) return {}; + const result: PluginLaunchFieldOptions = {}; + for (const field of fields) { + const offered = answer[field.key]; + if (!Array.isArray(offered)) continue; + const seen = new Set(field.options?.map((option) => option.value)); + const choices: Array<{ value: string; label: string }> = []; + for (const option of offered.slice(0, MAX_SERVICE_OPTIONS)) { + if (!isRecord(option) || typeof option.value !== "string" || typeof option.label !== "string") continue; + const { value, label } = option; + if (!value || value.length > 200 || /[\u0000-\u001f\u007f]/.test(value) || seen.has(value)) continue; + const shown = label.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, 120); + if (!shown) continue; + seen.add(value); + choices.push({ value, label: shown }); + } + if (choices.length > 0) result[field.key] = choices; + } + return result; + } + + /** + * Asks every selected plugin to prepare this launch, and every launch policy that applies. Contributors run + * side by side and are merged in plugin-id order. Any refusal, timeout, error, invalid answer or conflict + * refuses the whole launch with a reason; nothing is ever launched without a contribution the person selected, + * or past a policy that did not answer. + */ + async prepare(context: LaunchSessionContext): Promise { + const contributors = new Map(this.dependencies.contributors().map((contributor) => [contributor.pluginId, contributor])); + const selected = Object.keys(context.options).sort(); + for (const pluginId of selected) { + if (!contributors.has(pluginId)) return { ok: false, reason: unavailableReason(pluginId) }; + } + const policies = this.policies(context.provider, selected).sort((a, b) => a.pluginId.localeCompare(b.pluginId)); + const answers = await Promise.all([ + ...selected.map((pluginId) => this.ask(contributors.get(pluginId)!, context, true)), + ...policies.map((contributor) => this.ask(contributor, context, false)) + ]); + const runDirectory = join(this.dependencies.runsRoot, safeSegment(context.sessionId), randomUUID()); + const cleanup = (): Promise => rm(runDirectory, { recursive: true, force: true }); + const refuse = async (reason: string): Promise => { + await cleanup().catch(() => undefined); + return { ok: false, reason }; + }; + + const env: Record = {}; + const envSources: Record = {}; + const args: string[] = []; + const secrets: string[] = []; + 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 (context.provider === "terminal" && contribution.args.length > 0) { + return refuse(`${name} added arguments to a plain terminal, which takes none.`); + } + const forbidden = contribution.args.find((argument) => coreOwnedLaunchArgument(context.provider, argument)); + if (forbidden) return refuse(`${name} added ${forbidden.slice(0, 60)}, which only CanvasTTY may pass.`); + let filesDirectory: string | null = null; + if (contribution.files.length > 0) { + filesDirectory = join(runDirectory, safeSegment(contributor.pluginId)); + try { + for (const file of contribution.files) { + const path = join(filesDirectory, ...file.relPath.split("/")); + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + await writeFile(path, file.content, { encoding: "utf8", mode: 0o600, flag: "wx" }); + } + } catch (error) { + return refuse(`${name}'s launch files could not be written: ${errorText(error)}`); + } + } + const expand = (value: string): string => filesDirectory ? value.split(LAUNCH_FILES_TOKEN).join(filesDirectory) : value; + const claim = (key: string): string | null => { + const owner = envSources[key]; + if (owner) return owner === name ? `${name} sets ${key} twice.` : `${owner} and ${name} both set ${key}.`; + envSources[key] = name; + return null; + }; + for (const [key, value] of Object.entries(contribution.env)) { + const conflict = claim(key); + if (conflict) return refuse(conflict); + env[key] = expand(value); + } + for (const [key, secretKey] of Object.entries(contribution.secretEnv)) { + const conflict = claim(key); + if (conflict) return refuse(conflict); + if (!contributor.secrets) return refuse(`${name} asked for a secret without the secrets permission.`); + const value = await this.dependencies.secret(contributor.pluginId, secretKey).catch(() => null); + if (typeof value !== "string" || value.length === 0) return refuse(`${name}: its secret ${secretKey} is not set.`); + if (value.includes("\u0000")) return refuse(`${name}: its secret ${secretKey} cannot be passed in the environment.`); + env[key] = value; + secrets.push(value); + } + args.push(...contribution.args.map(expand)); + } + return { ok: true, env, args, secrets, envSources, cleanup }; + } + + private async ask( + contributor: LaunchContributor, + context: LaunchSessionContext, + chosen: boolean + ): Promise<{ contributor: LaunchContributor; contribution: Contribution } | { refuse: string }> { + const name = contributor.pluginName; + const params: LaunchContext = { ...context, options: chosen ? context.options[contributor.pluginId]! : {}, chosen }; + let timer: NodeJS.Timeout | undefined; + try { + const answer = await Promise.race([ + this.dependencies.call(contributor.pluginId, contributor.serviceId, "canvastty.launch.prepare", params, this.timeoutMs), + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new TimeoutError()), this.timeoutMs); + }) + ]); + const contribution = validContribution(answer); + if (typeof contribution === "string") return { refuse: `${name} answered with an invalid launch contribution: ${contribution}` }; + if (contribution.refuse !== undefined) return { refuse: `${name}: ${contribution.refuse}` }; + if (!chosen && (Object.keys(contribution.env).length || Object.keys(contribution.secretEnv).length || contribution.args.length || contribution.files.length)) { + return { refuse: `${name} answered its launch policy with a contribution; a policy may only refuse.` }; + } + return { contributor, contribution }; + } catch (error) { + if (error instanceof TimeoutError || /timed out/i.test(errorText(error))) { + return { refuse: `${name} did not ${chosen ? "prepare the launch" : "answer its launch policy"} within ${Number((this.timeoutMs / 1000).toFixed(1))} s, so it was not started.` }; + } + return { refuse: `${name} could not prepare the launch: ${errorText(error)}` }; + } finally { + if (timer) clearTimeout(timer); + } + } +} + +interface Contribution { + env: Record; + secretEnv: Record; + args: string[]; + files: Array<{ relPath: string; content: string }>; + refuse?: string; +} + +/** Strict shape check of a service's answer; returns the problem as text when invalid. */ +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", "refuse"].includes(key)); + if (unknown) return `unknown key ${unknown.slice(0, 40)}`; + if (value.refuse !== undefined) { + const reason = isRecord(value.refuse) ? value.refuse.reason : undefined; + if (typeof reason !== "string" || reason.trim().length === 0) return "refuse needs a reason"; + return { env: {}, secretEnv: {}, args: [], files: [], refuse: reason.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, MAX_REASON) }; + } + const env = stringMap(value.env, MAX_ENV, "env"); + if (typeof env === "string") return env; + for (const [key, entry] of Object.entries(env)) { + if (entry.includes("\u0000") || Buffer.byteLength(entry, "utf8") > MAX_ENV_VALUE_BYTES) return `env ${key} value is invalid or larger than 8 KB`; + } + const secretEnv = stringMap(value.secretEnv, MAX_SECRET_ENV, "secretEnv"); + if (typeof secretEnv === "string") return secretEnv; + for (const [key, entry] of Object.entries(secretEnv)) { + if (!/^[A-Za-z0-9._-]{1,80}$/.test(entry)) return `secretEnv ${key} must name a plugin secret key`; + } + const args = value.args ?? []; + if (!Array.isArray(args) || args.length > MAX_ARGS) return `args must be an array of at most ${MAX_ARGS}`; + for (const argument of args) { + if (typeof argument !== "string" || argument.length === 0 || argument.length > MAX_ARG_LENGTH || /[\u0000-\u001f\u007f]/.test(argument)) { + return "every arg must be non-empty text without control characters, at most 1024 characters"; + } + } + const filesValue = value.files ?? []; + if (!Array.isArray(filesValue) || filesValue.length > MAX_FILES) return `files must be an array of at most ${MAX_FILES}`; + const files: Contribution["files"] = []; + const paths = new Set(); + let bytes = 0; + for (const file of filesValue) { + if (!isRecord(file) || typeof file.relPath !== "string" || typeof file.content !== "string") return "every file needs relPath and content"; + const segments = file.relPath.split("/"); + if (segments.length > 4 || segments.some((segment) => !/^[A-Za-z0-9._-]{1,64}$/.test(segment) || /^\.+$/.test(segment))) { + return `file path ${file.relPath.slice(0, 80)} is not a plain relative path`; + } + if (paths.has(file.relPath)) return `file ${file.relPath} is listed twice`; + paths.add(file.relPath); + bytes += Buffer.byteLength(file.content, "utf8"); + 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 }; +} + +/** Env names a plugin may set: valid, not reserved for CanvasTTY or the loader, text values. */ +export function stringMap(value: unknown, limit: number, field: string): Record | string { + if (value === undefined) return {}; + if (!isRecord(value) || Object.keys(value).length > limit) return `${field} must be an object of at most ${limit} entries`; + for (const [key, entry] of Object.entries(value)) { + if (!ENV_NAME.test(key)) return `${field} name ${key.slice(0, 40)} is invalid`; + if (RESERVED_ENV.test(key)) return `${field} ${key} is reserved for CanvasTTY`; + if (typeof entry !== "string") return `${field} ${key} must be text`; + } + return value as Record; +} + +function unavailableReason(pluginId: string): string { + return `Needs plugin ${pluginId} for its launch options; it is disabled, removed, or its native code is not trusted.`; +} + +function safeSegment(value: string): string { + return value.replace(/[^A-Za-z0-9._-]/g, "_").replace(/^\.+/, "_").slice(0, 128) || "_"; +} + +class TimeoutError extends Error {} + +export function errorText(error: unknown): string { + return (error instanceof Error ? error.message : String(error)).replace(/[\u0000-\u001f\u007f]/g, " ").slice(0, MAX_REASON); +} + +export function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} diff --git a/src/main/services/PluginManager.ts b/src/main/services/PluginManager.ts index 17e63527..9118c64d 100644 --- a/src/main/services/PluginManager.ts +++ b/src/main/services/PluginManager.ts @@ -27,6 +27,11 @@ import type { PluginModule, PluginModuleAsset, PluginPermission, + PluginEnvironmentKind, + PluginLaunchField, + PluginService, + PluginServiceDecide, + PluginServiceLaunch, PluginUpdateStatus, Size } from "../../shared/contracts"; @@ -36,6 +41,11 @@ import { PLUGIN_API_VERSION } from "../../shared/contracts.ts"; import { isValidSemver } from "../../shared/hostVersion.ts"; +import type { PluginServiceSpec } from "./PluginServiceSupervisor.ts"; +import type { LaunchContributor } from "./LaunchPipeline.ts"; +import type { EnvironmentProvider } from "./EnvironmentRegistry.ts"; +import type { DecisionService } from "./DecisionHooks.ts"; +import { MAX_DECIDE_TIMEOUT_MS, MIN_DECIDE_TIMEOUT_MS } from "../../agent-runtime/runtime-protocol.mjs"; const MANIFEST_FILE = "canvastty.plugin.json"; /** Plugins keep their metadata (manifest, icon, etc.) in the metadata/ folder. */ @@ -71,6 +81,8 @@ const MAX_STORAGE_BYTES = 64 * 1024; const MAX_MANIFEST_BYTES = 128 * 1024; const MAX_RUNTIME_HOOK_REGISTRY_BYTES = 1024 * 1024; const MAX_PLUGIN_ICON_BYTES = 512 * 1024; +const MAX_PLUGIN_SERVICES = 8; +const PLUGIN_DATA_DIR = "plugin-data"; const PLUGIN_INPUT_BRIDGE_URL = "canvastty-plugin://host/input-bridge.js"; const AGENT_PROVIDERS = new Set([ "codex", "claude", "qwen", "kimi", "opencode", "hermes", "grok", "omp", "pi", "cursor", "minimax", "devin", "antigravity" @@ -106,7 +118,10 @@ const PLUGIN_PERMISSIONS = new Set([ "playlists:read", "playlists:write", "hermes:hud", - "network" + "network", + "launch:contribute", + "environment:provide", + "decision:provide" ]); interface StoredPluginRecord { @@ -115,6 +130,10 @@ interface StoredPluginRecord { installedAt: number; selectedModules?: string[]; enabledHooks?: string[]; + /** Service id -> SHA-256 of its entry when the user trusted the plugin's native code. */ + trustedServices?: Record; + /** The user let the plugin's decision service allow tool calls (kept only with trustedServices). */ + decisionsMayAllow?: boolean; } export interface RuntimePluginHookRegistration { @@ -162,7 +181,11 @@ export class PluginManager { private readonly registryPath: string; private readonly versionsPath: string; private readonly hookRegistryPath: string; + private readonly dataRoot: string; private readonly plugins = new Map(); + /** Plugin id -> service id -> trusted entry SHA-256. Present only while native code is trusted. */ + private readonly serviceTrust = new Map>(); + private serviceObserver: ((specs: PluginServiceSpec[]) => Promise) | null = null; private readonly pending = new Map(); private readonly updatingPlugins = new Map>(); private readonly storageWrites = new Map>(); @@ -185,6 +208,7 @@ export class PluginManager { this.registryPath = join(userDataPath, REGISTRY_FILE); this.versionsPath = join(userDataPath, VERSIONS_FILE); this.hookRegistryPath = join(userDataPath, "lifecycle", RUNTIME_HOOK_REGISTRY_FILE); + this.dataRoot = join(userDataPath, PLUGIN_DATA_DIR); this.downloadRepository = downloadRepository ?? downloadGithubManifest; this.downloadFullRepository = downloadRepository ?? downloadGithubRepository; this.downloadModuleFiles = downloadModuleFiles; @@ -227,6 +251,7 @@ export class PluginManager { const persistedRuntimeHooks = await readRuntimeHookRegistry(this.hookRegistryPath); this.plugins.clear(); + this.serviceTrust.clear(); for (const [pluginId, record] of Object.entries(registry)) { if (!isStoredRecord(record) || !isPluginId(pluginId)) continue; try { @@ -249,8 +274,20 @@ export class PluginManager { active.hooks?.find((hook) => hook.id === hookId) ) )) - : [] + : [], + nativeCodeTrusted: false, + decisionsMayAllow: false }); + if (record.enabled && record.trustedServices) { + const trust = await this.currentServiceTrust(pluginId, active).catch(() => null); + // Any difference from what the user trusted (a changed file, another module set) revokes it. + if (trust && sameServiceTrust(trust, record.trustedServices)) { + this.serviceTrust.set(pluginId, trust); + const plugin = this.plugins.get(pluginId)!; + plugin.nativeCodeTrusted = true; + plugin.decisionsMayAllow = record.decisionsMayAllow === true && Boolean(active.services?.some((service) => service.decide)); + } + } } catch (error) { console.warn(`CanvasTTY plugin ${pluginId} could not be loaded.`, error); } @@ -356,7 +393,9 @@ export class PluginManager { enabled: true, installedAt: Date.now(), selectedModules: modules, - enabledHooks: [] + enabledHooks: [], + nativeCodeTrusted: false, + decisionsMayAllow: false }; this.plugins.set(installed.manifest.id, installed); await this.persistRegistry(); @@ -374,14 +413,22 @@ export class PluginManager { const plugin = this.requirePlugin(pluginId); const wasEnabled = plugin.enabled; const previousEnabledHooks = [...plugin.enabledHooks]; + const previousTrust = this.serviceTrust.get(pluginId); plugin.enabled = Boolean(enabled); - if (!plugin.enabled || !wasEnabled) plugin.enabledHooks = []; + if (!plugin.enabled || !wasEnabled) { + plugin.enabledHooks = []; + this.revokeNativeCode(plugin); + } try { await this.persistRegistry(); } catch (error) { if (plugin.enabled) { plugin.enabled = wasEnabled; plugin.enabledHooks = previousEnabledHooks; + if (previousTrust) { + this.serviceTrust.set(pluginId, previousTrust); + plugin.nativeCodeTrusted = true; + } } await this.persistRegistry().catch(() => undefined); throw error; @@ -411,6 +458,157 @@ export class PluginManager { return structuredClone(activePlugin(plugin)); } + /** + * The separate "Native code" confirmation: lets every service of this plugin run as a process with + * the user's OS privileges. It pins each entry's SHA-256; update, module change and disable revoke it. + */ + async setNativeCodeTrusted(pluginId: string, trusted: boolean): Promise { + const plugin = this.requireEnabledPlugin(pluginId); + const active = activeManifest(plugin.manifest, plugin.selectedModules); + if (!active.services?.length) throw new Error("Plugin has no services."); + const previous = this.serviceTrust.get(pluginId); + if (trusted) { + this.serviceTrust.set(pluginId, await this.currentServiceTrust(pluginId, active)); + plugin.nativeCodeTrusted = true; + } else { + this.revokeNativeCode(plugin); + } + try { + await this.persistRegistry(); + } catch (error) { + if (trusted) { + if (previous) this.serviceTrust.set(pluginId, previous); + else this.revokeNativeCode(plugin); + plugin.nativeCodeTrusted = Boolean(previous); + } + await this.persistRegistry().catch(() => undefined); + throw error; + } + return structuredClone(activePlugin(plugin)); + } + + /** + * The second confirmation for a decision service: its `allow` answers count (tool calls run without the + * agent's own prompt). Needs native code trust; revoked with it (update, module change, disable). + */ + async setDecisionsMayAllow(pluginId: string, allowed: boolean): Promise { + const plugin = this.requireEnabledPlugin(pluginId); + if (allowed) { + if (!plugin.nativeCodeTrusted) throw new Error("Trust the plugin's native code first."); + if (!activeManifest(plugin.manifest, plugin.selectedModules).services?.some((service) => service.decide)) { + throw new Error("Plugin has no decision service."); + } + } + const previous = plugin.decisionsMayAllow; + plugin.decisionsMayAllow = Boolean(allowed); + try { + await this.persistRegistry(); + } catch (error) { + plugin.decisionsMayAllow = previous; + await this.persistRegistry().catch(() => undefined); + throw error; + } + return structuredClone(activePlugin(plugin)); + } + + /** Called with the trusted services after every registry change (the supervisor's desired set). */ + setServiceObserver(observer: ((specs: PluginServiceSpec[]) => Promise) | null): void { + this.serviceObserver = observer; + } + + trustedServiceSpecs(): PluginServiceSpec[] { + const specs: PluginServiceSpec[] = []; + for (const plugin of this.plugins.values()) { + const trust = this.serviceTrust.get(plugin.manifest.id); + if (!plugin.enabled || !plugin.nativeCodeTrusted || !trust) continue; + const manifest = activeManifest(plugin.manifest, plugin.selectedModules); + const root = join(this.pluginRoot, plugin.manifest.id); + for (const service of manifest.services ?? []) { + const sha256 = trust[service.id]; + if (!sha256) continue; + specs.push({ + pluginId: plugin.manifest.id, + serviceId: service.id, + root, + entryPath: join(root, ...service.entry.split("/")), + sha256, + dataDir: join(this.dataRoot, plugin.manifest.id), + permissions: [...manifest.permissions] + }); + } + } + return specs; + } + + /** Services that may prepare launches now: enabled, native code trusted, `launch:contribute` granted. */ + launchContributors(): LaunchContributor[] { + const contributors: LaunchContributor[] = []; + for (const plugin of this.plugins.values()) { + const trust = this.serviceTrust.get(plugin.manifest.id); + if (!plugin.enabled || !plugin.nativeCodeTrusted || !trust) continue; + const manifest = activeManifest(plugin.manifest, plugin.selectedModules); + if (!manifest.permissions.includes("launch:contribute")) continue; + const service = manifest.services?.find((candidate) => candidate.launch && trust[candidate.id]); + if (!service?.launch) continue; + contributors.push({ + pluginId: plugin.manifest.id, + pluginName: manifest.name, + serviceId: service.id, + launch: structuredClone(service.launch), + secrets: manifest.permissions.includes("secrets") + }); + } + return contributors; + } + + /** Services that may place sessions now: enabled, native code trusted, `environment:provide` granted. */ + environmentProviders(): EnvironmentProvider[] { + return this.trustedServicesWith("environment:provide", (service) => service.environments).map(({ plugin, service, name, secrets }) => ({ + pluginId: plugin, pluginName: name, serviceId: service.id, kinds: structuredClone(service.environments!), secrets + })); + } + + /** Services that may decide on agents' tool calls now: enabled, native code trusted, `decision:provide` granted. */ + decisionServices(): DecisionService[] { + const services: DecisionService[] = []; + for (const plugin of this.plugins.values()) { + const trust = this.serviceTrust.get(plugin.manifest.id); + if (!plugin.enabled || !plugin.nativeCodeTrusted || !trust) continue; + const manifest = activeManifest(plugin.manifest, plugin.selectedModules); + if (!manifest.permissions.includes("decision:provide")) continue; + const service = manifest.services?.find((candidate) => candidate.decide && trust[candidate.id]); + if (!service?.decide) continue; + services.push({ + pluginId: plugin.manifest.id, + pluginName: manifest.name, + serviceId: service.id, + ...(service.decide.appliesTo ? { appliesTo: [...service.decide.appliesTo] } : {}), + ...(service.decide.timeoutMs !== undefined ? { timeoutMs: service.decide.timeoutMs } : {}), + mayAllow: plugin.decisionsMayAllow + }); + } + return services; + } + + private trustedServicesWith( + permission: PluginPermission, + declares: (service: PluginService) => unknown + ): Array<{ plugin: string; name: string; service: PluginService; secrets: boolean }> { + const found: Array<{ plugin: string; name: string; service: PluginService; secrets: boolean }> = []; + for (const plugin of this.plugins.values()) { + const trust = this.serviceTrust.get(plugin.manifest.id); + if (!plugin.enabled || !plugin.nativeCodeTrusted || !trust) continue; + const manifest = activeManifest(plugin.manifest, plugin.selectedModules); + if (!manifest.permissions.includes(permission)) continue; + for (const service of manifest.services ?? []) { + if (declares(service) && trust[service.id]) { + found.push({ plugin: plugin.manifest.id, name: manifest.name, service, secrets: manifest.permissions.includes("secrets") }); + } + } + } + return found; + } + get runtimeHookRegistryPath(): string { return this.hookRegistryPath; } @@ -436,8 +634,9 @@ export class PluginManager { if (!plugin.manifest.modules?.length) throw new Error("Plugin does not declare optional modules."); const selected = normalizeSelectedModules(plugin.manifest, selectedModules); if (selected.length !== new Set(selectedModules).size) throw new Error("Plugin module selection is invalid."); - if (plugin.enabledHooks.length > 0) { + if (plugin.enabledHooks.length > 0 || plugin.nativeCodeTrusted) { plugin.enabledHooks = []; + this.revokeNativeCode(plugin); await this.persistRegistry(); } const directory = await mkdtemp(join(this.stagingRoot, "modules-")); @@ -485,8 +684,9 @@ export class PluginManager { async uninstall(pluginId: string): Promise { const plugin = this.requirePlugin(pluginId); - if (plugin.enabledHooks.length > 0) { + if (plugin.enabledHooks.length > 0 || plugin.nativeCodeTrusted) { plugin.enabledHooks = []; + this.revokeNativeCode(plugin); try { await this.persistRegistry(); } catch (error) { @@ -504,6 +704,7 @@ export class PluginManager { } await rm(join(this.pluginRoot, plugin.manifest.id), { recursive: true, force: true }); await rm(join(this.storageRoot, `${plugin.manifest.id}.json`), { force: true }); + await rm(join(this.dataRoot, plugin.manifest.id), { recursive: true, force: true }); } async searchGithubPlugins(query: string): Promise { @@ -698,8 +899,9 @@ export class PluginManager { private async performPluginUpdate(pluginId: string): Promise { const plugin = this.requirePlugin(pluginId); - if (plugin.enabledHooks.length > 0) { + if (plugin.enabledHooks.length > 0 || plugin.nativeCodeTrusted) { plugin.enabledHooks = []; + this.revokeNativeCode(plugin); await this.persistRegistry(); } const sourceUrl = plugin.sourceUrl; @@ -742,8 +944,10 @@ export class PluginManager { enabled: plugin.enabled, installedAt: plugin.installedAt, selectedModules: selected, - // Updated native hook code must be reviewed and trusted again. - enabledHooks: [] + // Updated native hook and service code must be reviewed and trusted again. + enabledHooks: [], + nativeCodeTrusted: false, + decisionsMayAllow: false }; this.plugins.set(pluginId, updated); await this.persistRegistry(); @@ -824,6 +1028,13 @@ export class PluginManager { return structuredClone(contribution); } + assertService(pluginId: string, serviceId: string): void { + const plugin = activePlugin(this.requireEnabledPlugin(pluginId)); + if (!plugin.manifest.services?.some((service) => service.id === serviceId)) { + throw new Error("Plugin service does not exist."); + } + } + hasPermission(pluginId: string, permission: PluginPermission): boolean { const plugin = activePlugin(this.requireEnabledPlugin(pluginId)); return plugin.manifest.permissions.includes(permission); @@ -958,7 +1169,10 @@ export class PluginManager { enabled: plugin.enabled, installedAt: plugin.installedAt, selectedModules: plugin.selectedModules, - enabledHooks: plugin.enabledHooks + enabledHooks: plugin.enabledHooks, + ...(plugin.nativeCodeTrusted && this.serviceTrust.has(id) + ? { trustedServices: { ...this.serviceTrust.get(id)! }, ...(plugin.decisionsMayAllow ? { decisionsMayAllow: true } : {}) } + : {}) }] satisfies [string, StoredPluginRecord])); const snapshot = JSON.stringify(registry, null, 2); const desiredHookRegistry = this.runtimeHookRegistry(); @@ -967,6 +1181,7 @@ export class PluginManager { throw new Error("Enabled plugin hooks exceed the runtime registry limit."); } const temporaryPath = `${this.registryPath}.tmp`; + const services = this.trustedServiceSpecs(); const write = this.registryWrite.catch(() => undefined).then(async () => { const currentHookRegistry = await readRuntimeHookRegistry(this.hookRegistryPath); const interimHookRegistry = safeRuntimeHookInterim(currentHookRegistry, desiredHookRegistry); @@ -982,11 +1197,33 @@ export class PluginManager { if (interimHookSnapshot !== hookSnapshot) { await writeRuntimeHookRegistry(this.hookRegistryPath, hookSnapshot); } + // Revocations stop services before the caller replaces or removes their files. + await this.serviceObserver?.(services).catch((error: unknown) => { + console.warn("CanvasTTY plugin services could not be updated.", error); + }); }); this.registryWrite = write; return write; } + private revokeNativeCode(plugin: InstalledPlugin): void { + plugin.nativeCodeTrusted = false; + plugin.decisionsMayAllow = false; + this.serviceTrust.delete(plugin.manifest.id); + } + + private async currentServiceTrust(pluginId: string, manifest: PluginManifest): Promise> { + const root = join(this.pluginRoot, pluginId); + const trust: Record = {}; + for (const service of manifest.services ?? []) { + const path = await containedFile(root, service.entry); + const metadata = await stat(path); + if (metadata.size > MAX_ASSET_BYTES) throw new Error(`Plugin service entry is too large: ${service.entry}.`); + trust[service.id] = createHash("sha256").update(await readFile(path)).digest("hex"); + } + return trust; + } + private runtimeHookRegistry(): RuntimeHookRegistry { const hooks: Record = {}; for (const plugin of this.plugins.values()) { @@ -1046,10 +1283,14 @@ export function validatePluginManifest(candidate: unknown): PluginManifest { assertOnlyKeys(candidate, [ "apiVersion", "id", "name", "version", "description", "description.ru", "description.en", "icon", "author", "homepage", "settingsContribution", "coreFiles", "modules", "permissions", - "contributions", "hooks", "platforms", "minHostVersion" + "contributions", "hooks", "services", "platforms", "minHostVersion" ], "Plugin manifest"); - if (candidate.apiVersion !== PLUGIN_API_VERSION) { - throw new Error(`Plugin apiVersion must be ${PLUGIN_API_VERSION}.`); + if (candidate.apiVersion !== 1 && candidate.apiVersion !== PLUGIN_API_VERSION) { + throw new Error(`Plugin apiVersion must be 1 or ${PLUGIN_API_VERSION}.`); + } + const apiVersion = candidate.apiVersion === 1 ? 1 : PLUGIN_API_VERSION; + if (apiVersion === 1 && candidate.services !== undefined) { + throw new Error(`Plugin services require apiVersion ${PLUGIN_API_VERSION}.`); } const id = requiredString(candidate.id, "id", 80); if (!isPluginId(id) || id === "host") throw new Error("Plugin id must be a lowercase DNS-style identifier."); @@ -1101,6 +1342,21 @@ export function validatePluginManifest(candidate: unknown): PluginManifest { const modules = validateModules(candidate.modules); const moduleIds = new Set(modules.map((module) => module.id)); const hooks = validateAgentHooks(candidate.hooks, moduleIds); + const services = validateServices(candidate.services, moduleIds); + for (const service of services) { + const granted = service.module + ? modules.find((module) => module.id === service.module)?.permissions + : undefined; + if (service.launch && !permissions.includes("launch:contribute") && !granted?.includes("launch:contribute")) { + throw new Error(`Plugin service ${service.id} contributes to launches and needs the launch:contribute permission.`); + } + if (service.environments && !permissions.includes("environment:provide") && !granted?.includes("environment:provide")) { + throw new Error(`Plugin service ${service.id} provides environments and needs the environment:provide permission.`); + } + if (service.decide && !permissions.includes("decision:provide") && !granted?.includes("decision:provide")) { + throw new Error(`Plugin service ${service.id} decides on tool calls and needs the decision:provide permission.`); + } + } const coreFiles = candidate.coreFiles === undefined ? [] : validateModuleFiles(candidate.coreFiles, "coreFiles"); if (modules.length > 0 && coreFiles.length === 0) { throw new Error("Modular plugins must declare at least one coreFiles asset."); @@ -1116,8 +1372,8 @@ export function validatePluginManifest(candidate: unknown): PluginManifest { if (!Array.isArray(candidate.contributions) || candidate.contributions.length > 32) { throw new Error("Plugin contributions must be an array of at most 32 items."); } - if (candidate.contributions.length === 0 && hooks.length === 0) { - throw new Error("Plugin must declare at least one contribution or agent hook."); + if (candidate.contributions.length === 0 && hooks.length === 0 && services.length === 0) { + throw new Error("Plugin must declare at least one contribution, agent hook, or service."); } const contributionIds = new Set(); const contributions = candidate.contributions.map((value) => { @@ -1138,7 +1394,7 @@ export function validatePluginManifest(candidate: unknown): PluginManifest { } return { - apiVersion: PLUGIN_API_VERSION, + apiVersion, id, name, version, @@ -1153,6 +1409,7 @@ export function validatePluginManifest(candidate: unknown): PluginManifest { permissions, contributions, ...(hooks.length ? { hooks } : {}), + ...(services.length ? { services } : {}), ...(settingsContribution ? { settingsContribution } : {}), ...(coreFiles.length ? { coreFiles } : {}), ...(modules.length ? { modules } : {}) @@ -1221,6 +1478,192 @@ function validateAgentHooks(value: unknown, moduleIds: ReadonlySet): Plu }); } +function validateServices(value: unknown, moduleIds: ReadonlySet): PluginService[] { + if (value === undefined) return []; + if (!Array.isArray(value) || value.length === 0 || value.length > MAX_PLUGIN_SERVICES) { + throw new Error(`Plugin services must contain between 1 and ${MAX_PLUGIN_SERVICES} items.`); + } + const ids = new Set(); + const services = value.map((candidate): PluginService => { + if (!isRecord(candidate)) throw new Error("Every plugin service must be an object."); + assertOnlyKeys(candidate, ["id", "title", "description", "entry", "module", "launch", "environments", "decide"], "Plugin service"); + const id = requiredString(candidate.id, "service id", 64); + if (!isContributionId(id) || ids.has(id)) throw new Error(`Plugin service id is invalid or duplicated: ${id}.`); + ids.add(id); + const title = requiredString(candidate.title, "service title", 80); + const description = optionalString(candidate.description, "service description", 240); + const entry = assetPath(requiredString(candidate.entry, "service entry", 180)); + if (![".js", ".mjs", ".cjs"].includes(extname(entry))) { + throw new Error("Plugin service entry must be a bundled JavaScript file."); + } + const module = optionalString(candidate.module, "service module", 64); + if (module && (!isContributionId(module) || !moduleIds.has(module))) { + throw new Error(`Plugin service references an unknown module: ${module}.`); + } + const launch = candidate.launch === undefined ? undefined : validateServiceLaunch(candidate.launch); + const environments = candidate.environments === undefined ? undefined : validateServiceEnvironments(candidate.environments); + const decide = candidate.decide === undefined ? undefined : validateServiceDecide(candidate.decide); + return { + id, title, ...(description ? { description } : {}), entry, ...(module ? { module } : {}), + ...(launch ? { launch } : {}), + ...(environments ? { environments } : {}), + ...(decide ? { decide } : {}) + }; + }); + // "Allow decisions" is confirmed per plugin, so one service per plugin answers. + if (services.filter((service) => service.decide).length > 1) { + throw new Error("At most one plugin service may decide on tool calls."); + } + // Saved environment refs name the plugin and kind, so exactly one service answers for each kind (a plugin + // may split its kinds over services, for example one per module). + const kinds = services.flatMap((service) => service.environments ?? []).map((environment) => environment.kind); + if (new Set(kinds).size !== kinds.length) throw new Error("Plugin environment kinds must be unique across its services."); + if (kinds.length > MAX_ENVIRONMENT_KINDS) throw new Error(`A plugin may offer at most ${MAX_ENVIRONMENT_KINDS} environment kinds.`); + // Launch options are saved per plugin, so one service per plugin answers for them. + if (services.filter((service) => service.launch).length > 1) { + throw new Error("At most one plugin service may declare launch options."); + } + return services; +} + +const MAX_LAUNCH_FIELDS = 8; +const MAX_LAUNCH_TEXT = 200; + +function validateServiceLaunch(value: unknown): PluginServiceLaunch { + if (!isRecord(value)) throw new Error("Plugin service launch must be an object."); + assertOnlyKeys(value, ["appliesTo", "fields", "policy"], "Plugin service launch"); + if (value.policy !== undefined && typeof value.policy !== "boolean") throw new Error("Plugin launch policy must be true or false."); + let appliesTo: AgentProviderId[] | undefined; + if (value.appliesTo !== undefined) { + if (!Array.isArray(value.appliesTo) || value.appliesTo.length === 0 + || value.appliesTo.some((provider) => !AGENT_PROVIDERS.has(provider as AgentProviderId))) { + throw new Error("Plugin launch appliesTo must list agent providers."); + } + appliesTo = [...new Set(value.appliesTo as AgentProviderId[])]; + } + return { ...(appliesTo ? { appliesTo } : {}), fields: validateLaunchFields(value.fields), ...(value.policy === true ? { policy: true } : {}) }; +} + +function validateServiceDecide(value: unknown): PluginServiceDecide { + if (!isRecord(value)) throw new Error("Plugin service decide must be an object."); + assertOnlyKeys(value, ["events", "appliesTo", "timeoutMs"], "Plugin service decide"); + if (value.timeoutMs !== undefined && (!Number.isInteger(value.timeoutMs) + || (value.timeoutMs as number) < MIN_DECIDE_TIMEOUT_MS || (value.timeoutMs as number) > MAX_DECIDE_TIMEOUT_MS)) { + throw new Error(`Plugin decide timeoutMs must be ${MIN_DECIDE_TIMEOUT_MS} to ${MAX_DECIDE_TIMEOUT_MS}.`); + } + if (!Array.isArray(value.events) || value.events.length === 0 || value.events.some((event) => event !== "pre-tool")) { + throw new Error("Plugin decide events must list pre-tool."); + } + let appliesTo: AgentProviderId[] | undefined; + if (value.appliesTo !== undefined) { + if (!Array.isArray(value.appliesTo) || value.appliesTo.length === 0 + || value.appliesTo.some((provider) => !AGENT_PROVIDERS.has(provider as AgentProviderId))) { + throw new Error("Plugin decide appliesTo must list agent providers."); + } + appliesTo = [...new Set(value.appliesTo as AgentProviderId[])]; + } + return { events: ["pre-tool"], ...(appliesTo ? { appliesTo } : {}), ...(value.timeoutMs !== undefined ? { timeoutMs: value.timeoutMs as number } : {}) }; +} + +const MAX_ENVIRONMENT_KINDS = 8; +const PROVIDER_IDS = new Set(["terminal", ...AGENT_PROVIDERS]); + +function validateServiceEnvironments(value: unknown): PluginEnvironmentKind[] { + if (!Array.isArray(value) || value.length === 0 || value.length > MAX_ENVIRONMENT_KINDS) { + throw new Error(`Plugin service environments must contain between 1 and ${MAX_ENVIRONMENT_KINDS} kinds.`); + } + const kinds = new Set(); + return value.map((candidate): PluginEnvironmentKind => { + if (!isRecord(candidate)) throw new Error("Every plugin environment must be an object."); + assertOnlyKeys(candidate, ["kind", "label", "description", "appliesTo", "fields"], "Plugin environment"); + const kind = requiredString(candidate.kind, "environment kind", 32); + // Same shape the session store accepts for a saved environment's kind. + if (!/^[a-z0-9][a-z0-9-]{0,31}$/.test(kind) || kinds.has(kind)) { + throw new Error(`Plugin environment kind is invalid or duplicated: ${kind}.`); + } + kinds.add(kind); + const label = requiredString(candidate.label, "environment label", 80); + const description = optionalString(candidate.description, "environment description", 240); + let appliesTo: PluginEnvironmentKind["appliesTo"]; + if (candidate.appliesTo !== undefined) { + if (!Array.isArray(candidate.appliesTo) || candidate.appliesTo.length === 0 + || candidate.appliesTo.some((provider) => !PROVIDER_IDS.has(provider as string))) { + throw new Error(`Plugin environment ${kind} appliesTo must list providers.`); + } + appliesTo = [...new Set(candidate.appliesTo as NonNullable)]; + } + const fields = candidate.fields === undefined ? undefined : validateLaunchFields(candidate.fields); + return { + kind, label, ...(description ? { description } : {}), ...(appliesTo ? { appliesTo } : {}), + ...(fields?.length ? { fields } : {}) + }; + }); +} + +function validateLaunchFields(value: unknown): PluginLaunchField[] { + if (!Array.isArray(value) || value.length > MAX_LAUNCH_FIELDS) { + throw new Error(`Plugin launch fields must be an array of at most ${MAX_LAUNCH_FIELDS} items.`); + } + const keys = new Set(); + return value.map((field): PluginLaunchField => { + if (!isRecord(field)) throw new Error("Every plugin launch field must be an object."); + assertOnlyKeys(field, ["key", "label", "kind", "options", "optionsFrom", "default", "maxLength"], "Plugin launch field"); + const key = requiredString(field.key, "launch field key", 40); + if (!/^[A-Za-z][A-Za-z0-9_]{0,39}$/.test(key) || keys.has(key)) { + throw new Error(`Plugin launch field key is invalid or duplicated: ${key}.`); + } + keys.add(key); + const label = requiredString(field.label, "launch field label", 80); + if (field.optionsFrom !== undefined && (field.kind !== "select" || field.optionsFrom !== "service")) { + throw new Error(`Plugin launch field ${key} optionsFrom must be "service" on a select.`); + } + if (field.kind === "boolean") { + if (field.options !== undefined || field.maxLength !== undefined) throw new Error(`Plugin launch field ${key} has keys its kind does not use.`); + if (field.default !== undefined && typeof field.default !== "boolean") throw new Error(`Plugin launch field ${key} default must be a boolean.`); + return { key, label, kind: "boolean", ...(field.default !== undefined ? { default: field.default } : {}) }; + } + if (field.kind === "select") { + if (field.maxLength !== undefined) throw new Error(`Plugin launch field ${key} has keys its kind does not use.`); + if (!Array.isArray(field.options) || field.options.length === 0 || field.options.length > 16) { + throw new Error(`Plugin launch field ${key} needs 1 to 16 options.`); + } + const options = field.options.map((option) => { + if (!isRecord(option)) throw new Error(`Plugin launch field ${key} options must be objects.`); + assertOnlyKeys(option, ["value", "label"], "Plugin launch option"); + return { + value: requiredString(option.value, "launch option value", MAX_LAUNCH_TEXT), + label: requiredString(option.label, "launch option label", 80) + }; + }); + if (new Set(options.map((option) => option.value)).size !== options.length) throw new Error(`Plugin launch field ${key} repeats an option.`); + if (field.default !== undefined && !options.some((option) => option.value === field.default)) { + throw new Error(`Plugin launch field ${key} default must be one of its options.`); + } + return { + key, label, kind: "select", options, + ...(field.optionsFrom === "service" ? { optionsFrom: "service" as const } : {}), + ...(field.default !== undefined ? { default: field.default as string } : {}) + }; + } + if (field.kind === "text") { + if (field.options !== undefined) throw new Error(`Plugin launch field ${key} has keys its kind does not use.`); + const maxLength = field.maxLength === undefined ? MAX_LAUNCH_TEXT : field.maxLength; + if (!Number.isInteger(maxLength) || (maxLength as number) < 1 || (maxLength as number) > MAX_LAUNCH_TEXT) { + throw new Error(`Plugin launch field ${key} maxLength must be 1 to ${MAX_LAUNCH_TEXT}.`); + } + if (field.default !== undefined && (typeof field.default !== "string" || field.default.length > (maxLength as number))) { + throw new Error(`Plugin launch field ${key} default must be text within maxLength.`); + } + return { + key, label, kind: "text", + ...(field.maxLength !== undefined ? { maxLength: maxLength as number } : {}), + ...(field.default !== undefined ? { default: field.default as string } : {}) + }; + } + throw new Error(`Plugin launch field ${key} kind must be boolean, select or text.`); + }); +} + function validateContribution(value: unknown): PluginContribution { if (!isRecord(value)) throw new Error("Every plugin contribution must be an object."); assertOnlyKeys(value, [ @@ -1315,6 +1758,7 @@ async function assertManifestAssets(root: string, manifest: PluginManifest): Pro if (contribution.icon) await containedFile(root, contribution.icon); } for (const hook of manifest.hooks ?? []) await containedFile(root, hook.entry); + for (const service of manifest.services ?? []) await containedFile(root, service.entry); } async function containedFile(root: string, relativePath: string): Promise { @@ -2107,6 +2551,12 @@ function assertModularContributionFiles(manifest: PluginManifest): void { throw new Error(`Hook entry must belong to its declared module: ${hook.id}.`); } } + for (const service of manifest.services ?? []) { + const available = service.module ? moduleFiles.get(service.module) : coreFiles; + if (!available?.has(service.entry)) { + throw new Error(`Service entry must belong to its declared module: ${service.id}.`); + } + } } async function materializeModularPackage( @@ -2138,8 +2588,9 @@ async function materializeModularPackage( function activeManifest(manifest: PluginManifest, selectedModules: readonly string[]): PluginManifest { const selected = new Set(selectedModules); const contributions = manifest.contributions.filter((contribution) => !contribution.module || selected.has(contribution.module)); - const { settingsContribution, hooks: declaredHooks = [], ...rest } = manifest; + const { settingsContribution, hooks: declaredHooks = [], services: declaredServices = [], ...rest } = manifest; const hooks = declaredHooks.filter((hook) => !hook.module || selected.has(hook.module)); + const services = declaredServices.filter((service) => !service.module || selected.has(service.module)); const permissions = [ ...manifest.permissions, ...(manifest.modules ?? []).filter((module) => selected.has(module.id)).flatMap((module) => module.permissions) @@ -2149,6 +2600,7 @@ function activeManifest(manifest: PluginManifest, selectedModules: readonly stri permissions: [...new Set(permissions)], contributions, ...(hooks.length ? { hooks } : {}), + ...(services.length ? { services } : {}), ...(settingsContribution && contributions.some((item) => item.id === settingsContribution) ? { settingsContribution } : {}) @@ -2387,9 +2839,19 @@ function isStoredRecord(value: unknown): value is StoredPluginRecord { && (value.enabledHooks === undefined || ( Array.isArray(value.enabledHooks) && value.enabledHooks.every((item) => typeof item === "string") )) + && (value.trustedServices === undefined || ( + isRecord(value.trustedServices) + && Object.values(value.trustedServices).every((hash) => typeof hash === "string" && /^[a-f0-9]{64}$/.test(hash)) + )) + && (value.decisionsMayAllow === undefined || typeof value.decisionsMayAllow === "boolean") ); } +function sameServiceTrust(current: Record, stored: Record): boolean { + const ids = Object.keys(current); + return ids.length === Object.keys(stored).length && ids.every((id) => stored[id] === current[id]); +} + function runtimeHookKey(pluginId: string, hookId: string): string { return `${pluginId}:${hookId}`; } @@ -2560,6 +3022,7 @@ const PLUGIN_SDK_SOURCE = `(() => { const pending = new Map(); const listeners = new Set(); const storageListeners = new Set(); + const serviceListeners = new Set(); let nextId = 1; const post = (message) => parent.postMessage({ source: "canvastty-plugin", ...message }, "*"); const request = (method, params = {}) => new Promise((resolve, reject) => { @@ -2581,6 +3044,7 @@ const PLUGIN_SDK_SOURCE = `(() => { if (message.type === "storage-change") { storageListeners.forEach((listener) => listener(message.key, message.value)); } + if (message.type === "service-event") serviceListeners.forEach((listener) => listener(message.value)); }); window.CanvasTTYPlugin = Object.freeze({ ready: () => post({ type: "ready" }), @@ -2613,6 +3077,13 @@ const PLUGIN_SDK_SOURCE = `(() => { open: () => request("hermesHud.open"), close: () => request("hermesHud.close") }), + service: Object.freeze({ + request: (serviceId, method, params) => request("service.request", { serviceId, method, params }), + onEvent: (listener) => { + serviceListeners.add(listener); + return () => serviceListeners.delete(listener); + } + }), onContext: (listener) => { listeners.add(listener); return () => listeners.delete(listener); diff --git a/src/main/services/PluginServiceSupervisor.ts b/src/main/services/PluginServiceSupervisor.ts new file mode 100644 index 00000000..4920ee2a --- /dev/null +++ b/src/main/services/PluginServiceSupervisor.ts @@ -0,0 +1,548 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdir, readFile } from "node:fs/promises"; +import type { + PluginPermission, + PluginServiceLogEntry, + PluginServiceReport, + PluginServiceState, + PluginServiceStatus +} from "../../shared/contracts"; + +/** One trusted service the supervisor should keep running. Built by PluginManager. */ +export interface PluginServiceSpec { + pluginId: string; + serviceId: string; + /** Plugin package root: the process working directory. */ + root: string; + /** Absolute entry path inside `root`. */ + entryPath: string; + /** SHA-256 of the entry recorded when the user trusted it; checked before every start. */ + sha256: string; + /** `/plugin-data/`, created before start and removed on uninstall. */ + dataDir: string; + permissions: readonly PluginPermission[]; +} + +/** Host calls a service may make back. Everything else is rejected. */ +export interface PluginServiceHost { + storageGet(pluginId: string, key: string): Promise; + storageSet(pluginId: string, key: string, value: unknown): Promise; + 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; + /** One of the plugin's own secrets (already checked for `secrets`), or null when it is not set. */ + secretGet?(pluginId: string, key: string): Promise; +} + +export interface PluginServiceSupervisorOptions { + /** Executable that runs JavaScript: Electron's `process.execPath` with ELECTRON_RUN_AS_NODE. */ + command: string; + hostVersion: string; + locale(): string; + host: PluginServiceHost; + /** Environment the minimal child environment is picked from (default `process.env`). */ + environment?: NodeJS.ProcessEnv; + requestTimeoutMs?: number; + stopGraceMs?: number; + restartDelaysMs?: readonly number[]; + maxRestarts?: number; + restartWindowMs?: number; + maxFrameBytes?: number; +} + +const DEFAULT_REQUEST_TIMEOUT_MS = 15_000; +const DEFAULT_STOP_GRACE_MS = 2_000; +const DEFAULT_RESTART_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 16_000]; +const DEFAULT_MAX_RESTARTS = 5; +const DEFAULT_RESTART_WINDOW_MS = 10 * 60_000; +export const PLUGIN_SERVICE_MAX_FRAME_BYTES = 1024 * 1024; +const MAX_PENDING_REQUESTS = 64; +const MAX_LOG_ENTRIES = 300; +const MAX_LOG_MESSAGE = 2_000; +const HOST_METHOD_PREFIX = "canvastty."; + +/** + * Variables a service inherits. Everything else (provider keys, tokens, CANVASTTY_* runtime + * internals, NODE_OPTIONS) is dropped: a service gets what it needs to find system tools, no more. + */ +const INHERITED_ENVIRONMENT = new Set([ + "PATH", "Path", "HOME", "USER", "LOGNAME", "SHELL", "LANG", "LANGUAGE", "TERM", "TZ", + "TMPDIR", "TMP", "TEMP", "SSH_AUTH_SOCK", + "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME", + "SystemRoot", "SYSTEMROOT", "windir", "WINDIR", "ComSpec", "COMSPEC", "PATHEXT", + "USERPROFILE", "APPDATA", "LOCALAPPDATA", "ProgramData", "HOMEDRIVE", "HOMEPATH" +]); + +export function pluginServiceEnvironment(source: NodeJS.ProcessEnv): Record { + const environment: Record = {}; + for (const [name, value] of Object.entries(source)) { + if (typeof value === "string" && (INHERITED_ENVIRONMENT.has(name) || name.startsWith("LC_"))) { + environment[name] = value; + } + } + environment.ELECTRON_RUN_AS_NODE = "1"; + return environment; +} + +interface PendingRequest { + resolve(value: unknown): void; + reject(error: Error): void; + timer: NodeJS.Timeout; +} + +interface ServiceRecord { + spec: PluginServiceSpec; + state: PluginServiceState; + child: ChildProcess | null; + pending: Map; + nextId: number; + stdout: string; + discarding: boolean; + crashes: number[]; + restarts: number; + restartTimer: NodeJS.Timeout | null; + removed: boolean; + exited: Promise | null; + lastError?: string; +} + +/** + * Runs trusted plugin services as separate processes and speaks newline-delimited JSON-RPC 2.0 + * with them over stdio. Plugin code never runs in the Electron main process. + */ +export class PluginServiceSupervisor { + private readonly services = new Map(); + private readonly logs = new Map(); + private readonly options: Required> & { + environment: NodeJS.ProcessEnv; + }; + private syncing = Promise.resolve(); + private disposed = false; + + constructor(options: PluginServiceSupervisorOptions) { + this.options = { + environment: process.env, + requestTimeoutMs: DEFAULT_REQUEST_TIMEOUT_MS, + stopGraceMs: DEFAULT_STOP_GRACE_MS, + restartDelaysMs: DEFAULT_RESTART_DELAYS_MS, + maxRestarts: DEFAULT_MAX_RESTARTS, + restartWindowMs: DEFAULT_RESTART_WINDOW_MS, + maxFrameBytes: PLUGIN_SERVICE_MAX_FRAME_BYTES, + ...options + }; + } + + /** Makes the running set equal to `specs`: stops removed or changed services, starts new ones. */ + sync(specs: readonly PluginServiceSpec[]): Promise { + const next = this.syncing.catch(() => undefined).then(async () => { + const desired = new Map(specs.map((spec) => [serviceKey(spec.pluginId, spec.serviceId), spec])); + const stops: Promise[] = []; + for (const [key, record] of this.services) { + const spec = desired.get(key); + if (!spec || !sameSpec(spec, record.spec) || this.disposed) stops.push(this.remove(key, record)); + } + await Promise.all(stops); + if (this.disposed) return; + for (const [key, spec] of desired) { + if (this.services.has(key)) continue; + const record: ServiceRecord = { + spec, + state: "stopped", + child: null, + pending: new Map(), + nextId: 1, + stdout: "", + discarding: false, + crashes: [], + restarts: 0, + restartTimer: null, + removed: false, + exited: null + }; + this.services.set(key, record); + await this.start(record); + } + }); + this.syncing = next; + return next; + } + + /** Sends a request from the plugin's own UI to its own service. */ + request(pluginId: string, serviceId: string, method: string, params: unknown): Promise { + if (typeof method !== "string" || !/^[A-Za-z0-9_.:/-]{1,80}$/.test(method) || method.startsWith(HOST_METHOD_PREFIX)) { + return Promise.reject(new Error("Plugin service method is invalid.")); + } + return this.send(pluginId, serviceId, method, params, this.options.requestTimeoutMs); + } + + /** A host-initiated call (`canvastty.*`, which plugin surfaces cannot send) with its own time budget. */ + hostCall(pluginId: string, serviceId: string, method: `canvastty.${string}`, params: unknown, timeoutMs: number): Promise { + return this.send(pluginId, serviceId, method, params, Math.min(timeoutMs, this.options.requestTimeoutMs)); + } + + private send(pluginId: string, serviceId: string, method: string, params: unknown, timeoutMs: number): Promise { + const record = this.services.get(serviceKey(pluginId, serviceId)); + if (!record || !record.child || (record.state !== "running" && record.state !== "starting")) { + return Promise.reject(new Error("Plugin service is not running.")); + } + if (record.pending.size >= MAX_PENDING_REQUESTS) { + return Promise.reject(new Error("Plugin service is busy.")); + } + const id = record.nextId++; + let frame: string; + try { + frame = JSON.stringify({ jsonrpc: "2.0", id, method, params: params === undefined ? null : params }); + } catch { + return Promise.reject(new Error("Plugin service request must be JSON serializable.")); + } + if (Buffer.byteLength(frame, "utf8") >= this.options.maxFrameBytes) { + return Promise.reject(new Error("Plugin service request exceeds the 1 MB message limit.")); + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + record.pending.delete(id); + reject(new Error("Plugin service request timed out.")); + }, timeoutMs); + timer.unref(); + record.pending.set(id, { resolve, reject, timer }); + if (!this.write(record, frame)) { + clearTimeout(timer); + record.pending.delete(id); + reject(new Error("Plugin service is not running.")); + } + }); + } + + report(pluginId: string): PluginServiceReport { + const services: PluginServiceStatus[] = []; + for (const record of this.services.values()) { + if (record.spec.pluginId !== pluginId) continue; + services.push({ + serviceId: record.spec.serviceId, + state: record.state, + restarts: record.restarts, + ...(record.lastError ? { lastError: record.lastError } : {}) + }); + } + return { services, log: structuredClone(this.logs.get(pluginId) ?? []) }; + } + + /** Drops the in-memory log of an uninstalled plugin. */ + forget(pluginId: string): void { + this.logs.delete(pluginId); + } + + async dispose(): Promise { + this.disposed = true; + await this.sync([]); + } + + private async start(record: ServiceRecord): Promise { + const { spec } = record; + record.restartTimer = null; + if (record.removed || this.disposed) return; + record.state = "starting"; + try { + const content = await readFile(spec.entryPath); + if (createHash("sha256").update(content).digest("hex") !== spec.sha256) { + // The file changed after the user trusted it: never run it, and do not retry. + this.fail(record, "The service entry changed after it was trusted. Trust the plugin's native code again."); + return; + } + await mkdir(spec.dataDir, { recursive: true, mode: 0o700 }); + } catch (error) { + this.fail(record, `The service could not start: ${errorText(error)}`); + return; + } + if (record.removed || this.disposed) { + record.state = "stopped"; + return; + } + + const child = spawn(this.options.command, [spec.entryPath], { + cwd: spec.root, + env: pluginServiceEnvironment(this.options.environment), + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true + }); + record.child = child; + record.stdout = ""; + record.discarding = false; + record.exited = new Promise((resolve) => { + let settled = false; + const finish = (code: number | null, signal: NodeJS.Signals | null, error?: Error): void => { + if (settled) return; + settled = true; + resolve(); + this.exited(record, child, code, signal, error); + }; + child.once("error", (error) => finish(null, null, error)); + child.once("exit", (code, signal) => finish(code, signal)); + }); + child.once("spawn", () => { + if (record.child === child) record.state = "running"; + this.log(spec, "host", "info", `Started (pid ${child.pid ?? "?"}).`); + }); + child.stdin?.on("error", () => undefined); + child.stdout?.setEncoding("utf8"); + child.stdout?.on("data", (chunk: string) => this.stdout(record, chunk)); + child.stderr?.setEncoding("utf8"); + let stderr = ""; + child.stderr?.on("data", (chunk: string) => { + stderr += chunk; + const lines = stderr.split("\n"); + stderr = lines.pop() ?? ""; + if (stderr.length > MAX_LOG_MESSAGE) { + lines.push(stderr); + stderr = ""; + } + for (const line of lines) if (line.trim()) this.log(spec, "stderr", "warn", line); + }); + this.write(record, JSON.stringify({ + jsonrpc: "2.0", + method: "canvastty.initialize", + params: { + apiVersion: 2, + pluginId: spec.pluginId, + serviceId: spec.serviceId, + dataDir: spec.dataDir, + locale: this.options.locale(), + hostVersion: this.options.hostVersion + } + })); + } + + private exited( + record: ServiceRecord, + child: ChildProcess, + code: number | null, + signal: NodeJS.Signals | null, + error?: Error + ): void { + if (record.child !== child) return; + record.child = null; + for (const [id, pending] of record.pending) { + clearTimeout(pending.timer); + pending.reject(new Error("Plugin service stopped.")); + record.pending.delete(id); + } + if (record.removed || this.disposed) { + record.state = "stopped"; + this.log(record.spec, "host", "info", "Stopped."); + return; + } + const reason = error ? errorText(error) : signal ? `signal ${signal}` : `exit code ${code ?? "?"}`; + const now = Date.now(); + record.crashes = record.crashes.filter((at) => now - at < this.options.restartWindowMs); + record.crashes.push(now); + if (record.crashes.length > this.options.maxRestarts) { + this.fail(record, `The service stopped unexpectedly (${reason}) too often and will not be restarted.`); + return; + } + const delays = this.options.restartDelaysMs; + const delay = delays[Math.min(record.crashes.length - 1, delays.length - 1)] ?? 1_000; + record.state = "backoff"; + record.lastError = `The service stopped unexpectedly (${reason}).`; + this.log(record.spec, "host", "warn", `${record.lastError} Restarting in ${Math.round(delay / 1000)} s.`); + record.restartTimer = setTimeout(() => { + record.restarts += 1; + void this.start(record); + }, delay); + record.restartTimer.unref(); + } + + private fail(record: ServiceRecord, message: string): void { + record.state = "failed"; + record.lastError = message; + this.log(record.spec, "host", "error", message); + } + + private async remove(key: string, record: ServiceRecord): Promise { + record.removed = true; + this.services.delete(key); + if (record.restartTimer) clearTimeout(record.restartTimer); + record.restartTimer = null; + const child = record.child; + if (!child) { + record.state = "stopped"; + return; + } + // Polite first: a shutdown notification and closed stdin, then SIGTERM, then SIGKILL. + this.write(record, JSON.stringify({ jsonrpc: "2.0", method: "canvastty.shutdown", params: {} })); + child.stdin?.end(); + const exited = record.exited ?? Promise.resolve(); + if (await settlesWithin(exited, this.options.stopGraceMs)) return; + child.kill("SIGTERM"); + if (await settlesWithin(exited, this.options.stopGraceMs)) return; + child.kill("SIGKILL"); + await settlesWithin(exited, this.options.stopGraceMs); + } + + private write(record: ServiceRecord, frame: string): boolean { + const stdin = record.child?.stdin; + if (!stdin || stdin.destroyed || !stdin.writable) return false; + stdin.write(`${frame}\n`); + return true; + } + + private stdout(record: ServiceRecord, chunk: string): void { + record.stdout += chunk; + let newline = record.stdout.indexOf("\n"); + while (newline >= 0) { + const line = record.stdout.slice(0, newline); + record.stdout = record.stdout.slice(newline + 1); + if (record.discarding) record.discarding = false; + else if (Buffer.byteLength(line, "utf8") > this.options.maxFrameBytes) this.dropFrame(record); + else this.frame(record, line); + newline = record.stdout.indexOf("\n"); + } + if (Buffer.byteLength(record.stdout, "utf8") > this.options.maxFrameBytes) { + // Skip the rest of an oversized frame up to its newline instead of buffering it. + if (!record.discarding) this.dropFrame(record); + record.discarding = true; + record.stdout = ""; + } + } + + private dropFrame(record: ServiceRecord): void { + this.log(record.spec, "host", "warn", "Dropped a service message larger than 1 MB."); + } + + private frame(record: ServiceRecord, line: string): void { + if (!line.trim()) return; + let message: unknown; + try { + message = JSON.parse(line); + } catch { + this.log(record.spec, "stdout", "info", line); + return; + } + if (!isRecord(message)) return; + const id = message.id; + if (typeof message.method === "string") { + if (typeof id === "number" || typeof id === "string") { + void this.hostRequest(record, message.method, message.params).then( + (result) => this.write(record, JSON.stringify({ jsonrpc: "2.0", id, result: result ?? null })), + (error: unknown) => this.write(record, JSON.stringify({ + jsonrpc: "2.0", + id, + error: { code: error instanceof UnknownMethodError ? -32601 : -32000, message: errorText(error) } + })) + ); + } else { + this.hostNotification(record, message.method, message.params); + } + return; + } + if (typeof id !== "number") return; + const pending = record.pending.get(id); + if (!pending) return; + record.pending.delete(id); + clearTimeout(pending.timer); + if (isRecord(message.error)) { + const text = typeof message.error.message === "string" ? message.error.message : "Plugin service request failed."; + pending.reject(new Error(text.slice(0, 240))); + } else { + pending.resolve(message.result ?? null); + } + } + + /** The complete host API a service can call. Each method is checked against the manifest. */ + private async hostRequest(record: ServiceRecord, method: string, params: unknown): Promise { + const { spec } = record; + const values = isRecord(params) ? params : {}; + if (method === "log") { + this.serviceLog(spec, values); + return null; + } + if (method === "storage.get" || method === "storage.set") { + if (!spec.permissions.includes("storage")) throw new Error("Plugin does not have the storage permission."); + if (typeof values.key !== "string") throw new Error("Plugin storage key is invalid."); + if (method === "storage.get") return this.options.host.storageGet(spec.pluginId, values.key); + await this.options.host.storageSet(spec.pluginId, values.key, values.value); + return null; + } + if (method === "secrets.get" && this.options.host.secretGet) { + // The plugin's own secrets only, to its own trusted native code; never to a web surface of another plugin. + if (!spec.permissions.includes("secrets")) throw new Error("Plugin does not have the secrets permission."); + if (typeof values.key !== "string") throw new Error("Plugin secret key is invalid."); + const value = await this.options.host.secretGet(spec.pluginId, values.key); + // A secret a service read is masked in everything agents read from then on, like launch secrets. + if (typeof value === "string" && value.length >= 8) this.options.host.registerSecrets?.(spec.pluginId, [value]); + return value; + } + if (method === "redaction.register") { + // Only ever hides text; any service may use it. Values stay in memory and are never logged. + if (!Array.isArray(values.values) || values.values.length > 32 + || values.values.some((value) => typeof value !== "string" || value.length > 4_096)) { + throw new Error("Redaction values must be at most 32 strings of up to 4096 characters."); + } + this.options.host.registerSecrets?.(spec.pluginId, values.values as string[]); + return null; + } + throw new UnknownMethodError(`Unknown host method: ${method.slice(0, 80)}.`); + } + + private hostNotification(record: ServiceRecord, method: string, params: unknown): void { + const values = isRecord(params) ? params : {}; + if (method === "log") { + this.serviceLog(record.spec, values); + return; + } + if (method === "event" && typeof values.event === "string" && /^[A-Za-z0-9_.:-]{1,64}$/.test(values.event)) { + this.options.host.emit(record.spec.pluginId, record.spec.serviceId, values.event, values.data ?? null); + } + } + + private serviceLog(spec: PluginServiceSpec, values: Record): void { + const level = values.level === "warn" || values.level === "error" ? values.level : "info"; + const message = typeof values.message === "string" ? values.message : JSON.stringify(values.message ?? ""); + this.log(spec, "service", level, message); + } + + private log( + spec: PluginServiceSpec, + source: PluginServiceLogEntry["source"], + level: PluginServiceLogEntry["level"], + message: string + ): void { + const entries = this.logs.get(spec.pluginId) ?? []; + entries.push({ at: Date.now(), serviceId: spec.serviceId, source, level, message: message.slice(0, MAX_LOG_MESSAGE) }); + if (entries.length > MAX_LOG_ENTRIES) entries.splice(0, entries.length - MAX_LOG_ENTRIES); + this.logs.set(spec.pluginId, entries); + } +} + +class UnknownMethodError extends Error {} + +function serviceKey(pluginId: string, serviceId: string): string { + return `${pluginId}:${serviceId}`; +} + +function sameSpec(left: PluginServiceSpec, right: PluginServiceSpec): boolean { + return left.root === right.root + && left.entryPath === right.entryPath + && left.sha256 === right.sha256 + && left.dataDir === right.dataDir + && left.permissions.length === right.permissions.length + && left.permissions.every((permission) => right.permissions.includes(permission)); +} + +function settlesWithin(promise: Promise, timeoutMs: number): Promise { + return new Promise((resolve) => { + const timer = setTimeout(() => resolve(false), timeoutMs); + void promise.then(() => { + clearTimeout(timer); + resolve(true); + }); + }); +} + +function errorText(error: unknown): string { + return (error instanceof Error ? error.message : String(error)).slice(0, 240); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} diff --git a/src/main/services/ProviderSecretsService.ts b/src/main/services/ProviderSecretsService.ts index 01165e59..70de552c 100644 --- a/src/main/services/ProviderSecretsService.ts +++ b/src/main/services/ProviderSecretsService.ts @@ -14,10 +14,13 @@ export class ProviderSecretsService { private readonly root: string; private write: Promise = Promise.resolve(); private readonly encryption: SecretEncryption; + /** Every value this process reads or writes is handed here, so no agent reads it back through another card. */ + private readonly remember: (values: string[]) => void; - constructor(userDataPath: string, encryption: SecretEncryption) { + constructor(userDataPath: string, encryption: SecretEncryption, remember: (values: string[]) => void = () => undefined) { this.root = join(userDataPath, "provider-secrets.bin"); this.encryption = encryption; + this.remember = remember; } async load(): Promise { @@ -34,6 +37,7 @@ export class ProviderSecretsService { if (typeof value !== "string" || value.length === 0 || Buffer.byteLength(value) > MAX_SECRET_VALUE_BYTES) { throw new Error("Provider secret must be a non-empty string no larger than 16 KB."); } + this.remember([value]); await this.mutate((values) => { values[secretId] = value; }); @@ -94,6 +98,7 @@ export class ProviderSecretsService { if (Buffer.byteLength(plaintext) > MAX_SECRET_PAYLOAD_BYTES) throw new Error("Secret payload is too large."); const candidate: unknown = JSON.parse(plaintext); if (!isSecretRecord(candidate)) throw new Error("Secret payload is invalid."); + this.remember(Object.values(candidate)); return { ...candidate }; } catch { throw new Error("Provider secrets could not be decrypted."); diff --git a/src/main/services/SettingsStore.ts b/src/main/services/SettingsStore.ts index a75a6e3a..dd766bc1 100644 --- a/src/main/services/SettingsStore.ts +++ b/src/main/services/SettingsStore.ts @@ -28,6 +28,7 @@ import type { PluginCanvasInstance, ProviderSecretId, RadialLauncherItemId, + SessionRestoreMode, SessionRowColorMode, ShortcutBindings, StickyNote, @@ -63,13 +64,15 @@ import { } from "../../shared/canvasNavigation.ts"; const LOCALES = new Set(["ru", "en"]); +const SESSION_RESTORE_MODES = new Set(["off", "reopen", "continue"]); const PALETTES = new Set(["sage", "lilac", "night"]); const HOME_ACCENT_PRESETS = new Set(["classic", "warm", "cool", "mono", "custom"]); const SESSION_ROW_COLOR_MODES = new Set(["monochrome", "status"]); const CANVAS_COLORS = new Set(["sage", "lilac", "night", "sand", "mist", "rose", "slate"]); const PATTERNS = new Set(["dots", "grid", "waves", "diagonal", "rings", "none"]); const MEDIA_FITS = new Set(["cover", "contain"]); -const SETTINGS_VERSION = 20; +// 21: the on/off "restoreTerminalSessions" became sessionRestoreMode (off / reopen / continue). +const SETTINGS_VERSION = 21; const GROK_LAUNCHER_SETTINGS_VERSION = 3; const EXPANDED_LIMIT_SETTINGS_VERSION = 5; const QWEN_SETTINGS_VERSION = 6; @@ -156,6 +159,7 @@ export class SettingsStore { || !("radialLauncherItems" in source) || !("radialLauncherEnabled" in source) || !("agentLifecycleHooksEnabled" in source) + || !("baseProtectionEnabled" in source) || !("uiScale" in source) || !("canvasColor" in source) || !("minimapPlacement" in source) @@ -165,7 +169,7 @@ export class SettingsStore { || !("attentionQueueVisible" in source) || !("attentionQueuePlacement" in source) || !("agentControlEnabled" in source) - || !("restoreTerminalSessions" in source) + || !("sessionRestoreMode" in source) || !("persistCanvasRegions" in source) || !("persistStickyNotes" in source) || !("canvasRegions" in source) @@ -299,6 +303,15 @@ export class SettingsStore { } } +/** The old boolean migrates as it behaved: saved windows continued their conversations. */ +function normalizeSessionRestoreMode(source: Record, fallback: SessionRestoreMode | undefined): SessionRestoreMode { + if (SESSION_RESTORE_MODES.has(source.sessionRestoreMode as SessionRestoreMode)) { + return source.sessionRestoreMode as SessionRestoreMode; + } + if (typeof source.restoreTerminalSessions === "boolean") return source.restoreTerminalSessions ? "continue" : "off"; + return fallback ?? "off"; +} + function isLegacyDefaultLimitSelection(candidate: unknown[] | null): boolean { return candidate !== null && candidate.length === LEGACY_LIMIT_PROVIDERS.length @@ -314,7 +327,7 @@ function isPreQwenDefaultSelection(candidate: unknown[] | null function createDefaults(systemLocale: string, platform: CanvasNavigationPlatform): AppSettings { return { locale: systemLocale.toLowerCase().startsWith("ru") ? "ru" : "en", - restoreTerminalSessions: false, + sessionRestoreMode: "off", persistCanvasRegions: true, persistStickyNotes: true, palette: "sage", @@ -327,6 +340,7 @@ function createDefaults(systemLocale: string, platform: CanvasNavigationPlatform radialLauncherItems: [...DEFAULT_RADIAL_LAUNCHER_ITEMS], radialLauncherEnabled: false, agentLifecycleHooksEnabled: true, + baseProtectionEnabled: true, uiScale: DEFAULT_UI_SCALE, canvasColor: "sage", pattern: "dots", @@ -484,9 +498,7 @@ export function normalizeSettings( return { locale: LOCALES.has(source.locale as LocaleId) ? source.locale as LocaleId : fallback.locale, - restoreTerminalSessions: typeof source.restoreTerminalSessions === "boolean" - ? source.restoreTerminalSessions - : fallback.restoreTerminalSessions ?? false, + sessionRestoreMode: normalizeSessionRestoreMode(source as Record, fallback.sessionRestoreMode), persistCanvasRegions: typeof source.persistCanvasRegions === "boolean" ? source.persistCanvasRegions : fallback.persistCanvasRegions ?? true, @@ -511,6 +523,10 @@ export function normalizeSettings( agentLifecycleHooksEnabled: typeof source.agentLifecycleHooksEnabled === "boolean" ? source.agentLifecycleHooksEnabled : fallback.agentLifecycleHooksEnabled, + // On unless the person turned it off; an install from before it existed gets it on. + baseProtectionEnabled: typeof source.baseProtectionEnabled === "boolean" + ? source.baseProtectionEnabled + : fallback.baseProtectionEnabled ?? true, uiScale: normalizeUiScale(source.uiScale, fallback.uiScale ?? DEFAULT_UI_SCALE), canvasColor, pattern: PATTERNS.has(source.pattern as CanvasPatternId) diff --git a/src/main/services/TerminalManager.ts b/src/main/services/TerminalManager.ts index 66e43358..69640dde 100644 --- a/src/main/services/TerminalManager.ts +++ b/src/main/services/TerminalManager.ts @@ -8,10 +8,12 @@ import type { Point, ProviderId, SessionBounds, + SessionEnvironmentChoice, SessionRole, SessionEvent, SessionMetadata, SessionRemovedEvent, + SessionRestoreMode, SessionSnapshot, TerminalBufferSnapshot, TerminalDataEvent @@ -36,7 +38,8 @@ import { AGENT_RUNTIME_ENV, CAPTURE_ANSWER_ENV, CAPTURE_ANSWER_EXPIRES_AT_ENV, - CAPTURE_RESULT_ENV + CAPTURE_RESULT_ENV, + normalizeThreadId } from "../../agent-runtime/runtime-protocol.mjs"; import { CONTROL_CLI_ENV, @@ -45,14 +48,20 @@ import { type ControlConnection } from "./agent-control/controlCapabilities.ts"; import { mergeOpenCodeLaunchEnvironment } from "./agent-runtime/ProviderRuntimeLaunch.ts"; +import { SecretRedactionRegistry } from "./safety/SecretRedaction.ts"; +import type { DecisionSession } from "./DecisionHooks.ts"; import { tryPtyOperation } from "./ptySafety.ts"; import { terminalFailureDetails } from "./terminalFailureDetails.ts"; import { resolveTerminalLaunch } from "./terminalLaunch.ts"; +import { RESERVED_ENV, type LaunchPipeline, type PreparedLaunch } from "./LaunchPipeline.ts"; +import type { EnvironmentRegistry } from "./EnvironmentRegistry.ts"; import { persistedTerminalSession, - type PersistedTerminalSession, + type PersistedEnvironmentRef, + type PersistedSessionExtras, type TerminalSessionStore } from "./TerminalSessionStore.ts"; +import { chooseResume, planSessionRestore, type ResumeRequest, type RestoreStep } from "./sessionRestorePlan.ts"; import type { ProviderCliRegistry, UnavailableProviderCli } from "./providerCliRegistry.ts"; import { createProviderLifecycleParser, @@ -82,14 +91,51 @@ interface ManagedSession { agentOrchestration: PreparedOrchestrationPtyLaunch | null; lifecycle: ProviderLifecycleParser | null; awaitingInitialResize: boolean; - resumeOnLaunch: boolean; + resumeOnLaunch: ResumeRequest; + /** The provider's own conversation id, once its hook reported it (or from the saved record). */ + threadId?: string; captureResult: boolean; + /** Plugin options and environment ref carried into the saved record. */ + extras: PersistedSessionExtras; + /** Bumped per launch attempt, so a late plugin answer never starts a superseded launch. */ + launchToken: number; + /** Removes the current run's plugin files; called when the process exits. */ + launchCleanup: (() => Promise) | null; + /** A restored grok card waits for its grid before launching; plugins still learn it is a restore. */ + restoringLaunch: boolean; + /** The launcher's environment choice until the plugin has prepared it (then `extras.environment`). */ + environmentChoice: SessionEnvironmentChoice | null; + /** The environment was prepared or resumed in this run of the app, so it can be wrapped now. */ + environmentReady: boolean; } +type EnvironmentService = Pick; +type LaunchOutcome = "launched" | "failed" | "superseded"; + +interface PlannedSpawn { + command: string; + args: string[] | string; + cwd: string; + /** The full environment the PTY gets. */ + env: Record; + /** What CanvasTTY and launch contributors set for this launch (without the person's own environment). */ + launchEnvironment: Record; + agentBrowser: PreparedAgentBrowserPtyLaunch | null; + agentRuntime: PreparedAgentRuntimePtyLaunch | null; + agentOrchestration: PreparedOrchestrationPtyLaunch | null; + cleanup(): void; +} +/** Quitting with saving off asks environments to stop compute, but never waits longer than this. */ +const QUIT_RELEASE_TIMEOUT_MS = 3_000; + +type LaunchContribution = Extract; + export interface ProviderLifecycleSignal { kind: "lifecycle"; state: "idle" | "working" | "needs_approval"; requestId?: string; + threadId?: string; } /** @@ -118,8 +164,17 @@ export class TerminalManager { private readonly hiddenSinceOffset = new Map(); private lifecycleHooksEnabled: boolean; private agentOrchestration: OrchestrationLaunchCoordinator | null = null; + private launchPipeline: (Pick & Partial>) | null = null; private sessionStore: TerminalSessionStore | null = null; - private sessionPersistenceEnabled = false; + private sessionRestoreMode: SessionRestoreMode = "off"; + // Without the registry a placed session can only come back stopped: it never runs locally. + private environments: EnvironmentService | null = null; + // Every text an agent reads from another card passes through it (EP-8). + private redaction = new SecretRedactionRegistry(); + // Where each running card was actually started (an environment may move it) and its agent config folder. + private readonly launchContexts = new Map(); + private quitting = false; + private readonly quitReleases: Promise[] = []; private suppressPersistence = false; // The live agent-control descriptor, handed only to orchestrator-role sessions // spawned while it is set; null while the endpoint is off. @@ -149,37 +204,92 @@ export class TerminalManager { this.agentOrchestration = coordinator; } - configureSessionPersistence(store: TerminalSessionStore, enabled: boolean): void { + /** Plugin launch contributors; without them a session with launch options is never launched. */ + configureLaunchPipeline(pipeline: (Pick & Partial>) | null): void { + this.launchPipeline = pipeline; + } + + /** Plugin session environments; without them a placed session is never launched. */ + configureEnvironments(registry: EnvironmentService | null): void { + this.environments = registry; + } + + /** The app-wide redaction registry (vault keys, plugin secrets); cards add their launch secrets to it. */ + configureRedaction(registry: SecretRedactionRegistry): void { + this.redaction = registry; + } + + /** 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; + } + + /** What decision hooks need to know about a running agent card; null for terminals and unknown ids. */ + decisionContext(id: string): DecisionSession | null { + const session = this.sessions.get(id); + if (!session || session.metadata.provider === "terminal") return null; + const launched = this.launchContexts.get(id); + return { + provider: session.metadata.provider, + role: session.metadata.role ?? "agent", + cwd: launched?.cwd ?? session.metadata.cwd, + configDirs: launched?.configDir ? [launched.configDir] : [] + }; + } + + configureSessionPersistence(store: TerminalSessionStore, mode: SessionRestoreMode): void { this.sessionStore = store; - this.sessionPersistenceEnabled = Boolean(enabled); + this.sessionRestoreMode = mode; } async restorePersistedSessions(): Promise { const store = this.sessionStore; if (!store) return; const persisted = await store.load(); - if (!this.sessionPersistenceEnabled) { + if (this.sessionRestoreMode === "off") { if (persisted.length > 0) await store.clear(); return; } - // A subagent whose owning session is gone restores as nothing: its - // parent's runtime state no longer exists to collect its result. - const restorable = persisted.filter((descriptor) => ( - descriptor.role !== "subagent" - || persisted.some((candidate) => candidate.id === descriptor.parentSessionId) - || this.sessions.has(descriptor.parentSessionId ?? "") - )); - for (const descriptor of restorable) this.restorePersistedSession(descriptor); + // Environments resume first; a card whose environment stopped comes back + // stopped with the plugin's reason and never runs locally instead. + const resumed = new Map(); + const environments = this.environments; + if (environments) { + await Promise.all(persisted.map(async (record) => { + if (!record.environment || !record.restore || record.lastState !== "running") return; + if (!environments.available(record.environment)) return; + resumed.set(record.id, await environments.resume(record.environment, record.id)); + })); + } + // Then parents come first; a subagent whose owning session is gone restores + // as nothing, since its parent's runtime state no longer exists. + const steps = planSessionRestore(persisted, this.sessionRestoreMode, { + isLiveSession: (id) => this.sessions.has(id), + environmentAvailable: (environment, record) => resumed.get(record.id)?.ok ?? this.environmentUsable(environment), + launchOptionsAvailable: (options) => this.unavailableLaunchPlugins(options).length === 0 + }); + for (const step of steps) this.restorePersistedSession(step, resumed.get(step.record.id)); await this.persistSessions(); } - async setSessionPersistenceEnabled(enabled: boolean): Promise { - const next = Boolean(enabled); - if (this.sessionPersistenceEnabled === next) return; - this.sessionPersistenceEnabled = next; - if (next) await this.persistSessions(); - else await this.sessionStore?.clear(); + async setSessionRestoreMode(mode: SessionRestoreMode): Promise { + if (this.sessionRestoreMode === mode) return; + this.sessionRestoreMode = mode; + if (mode === "off") await this.sessionStore?.clear(); + else await this.persistSessions(); + } + + /** The per-card "Don't restore this card" choice. */ + setRestore(id: string, restore: boolean): SessionMetadata { + const session = this.sessions.get(id); + if (!session) throw new Error("Terminal session does not exist."); + if (typeof restore !== "boolean") throw new Error("Restore choice is invalid."); + if (restore) delete session.metadata.skipRestore; + else session.metadata.skipRestore = true; + this.emitSession(session.metadata); + this.schedulePersistence(); + return structuredClone(session.metadata); } async shutdown(): Promise { @@ -187,7 +297,10 @@ export class TerminalManager { console.warn("CanvasTTY terminal window state could not be saved during shutdown.", error); }); this.suppressPersistence = true; + // Quitting keeps every environment for the next start; nothing is released as "closed". + this.quitting = true; this.disposeAll(); + await Promise.allSettled(this.quitReleases.splice(0)); if (this.sessionStore) await this.sessionStore.flush().catch(() => undefined); } @@ -249,6 +362,13 @@ export class TerminalManager { throw new Error("Parent terminal session does not exist."); } + const launchOptions = this.launchPipeline + ? this.launchPipeline.normalizeOptions(request.provider, request.launchOptions) + : request.launchOptions === undefined ? undefined : failWith("Plugin launch options are not available."); + const environmentChoice = this.environments + ? this.environments.normalizeChoice(request.provider, request.environment) ?? null + : request.environment === undefined ? null : failWith("Plugin environments are not available."); + const id = randomUUID(); const metadata: SessionMetadata = { id, @@ -269,10 +389,12 @@ export class TerminalManager { }; const awaitMeasuredGrid = request.provider === "grok" && this.providerClis.get(request.provider).state === "available"; - const launched = awaitMeasuredGrid + // 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 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, false, control.captureResult, role, + INITIAL_TERMINAL_COLS, INITIAL_TERMINAL_ROWS, null, control.captureResult, role, control.answerCaptureGrantExpiresAt); if (launched.failure) applyLaunchFailure(metadata, launched.failure); @@ -294,11 +416,18 @@ export class TerminalManager { ? createProviderLifecycleParser(request.provider, request.cwd) : null, awaitingInitialResize: awaitMeasuredGrid, - resumeOnLaunch: false, - captureResult: control.captureResult === true + resumeOnLaunch: null, + captureResult: control.captureResult === true, + extras: launchOptions ? { options: launchOptions } : {}, + launchToken: 0, + launchCleanup: null, + restoringLaunch: false, + environmentChoice, + environmentReady: false }; this.sessions.set(id, session); if (launched.process) this.bindProcess(id, session, launched.process); + if (contributed) this.launchContributed(id, session, null, null, control.answerCaptureGrantExpiresAt); const runtimeStatus = this.agentRuntime?.currentStatus(id); if (runtimeStatus) session.metadata.status = runtimeStatus; @@ -307,10 +436,31 @@ export class TerminalManager { return snapshot(session); } - restart(id: string): SessionSnapshot { + restart(id: string, options: { resume?: boolean } = {}): SessionSnapshot { 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."); + 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 missingPlugins = this.unavailableLaunchPlugins(session.extras.options); + if (missingPlugins.length > 0) throw new Error(`Launch refused: ${missingLaunchPlugins(missingPlugins)}`); + delete session.extras.heldState; + delete session.metadata.restoreNote; + let resume: ResumeRequest = null; + if (options.resume === true && session.metadata.provider !== "terminal") { + const peers = [...this.sessions.values()].filter((candidate) => ( + candidate.metadata.provider === session.metadata.provider && candidate.metadata.cwd === session.metadata.cwd + )).length; + const chosen = chooseResume(session.metadata.provider, session.threadId, peers); + resume = chosen.resume; + if (chosen.note) session.metadata.restoreNote = chosen.note; + } else { + // A plain restart is a new conversation, so the old id must not be resumed later. + delete session.threadId; + } if (session.metadata.provider === "grok") { session.agentBrowser?.cleanup(); @@ -323,16 +473,34 @@ export class TerminalManager { ? createProviderLifecycleParser(session.metadata.provider, session.metadata.cwd) : null; session.awaitingInitialResize = true; - session.resumeOnLaunch = false; + session.resumeOnLaunch = resume; session.metadata.startedAt = Date.now(); session.metadata.status = initialSessionStatus(session.metadata.provider); session.metadata.exitCode = null; session.metadata.failureDetails = null; this.emitSession(session.metadata); + this.schedulePersistence(); return snapshot(session); } session.agentOrchestration?.cleanup(); + if (this.contributed(session)) { + session.process = null; + session.agentBrowser = null; + session.agentRuntime = null; + session.agentOrchestration = null; + session.awaitingInitialResize = false; + session.lifecycle = this.lifecycleHooksEnabled + ? createProviderLifecycleParser(session.metadata.provider, session.metadata.cwd) + : null; + session.metadata.startedAt = Date.now(); + session.metadata.status = initialSessionStatus(session.metadata.provider); + session.metadata.exitCode = null; + session.metadata.failureDetails = null; + this.emitSession(session.metadata); + this.launchContributed(id, session, resume, "user"); + return snapshot(session); + } const launched = this.spawnProcess( id, session.metadata.provider, @@ -340,7 +508,7 @@ export class TerminalManager { session.metadata.cwd, session.cols, session.rows, - false, + resume, session.captureResult, session.metadata.role ); @@ -368,6 +536,7 @@ export class TerminalManager { if (runtimeStatus) session.metadata.status = runtimeStatus; } this.emitSession(session.metadata, failureOrigin); + this.schedulePersistence(); return snapshot(session); } @@ -432,6 +601,12 @@ export class TerminalManager { const session = this.sessions.get(id); if (!this.lifecycleHooksEnabled || !session || session.metadata.status === "done" || session.metadata.status === "failed") return; + const threadId = normalizeThreadId(session.metadata.provider, signal.threadId); + if (threadId && threadId !== session.threadId) { + session.threadId = threadId; + this.schedulePersistence(); + } + const nextStatus = signal.state; if (session.metadata.status === nextStatus) return; session.metadata.status = nextStatus; @@ -505,13 +680,23 @@ export class TerminalManager { } } - dispose(id: string): void { + /** + * 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 { const session = this.sessions.get(id); if (!session) return; this.flushOutput(id, session); this.sessions.delete(id); this.hiddenSinceOffset.delete(id); + this.launchContexts.delete(id); + this.redaction.clear(`session:${id}`); + session.launchToken += 1; + void session.launchCleanup?.().catch(() => undefined); + session.launchCleanup = null; + if (session.extras.options) void this.launchPipeline?.forgetSession(id).catch(() => undefined); session.agentBrowser?.cleanup(); session.agentRuntime?.cleanup(); session.agentOrchestration?.cleanup(); @@ -522,6 +707,17 @@ export class TerminalManager { console.warn(`PTY ${id} could not be killed cleanly.`, error); } } + const environment = session.extras.environment; + if (environment && this.environments) { + if (!this.quitting) { + void this.environments.release(environment, id, { keepData: options.keepEnvironmentData !== false, 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, { + keepData: true, reason: "quit", timeoutMs: QUIT_RELEASE_TIMEOUT_MS + })); + } + } this.emit(IPC.terminalRemoved, { id }); this.schedulePersistence(); } @@ -532,7 +728,8 @@ export class TerminalManager { } } - private restorePersistedSession(descriptor: PersistedTerminalSession): void { + private restorePersistedSession(step: RestoreStep, resumed?: { ok: true } | { ok: false; reason: string }): void { + const descriptor = step.record; if (this.sessions.has(descriptor.id)) return; const metadata: SessionMetadata = { id: descriptor.id, @@ -549,27 +746,59 @@ export class TerminalManager { status: initialSessionStatus(descriptor.provider), startedAt: Date.now(), exitCode: null, - failureDetails: null + failureDetails: null, + ...(step.note ? { restoreNote: step.note } : {}), + ...(descriptor.environment ? { environment: environmentBadge(descriptor.environment) } : {}) + }; + const extras: PersistedSessionExtras = { + ...(descriptor.options ? { options: descriptor.options } : {}), + ...(descriptor.environment ? { environment: descriptor.environment } : {}) }; let process: IPty | null = null; let agentBrowser: PreparedAgentBrowserPtyLaunch | null = null; let agentRuntime: PreparedAgentRuntimePtyLaunch | null = null; let agentOrchestration: PreparedOrchestrationPtyLaunch | null = null; - let directoryReady = true; - try { - assertDirectory(descriptor.cwd); - } catch (error) { - directoryReady = false; - metadata.status = "failed"; - metadata.exitCode = 1; - metadata.failureDetails = error instanceof Error ? error.message : String(error); + let directoryReady = step.launch !== "stopped"; + if (step.launch === "stopped") { + // A finished card comes back as it ended; a placed card whose environment + // is unavailable is held with its reason and keeps its saved state. + if (step.note === "environment-unavailable" && descriptor.environment) { + extras.heldState = descriptor.lastState; + metadata.status = "failed"; + metadata.exitCode = descriptor.exitCode ?? 1; + metadata.failureDetails = resumed && !resumed.ok + ? `Environment stopped: ${resumed.reason}` + : this.environments?.unavailableReason(descriptor.environment) + ?? `Needs plugin ${descriptor.environment.pluginId} (${descriptor.environment.label}). It was not started locally.`; + } else if (step.note === "plugin-unavailable" && descriptor.options) { + extras.heldState = descriptor.lastState; + metadata.status = "failed"; + metadata.exitCode = descriptor.exitCode ?? 1; + metadata.failureDetails = `Launch refused: ${missingLaunchPlugins(this.unavailableLaunchPlugins(descriptor.options))}`; + } else { + metadata.exitCode = descriptor.exitCode ?? (descriptor.lastState === "exited" ? 0 : 1); + metadata.status = metadata.exitCode === 0 ? "done" : "failed"; + } } + if (directoryReady) { + try { + assertDirectory(descriptor.cwd); + } catch (error) { + directoryReady = false; + metadata.status = "failed"; + metadata.exitCode = 1; + metadata.failureDetails = error instanceof Error ? error.message : String(error); + } + } + const resume: ResumeRequest = step.launch === "stopped" ? null : step.launch; const awaitMeasuredGrid = directoryReady && descriptor.provider === "grok" && this.providerClis.get(descriptor.provider).state === "available"; - if (directoryReady && !awaitMeasuredGrid) { + const contributed = directoryReady && !awaitMeasuredGrid + && (Boolean(extras.options) || Boolean(extras.environment) || this.policyApplies(descriptor.provider)); + if (directoryReady && !awaitMeasuredGrid && !contributed) { try { const launched = this.spawnProcess( descriptor.id, @@ -578,7 +807,7 @@ export class TerminalManager { descriptor.cwd, INITIAL_TERMINAL_COLS, INITIAL_TERMINAL_ROWS, - descriptor.provider !== "terminal", + resume, false, descriptor.role ); @@ -594,6 +823,11 @@ export class TerminalManager { } } + // A card that started (or whose plugins are preparing its launch) comes back tied to + // the conversation the plan chose (none for a fresh start); one that did not start + // keeps its recorded id for Continue. + const started = process !== null || awaitMeasuredGrid || contributed; + const threadId = started ? step.threadId : descriptor.threadId; const session: ManagedSession = { metadata, process, @@ -612,11 +846,19 @@ export class TerminalManager { ? createProviderLifecycleParser(descriptor.provider, descriptor.cwd) : null, awaitingInitialResize: awaitMeasuredGrid, - resumeOnLaunch: awaitMeasuredGrid && descriptor.provider !== "terminal", - captureResult: false + resumeOnLaunch: awaitMeasuredGrid ? resume : null, + ...(threadId ? { threadId } : {}), + captureResult: false, + extras, + launchToken: 0, + launchCleanup: null, + restoringLaunch: awaitMeasuredGrid, + environmentChoice: null, + environmentReady: resumed?.ok === true }; this.sessions.set(descriptor.id, session); if (process) this.bindProcess(descriptor.id, session, process); + if (contributed) this.launchContributed(descriptor.id, session, resume, "restore", undefined, true); const runtimeStatus = this.agentRuntime?.currentStatus(descriptor.id); if (runtimeStatus) session.metadata.status = runtimeStatus; // Restoring re-derives a persisted session's status, so a failure here is @@ -627,11 +869,11 @@ export class TerminalManager { } private persistSessions(): Promise { - if (!this.sessionPersistenceEnabled || this.suppressPersistence || !this.sessionStore) { + if (this.sessionRestoreMode === "off" || this.suppressPersistence || !this.sessionStore) { return Promise.resolve(); } return this.sessionStore.replace( - [...this.sessions.values()].map((session) => persistedTerminalSession(session.metadata)) + [...this.sessions.values()].map((session) => persistedTerminalSession(session.metadata, session.threadId, session.extras)) ); } @@ -652,8 +894,13 @@ export class TerminalManager { private launchAwaitingSession(id: string, session: ManagedSession): void { if (!session.awaitingInitialResize) return; session.awaitingInitialResize = false; - const resumePrevious = session.resumeOnLaunch; - session.resumeOnLaunch = false; + const resume = session.resumeOnLaunch; + session.resumeOnLaunch = null; + if (this.contributed(session)) { + this.launchContributed(id, session, resume, null, undefined, session.restoringLaunch); + session.restoringLaunch = false; + return; + } try { const launched = this.spawnProcess( id, @@ -662,7 +909,7 @@ export class TerminalManager { session.metadata.cwd, session.cols, session.rows, - resumePrevious, + resume, session.captureResult, session.metadata.role ); @@ -698,10 +945,11 @@ export class TerminalManager { cwd: string, cols = INITIAL_TERMINAL_COLS, rows = INITIAL_TERMINAL_ROWS, - resumePrevious = false, + resume: ResumeRequest = null, captureResult = false, role: SessionRole = "agent", - answerCaptureGrantExpiresAt?: number + answerCaptureGrantExpiresAt?: number, + contribution: LaunchContribution | null = null ): { process: IPty | null; agentBrowser: PreparedAgentBrowserPtyLaunch | null; @@ -709,10 +957,42 @@ export class TerminalManager { agentOrchestration: PreparedOrchestrationPtyLaunch | null; failure: UnavailableProviderCli | null; } { - const providerCli = provider === "terminal" ? undefined : this.providerClis.get(provider); - if (providerCli?.state === "unavailable") { - return { process: null, agentBrowser: null, agentRuntime: null, agentOrchestration: null, failure: providerCli }; + const planned = this.planSpawn(id, provider, profile, cwd, resume, captureResult, role, answerCaptureGrantExpiresAt, contribution); + if ("failure" in planned) { + return { process: null, agentBrowser: null, agentRuntime: null, agentOrchestration: null, failure: planned.failure }; + } + try { + const process = this.spawnPty(planned.command, planned.args, { + name: "xterm-256color", cols, rows, cwd: planned.cwd, env: planned.env + }); + this.launchContexts.set(id, { cwd: planned.cwd, configDir: planned.env.CLAUDE_CONFIG_DIR ?? null }); + return { + process, + agentBrowser: planned.agentBrowser, + agentRuntime: planned.agentRuntime, + agentOrchestration: planned.agentOrchestration, + failure: null + }; + } catch (error) { + planned.cleanup(); + throw error; } + } + + /** Everything a launch needs short of the PTY, so an environment can wrap it first. */ + private planSpawn( + id: string, + provider: ProviderId, + profile: CreateSessionRequest["profile"], + cwd: string, + resume: ResumeRequest, + captureResult: boolean, + role: SessionRole, + answerCaptureGrantExpiresAt: number | undefined, + contribution: LaunchContribution | null + ): PlannedSpawn | { failure: UnavailableProviderCli } { + const providerCli = provider === "terminal" ? undefined : this.providerClis.get(provider); + if (providerCli?.state === "unavailable") return { failure: providerCli }; const agentRuntime = provider === "terminal" ? null : this.agentRuntime?.prepareLaunch({ terminalSessionId: id, provider, cwd, @@ -722,6 +1002,11 @@ export class TerminalManager { ? this.agentOrchestration.prepareLaunch({ terminalSessionId: id }) : null; let agentBrowser: PreparedAgentBrowserPtyLaunch | null = null; + const cleanup = (): void => { + agentBrowser?.cleanup(); + agentRuntime?.cleanup(); + agentOrchestration?.cleanup(); + }; try { // omp and pi take no browser bridge, exactly like grok: the adapter chain below // ends in the Kimi MCP configuration, which would hand them foreign launch flags. @@ -752,32 +1037,262 @@ export class TerminalManager { const providerArgs = [...(agentRuntime?.args ?? []), ...(agentBrowser?.args ?? [])]; // Stable terminal observations for the CLI controller; leave ordinary launches unchanged. if (captureResult && provider === "codex") providerArgs.push("-c", "tui.animations=false"); + // Plugin arguments follow the core's own and precede the resume selection. + if (contribution) providerArgs.push(...contribution.args); const launch = resolveTerminalLaunch(provider, profile, providerArgs, { environment: { ...baseEnvironment, ...providerEnvironment }, ...(providerCli ? { providerCli } : {}), - resumePrevious + resumePrevious: resume !== null, + ...(resume && typeof resume === "object" ? { resumeThreadId: resume.threadId } : {}) }); + // A plugin may add to the person's environment, never replace what the core sets for this launch. + const contributedEnvironment = contribution?.env ?? {}; + const collision = Object.keys(contributedEnvironment) + .find((key) => key in providerEnvironment || key in (launch.environment ?? {})); + if (collision) { + throw new Error(`Launch refused: ${contribution!.envSources[collision]} sets ${collision}, which CanvasTTY sets for this launch.`); + } + const launchEnvironment = { ...contributedEnvironment, ...providerEnvironment, ...launch.environment }; return { - process: this.spawnPty(launch.command, launch.args, { - name: "xterm-256color", - cols, - rows, - cwd, - env: { ...baseEnvironment, ...providerEnvironment, ...launch.environment } - }), + command: launch.command, + args: launch.args, + cwd, + env: { ...baseEnvironment, ...launchEnvironment }, + launchEnvironment, agentBrowser, agentRuntime, agentOrchestration, - failure: null + cleanup }; } catch (error) { - agentBrowser?.cleanup(); - agentRuntime?.cleanup(); - agentOrchestration?.cleanup(); + cleanup(); throw error; } } + private contributed(session: ManagedSession): boolean { + return Boolean(session.extras.options) || Boolean(session.extras.environment) || Boolean(session.environmentChoice) + || this.policyApplies(session.metadata.provider); + } + + /** A trusted plugin's launch policy applies to this agent: its launches wait for the policy's answer. */ + private policyApplies(provider: ProviderId): boolean { + try { return this.launchPipeline?.hasPolicy?.(provider) === true; } catch { return false; } + } + + private environmentUsable(environment: PersistedEnvironmentRef): boolean { + return this.environments?.available(environment) ?? false; + } + + private unavailableLaunchPlugins(options: Record | undefined): string[] { + if (!options) return []; + return this.launchPipeline ? this.launchPipeline.unavailable(options) : Object.keys(options).sort(); + } + + /** + * Launches through plugins: the environment is prepared (or resumed), the chosen launch services + * contribute, and the environment wraps the command. The card waits until they answer; a refusal, + * timeout, error or conflict leaves it failed with the reason, and nothing runs locally instead. + */ + private launchContributed( + id: string, + session: ManagedSession, + resume: ResumeRequest, + failureOrigin: FailureOrigin | null, + answerCaptureGrantExpiresAt?: number, + restoring = false + ): void { + const token = ++session.launchToken; + const { metadata } = session; + void 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"; + }) + .then((outcome) => { + if (outcome === "superseded" || this.sessions.get(id) !== session || session.launchToken !== token) return; + if (outcome === "failed" && metadata.status !== "failed") { + metadata.status = "failed"; + metadata.exitCode = 1; + } + this.emitSession(metadata, outcome === "failed" ? failureOrigin : null); + this.schedulePersistence(); + }); + } + + private async runContributedLaunch( + id: string, + session: ManagedSession, + token: number, + resume: ResumeRequest, + restoring: boolean, + answerCaptureGrantExpiresAt: number | undefined + ): Promise { + const { metadata } = session; + const live = (): boolean => this.sessions.get(id) === session && session.launchToken === token && !session.process; + 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; + + // 1. Place a new session where the person chose. + const choice = session.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 }); + if (!live()) { + // Closed while it was being prepared: nobody used it, so nothing is kept. + if (placed.ok) void environments.release(placed.environment, id, { keepData: false, reason: "closed" }); + return "superseded"; + } + if (!placed.ok) return refuse(placed.reason); + session.environmentChoice = null; + session.environmentReady = true; + session.extras.environment = placed.environment; + metadata.environment = environmentBadge(placed.environment); + if (placed.cwd && placed.cwd !== metadata.cwd) { + metadata.cwd = placed.cwd; + session.lifecycle = this.lifecycleHooksEnabled + ? createProviderLifecycleParser(metadata.provider, metadata.cwd) + : null; + } + this.schedulePersistence(); + } + + // 2. A saved environment resumes before its first launch in this run. + const environment = session.extras.environment; + if (environment && !session.environmentReady) { + if (!environments?.available(environment)) { + return refuse(environments?.unavailableReason(environment) ?? `needs plugin ${environment.pluginId}; it was not started locally.`); + } + const resumed = await environments.resume(environment, id); + 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; + 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 + }); + if (!live()) { + if (prepared.ok) void prepared.cleanup().catch(() => undefined); + return "superseded"; + } + if (!prepared.ok) return refuse(prepared.reason); + contribution = prepared; + this.addLaunchSecrets(session, prepared.secrets); + } + const dropContribution = (): void => { + void contribution?.cleanup().catch(() => undefined); + }; + + // 4. The host spawns the PTY; an environment only rewrites what is spawned. + let planned: PlannedSpawn | { failure: UnavailableProviderCli }; + try { + planned = this.planSpawn(id, metadata.provider, metadata.profile, metadata.cwd, resume, + session.captureResult, metadata.role, answerCaptureGrantExpiresAt, contribution); + } 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(); + }; + 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."); + } + 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: planned.env.PATH + }); + if (!live()) { + abandon(); + return "superseded"; + } + if (!wrapped.ok) { + abandon(); + return refuse(wrapped.reason); + } + this.addLaunchSecrets(session, wrapped.secrets); + spawn = { command: wrapped.command, args: wrapped.args, cwd: wrapped.cwd, env: { ...planned.env, ...wrapped.env } }; + } + 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(); + metadata.failureDetails = this.redactSecrets(error instanceof Error ? error.message : String(error)); + return "failed"; + } + session.process = process; + 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 { + this.redaction.add(`session:${session.metadata.id}`, secrets); + } + + /** Refreshes the card badge from the plugin (for example the worktree's current branch). */ + private describeEnvironment(id: string, session: ManagedSession, environment: PersistedEnvironmentRef): void { + void this.environments?.describe(environment, id).then((described) => { + if (!described || this.sessions.get(id) !== session || session.extras.environment !== environment) return; + environment.label = described.label; + session.metadata.environment = { ...environmentBadge(environment), ...(described.detail ? { detail: described.detail } : {}) }; + this.emitSession(session.metadata); + this.schedulePersistence(); + }).catch(() => undefined); + } + private bindProcess(id: string, session: ManagedSession, process: IPty): void { process.onData((data) => { const current = this.sessions.get(id); @@ -798,14 +1313,18 @@ export class TerminalManager { current.metadata.status = exitCode === 0 ? "done" : "failed"; current.metadata.failureDetails = exitCode === 0 ? null - : terminalFailureDetails(current.bufferChunks.slice(current.bufferStart).join("")); + : this.redactSecrets(terminalFailureDetails(current.bufferChunks.slice(current.bufferStart).join(""))); 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; this.emitSession(current.metadata); + // Recorded at the moment of exit, so a finished agent is never relaunched. + this.schedulePersistence(); }); } @@ -846,6 +1365,18 @@ export function reachesRenderer(payload: TerminalDataEvent | SessionEvent | Sess return !("audience" in payload) || payload.audience !== "observers"; } +function environmentBadge(environment: PersistedEnvironmentRef): NonNullable { + return { pluginId: environment.pluginId, kind: environment.kind, label: environment.label }; +} + +function missingLaunchPlugins(pluginIds: readonly string[]): string { + return `needs plugin ${pluginIds.join(", ")} for its launch options; it is disabled, removed, or its native code is not trusted.`; +} + +function failWith(message: string): never { + throw new Error(message); +} + function applyLaunchFailure(metadata: SessionMetadata, failure: UnavailableProviderCli): void { metadata.status = "failed"; metadata.exitCode = 127; diff --git a/src/main/services/TerminalSessionStore.ts b/src/main/services/TerminalSessionStore.ts index e8389abc..a70969d1 100644 --- a/src/main/services/TerminalSessionStore.ts +++ b/src/main/services/TerminalSessionStore.ts @@ -8,9 +8,13 @@ import type { SessionMetadata, Size } from "../../shared/contracts.ts"; +import { normalizeThreadId } from "../../agent-runtime/runtime-protocol.mjs"; -export const TERMINAL_SESSION_STORE_VERSION = 1; +export const TERMINAL_SESSION_STORE_VERSION = 2; const MAX_PERSISTED_SESSIONS = 64; +/** Opaque plugin-owned JSON (launch options, environment refs) is capped per value. */ +export const MAX_PLUGIN_SLOT_BYTES = 4_096; +const MAX_OPTION_PLUGINS = 16; const PROVIDERS = new Set([ "terminal", "codex", @@ -40,6 +44,26 @@ export interface PersistedTerminalSession { position: Point; size: Size; parentSessionId?: string; + /** The provider's own conversation id (Codex thread, Claude or OpenCode session) its hook reported. */ + threadId?: string; + /** State at quit or at the moment the process exited; v1 records read as "running". */ + lastState: PersistedLastState; + exitCode?: number | null; + /** False when the person chose "Don't restore this card". */ + restore: boolean; + /** Plugin launch options keyed by plugin id, each opaque and at most 4 KB. */ + options?: Record; + /** Where the session runs when a plugin placed it; opaque to core, at most 4 KB. */ + environment?: PersistedEnvironmentRef; +} + +export type PersistedLastState = "running" | "exited" | "failed"; + +export interface PersistedEnvironmentRef { + pluginId: string; + kind: string; + ref: unknown; + label: string; } interface PersistedTerminalSessionState { @@ -106,7 +130,24 @@ export class TerminalSessionStore { } } -export function persistedTerminalSession(metadata: SessionMetadata): PersistedTerminalSession { +function normalizeStoredThreadId(provider: ProviderId, candidate: unknown): string | undefined { + return typeof candidate === "string" ? normalizeThreadId(provider, candidate.trim()) : undefined; +} + +/** What core keeps beside the live metadata: nothing here is scrollback, prompts or secrets. */ +export type PersistedSessionExtras = Pick & { + /** Overrides the derived state while a card is held stopped (its environment is unavailable). */ + heldState?: PersistedLastState; +}; + +export function persistedTerminalSession( + metadata: SessionMetadata, + threadId?: unknown, + extras: PersistedSessionExtras = {} +): PersistedTerminalSession { + const normalizedThreadId = normalizeStoredThreadId(metadata.provider, threadId); + const lastState: PersistedLastState = extras.heldState + ?? (metadata.exitCode === null ? "running" : metadata.exitCode === 0 ? "exited" : "failed"); return { id: metadata.id, provider: metadata.provider, @@ -117,14 +158,21 @@ export function persistedTerminalSession(metadata: SessionMetadata): PersistedTe cwd: metadata.cwd, position: { ...metadata.position }, size: { ...metadata.size }, - ...(metadata.parentSessionId !== undefined ? { parentSessionId: metadata.parentSessionId } : {}) + ...(metadata.parentSessionId !== undefined ? { parentSessionId: metadata.parentSessionId } : {}), + ...(normalizedThreadId !== undefined ? { threadId: normalizedThreadId } : {}), + lastState, + ...(lastState !== "running" ? { exitCode: metadata.exitCode } : {}), + restore: metadata.skipRestore !== true, + ...(extras.options ? { options: structuredClone(extras.options) } : {}), + ...(extras.environment ? { environment: structuredClone(extras.environment) } : {}) }; } export function normalizePersistedTerminalSessions(candidate: unknown): PersistedTerminalSessionState { if (!candidate || typeof candidate !== "object") return structuredClone(EMPTY_STATE); - const source = candidate as Partial; - if (source.version !== TERMINAL_SESSION_STORE_VERSION || !Array.isArray(source.sessions)) { + const source = candidate as { version?: unknown; sessions?: unknown }; + // v1 is read-compatible: missing v2 fields mean unknown conversation, no environment, running. + if ((source.version !== 1 && source.version !== TERMINAL_SESSION_STORE_VERSION) || !Array.isArray(source.sessions)) { return structuredClone(EMPTY_STATE); } @@ -132,7 +180,8 @@ export function normalizePersistedTerminalSessions(candidate: unknown): Persiste const ids = new Set(); for (const value of source.sessions.slice(0, MAX_PERSISTED_SESSIONS)) { if (!value || typeof value !== "object") continue; - const session = value as Partial; + // codexThreadId: the v1 name of threadId (Codex only). + const session = value as Partial & { codexThreadId?: unknown }; if (!isSessionId(session.id) || ids.has(session.id)) continue; if (!PROVIDERS.has(session.provider as ProviderId)) continue; if (session.profile !== "normal" && session.profile !== "yolo") continue; @@ -150,6 +199,20 @@ export function normalizePersistedTerminalSessions(candidate: unknown): Persiste : undefined; if (session.parentSessionId !== undefined && parentSessionId === undefined) continue; if (role === "subagent" && parentSessionId === undefined) continue; + // A damaged or obsolete conversation ID must not make the whole card disappear. + // It can still restore with Codex's interactive resume picker. + const threadId = normalizeStoredThreadId( + session.provider as ProviderId, + session.threadId ?? (session.provider === "codex" ? session.codexThreadId : undefined) + ); + const lastState: PersistedLastState = session.lastState === "exited" || session.lastState === "failed" + ? session.lastState + : "running"; + const exitCode = Number.isInteger(session.exitCode) ? session.exitCode as number : null; + const options = normalizeOptions(session.options); + const environment = normalizeEnvironment(session.environment); + // A placed session whose ref is unreadable must not come back as a local one. + if (session.environment !== undefined && !environment) continue; sessions.push({ id: session.id, provider: session.provider as ProviderId, @@ -163,13 +226,61 @@ export function normalizePersistedTerminalSessions(candidate: unknown): Persiste width: clamp(session.size.width, 420, 1_600), height: clamp(session.size.height, 260, 1_100) }, - ...(parentSessionId !== undefined ? { parentSessionId } : {}) + ...(parentSessionId !== undefined ? { parentSessionId } : {}), + ...(threadId !== undefined ? { threadId } : {}), + lastState, + ...(lastState !== "running" ? { exitCode } : {}), + restore: session.restore !== false, + ...(options ? { options } : {}), + ...(environment ? { environment } : {}) }); ids.add(session.id); } return { version: TERMINAL_SESSION_STORE_VERSION, sessions }; } +function normalizeOptions(value: unknown): Record | undefined { + if (!isRecord(value)) return undefined; + const options: Record = {}; + for (const [pluginId, entry] of Object.entries(value).slice(0, MAX_OPTION_PLUGINS)) { + if (!isPluginId(pluginId) || !fitsPluginSlot(entry)) continue; + options[pluginId] = structuredClone(entry); + } + return Object.keys(options).length > 0 ? options : undefined; +} + +function normalizeEnvironment(value: unknown): PersistedEnvironmentRef | undefined { + if (!isRecord(value) || !isPluginId(value.pluginId)) return undefined; + if (typeof value.kind !== "string" || !/^[a-z0-9][a-z0-9-]{0,31}$/.test(value.kind)) return undefined; + if (typeof value.label !== "string" || value.label.trim().length === 0) return undefined; + if (value.ref === undefined || !fitsPluginSlot(value.ref)) return undefined; + return { + pluginId: value.pluginId, + kind: value.kind, + ref: structuredClone(value.ref), + label: value.label.trim().slice(0, 80) + }; +} + +/** Same shape PluginManager accepts for plugin ids. */ +function isPluginId(value: unknown): value is string { + return typeof value === "string" && value.length >= 3 && value.length <= 80 + && /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/.test(value) && !value.includes(".."); +} + +function fitsPluginSlot(value: unknown): boolean { + try { + const json = JSON.stringify(value); + return typeof json === "string" && Buffer.byteLength(json, "utf8") <= MAX_PLUGIN_SLOT_BYTES; + } catch { + return false; + } +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + function isSessionId(value: unknown): value is string { return typeof value === "string" && /^[a-zA-Z0-9._-]{1,128}$/.test(value); } diff --git a/src/main/services/agent-browser/OrchestrationTools.ts b/src/main/services/agent-browser/OrchestrationTools.ts index cc7e3cc9..55373658 100644 --- a/src/main/services/agent-browser/OrchestrationTools.ts +++ b/src/main/services/agent-browser/OrchestrationTools.ts @@ -1,6 +1,6 @@ import type { OrchestrationCommandHandler, OrchestrationRequest } from "./orchestration-protocol.ts"; import { orchestrationBridgeError } from "./orchestration-protocol.ts"; -import type { AgentControlService } from "../AgentControlService.ts"; +import type { AgentControlService, SpawnAgentRequest } from "../AgentControlService.ts"; /** * The only bridge between the orchestration MCP surface and session control. @@ -49,7 +49,8 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { provider: args.provider as never, cwd: args.cwd as string, ...(args.title !== undefined ? { title: args.title as string } : {}), - ...(args.prompt !== undefined ? { initialPrompt: args.prompt as string } : {}) + ...(args.prompt !== undefined ? { initialPrompt: args.prompt as string } : {}), + ...(args.launchOptions !== undefined ? { launchOptions: args.launchOptions as SpawnAgentRequest["launchOptions"] } : {}) }); return { sessionId: created.id, diff --git a/src/main/services/agent-control/AgentControlGateway.ts b/src/main/services/agent-control/AgentControlGateway.ts index b8c37e9c..96187efc 100644 --- a/src/main/services/agent-control/AgentControlGateway.ts +++ b/src/main/services/agent-control/AgentControlGateway.ts @@ -24,6 +24,8 @@ interface TerminalPort { readBuffer(id: string): TerminalBufferSnapshot; inputChecked(id: string, text: string): boolean; geometry(id: string): { cols: number; rows: number }; + /** Masks plugin launch secrets in text handed to a controller. */ + redactSecrets?(text: string): string; } interface ControlRequest { @@ -268,7 +270,7 @@ export class AgentControlGateway { fields(params, []); return { sessions: this.options.terminals.listMetadata().filter((s) => { const owned = this.sessions.get(s.id); return owned?.owner === owner && owned.startedAt === s.startedAt; - }).map((s) => ({ ...s, capabilities: controlCapabilities(s.provider) })) }; + }).map((s) => ({ ...this.redactMetadata(s), capabilities: controlCapabilities(s.provider) })) }; } fields(params, request.method === "send" ? ["sessionId", "text"] : request.method === "result" ? ["sessionId", "after"] : request.method === "choose" ? ["sessionId", "choice", "revision"] @@ -278,14 +280,14 @@ export class AgentControlGateway { const metadata = this.options.terminals.listMetadata().find((s) => s.id === id); if (!owned || owned.owner !== owner || !metadata) throw new ControlError("SESSION_NOT_FOUND", "No owned session has that ID."); if (metadata.startedAt !== owned.startedAt) throw new ControlError("STALE_SESSION", "Session restarted; its old control grant is no longer valid."); - if (request.method === "status") return { session: metadata, resultRevision: owned.resultRevision, + if (request.method === "status") return { session: this.redactMetadata(metadata), resultRevision: owned.resultRevision, turn: owned.turn ? { id: owned.turn.id, state: owned.turn.state } : null }; if (request.method === "result") { const after = params.after === undefined ? 0 : params.after; if (!Number.isSafeInteger(after) || Number(after) < 0) throw new ControlError("INVALID_PARAMS", "after must be a non-negative result revision."); const fresh = owned.resultRevision > Number(after); - return { session: metadata, resultRevision: owned.resultRevision, fresh, - turn: fresh ? owned.completedTurn : null }; + return { session: this.redactMetadata(metadata), resultRevision: owned.resultRevision, fresh, + turn: fresh && owned.completedTurn ? this.redactTurn(owned.completedTurn) : null }; } await owned.ready; if (this.closed) throw new ControlError("CLOSED", "Agent control is shutting down."); @@ -297,7 +299,7 @@ export class AgentControlGateway { throw new ControlError("STALE_SESSION", "Session restarted; its old control grant is no longer valid."); } const capabilities = controlCapabilities(fresh.provider); - const screen = viewport(owned.terminal); + const screen = this.redact(viewport(owned.terminal)); if (request.method === "screen") return { sessionId: id, text: screen, revision: hash(screen), outputOffset: owned.outputOffset, interaction: capabilities.menus ? codexChoices(screen) : null }; if ((request.method === "choose" || request.method === "dismiss") && !capabilities.menus) { @@ -352,6 +354,19 @@ export class AgentControlGateway { return { sessionId: id, turnId: request.id, resultRevisionBefore: owned.resultRevision, delivery: "written-to-pty" }; } finally { this.busy.delete(id); } } + + private redact(text: string): string { + return this.options.terminals.redactSecrets?.(text) ?? text; + } + + /** Failure details quote the child's last output: masked like the screen. */ + private redactMetadata(metadata: T): T { + return metadata.failureDetails ? { ...metadata, failureDetails: this.redact(metadata.failureDetails) } : metadata; + } + + private redactTurn(turn: Turn): Turn { + return turn.result ? { ...turn, result: { ...turn.result, text: this.redact(turn.result.text) } } : turn; + } } export function codexComposerReady(screen: string): boolean { diff --git a/src/main/services/agent-runtime/AgentRuntimeBridge.ts b/src/main/services/agent-runtime/AgentRuntimeBridge.ts index 33a073dd..753ba4ca 100644 --- a/src/main/services/agent-runtime/AgentRuntimeBridge.ts +++ b/src/main/services/agent-runtime/AgentRuntimeBridge.ts @@ -18,11 +18,15 @@ export interface PrepareAgentRuntimeLaunchInput { captureResult?: boolean; /** Owner-issued, per-session answer-capture grant expiry (Unix milliseconds). */ answerCaptureGrantExpiresAt?: number; + /** Install the decision hook (base protection and plugin decisions); by default `wantsDecisions` says. */ + decisions?: boolean; } export interface PreparedAgentRuntimePtyLaunch { args: string[]; environment: Record; + /** The decision hook was installed (the provider has one and the gateway runs). */ + decisions?: boolean; cleanup(): void; } @@ -34,41 +38,58 @@ export interface AgentRuntimeLaunchCoordinator { export interface AgentRuntimeBridgeOptions extends ProviderRuntimeLaunchOptions { recoverOnStart?: boolean; coreHooksEnabled?: boolean; + /** Whether a launch of this agent needs the decision hook (base protection on, or a decision plugin applies). */ + wantsDecisions?(provider: Exclude): boolean; + /** The longest decision budget for this agent (ms); the session's gate deadlines are sized from it. */ + decisionBudgetMs?(provider: Exclude): number; } export class AgentRuntimeBridge implements AgentRuntimeLaunchCoordinator { private readonly gateway: RuntimeGateway; private readonly providers: ProviderRuntimeLaunchAdapters; - private readonly activeSessions = new Set(); + /** Running sessions and whether each got the decision hook. */ + private readonly activeSessions = new Map(); private coreHooksEnabled: boolean; + private readonly wantsDecisions: AgentRuntimeBridgeOptions["wantsDecisions"]; + private readonly decisionBudgetMs: AgentRuntimeBridgeOptions["decisionBudgetMs"]; constructor(gateway: RuntimeGateway, options: AgentRuntimeBridgeOptions) { this.gateway = gateway; + this.wantsDecisions = options.wantsDecisions; + this.decisionBudgetMs = options.decisionBudgetMs; this.providers = new ProviderRuntimeLaunchAdapters(options); this.coreHooksEnabled = options.coreHooksEnabled !== false; if (options.recoverOnStart) this.providers.recoverConfigurations(); } prepareLaunch(input: PrepareAgentRuntimeLaunchInput): PreparedAgentRuntimePtyLaunch { - const capability = this.coreHooksEnabled + // 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 ? this.gateway.registerSession( input.terminalSessionId, input.provider, input.captureResult === true, - isLiveGrant(input.answerCaptureGrantExpiresAt) ? input.answerCaptureGrantExpiresAt : undefined + isLiveGrant(input.answerCaptureGrantExpiresAt) ? input.answerCaptureGrantExpiresAt : undefined, + decisions, + budgetMs ) : null; let prepared; try { - prepared = this.providers.prepare(input.provider, input.terminalSessionId, this.coreHooksEnabled); + prepared = this.providers.prepare(input.provider, input.terminalSessionId, this.coreHooksEnabled, decisions, budgetMs); } catch (error) { if (capability) this.gateway.revokeTerminalSession(input.terminalSessionId); throw error; } - this.activeSessions.add(input.terminalSessionId); + this.activeSessions.set(input.terminalSessionId, decisions); let cleaned = false; return { args: prepared.args, + decisions, environment: { ...prepared.environment, ...(input.captureResult ? { [CAPTURE_RESULT_ENV]: "1" } : {}), @@ -105,8 +126,9 @@ export class AgentRuntimeBridge implements AgentRuntimeLaunchCoordinator { if (this.coreHooksEnabled === next) return; this.coreHooksEnabled = next; if (next) return; - for (const terminalSessionId of this.activeSessions) { - this.gateway.revokeTerminalSession(terminalSessionId); + // 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); } } } diff --git a/src/main/services/agent-runtime/ProviderRuntimeLaunch.ts b/src/main/services/agent-runtime/ProviderRuntimeLaunch.ts index a5f798bd..8ad3d8c9 100644 --- a/src/main/services/agent-runtime/ProviderRuntimeLaunch.ts +++ b/src/main/services/agent-runtime/ProviderRuntimeLaunch.ts @@ -16,6 +16,7 @@ import { dirname, isAbsolute, join, win32 } from "node:path"; import { pathToFileURL } from "node:url"; import { parseDocument } from "yaml"; import type { PluginAgentHookEvent, ProviderId } from "../../../shared/contracts.ts"; +import { DECISION_BUDGET_ENV, OPENCODE_DECISIONS_ENV, permissionGateTimings } from "../../../agent-runtime/runtime-protocol.mjs"; const FILE_MODE = 0o600; const DIRECTORY_MODE = 0o700; @@ -76,6 +77,8 @@ export interface ProviderRuntimeLaunchOptions { environment?: Readonly>; platform?: NodeJS.Platform; pluginHooks?: RuntimePluginHookSource; + /** The decision hook (permission-gate.mjs); without it decision hooks are never installed. */ + permissionGate?: RuntimeHookHelperLaunch; } export interface PreparedProviderRuntimeLaunch { @@ -112,6 +115,12 @@ export class ProviderRuntimeLaunchAdapters { if (!isAbsolute(options.openCodePluginPath)) { throw new Error("OpenCode lifecycle plugin path must be absolute."); } + if (options.permissionGate) { + validateHelper(options.permissionGate); + if (options.permissionGate.args.length !== 1 || !isAbsolute(options.permissionGate.args[0])) { + throw new Error("Permission gate must reference one absolute script path."); + } + } if (options.pluginHooks) { validateHelper(options.pluginHooks.runner); if (!isAbsolute(options.pluginHooks.registryPath)) { @@ -143,17 +152,29 @@ export class ProviderRuntimeLaunchAdapters { ); } + /** + * `decisions` adds the decision hook: PreToolUse for Claude Code, Codex and Qwen Code, the CanvasTTY plugin's + * guard for OpenCode. Without it the arguments are exactly what they were before decision hooks existed. + * `decisionBudgetMs` (a decision service's `decide.timeoutMs`) lengthens the hook's deadlines to fit it. + */ prepare( provider: AgentProvider, terminalSessionId: string, - coreHooksEnabled = true + coreHooksEnabled = true, + decisions = false, + decisionBudgetMs?: number ): PreparedProviderRuntimeLaunch { const pluginRegistrations = this.options.pluginHooks?.list(provider) ?? []; - const pluginCommands = this.pluginHookCommands(provider, pluginRegistrations); + const gate = decisions && this.decisionsSupported(provider); + const pluginCommands = [ + ...this.pluginHookCommands(provider, pluginRegistrations), + ...(gate && provider !== "opencode" ? decisionHookCommands(provider as DecisionHookProvider, this.options.permissionGate!, this.platform, decisionBudgetMs) : []) + ]; + const openCodeDecisions = gate && provider === "opencode"; // Only providers with a hook adapter get lifecycle configuration. Anything else (omp, pi, // cursor, minimax, devin, antigravity) must never reach Grok's shared hook overlay. const hasHooks = HOOK_PROVIDERS.has(provider) - && (coreHooksEnabled || pluginCommands.length > 0 || (provider === "opencode" && pluginRegistrations.length > 0)); + && (coreHooksEnabled || pluginCommands.length > 0 || (provider === "opencode" && (pluginRegistrations.length > 0 || openCodeDecisions))); const environment = hasHooks ? { ...(pluginRegistrations.length > 0 ? { @@ -191,6 +212,7 @@ export class ProviderRuntimeLaunchAdapters { return prepared([], { ...environment, ...pluginEnvironment, + ...(openCodeDecisions ? { [OPENCODE_DECISIONS_ENV]: "1", ...budgetEnvironment(decisionBudgetMs) } : {}), [OPENCODE_CONFIG_CONTENT]: openCodeLifecycleConfig( this.environment[OPENCODE_CONFIG_CONTENT], this.options.openCodePluginPath @@ -308,6 +330,11 @@ export class ProviderRuntimeLaunchAdapters { }); } + /** Whether this provider can take the decision hook: Claude Code, Codex, Qwen Code (PreToolUse) and OpenCode. */ + decisionsSupported(provider: AgentProvider): boolean { + return Boolean(this.options.permissionGate) && (provider === "opencode" || Object.hasOwn(DECISION_TOOL_MATCHERS, provider)); + } + private pluginHookCommands( provider: AgentProvider, registrations: readonly RuntimePluginHookRegistration[] @@ -483,6 +510,41 @@ const PLUGIN_HOOK_TRIGGERS: Record> = { + claude: "Bash|Write|Edit|MultiEdit|NotebookEdit", + codex: "Bash|apply_patch|Edit|Write", + qwen: "^(run_shell_command|write_file|edit|replace)$" +}; + +export function decisionHookCommands( + provider: DecisionHookProvider, + gate: RuntimeHookHelperLaunch, + platform: NodeJS.Platform, + decisionBudgetMs?: number +): ProviderHookCommand[] { + validateHelper(gate); + const { hookSeconds } = permissionGateTimings(decisionBudgetMs); + return [{ + event: "PreToolUse", + matcher: DECISION_TOOL_MATCHERS[provider], + command: commandWithEnvironment([gate.command, ...gate.args, "pretool"], { ...(gate.env ?? {}), ...budgetEnvironment(decisionBudgetMs) }, platform), + // Qwen hook timeouts are milliseconds; Claude's and Codex's are seconds. + timeout: provider === "qwen" ? hookSeconds * 1_000 : hookSeconds + }]; +} + +/** The helper learns a longer decision budget from its environment; the default budget adds nothing. */ +function budgetEnvironment(decisionBudgetMs: number | undefined): Record { + const { budgetMs } = permissionGateTimings(decisionBudgetMs); + return decisionBudgetMs === undefined || budgetMs === permissionGateTimings().budgetMs ? {} : { [DECISION_BUDGET_ENV]: String(budgetMs) }; +} + export function claudeLifecycleArgs( helper: RuntimeHookHelperLaunch, platform: NodeJS.Platform = process.platform diff --git a/src/main/services/agent-runtime/RuntimeGateway.ts b/src/main/services/agent-runtime/RuntimeGateway.ts index a575cd75..8b2b7463 100644 --- a/src/main/services/agent-runtime/RuntimeGateway.ts +++ b/src/main/services/agent-runtime/RuntimeGateway.ts @@ -9,6 +9,9 @@ import { MAX_ANSWER_CHARS, MAX_RUNTIME_MESSAGE_BYTES, MAX_RESULT_CHARS, + normalizeThreadId, + PERMISSION_GATE, + permissionGateTimings, RUNTIME_PROTOCOL_VERSION, RUNTIME_STATES } from "../../../agent-runtime/runtime-protocol.mjs"; @@ -22,6 +25,10 @@ const AGENT_PROVIDERS = new Set([ "codex", "claude", "qwen", "kimi", "opencode", "hermes", "grok", "omp", "pi", "cursor", "minimax", "devin", "antigravity" ]); const MAX_RUNTIME_SESSIONS = 32; +/** Decision checks in flight, per session and in total; over a cap the call is refused with advice to slow down. */ +const MAX_DECISIONS_PER_SESSION = 8; +const MAX_DECISIONS_TOTAL = 32; +const OVERLOADED_MESSAGE = "CanvasTTY is checking too many tool calls from this session at once. Wait a few seconds and run the command again, one at a time."; export type RuntimeLifecycleState = "idle" | "working" | "needs_approval"; @@ -29,6 +36,8 @@ export interface RuntimeLifecycleSignal { state: RuntimeLifecycleState; event: string; turnId: string | null; + /** 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; answerCaptureGrantExpiresAt?: number; @@ -49,6 +58,31 @@ interface RuntimeLease { latest: RuntimeLifecycleSignal | null; captureResult: boolean; answerCaptureGrantExpiresAt: number | null; + /** Launched with decision hooks; otherwise permission requests are refused. */ + decisions: boolean; + /** How long a decision may take for this session (sized at launch from the decision services' budgets). */ + gatewayMs: number; + checks: Set; +} + +/** One decision hook call (permission-gate.mjs). The tool input is agent-influenced data, never instructions. */ +export interface RuntimePermissionRequest { + requestId: string; + provider: Exclude; + toolName: string; + /** The whole tool input, or null when it was over the bound (then `truncated`, and only a preview). */ + toolInput: unknown; + toolInputPreview: string | null; + toolInputSha256: string; + truncated: boolean; + /** The agent's current folder as its CLI reported it. */ + cwd: string | null; +} + +/** `none`: no opinion, the CLI goes on as it would without CanvasTTY. */ +export interface RuntimePermissionDecision { + behavior: "allow" | "deny" | "ask" | "none"; + message?: string; } interface ParsedLifecycleMessage { @@ -58,6 +92,7 @@ interface ParsedLifecycleMessage { state: RuntimeLifecycleState; event: string; turnId: string | null; + threadId?: string; result?: { text: string; truncated: boolean }; lastAssistantMessage?: string; } @@ -69,6 +104,11 @@ export interface RuntimeGatewayOptions { windowsPipeHostFactory?: (options: WindowsPipeHostTransportOptions) => WindowsPipeHostTransport; onSignal?(terminalSessionId: string, signal: RuntimeLifecycleSignal): void; onAnswerCaptureRevoked?(terminalSessionId: string): void; + /** + * Decision hooks: answers one tool call. `signal` aborts when the hook's socket closes, the session is revoked or + * the gateway's deadline passes; the answer is then `ask`. + */ + onPermissionRequest?(terminalSessionId: string, request: RuntimePermissionRequest, signal: AbortSignal): Promise | RuntimePermissionDecision; now?: () => number; } @@ -79,7 +119,9 @@ export class RuntimeGateway { private readonly windowsPipeHostFactory: (options: WindowsPipeHostTransportOptions) => WindowsPipeHostTransport; private readonly onSignal: RuntimeGatewayOptions["onSignal"]; private readonly onAnswerCaptureRevoked: RuntimeGatewayOptions["onAnswerCaptureRevoked"]; + private readonly onPermissionRequest: RuntimeGatewayOptions["onPermissionRequest"]; private readonly now: () => number; + private readonly checks = new Set(); private readonly leases = new Map(); private readonly sockets = new Set(); private server: Server | null = null; @@ -95,6 +137,7 @@ export class RuntimeGateway { ?? ((transportOptions) => new WindowsPipeHostTransport(transportOptions)); this.onSignal = options.onSignal; this.onAnswerCaptureRevoked = options.onAnswerCaptureRevoked; + this.onPermissionRequest = options.onPermissionRequest; this.now = options.now ?? Date.now; } @@ -143,7 +186,9 @@ export class RuntimeGateway { terminalSessionId: string, provider: Exclude, captureResultOrGrantExpiresAt: boolean | number = false, - answerCaptureGrantExpiresAt?: number + answerCaptureGrantExpiresAt?: number, + decisions = false, + decisionBudgetMs?: number ): RuntimeSessionCapability { if (!this.endpoint || (!this.server && !this.windowsTransport?.isRunning)) { throw new Error("Agent runtime gateway must be started before launching agents."); @@ -170,7 +215,10 @@ export class RuntimeGateway { && Number.isFinite(grantExpiresAt) && grantExpiresAt > this.now() ? grantExpiresAt - : null + : null, + decisions, + gatewayMs: permissionGateTimings(decisionBudgetMs).gatewayMs, + checks: new Set() }); return { address: this.endpoint, terminalSessionId, provider, capabilityToken }; } @@ -184,6 +232,7 @@ export class RuntimeGateway { if (!lease) return; lease.tokenDigest.fill(0); this.leases.delete(terminalSessionId); + for (const check of lease.checks) check.abort(); if (lease.answerCaptureGrantExpiresAt !== null) { this.onAnswerCaptureRevoked?.(terminalSessionId); } @@ -194,6 +243,7 @@ export class RuntimeGateway { this.sockets.clear(); for (const lease of this.leases.values()) { lease.tokenDigest.fill(0); + for (const check of lease.checks) check.abort(); if (lease.answerCaptureGrantExpiresAt !== null) { this.onAnswerCaptureRevoked?.(lease.terminalSessionId); } @@ -235,6 +285,8 @@ export class RuntimeGateway { handled = true; try { const value: unknown = JSON.parse(pending.subarray(0, newline).toString("utf8")); + // Decision hooks keep the socket open for the answer; every other message is unchanged. + if (isPermissionRequest(value)) return this.acceptPermission(socket, value, close); if (isAnswerCaptureCheck(value)) { const answerCapture = this.answerCaptureIsActive(value); socket.write(Buffer.from(`${JSON.stringify({ @@ -314,6 +366,7 @@ export class RuntimeGateway { state: message.state, event: message.event, turnId: message.turnId, + ...(message.threadId === undefined ? {} : { threadId: message.threadId }), ...(message.result === undefined ? {} : { result: message.result }), ...(message.lastAssistantMessage === undefined ? {} : { lastAssistantMessage: message.lastAssistantMessage }) }; @@ -321,9 +374,119 @@ export class RuntimeGateway { signal.answerCaptureGrantExpiresAt = lease.answerCaptureGrantExpiresAt; } // Captured text is delivered once and never stored in the lifecycle lease. - lease.latest = { state: signal.state, event: signal.event, turnId: signal.turnId }; + lease.latest = { + state: signal.state, + event: signal.event, + turnId: signal.turnId, + ...(signal.threadId === undefined ? {} : { threadId: signal.threadId }) + }; this.onSignal?.(message.terminalSessionId, signal); } + + /** + * One decision hook call. Authenticated exactly like a lifecycle message, and only for a session launched with + * decision hooks. The socket stays open until the answer; a closed socket, a revoke or the gateway deadline + * aborts the check, and the answer is then `ask`. Checks are capped per session and in total; over a cap the + * call is refused with a message asking the model to slow down (a flood must not slip past the rules). + */ + private acceptPermission(socket: AgentGatewaySocket, value: Record, close: () => void): void { + let request: RuntimePermissionRequest & { terminalSessionId: string; capabilityToken: string }; + try { + request = parsePermissionMessage(value); + } catch { + return close(); + } + const lease = this.leases.get(request.terminalSessionId); + if (!lease || lease.provider !== request.provider) return close(); + const supplied = digest(request.capabilityToken); + const valid = supplied.length === lease.tokenDigest.length && timingSafeEqual(supplied, lease.tokenDigest); + supplied.fill(0); + if (!valid || !lease.decisions) return close(); + const { terminalSessionId, capabilityToken: _token, ...forwarded } = request; + let answered = false; + const answer = (decision: RuntimePermissionDecision): void => { + if (answered) return; + answered = true; + const line = { + v: RUNTIME_PROTOCOL_VERSION, + type: "permission_decision", + requestId: request.requestId, + behavior: decision.behavior, + ...(decision.message ? { message: decision.message } : {}) + }; + try { + socket.write(Buffer.from(`${JSON.stringify(line)}\n`, "utf8")); + } catch { /* the hook is gone: the CLI goes on without an answer */ } + const timeout = setTimeout(close, 1_000); + timeout.unref(); + }; + if (lease.checks.size >= MAX_DECISIONS_PER_SESSION || this.checks.size >= MAX_DECISIONS_TOTAL) { + return answer({ behavior: "deny", message: OVERLOADED_MESSAGE }); + } + const controller = new AbortController(); + lease.checks.add(controller); + this.checks.add(controller); + const deadline = setTimeout(() => controller.abort(), lease.gatewayMs); + deadline.unref(); + const settle = (decision: RuntimePermissionDecision): void => { + clearTimeout(deadline); + lease.checks.delete(controller); + this.checks.delete(controller); + answer(controller.signal.aborted ? { behavior: "ask" } : enforceDecision(forwarded, decision)); + }; + controller.signal.addEventListener("abort", () => settle({ behavior: "ask" }), { once: true }); + socket.on("close", () => controller.abort()); + const handler = this.onPermissionRequest; + if (!handler) return settle({ behavior: "none" }); + let pendingAnswer: Promise; + try { + pendingAnswer = Promise.resolve(handler(terminalSessionId, forwarded, controller.signal)); + } catch { + return settle({ behavior: "ask" }); + } + pendingAnswer.then(settle, () => settle({ behavior: "ask" })); + } +} + +/** What leaves the gateway, whatever the handler said: never an allow of cut input. */ +function enforceDecision(request: RuntimePermissionRequest, decision: RuntimePermissionDecision): RuntimePermissionDecision { + if (!decision || !["allow", "deny", "ask", "none"].includes(decision.behavior)) return { behavior: "ask" }; + if (decision.behavior === "allow" && request.truncated) return { behavior: "ask" }; + const message = typeof decision.message === "string" ? decision.message.slice(0, PERMISSION_GATE.messageChars) : ""; + return { behavior: decision.behavior, ...(message ? { message } : {}) }; +} + +function isPermissionRequest(value: unknown): value is Record { + return isRecord(value) && value.type === "permission_request"; +} + +const PERMISSION_KEYS = [ + "capabilityToken", "cwd", "provider", "requestId", "terminalSessionId", "toolInput", + "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."); + 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 + || typeof value.provider !== "string" || !AGENT_PROVIDERS.has(value.provider as ProviderId) + || typeof value.capabilityToken !== "string" || value.capabilityToken.length < 32 + || typeof value.requestId !== "string" || !/^[A-Za-z0-9-]{8,80}$/u.test(value.requestId) + || typeof value.toolName !== "string" || !value.toolName || value.toolName.length > PERMISSION_GATE.toolNameChars + || 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.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. + if (value.truncated ? value.toolInput !== null || value.toolInputPreview === null : value.toolInputPreview !== null) { + throw new Error("Permission request input is inconsistent."); + } + 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 }; } function isAnswerCaptureCheck(value: unknown): value is Record { @@ -337,6 +500,7 @@ function parseLifecycleMessage(value: unknown): ParsedLifecycleMessage { "capabilityToken", "event", "provider", "state", "terminalSessionId", "turnId", "type", "v" ]; if (value.result !== undefined) expected.push("result"); + if (value.threadId !== undefined) expected.push("threadId"); expected.sort(); if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) { throw new Error("Runtime message has an invalid schema."); @@ -359,6 +523,10 @@ function parseLifecycleMessage(value: unknown): ParsedLifecycleMessage { || value.event.length > 80 || (value.turnId !== null && (typeof value.turnId !== "string" || value.turnId.length > 160)) ) throw new Error("Runtime message fields are invalid."); + // Only the provider's own id shape, already in its stored form, is accepted. + if (value.threadId !== undefined && normalizeThreadId(String(value.provider), value.threadId) !== value.threadId) { + throw new Error("Runtime threadId is invalid."); + } if (value.result !== undefined && ( value.state !== "idle" || value.event !== "Stop" || !isRecord(value.result) || Object.keys(value.result).sort().join(",") !== "text,truncated" diff --git a/src/main/services/safety/SecretRedaction.ts b/src/main/services/safety/SecretRedaction.ts new file mode 100644 index 00000000..10b29484 --- /dev/null +++ b/src/main/services/safety/SecretRedaction.ts @@ -0,0 +1,152 @@ +/** + * The secret redaction registry (EP-8): every text the core hands to another agent (canvastty_agents + * observe/result, the control CLI's screen, result and failure details) passes through `redact` first. + * Always on; nothing switches it off. It never touches what a person or an agent writes *to* an agent. + * + * 1. Known values: keys from the provider secret vault as this process reads them, plugin `secretEnv` values + * of open cards, and values a trusted plugin service registers. Each is removed where it stands, also when + * the terminal's wrapping put a line break, indentation or a box side between its characters, and in its + * JSON-escaped form. + * 2. JSON string values under key-like names (`"apiKey"`, `"token"`, `"authorization"`, …), across lines too. + * 3. Generic shapes: PEM private keys, `sk-…`, GitHub, Slack, AWS, Google, xAI tokens, JWTs, `Bearer …`, + * `Authorization:` values, URL credentials, secret-looking query values and assignments, and long + * high-entropy runs. + * + * Each match becomes ``. Idempotent: a marker is never masked again. Values live in memory only + * and are never logged, sent or shown. + */ + +const SECRET_MARKER = ''; +/** Shorter values are not keys, and removing them would only garble ordinary text. */ +const MIN_SECRET_CHARS = 8; +const MAX_SECRET_CHARS = 4_096; +const MAX_VALUES_PER_OWNER = 64; +const MAX_OWNERS = 512; +/** What wrapping may put between two characters of a key: whitespace and line breaks, box-drawing sides. */ +const WRAP_GAP = '[\\s\\u2500-\\u257f]{0,64}'; +const JSON_SECRET_VALUE = /("(?:[A-Za-z0-9_.-]{0,40}(?:api[_-]?key|token|secret|password|authorization))"\s*:\s*")(?!>(); + private pattern: RegExp | null = null; + private dirty = false; + + /** Adds values under an owner (`vault`, `session:`, `plugin:`); short or oversized values are ignored. */ + add(owner: string, values: Iterable): void { + let set = this.owners.get(owner); + for (const value of values) { + const trimmed = typeof value === 'string' ? value.trim() : ''; + if (trimmed.length < MIN_SECRET_CHARS || trimmed.length > MAX_SECRET_CHARS) continue; + if (!set) { + if (this.owners.size >= MAX_OWNERS) return; + set = new Set(); + this.owners.set(owner, set); + } + if (set.has(trimmed)) continue; + if (set.size >= MAX_VALUES_PER_OWNER) set.delete(set.values().next().value!); + set.add(trimmed); + this.dirty = true; + } + } + + /** Forgets an owner's values (a card closed, a plugin stopped). */ + clear(owner: string): void { + if (this.owners.delete(owner)) this.dirty = true; + } + + redact(text: string): string { + if (typeof text !== 'string' || text.length === 0) return typeof text === 'string' ? text : ''; + let result = text; + const known = this.knownPattern(); + if (known) result = result.replace(known, SECRET_MARKER); + result = result.replace(JSON_SECRET_VALUE, (_match, prefix: string, closing: string) => `${prefix}${SECRET_MARKER}${closing}`); + return redactCredentials(result); + } + + /** One pattern for every held value and its JSON-escaped form, longest first; rebuilt only after a change. */ + private knownPattern(): RegExp | null { + if (!this.dirty) return this.pattern; + const forms = new Set(); + for (const values of this.owners.values()) { + for (const value of values) { + forms.add(value); + forms.add(JSON.stringify(value).slice(1, -1)); + } + } + const sources = [...forms].sort((a, b) => b.length - a.length).map(form => [...form].map(escapeCharacter).join(WRAP_GAP)); + this.pattern = sources.length ? new RegExp(sources.join('|'), 'gu') : null; + this.dirty = false; + return this.pattern; + } +} + +function escapeCharacter(character: string): string { + return character.replace(/[\\^$.*+?()[\]{}|/]/gu, '\\$&'); +} + +type Rule = { kind: string; pattern: RegExp; replace?: (match: string, ...groups: string[]) => string }; + +const marker = (kind: string): string => ``; +const NOT_MASKED = '(?!]{4,}`, 'giu'), replace: (_match, prefix) => `${prefix}${marker('authorization')}` }, + { kind: 'bearer', pattern: new RegExp(`\\bBearer\\s+${NOT_MASKED}[A-Za-z0-9._~+/=-]{8,}`, 'gu'), replace: () => `Bearer ${marker('bearer')}` }, + // URL userinfo (`https://user:secret@host`, or a token alone as the user). + { kind: 'url-credentials', pattern: /(\b[a-z][a-z0-9+.-]{1,20}:\/\/)([^\s/@<>"']+)@/giu, + replace: (match, scheme, userinfo) => userinfo!.includes(':') || userinfo!.length >= 16 ? `${scheme}${marker('url-credentials')}@` : match }, + // Query values of secret-looking parameters. + { kind: 'url-secret', pattern: new RegExp(`([?&](?:[A-Za-z0-9]+[_-])*(?:token|key|secret|password|sig|signature)=)${NOT_MASKED}[^&#\\s"'<>]+`, 'giu'), replace: (_match, prefix) => `${prefix}${marker('url-secret')}` }, + // Assignments whose name contains TOKEN / SECRET / PASSWORD / CREDENTIAL(S) or ends in KEY (`monkey` and + // `keyboard` stay). The name may be quoted and the separator is `:`, `=` or `=>`. Every repetition is bounded. + { kind: 'assignment', pattern: new RegExp(`(["']?\\b(?:[A-Za-z0-9_.-]{0,100}(?:[Tt]oken|TOKEN|[Ss]ecret|SECRET|[Pp]assw(?:or)?d|PASSW(?:OR)?D|[Cc]redentials?|CREDENTIALS?)|(?:[A-Za-z0-9]{1,40}[_.-]){0,8}(?:[A-Za-z0-9]{0,40}Key|[A-Z0-9]{1,40}KEY|(?:[Aa][Pp][Ii][_-]?)?(?:key|KEY)))(?![A-Za-z0-9])["']?\\s*(?:=>|[:=])\\s*)(?:"${NOT_MASKED}[^"\\r\\n]{4,}"|'${NOT_MASKED}[^'\\r\\n]{4,}'|\`${NOT_MASKED}[^\`\\r\\n]{4,}\`|${NOT_MASKED}[^\\s"'\`<>,;]{4,})`, 'gu'), + replace: (_match, prefix) => `${prefix}${marker('assignment')}` }, + // A random run the terminal wrapped over lines, judged as one run. + { kind: 'high-entropy', pattern: new RegExp(`(? { const joined = match.replace(/[\s\u2500-\u257f|]/gu, ''); return joined.length >= 32 && looksRandom(joined) ? marker('high-entropy') : match; } }, + // A long run that mixes upper case, lower case and digits with high entropy. Pure hex (a commit SHA) has no + // upper case and survives; paths never form one run because `/` and `.` end it. + { kind: 'high-entropy', pattern: /(? looksRandom(match) ? marker('high-entropy') : match } +]; + +function looksRandom(value: string): boolean { + const upper = value.match(/[A-Z]/gu)?.length ?? 0, lower = value.match(/[a-z]/gu)?.length ?? 0, digits = value.match(/[0-9]/gu)?.length ?? 0; + if (upper < 2 || lower < 2 || digits < 2) return false; + const counts = new Map(); + for (const character of value) counts.set(character, (counts.get(character) ?? 0) + 1); + let entropy = 0; + for (const count of counts.values()) { const p = count / value.length; entropy -= p * Math.log2(p); } + return entropy >= 4.2; +} + +/** The generic shapes alone (no registered values). */ +export function redactCredentials(text: string): string { + if (typeof text !== 'string' || text.length === 0) return typeof text === 'string' ? text : ''; + let result = text; + for (const rule of RULES) { + result = result.replace(rule.pattern, (match: string, ...rest: unknown[]) => { + // Arguments after the match: the capture groups, then the numeric offset and the whole input. + const end = rest.findIndex(value => typeof value === 'number'); + const groups = (end < 0 ? [] : rest.slice(0, end)).map(value => typeof value === 'string' ? value : ''); + return rule.replace ? rule.replace(match, ...groups) : marker(rule.kind); + }); + } + return result; +} diff --git a/src/main/services/safety/baseProtection.ts b/src/main/services/safety/baseProtection.ts new file mode 100644 index 00000000..43ba5314 --- /dev/null +++ b/src/main/services/safety/baseProtection.ts @@ -0,0 +1,135 @@ +import { tmpdir } from 'node:os'; +import { isAbsolute, relative } from 'node:path'; +import { analyzeAction, commandFromArgv, realish, type HardFacts, type ToolAction } from './commandFacts.ts'; + +/** + * Base protection: a small set of deny-only rules the core applies to every local agent tool call it sees through + * the agents' own hooks (Claude Code, Codex and Qwen Code PreToolUse; OpenCode's plugin), before any plugin decides. + * On by default; Settings → Agents → "Base protection" turns it off. It never allows anything and never asks a + * model: local rules only, no git, no network. + */ + +export const BASE_DENY_RULES = ['elevation', 'pipe-to-shell', 'download-exec', 'disk', 'fork-bomb', 'delete-outside', 'write-outside'] as const; +export type BaseDenyRule = typeof BASE_DENY_RULES[number]; + +/** What the model reads: why the call was refused and what to do instead. */ +const DENY_MESSAGES: Readonly> = { + elevation: 'CanvasTTY blocked this command: it asks for administrator rights (sudo, doas, runas). Do the work without elevation; if the task truly needs it, stop and ask the person to run that step.', + 'pipe-to-shell': 'CanvasTTY blocked this command: it pipes downloaded or generated text straight into a shell or interpreter. Download the file first, show what it contains, and ask the person before running it.', + 'download-exec': 'CanvasTTY blocked this command: it downloads code and runs it in one step. Download the file first, show what it contains, and ask the person before running it.', + disk: 'CanvasTTY blocked this command: it erases, formats or writes a disk directly. Do not do this; ask the person if disk changes are really needed.', + 'fork-bomb': 'CanvasTTY blocked this command: it would exhaust the computer\'s processes. Do not run it.', + 'delete-outside': 'CanvasTTY blocked this command: it deletes files outside the project folder (or the folder itself). Delete only inside the project; if something elsewhere must go, ask the person.', + 'write-outside': 'CanvasTTY blocked this command: it writes outside the project folder. Keep changes inside the project; if a file elsewhere must change, ask the person.' +}; +const TEMP_WRITE_MESSAGE = 'CanvasTTY blocked this command: it writes to the temporary folder (/tmp or $TMPDIR), which is outside the project folder. Make a scratch folder inside the project instead (for example ./tmp, added to .gitignore if needed) and use that; if a file elsewhere must change, ask the person.'; + +export interface BaseVerdict { rule: BaseDenyRule; message: string } + +/** Tool names of the CLIs' hooks: shells and file writes. Anything else is not checked. */ +const SHELL_TOOLS = new Set(['Bash', 'bash', 'run_shell_command', 'shell', 'local_shell', 'exec_command']); +const EDIT_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'write_file', 'edit', 'replace', 'apply_patch']); +const MAX_PATHS = 32; +const MAX_PATH_CHARS = 4_096; + +/** The files an `apply_patch` envelope touches. */ +export function patchPaths(patch: string): string[] { + const paths: string[] = []; + for (const match of patch.matchAll(/^\*\*\* (?:Add|Update|Delete) File: (.+)$|^\*\*\* Move to: (.+)$/gmu)) { + const path = (match[1] ?? match[2] ?? '').trim(); + if (path && path.length <= MAX_PATH_CHARS) paths.push(path); + if (paths.length >= MAX_PATHS) break; + } + return paths; +} + +const PREVIEW_PATH = /"(?:file_path|filePath|path|notebook_path|absolute_path)"\s*:\s*"((?:[^"\\]|\\.){1,4096})"/gu; + +/** + * A hook's tool call as the rules read it. Only a shell tool carries a command; only a file tool carries paths. + * For cut input only the path of a file tool at the start of its JSON preview is read (it can add a deny, never + * anything else). + */ +export function actionFromHook(toolName: string, toolInput: unknown, preview: string | null = null): ToolAction { + const raw = toolInput && typeof toolInput === 'object' && !Array.isArray(toolInput) ? toolInput as Record : null; + const text = (value: unknown): string | null => typeof value === 'string' && value.length > 0 && value.length <= MAX_PATH_CHARS ? value : null; + const commandCwd = text(raw?.cwd) ?? text(raw?.workdir) ?? text(raw?.directory); + if (SHELL_TOOLS.has(toolName)) { + const candidate = raw?.command ?? raw?.cmd; + const command = typeof candidate === 'string' ? candidate + : Array.isArray(candidate) && candidate.length && candidate.every(item => typeof item === 'string') ? commandFromArgv(candidate as string[]) : null; + return { kind: 'shell', command, commandCwd, paths: [] }; + } + if (!EDIT_TOOLS.has(toolName)) return { kind: null, command: null, commandCwd: null, paths: [] }; + if (!raw && preview && toolName !== 'apply_patch') { + const paths: string[] = []; + for (const match of preview.matchAll(PREVIEW_PATH)) { + try { const value: unknown = JSON.parse(`"${match[1]}"`); if (typeof value === 'string' && value) paths.push(value); } catch { /* a cut escape */ } + if (paths.length >= 4) break; + } + return { kind: 'edit', command: null, commandCwd: null, paths }; + } + if (toolName === 'apply_patch') { + const patch = text(raw?.command) ?? (typeof raw?.patch === 'string' ? raw.patch : typeof raw?.input === 'string' ? raw.input : typeof raw?.patchText === 'string' ? raw.patchText : ''); + return { kind: 'edit', command: null, commandCwd, paths: patchPaths(patch ?? '') }; + } + const path = text(raw?.file_path) ?? text(raw?.filePath) ?? text(raw?.path) ?? text(raw?.notebook_path) ?? text(raw?.absolute_path); + return { kind: 'edit', command: null, commandCwd: null, paths: path ? [path] : [] }; +} + +/** The first deny rule a set of facts breaks, in a fixed order. */ +export function denyRule(facts: HardFacts): BaseDenyRule | null { + if (facts.elevation) return 'elevation'; + if (facts.pipeToShell) return 'pipe-to-shell'; + if (facts.downloadExec) return 'download-exec'; + if (facts.disk) return 'disk'; + if (facts.forkBomb) return 'fork-bomb'; + if (facts.deletesOutside) return 'delete-outside'; + if (facts.writesOutside) return 'write-outside'; + return null; +} + +/** + * Base protection for one hook call: a deny with its message, or null (no opinion). `root` is the session's + * working folder; `agentRoots` the agent's own config folders, whose plan and memory folders are not "outside". + * Any failure is null: the rules only ever add a deny. + */ +export function checkBaseProtection(input: { + toolName: string; toolInput: unknown; preview?: string | null; root: string; commandCwd?: string | null; + home?: string; agentRoots?: readonly string[]; +}): BaseVerdict | null { + try { + const action = actionFromHook(input.toolName, input.toolInput, input.preview ?? null); + if (action.kind === null) return null; + // The agent's current folder (the hook reports it) resolves relative paths; the working folder stays the root. + if (!action.commandCwd && input.commandCwd) action.commandCwd = input.commandCwd; + const facts = analyzeAction(action, input.root, { + ...(input.home ? { home: input.home } : {}), + ...(input.agentRoots ? { agentRoots: input.agentRoots } : {}) + }); + const rule = denyRule(facts); + if (!rule) return null; + return { rule, message: rule === 'write-outside' && writesOnlyToTemp(facts) ? TEMP_WRITE_MESSAGE : DENY_MESSAGES[rule] }; + } catch { + return null; + } +} + +/** The temporary folders of this computer, resolved (macOS: /tmp is /private/tmp; $TMPDIR is under /var/folders). */ +function temporaryRoots(): string[] { + const roots = new Set(); + for (const path of ['/tmp', '/var/tmp', tmpdir(), process.env.TEMP ?? '', process.env.TMP ?? '']) { + if (!path) continue; + roots.add(path); + roots.add(realish(path)); + } + return [...roots]; +} + +const within = (path: string, root: string): boolean => { const rel = relative(root, path); return rel === '' || !rel.startsWith('..') && !isAbsolute(rel); }; + +function writesOnlyToTemp(facts: HardFacts): boolean { + if (facts.deletesOutside || !facts.outsideWrites.length) return false; + const roots = temporaryRoots(); + return facts.outsideWrites.every(path => roots.some(root => within(path, root))); +} diff --git a/src/main/services/safety/commandFacts.ts b/src/main/services/safety/commandFacts.ts new file mode 100644 index 00000000..180efc67 --- /dev/null +++ b/src/main/services/safety/commandFacts.ts @@ -0,0 +1,547 @@ +import { realpathSync } from 'node:fs'; +import { homedir, tmpdir } from 'node:os'; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { lexShell, shellQuote, type Segment, type Word } from './shellParse.ts'; + +/** + * The hard facts base protection decides on, computed by code from one tool call and the working folder. + * Nothing is executed and nothing is read except `realpath` of the paths involved. Only the facts a deny rule + * needs are computed: elevation, a pipe into a shell, download-and-run, disk commands, a fork bomb, and writes or + * deletes outside the working folder (deleting the folder itself included). + */ + +/** One tool call, source-neutral: a shell command or the files a file tool writes. */ +export interface ToolAction { + kind: 'shell' | 'edit' | null; + /** The shell command; null when none could be read. */ + command: string | null; + /** The command's own working directory, if the agent said one. */ + commandCwd: string | null; + /** Paths a file tool writes. */ + paths: string[]; +} + +export type Where = 'inside' | 'outside' | 'unresolved'; +export interface Target { + raw: string; + abs: string | null; + where: Where; + /** A block device (/dev/disk2, /dev/sda, \\.\PhysicalDrive0). */ + device: boolean; + /** Names the working folder itself (not through a glob): deleting it is deleting the project. */ + root: boolean; +} + +export interface HardFacts { + elevation: boolean; + pipeToShell: boolean; + downloadExec: boolean; + disk: boolean; + forkBomb: boolean; + writesOutside: boolean; + deletesOutside: boolean; + /** Absolute targets written outside the working folder (for the temporary-folder advice). */ + outsideWrites: string[]; +} + +// --------------------------------------------------------------------------- +// Paths +// --------------------------------------------------------------------------- + +/** realpath of the longest existing ancestor plus the rest (a symlink out of the project resolves outside). */ +export function realish(path: string): string { + let current = path; + const rest: string[] = []; + for (let i = 0; i < 256; i++) { + try { + const real = realpathSync.native(current); + return rest.length ? join(real, ...rest.reverse()) : real; + } catch { /* go up */ } + const parent = dirname(current); + if (parent === current) return path; + rest.push(basename(current)); + current = parent; + } + return path; +} + +const DEVICE = /^(?:\/dev\/(?:r?disk\d|sd[a-z]|hd[a-z]|nvme\d|mmcblk\d|xvd[a-z]|vd[a-z]|md\d|dm-\d|loop\d|mapper\/)|\\\\\.\\(?:physicaldrive|[a-z]:))/iu; +const HARMLESS_DEVICE = /^\/dev\/(?:null|zero|u?random|stdin|stdout|stderr|tty|fd\/\d+)$|^(?:nul|con)$/iu; +const WINDOWS_ABSOLUTE = /^(?:[A-Za-z]:[\\/]|[A-Za-z]:$|\\\\)/u; + +export interface PathContext { root: string; rootReal: string; home: string; temp: string; agentRoots: string[] } + +/** + * `agentRoots`: the agent's own config folders (Claude's ~/.claude or the run's CLAUDE_CONFIG_DIR). Their plan and + * memory folders belong to the agent, so writing there is not a write outside the project. + */ +export function pathContext(root: string, home = homedir(), agentRoots?: readonly string[]): PathContext { + return { root, rootReal: realish(resolve(root)), home, temp: tmpdir(), agentRoots: (agentRoots ?? [join(home, '.claude')]).map(dir => realish(resolve(dir))) }; +} + +const AGENT_SERVICE_DIR = /^(?:plans|projects[\\/][^\\/]+[\\/]memory)(?:[\\/]|$)/u; + +/** The path is inside an agent config folder's `plans/` or `projects//memory/` (already resolved, so no `..`). */ +export function isAgentServicePath(abs: string, ctx: PathContext): boolean { + return ctx.agentRoots.some(dir => { + const rel = relative(dir, abs); + return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel) && AGENT_SERVICE_DIR.test(rel); + }); +} + +const HOME_VARS = new Set(['HOME', 'USERPROFILE', 'ENV:USERPROFILE', 'ENV:HOME']); +const TEMP_VARS = new Set(['TMPDIR', 'TEMP', 'TMP', 'ENV:TEMP', 'ENV:TMP']); + +/** Expands only what is certain (~, HOME, PWD, TMPDIR); anything else is unresolved. */ +function expand(word: Word | string, cwd: string | null, ctx: PathContext): string | null { + if (typeof word !== 'string' && word.substitution) return null; + let text = typeof word === 'string' ? word : word.text; + const vars = typeof word === 'string' ? [] : word.vars; + for (const name of vars) { + let value: string | null = null; + if (HOME_VARS.has(name)) value = ctx.home; + else if (name === 'PWD' || name === 'ENV:PWD') value = cwd; + else if (TEMP_VARS.has(name)) value = ctx.temp; + if (value === null) return null; + text = text.replace(new RegExp(`\\$\\{${name}\\}|\\$${name}(?![A-Za-z0-9_])|%${name}%|\\$env:${name.replace(/^ENV:/u, '')}`, 'iu'), value); + } + if (typeof word !== 'string' ? word.tilde : text.startsWith('~')) { + if (text === '~' || text.startsWith('~/') || text.startsWith('~\\')) text = ctx.home + text.slice(1); + else return null; + } + return text; +} + +/** + * Where a word points. `noFollow`: the operation acts on the last path component itself (rm, unlink, mv of a + * symlink removes or renames the link, not what it points to), so only the folders above it are resolved. + */ +export function resolveTarget(word: Word | string, cwd: string | null, ctx: PathContext, noFollow = false): Target { + const raw = typeof word === 'string' ? word : word.text; + const blank: Target = { raw, abs: null, where: 'unresolved', device: DEVICE.test(raw), root: false }; + const globbed = typeof word !== 'string' && word.glob; + let text = expand(word, cwd, ctx); + if (text === null || text === '') return blank; + if (globbed) { + // A glob acts on everything under the folder before its first wildcard. + const first = text.search(/[*?[]/u); + const cut = text.slice(0, first).lastIndexOf('/'); + text = cut < 0 ? '.' : text.slice(0, cut) || '/'; + } + if (DEVICE.test(text)) return { ...blank, device: true, where: 'outside', abs: text }; + if (WINDOWS_ABSOLUTE.test(text) && sep === '/') return { ...blank, abs: text, where: 'outside' }; + if (!isAbsolute(text) && cwd === null) return blank; + const full = resolve(cwd ?? ctx.root, text); + const abs = noFollow && !/[\\/]$/u.test(text) && basename(full) !== '..' && basename(full) !== '.' && dirname(full) !== full ? join(realish(dirname(full)), basename(full)) : realish(full); + const rel = relative(ctx.rootReal, abs); + const inside = rel === '' || !rel.startsWith('..') && !isAbsolute(rel); + return { raw, abs, where: inside ? 'inside' : 'outside', device: false, root: rel === '' && !globbed }; +} + +// --------------------------------------------------------------------------- +// Programs +// --------------------------------------------------------------------------- + +const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'mksh', 'fish', 'csh', 'tcsh', 'ash', 'busybox']); +const INTERPRETERS = new Set(['python', 'python2', 'python3', 'pypy', 'pypy3', 'node', 'nodejs', 'ruby', 'perl', 'php', 'lua', 'luajit', 'rscript', 'tsx', 'ts-node', 'deno', 'bun', 'osascript', 'jshell', 'groovy', 'julia', 'elixir', 'swift']); +const POWERSHELLS = new Set(['powershell', 'pwsh']); +const EVAL_WORDS = new Set(['eval', 'iex', 'invoke-expression']); +const ELEVATION = new Set(['sudo', 'doas', 'pkexec', 'run0', 'runas', 'gsudo', 'please']); +const WRAPPERS = new Set(['nohup', 'time', 'nice', 'ionice', 'timeout', 'gtimeout', 'stdbuf', 'command', 'builtin', 'exec', 'caffeinate', 'watch', 'chronic', 'unbuffer', 'setsid', 'script']); +const DISK = new Set(['mkfs', 'mke2fs', 'mkswap', 'newfs', 'newfs_apfs', 'newfs_hfs', 'newfs_msdos', 'wipefs', 'fdisk', 'sfdisk', 'gdisk', 'sgdisk', 'cfdisk', 'parted', 'blkdiscard', 'diskpart', 'format-volume', 'clear-disk', 'initialize-disk', 'remove-partition', 'new-partition', 'set-disk', 'mdadm', 'lvremove', 'vgremove', 'pvremove', 'cryptsetup', 'asr', 'fdformat', 'gpt']); +const FETCHERS = new Set(['curl', 'wget', 'fetch', 'http', 'https', 'xh', 'aria2c', 'iwr', 'irm', 'invoke-webrequest', 'invoke-restmethod', 'start-bitstransfer', 'certutil', 'bitsadmin', 'lwp-download']); +const DELETERS = new Set(['rm', 'unlink', 'shred', 'trash', 'del', 'erase', 'rd', 'rmdir', 'remove-item', 'ri', 'rimraf', 'srm']); +const COPIERS = new Set(['cp', 'install', 'ln', 'copy', 'xcopy', 'robocopy', 'copy-item', 'cpi', 'mklink', 'ditto', 'rsync']); +const MOVERS = new Set(['mv', 'move', 'move-item', 'mi', 'ren', 'rename', 'rename-item', 'rni']); +const CREATORS = new Set(['touch', 'mkdir', 'md', 'truncate', 'tee', 'new-item', 'ni', 'set-content', 'add-content', 'ac', 'out-file', 'mkfifo', 'mktemp', 'gzip', 'gunzip', 'bzip2', 'xz', 'unxz', 'zstd']); +const MODE_CHANGERS = new Set(['chmod', 'chown', 'chgrp', 'chattr', 'setfacl', 'attrib', 'icacls', 'takeown']); +const GIT_READ = new Set(['status', 'log', 'diff', 'show', 'rev-parse', 'ls-files', 'blame', 'grep', 'describe', 'shortlog', 'reflog', 'cat-file', 'ls-tree', 'merge-base', 'count-objects', 'var', 'help', 'version', 'annotate', 'name-rev', 'show-ref', 'for-each-ref', 'check-ignore', 'fetch', 'ls-remote', 'branch', 'tag', 'remote', 'config', 'stash', 'push', 'pull']); +const WINDOWS_BUILTINS = new Set(['del', 'erase', 'rd', 'copy', 'xcopy', 'robocopy', 'move', 'ren', 'rename', 'format', 'cipher', 'attrib', 'icacls', 'takeown', 'mklink', 'md', 'mkdir', 'rmdir']); + +/** A program's name for the tables: basename, lower case, without a Windows executable suffix. */ +export function programName(argv0: string): string { + const name = argv0.replace(/\\/gu, '/').split('/').pop() ?? argv0; + return name.toLowerCase().replace(/\.(exe|cmd|bat|com)$/u, ''); +} + +const isFlag = (value: string, windows = false): boolean => value.startsWith('-') && value !== '-' || windows && /^\/[A-Za-z?]{1,3}(?::.*)?$/u.test(value); + +// --------------------------------------------------------------------------- +// Analysis +// --------------------------------------------------------------------------- + +interface Acc { + ctx: PathContext; + writes: Target[]; + deletes: Target[]; + flags: { elevation: boolean; pipeToShell: boolean; downloadExec: boolean; disk: boolean; forkBomb: boolean }; + depth: number; + budget: number; +} + +interface Stdin { pipeIn: boolean; heredoc: string | null } + +function wordOf(text: string): Word { return { text, quoted: false, vars: [], substitution: false, inner: [], glob: false, tilde: false }; } + +/** Analyses one shell command string (recursively for `bash -c`, `eval`, substitutions). */ +function analyzeText(command: string, cwd: string | null, acc: Acc): string | null { + if (acc.depth > 4 || --acc.budget < 0) return cwd; + acc.depth++; + try { + const lexed = lexShell(command); + if (/(\w+|:)\s*\(\s*\)\s*\{[^}]*\1\s*\|\s*\1/u.test(command)) acc.flags.forkBomb = true; + // PowerShell download-and-run: iex (iwr …), Invoke-Expression (New-Object Net.WebClient).DownloadString(…). + if (/\b(?:iex|invoke-expression)\b/iu.test(command) && /\b(?:iwr|irm|invoke-webrequest|invoke-restmethod|downloadstring|downloadfile|net\.webclient|start-bitstransfer|curl|wget)\b/iu.test(command)) acc.flags.downloadExec = true; + let current = cwd; + const downloadedHere: Target[] = []; + for (const segment of lexed.segments) current = analyzeSegment(segment, current, acc, downloadedHere); + return current; + } finally { acc.depth--; } +} + +function analyzeSegment(segment: Segment, cwd: string | null, acc: Acc, downloadedHere: Target[]): string | null { + const words = [...segment.words]; + // Leading NAME=value assignments. + while (words.length && /^[A-Za-z_][A-Za-z0-9_]*=/u.test(words[0]!.text) && !words[0]!.quoted) words.shift(); + for (const word of segment.words) inspectWord(word, acc); + for (const redirect of segment.redirects) { + if (redirect.fdDup || !redirect.target) continue; + inspectWord(redirect.target, acc); + if (redirect.op.includes('<<') || !redirect.op.includes('>')) continue; + if (HARMLESS_DEVICE.test(redirect.target.text)) continue; + const target = resolveTarget(redirect.target, cwd, acc.ctx); + if (target.device) { acc.flags.disk = true; continue; } + acc.writes.push(target); + } + if (!words.length) return cwd; + return analyzeArgv(words, cwd, acc, { pipeIn: segment.pipeIn, heredoc: segment.heredoc }, downloadedHere); +} + +function inspectWord(word: Word, acc: Acc): void { + // A substitution runs its own command. + if (word.substitution) for (const inner of word.inner) analyzeText(inner, null, acc); +} + +function fetchesIn(text: string): boolean { + return /(?:^|[\s;|&(`])(?:curl|wget|iwr|irm|invoke-webrequest|invoke-restmethod|fetch|http|aria2c|lwp-download)(?:\.exe)?(?=\s|$)/iu.test(text); +} + +/** Words → what the command does. Returns the working directory after it (for `cd`). */ +function analyzeArgv(argvWords: Word[], cwd: string | null, acc: Acc, stdin: Stdin, downloadedHere: Target[]): string | null { + const argv = argvWords.map(word => word.text); + const argv0 = argv[0]!; + const program = programName(argv0); + const args = argv.slice(1); + const argWords = argvWords.slice(1); + const innerFetch = argvWords.some(word => word.inner.some(inner => fetchesIn(inner))); + + // An argv0 that is itself a substitution or a variable: nobody can tell what runs. + if (argvWords[0]!.substitution || argvWords[0]!.vars.length) { + if (innerFetch) acc.flags.downloadExec = true; + return cwd; + } + + if (ELEVATION.has(program) || program === 'su' || (program === 'start-process' && args.some(arg => /^-verb$/iu.test(arg)) && args.some(arg => /^runas$/iu.test(arg)))) { + acc.flags.elevation = true; + const inner = program === 'su' ? args.indexOf('-c') : argWords.findIndex(word => !isFlag(word.text)); + if (program === 'su' && inner >= 0 && args[inner + 1]) analyzeText(args[inner + 1]!, cwd, acc); + else if (program !== 'su' && inner >= 0) analyzeArgv(argWords.slice(inner), cwd, acc, stdin, downloadedHere); + return cwd; + } + + // Wrappers run their argument as a command. + if (WRAPPERS.has(program) || program === 'env' && args.some(arg => !arg.startsWith('-') && !arg.includes('=')) || program === 'xargs') { + if (program === 'command' && (args[0] === '-v' || args[0] === '-V')) return cwd; + const takesValue = new Set(['-n', '-u', '-s', '-k', '-i', '-o', '-e', '-c', '-C', '-I', '-L', '-P', '-d', '--signal', '--kill-after', '--adjustment', '--unset', '--chdir', '--max-args', '--max-procs', '--replace', '--delimiter']); + let i = 0; + for (; i < argWords.length; i++) { + const text = argWords[i]!.text; + if (program === 'env' && /^[A-Za-z_][A-Za-z0-9_]*=/u.test(text)) continue; + if (text.startsWith('-')) { if (takesValue.has(text) && program !== 'xargs' || program === 'xargs' && ['-n', '-P', '-I', '-L', '-d', '-s', '-E'].includes(text)) i++; continue; } + if ((program === 'timeout' || program === 'gtimeout') && /^\d/u.test(text)) continue; + if (program === 'nice' && /^-?\d+$/u.test(text)) continue; + break; + } + if (i >= argWords.length) return cwd; + return analyzeArgv(argWords.slice(i), cwd, acc, program === 'xargs' ? { pipeIn: false, heredoc: null } : stdin, downloadedHere); + } + + if (program === 'cd' || program === 'pushd' || program === 'chdir' || program === 'set-location' || program === 'sl') { + const dest = argWords.filter(word => !isFlag(word.text))[0]; + if (!dest) return acc.ctx.home; + if (dest.text === '-') return null; + return resolveTarget(dest, cwd, acc.ctx).abs; + } + if (program === 'popd') return null; + + if (EVAL_WORDS.has(program)) { + const generated = program !== 'eval' || argWords.some(word => word.substitution || word.vars.length) || stdin.pipeIn; + if (generated) { + if (stdin.pipeIn || innerFetch) acc.flags.pipeToShell = true; + return cwd; + } + return analyzeText(args.join(' '), cwd, acc); + } + + if (program === 'source' || program === '.') { + const file = argWords.filter(word => !isFlag(word.text))[0]; + if (file?.substitution && innerFetch) acc.flags.downloadExec = true; + else if (file) runScript(resolveTarget(file, cwd, acc.ctx), acc, downloadedHere); + return cwd; + } + + if (SHELLS.has(program) || POWERSHELLS.has(program) || program === 'cmd') return runShell(program, argWords, cwd, acc, stdin, downloadedHere, innerFetch); + if (INTERPRETERS.has(program)) return runInterpreter(program, argWords, cwd, acc, stdin, downloadedHere, innerFetch); + + // A program named by path: running a file this command just downloaded. + if (/[\\/]/u.test(argv0)) runScript(resolveTarget(argvWords[0]!, cwd, acc.ctx), acc, downloadedHere); + + classifyProgram(program, argWords, cwd, acc, stdin, downloadedHere); + return cwd; +} + +function runScript(file: Target, acc: Acc, downloadedHere: Target[]): void { + if (downloadedHere.some(item => item.abs && item.abs === file.abs)) acc.flags.downloadExec = true; +} + +function runShell(program: string, argWords: Word[], cwd: string | null, acc: Acc, stdin: Stdin, downloadedHere: Target[], innerFetch: boolean): string | null { + const args = argWords.map(word => word.text); + const powershell = POWERSHELLS.has(program); + for (let i = 0; i < argWords.length; i++) { + const text = args[i]!; + const inline = program === 'cmd' ? /^\/[ck]$/iu.test(text) : powershell ? /^-(?:c|command)$/iu.test(text) : /^-[a-z]*c[a-z]*$/u.test(text) && !text.startsWith('--'); + if (inline) { + const rest = program === 'cmd' || powershell ? args.slice(i + 1).join(' ') : args[i + 1]; + if (innerFetch) acc.flags.downloadExec = true; + if (rest !== undefined && !(argWords[i + 1]?.substitution && program !== 'cmd')) analyzeText(rest, cwd, acc); + return cwd; + } + if (powershell && /^-(?:f|file)$/iu.test(text)) { + const file = argWords[i + 1]; + if (file) runScript(resolveTarget(file, cwd, acc.ctx), acc, downloadedHere); + return cwd; + } + if (text.toLowerCase() === '-s' && !powershell) break; + if (isFlag(text, program === 'cmd')) { if (/^--?(?:rcfile|init-file|o)$/u.test(text)) i++; continue; } + if (powershell && /^-/u.test(text)) continue; + // The first operand is a script file; the rest are its arguments. `bash <(curl …)`: a download run as a script. + const file = argWords[i]!; + if (file.substitution) { if (innerFetch) acc.flags.downloadExec = true; return cwd; } + runScript(resolveTarget(file, cwd, acc.ctx), acc, downloadedHere); + return cwd; + } + // No script: the shell reads stdin. + if (stdin.pipeIn) acc.flags.pipeToShell = true; + else if (stdin.heredoc !== null) analyzeText(stdin.heredoc, cwd, acc); + return cwd; +} + +function runInterpreter(program: string, argWords: Word[], cwd: string | null, acc: Acc, stdin: Stdin, downloadedHere: Target[], innerFetch: boolean): string | null { + const args = argWords.map(word => word.text); + if (args.length === 1 && /^(?:--?version|-v|-V)$/u.test(args[0]!)) return cwd; + const python = program.startsWith('python') || program.startsWith('pypy'); + for (let i = 0; i < argWords.length; i++) { + const text = args[i]!; + if (python && text === '-m') return cwd; + if (/^(?:-c|-e|--eval|-p|--print|-r|-E)$/u.test(text) || program === 'deno' && text === 'eval' || program === 'osascript' && text === '-e') { + if (innerFetch) acc.flags.downloadExec = true; + if (argWords[i + 1] && fetchesIn(args[i + 1]!) && /\b(?:exec|eval|system|spawn|child_process|subprocess|os\.system)\b/u.test(args[i + 1]!)) acc.flags.downloadExec = true; + return cwd; + } + if (text.startsWith('-')) { if (/^(?:-W|-X|--require|-r|--import|--loader|-I)$/u.test(text)) i++; continue; } + if ((program === 'deno' || program === 'bun') && ['run', 'x', 'test', 'task', 'check', 'lint', 'fmt', 'compile', 'build', 'install', 'add'].includes(text)) continue; + const file = argWords[i]!; + if (file.substitution) { if (innerFetch) acc.flags.downloadExec = true; return cwd; } + if (/^https?:\/\//iu.test(file.text)) { acc.flags.downloadExec = true; return cwd; } + runScript(resolveTarget(file, cwd, acc.ctx), acc, downloadedHere); + return cwd; + } + if (stdin.pipeIn) acc.flags.pipeToShell = true; + return cwd; +} + +function classifyProgram(program: string, argWords: Word[], cwd: string | null, acc: Acc, stdin: Stdin, downloadedHere: Target[]): void { + const args = argWords.map(word => word.text); + const windows = WINDOWS_BUILTINS.has(program) || args.some(arg => /^\/[sq]$/iu.test(arg)); + const positional = argWords.filter(word => !isFlag(word.text, windows)); + const target = (word: Word | string, noFollow = false): Target => resolveTarget(word, cwd, acc.ctx, noFollow); + const sub = positional[0]?.text; + + // Disks. + if (DISK.has(program) || program.startsWith('mkfs') || program.startsWith('newfs')) { acc.flags.disk = true; return; } + if (program === 'diskutil') { + if (/^(?:erase|zero|random|secure|partition|reformat|apfs|cs|appleraid|ar|resetfusion|repairdisk|mergepartitions|splitpartition|resizevolume|addpartition)/iu.test(sub ?? '') || /^(?:deletecontainer|deletevolume|erasevolume)$/iu.test(positional[1]?.text ?? '')) acc.flags.disk = true; + return; + } + if (program === 'format' && positional.some(word => /^[A-Za-z]:\\?$/u.test(word.text))) { acc.flags.disk = true; return; } + if (program === 'cipher' && args.some(arg => /^\/w/iu.test(arg))) { acc.flags.disk = true; return; } + if (program === 'dd') { + for (const arg of args) { + const m = /^of=(.*)$/u.exec(arg); + if (!m) continue; + const t = target(m[1]!); + if (t.device || /^\/dev\//u.test(m[1]!) && !HARMLESS_DEVICE.test(m[1]!)) acc.flags.disk = true; + else acc.writes.push(t); + } + return; + } + + if (program === 'git') { classifyGit(argWords, cwd, acc); return; } + // `uv run X`, `bundle exec X` and friends run their argument as a command. + if ((['uv', 'poetry', 'pipenv', 'conda'].includes(program) && sub === 'run') || (program === 'bundle' && sub === 'exec')) { + const start = argWords.findIndex(word => word.text === sub); + const inner = argWords.slice(start + 1).findIndex(word => !word.text.startsWith('-')); + if (inner >= 0) analyzeArgv(argWords.slice(start + 1 + inner), cwd, acc, stdin, downloadedHere); + return; + } + + // Deleting. + if (DELETERS.has(program)) { + for (const word of positional.filter(word => !/^-/u.test(word.text))) { + const t = target(word, true); + if (program === 'shred' && t.device) acc.flags.disk = true; + acc.deletes.push(t); + } + return; + } + if (program === 'find') { + const starts: Word[] = []; + let i = 0; + for (; i < argWords.length && !/^[-(!]/u.test(argWords[i]!.text); i++) starts.push(argWords[i]!); + if (!starts.length) starts.push(wordOf('.')); + const exec = args.findIndex(arg => /^-(?:exec|execdir|ok|okdir)$/u.test(arg)); + const printTo = args.findIndex(arg => /^-f(?:print0?|printf|ls)$/u.test(arg)); + if (printTo >= 0 && args[printTo + 1]) acc.writes.push(target(args[printTo + 1]!)); + // The start folders are the scope of the deletion, not deleted themselves. + if (args.includes('-delete')) { for (const start of starts) acc.deletes.push({ ...target(start), root: false }); return; } + if (exec >= 0) { + const end = args.findIndex((arg, index) => index > exec && (arg === ';' || arg === '+' || arg === '\\;')); + const inner = argWords.slice(exec + 1, end < 0 ? undefined : end).map(word => word.text === '{}' ? starts[0]! : word); + if (inner.length) analyzeArgv(inner, cwd, acc, { pipeIn: false, heredoc: null }, downloadedHere); + } + return; + } + + // Writing. + if (COPIERS.has(program)) { + const files = positional.filter(word => !/^-/u.test(word.text)); + const dest = files.length > 1 ? files[files.length - 1]! : program === 'install' && args.includes('-d') ? files[0] : undefined; + // rsync to `host:path` is a remote copy, not a local write. + if (dest && !(program === 'rsync' && /^[^/\\]*:/u.test(dest.text) && !/^[A-Za-z]:[\\/]/u.test(dest.text))) acc.writes.push(target(dest)); + return; + } + if (MOVERS.has(program)) { + // A move changes both ends (a moved symlink is the link itself; the destination may be a folder it enters). + const files = positional.filter(word => !/^-/u.test(word.text)); + files.forEach((word, index) => acc.writes.push(target(word, index < files.length - 1))); + return; + } + if (CREATORS.has(program)) { + const files = positional.filter(word => !/^-/u.test(word.text)); + if (program === 'truncate') { const size = args.findIndex(arg => arg === '-s'); if (size >= 0) files.splice(files.findIndex(word => word.text === args[size + 1]), 1); } + if (program === 'mktemp') acc.writes.push(files.length ? target(files[0]!) : target(acc.ctx.temp)); + else for (const word of files) acc.writes.push(target(word)); + return; + } + if (MODE_CHANGERS.has(program)) { + const files = positional.filter(word => !/^-/u.test(word.text)).slice(program === 'attrib' || program === 'icacls' || program === 'takeown' ? 0 : 1); + for (const word of files) acc.writes.push(target(word)); + return; + } + if (program === 'sed' || program === 'gsed' || program === 'perl') { + if (!args.some(arg => /^-[a-zA-Z]*i/u.test(arg) || arg.startsWith('--in-place'))) return; + const explicitScript = args.some(arg => arg === '-e' || arg === '-f' || arg.startsWith('--expression')); + for (const word of positional.filter(word => !/^-/u.test(word.text)).slice(explicitScript ? 0 : 1)) acc.writes.push(target(word)); + return; + } + if (program === 'tar' || program === 'bsdtar' || program === 'unzip' || program === '7z' || program === 'unrar') { + const extract = program === 'unzip' || program === 'unrar' || program === '7z' && sub === 'x' || /^-?[a-zA-Z]*x/u.test(args[0] ?? '') || args.includes('--extract') || args.includes('-x'); + const dirFlag = args.findIndex(arg => arg === '-C' || arg === '--directory' || arg === '-d' || arg.startsWith('-o')); + const dest = dirFlag >= 0 ? (args[dirFlag]!.startsWith('-o') && args[dirFlag]!.length > 2 ? args[dirFlag]!.slice(2) : args[dirFlag + 1]) : '.'; + if (extract && dest) acc.writes.push(target(dest)); + const fileFlag = args.findIndex(arg => /^-?[a-zA-Z]*f$/u.test(arg) || arg === '--file'); + if (!extract && fileFlag >= 0 && args[fileFlag + 1]) acc.writes.push(target(args[fileFlag + 1]!)); + return; + } + if (FETCHERS.has(program)) classifyFetch(program, argWords, cwd, acc, downloadedHere); +} + +/** Where a download lands: `-o file`, `-O` (the URL's name), wget's default. */ +function classifyFetch(program: string, argWords: Word[], cwd: string | null, acc: Acc, downloadedHere: Target[]): void { + const args = argWords.map(word => word.text); + const target = (word: Word | string): Target => resolveTarget(word, cwd, acc.ctx); + const urls = args.filter(arg => /^[a-z]+:\/\//iu.test(arg) || /^[\w.-]+\.[a-z]{2,}(?:[:/]|$)/iu.test(arg)); + const land = (t: Target): void => { acc.writes.push(t); downloadedHere.push(t); }; + const urlName = (): string => (urls[0] ?? '').replace(/[?#].*$/u, '').split('/').pop() || 'index.html'; + let explicit = false; + for (let i = 0; i < args.length; i++) { + const arg = args[i]!, next = argWords[i + 1]; + if ((arg === '-o' || arg === '--output' || arg === '-O' && program === 'wget' || arg === '--output-document' || /^-outfile$/iu.test(arg) || arg === '-P' || arg === '--directory-prefix') && next) { + explicit = true; + if (next.text !== '-') land(target(next)); + i++; continue; + } + if (arg === '-O' || arg === '--remote-name' || arg === '--remote-name-all') { explicit = true; land(target(urlName())); } + } + if (program === 'wget' && !explicit && !args.some(arg => arg === '-O-' || arg === '-qO-' || arg === '--spider')) land(target(urlName())); +} + +function classifyGit(argWords: Word[], cwd: string | null, acc: Acc): void { + const args = argWords.map(word => word.text); + let i = 0, dir = cwd; + for (; i < args.length; i++) { + const arg = args[i]!; + if (arg === '-C') { dir = args[i + 1] ? resolveTarget(argWords[i + 1]!, cwd, acc.ctx).abs : null; i++; continue; } + if (arg === '-c' || arg === '--git-dir' || arg === '--work-tree' || arg === '--namespace' || arg === '--exec-path') { i++; continue; } + if (arg.startsWith('-')) continue; + break; + } + const sub = args[i] ?? ''; + const restWords = argWords.slice(i + 1); + const rest = restWords.map(word => word.text); + if (GIT_READ.has(sub)) return; + // `git -C ` that changes or cleans that repository changes files outside. + const other = dir !== cwd && dir !== null ? resolveTarget(dir, cwd, acc.ctx) : null; + const elsewhere = other?.where === 'outside' ? { ...other, root: false } : null; + if (sub === 'clean' || sub === 'rm') { + for (const word of restWords.filter(word => !word.text.startsWith('-'))) acc.deletes.push(resolveTarget(word, dir, acc.ctx, true)); + if (elsewhere) acc.deletes.push(elsewhere); + return; + } + if (sub === 'clone') { + const positional = restWords.filter(word => !word.text.startsWith('-')); + const url = positional[0]?.text ?? ''; + acc.writes.push(resolveTarget(positional[1] ?? wordOf(url.replace(/[?#].*$/u, '').replace(/\.git$/u, '').split(/[/:]/u).pop() || 'repo'), dir, acc.ctx)); + return; + } + if (sub === 'worktree' && rest[0] === 'add') { + const dest = restWords.slice(1).find(word => !word.text.startsWith('-')); + if (dest) acc.writes.push(resolveTarget(dest, dir, acc.ctx)); + return; + } + if (elsewhere) acc.writes.push(elsewhere); +} + +// --------------------------------------------------------------------------- +// The whole call +// --------------------------------------------------------------------------- + +/** Converts an argv array (Codex style `["bash","-lc","…"]`) into one command string. */ +export function commandFromArgv(argv: readonly string[]): string { return argv.map(shellQuote).join(' '); } + +export function analyzeAction(action: ToolAction, root: string, options: { home?: string; agentRoots?: readonly string[] } = {}): HardFacts { + const ctx = pathContext(root, options.home, options.agentRoots); + const acc: Acc = { ctx, writes: [], deletes: [], depth: 0, budget: 64, flags: { elevation: false, pipeToShell: false, downloadExec: false, disk: false, forkBomb: false } }; + const commandCwd = action.commandCwd ? resolveTarget(action.commandCwd, ctx.rootReal, ctx) : null; + const cwd = commandCwd ? commandCwd.abs : ctx.rootReal; + if (action.kind === 'shell' && action.command) analyzeText(action.command, cwd, acc); + else if (action.kind === 'edit') acc.writes.push(...action.paths.map(path => resolveTarget(path, cwd, ctx))); + const outside = acc.writes.filter(t => t.where === 'outside' && !t.device && !(t.abs && isAgentServicePath(t.abs, ctx))); + return { + ...acc.flags, + writesOutside: outside.length > 0, + // Deleting the working folder itself counts as deleting outside it. + deletesOutside: acc.deletes.some(t => t.where === 'outside' || t.root), + outsideWrites: outside.map(t => t.abs).filter((abs): abs is string => abs !== null) + }; +} diff --git a/src/main/services/safety/shellParse.ts b/src/main/services/safety/shellParse.ts new file mode 100644 index 00000000..71cb4743 --- /dev/null +++ b/src/main/services/safety/shellParse.ts @@ -0,0 +1,287 @@ +/** + * A bounded, conservative shell lexer for base protection. It never runs anything and never expands anything: + * it only tells code what a command *could* do. Whatever it cannot read with certainty is a fact against the + * command (a substitution, an unresolved variable, an unterminated quote), never for it. + * + * It reads POSIX shells (sh, bash, zsh), and well enough the parts of cmd.exe and PowerShell that + * matter: `&`, `&&`, `||`, `|`, `;`, redirections, and Windows paths (a backslash escapes only a shell + * metacharacter, so `C:\Windows` stays a path). + */ + +export interface Word { + /** The word after quote removal; substitutions and variables stay as written. */ + text: string; + /** Any part was quoted. */ + quoted: boolean; + /** Variable references outside single quotes (`$X`, `${X}`, `%X%`, `$env:X`). */ + vars: string[]; + /** `$(…)`, `` `…` ``, `<(…)`, `>(…)`, `$((…))` inside the word. */ + substitution: boolean; + /** The inner text of each substitution, for a second look (download-and-run). */ + inner: string[]; + /** An unquoted glob character (`*`, `?`, `[`). */ + glob: boolean; + /** Starts with an unquoted `~`. */ + tilde: boolean; +} + +export interface Redirect { op: string; target: Word | null; fdDup: boolean } + +export interface Segment { + words: Word[]; + redirects: Redirect[]; + /** stdin comes from the previous segment's pipe. */ + pipeIn: boolean; + pipeOut: boolean; + /** A heredoc or here-string feeds stdin; the body is kept for shells that read it as a script. */ + heredoc: string | null; +} + +export interface Lexed { + segments: Segment[]; + /** `( … )` or `{ …; }` grouping. */ + grouping: boolean; + /** A trailing or inner `&`. */ + background: boolean; + /** An unterminated quote or substitution: the command cannot be read with certainty. */ + unterminated: boolean; + /** A `#` comment (the text is not kept). */ + comment: boolean; +} + +const SPACE = new Set([' ', '\t', '\r']); +const ESCAPABLE = new Set([' ', '\t', '\n', '\'', '"', '$', '`', '\\', ';', '&', '|', '<', '>', '(', ')', '{', '}', '*', '?', '[', ']', '#', '~', '!']); + +function emptyWord(): Word { return { text: '', quoted: false, vars: [], substitution: false, inner: [], glob: false, tilde: false }; } + +/** The index just past the `)` that closes the `(` at `open`, honouring quotes; -1 when it never closes. */ +function closeParen(text: string, open: number): number { + let depth = 0; + for (let i = open; i < text.length; i++) { + const c = text[i]!; + if (c === '\\') { i++; continue; } + if (c === '\'') { const end = text.indexOf('\'', i + 1); if (end < 0) return -1; i = end; continue; } + if (c === '"') { + let j = i + 1; + for (; j < text.length && text[j] !== '"'; j++) if (text[j] === '\\') j++; + if (j >= text.length) return -1; + i = j; continue; + } + if (c === '(') depth++; + else if (c === ')') { depth--; if (depth === 0) return i + 1; } + } + return -1; +} + +const MAX_INPUT = 65_536; + +export function lexShell(input: string): Lexed { + const text = input.length > MAX_INPUT ? input.slice(0, MAX_INPUT) : input; + const out: Lexed = { segments: [], grouping: false, background: false, unterminated: input.length > MAX_INPUT, comment: false }; + let segment: Segment = { words: [], redirects: [], pipeIn: false, pipeOut: false, heredoc: null }; + let word: Word | null = null; + let pendingRedirect: Redirect | null = null; + const heredocs: Array<{ delimiter: string; strip: boolean; segment: Segment }> = []; + + const finishWord = (): void => { + if (!word) return; + const done = word; word = null; + if (pendingRedirect) { pendingRedirect.target = done; pendingRedirect = null; return; } + // `{` and `}` alone group commands. + if (!done.quoted && (done.text === '{' || done.text === '}')) { out.grouping = true; return; } + segment.words.push(done); + }; + const finishSegment = (pipeOut: boolean): void => { + finishWord(); + if (pendingRedirect) { pendingRedirect = null; out.unterminated = true; } + segment.pipeOut = pipeOut; + if (segment.words.length || segment.redirects.length) out.segments.push(segment); + else if (pipeOut) out.unterminated = true; + segment = { words: [], redirects: [], pipeIn: pipeOut, pipeOut: false, heredoc: null }; + }; + const current = (): Word => (word ??= emptyWord()); + + for (let i = 0; i < text.length; i++) { + const c = text[i]!; + if (c === '\n') { + finishSegment(false); + // Heredoc bodies follow the line that opened them. + while (heredocs.length) { + const doc = heredocs.shift()!; + const lines: string[] = []; + let closed = false; + while (i + 1 < text.length) { + const end = text.indexOf('\n', i + 1); + const line = text.slice(i + 1, end < 0 ? text.length : end); + i = end < 0 ? text.length : end; + if ((doc.strip ? line.replace(/^\t+/u, '') : line) === doc.delimiter) { closed = true; break; } + lines.push(line); + } + if (!closed) out.unterminated = true; + doc.segment.heredoc = lines.join('\n'); + } + continue; + } + if (SPACE.has(c)) { finishWord(); continue; } + if (c === '#' && !word) { + out.comment = true; + const end = text.indexOf('\n', i); + i = (end < 0 ? text.length : end) - 1; + continue; + } + if (c === '\\') { + const next = text[i + 1]; + if (next === '\n') { i++; continue; } + if (next !== undefined && ESCAPABLE.has(next)) { current().text += next; current().quoted = true; i++; continue; } + current().text += c; + continue; + } + if (c === '\'') { + const end = text.indexOf('\'', i + 1); + const w = current(); w.quoted = true; + if (end < 0) { w.text += text.slice(i + 1); out.unterminated = true; i = text.length; continue; } + w.text += text.slice(i + 1, end); i = end; + continue; + } + if (c === '"') { + const w = current(); w.quoted = true; + let j = i + 1; + for (; j < text.length && text[j] !== '"'; j++) { + const d = text[j]!; + if (d === '\\' && j + 1 < text.length && '"\\$`\n'.includes(text[j + 1]!)) { if (text[j + 1] !== '\n') w.text += text[j + 1]; j++; continue; } + if (d === '$' || d === '`') { const used = dollar(text, j, w, out); if (used > j) { j = used - 1; continue; } } + w.text += d; + } + if (j >= text.length) out.unterminated = true; + i = j; + continue; + } + if (c === '$' || c === '`') { + const w = current(); + const used = dollar(text, i, w, out); + if (used > i) { i = used - 1; continue; } + w.text += c; + continue; + } + if ((c === '<' || c === '>') && text[i + 1] === '(') { + const end = closeParen(text, i + 1); + const w = current(); + w.substitution = true; + if (end < 0) { out.unterminated = true; w.text += text.slice(i); w.inner.push(text.slice(i + 2)); i = text.length; continue; } + w.text += text.slice(i, end); w.inner.push(text.slice(i + 2, end - 1)); i = end - 1; + continue; + } + if (c === '<' || c === '>' || (c === '&' && text[i + 1] === '>')) { + // An fd number right before the operator belongs to it (`2>`). + let fd = ''; + const before = word as Word | null; + if (before && !before.quoted && /^\d{1,2}$/u.test(before.text) && !pendingRedirect) { fd = before.text; word = null; } + finishWord(); + let op = c; + let j = i + 1; + if (c === '&') { op = '&>'; j = i + 2; if (text[j] === '>') { op = '&>>'; j++; } } + else if (c === '>') { if (text[j] === '>') { op = '>>'; j++; } else if (text[j] === '|') { op = '>|'; j++; } else if (text[j] === '&') { op = '>&'; j++; } } + else if (text[j] === '<') { op = '<<'; j++; if (text[j] === '<') { op = '<<<'; j++; } else if (text[j] === '-') { op = '<<-'; j++; } } + else if (text[j] === '&') { op = '<&'; j++; } else if (text[j] === '>') { op = '<>'; j++; } + i = j - 1; + const redirect: Redirect = { op: `${fd}${op}`, target: null, fdDup: op === '>&' || op === '<&' }; + segment.redirects.push(redirect); + if (redirect.fdDup) { + // `>&2`, `2>&1`, `>&-`: a descriptor, not a file (a word target after `>&` is bash's `&>`). + const m = /^(\d+|-)/u.exec(text.slice(i + 1)); + if (m) { i += m[0].length; continue; } + redirect.fdDup = false; + } + if (op === '<<' || op === '<<-') { + // The delimiter word follows; its body starts on the next line. + let k = i + 1; + while (SPACE.has(text[k] ?? '')) k++; + const m = /^(['"]?)([A-Za-z0-9_.-]+)\1/u.exec(text.slice(k)); + if (m) { heredocs.push({ delimiter: m[2]!, strip: op === '<<-', segment }); i = k + m[0].length - 1; redirect.target = { ...emptyWord(), text: m[2]!, quoted: true }; continue; } + out.unterminated = true; + continue; + } + pendingRedirect = redirect; + continue; + } + if (c === ';') { finishSegment(false); if (text[i + 1] === ';') i++; continue; } + if (c === '&') { + if (text[i + 1] === '&') { finishSegment(false); i++; continue; } + out.background = true; finishSegment(false); continue; + } + if (c === '|') { + if (text[i + 1] === '|') { finishSegment(false); i++; continue; } + if (text[i + 1] === '&') i++; + finishSegment(true); continue; + } + if (c === '(' || c === ')') { + // `name()` of a function definition, or a subshell group. + out.grouping = true; + finishSegment(false); + continue; + } + const w = current(); + if (!w.quoted && w.text === '' && c === '~') w.tilde = true; + if (c === '*' || c === '?' || c === '[') w.glob = true; + // cmd.exe variables: %NAME%. + if (c === '%') { + const m = /^%([A-Za-z_][A-Za-z0-9_()]*)%/u.exec(text.slice(i)); + if (m) { w.vars.push(m[1]!.toUpperCase()); w.text += m[0]; i += m[0].length - 1; continue; } + } + w.text += c; + } + finishSegment(false); + if (pendingRedirect) out.unterminated = true; + if (heredocs.length) out.unterminated = true; + return out; +} + +/** + * Reads a `$…` or backquote construct at `i` into `w`. Returns the index after it, or `i` when the `$` is a + * plain character. + */ +function dollar(text: string, i: number, w: Word, out: Lexed): number { + const c = text[i]!; + if (c === '`') { + const end = text.indexOf('`', i + 1); + w.substitution = true; + if (end < 0) { out.unterminated = true; w.inner.push(text.slice(i + 1)); w.text += text.slice(i); return text.length; } + w.inner.push(text.slice(i + 1, end)); w.text += text.slice(i, end + 1); + return end + 1; + } + const next = text[i + 1]; + if (next === '(') { + const end = closeParen(text, i + 1); + w.substitution = true; + if (end < 0) { out.unterminated = true; w.inner.push(text.slice(i + 2)); w.text += text.slice(i); return text.length; } + w.inner.push(text.slice(i + 2, end - 1)); w.text += text.slice(i, end); + return end; + } + if (next === '{') { + const end = text.indexOf('}', i + 2); + if (end < 0) { out.unterminated = true; w.text += text.slice(i); w.vars.push('?'); return text.length; } + const name = text.slice(i + 2, end); + w.vars.push(/^[A-Za-z_][A-Za-z0-9_]*$/u.test(name) ? name : `{${name.slice(0, 40)}}`); + w.text += text.slice(i, end + 1); + return end + 1; + } + if (next === '\'') { + // $'…' ANSI-C quoting: escapes can build any text, so it counts as an unresolved value. + const end = text.indexOf('\'', i + 2); + w.quoted = true; w.vars.push('$\''); + if (end < 0) { out.unterminated = true; w.text += text.slice(i + 2); return text.length; } + w.text += text.slice(i + 2, end); + return end + 1; + } + const env = /^\$env:([A-Za-z_][A-Za-z0-9_]*)/iu.exec(text.slice(i)); + if (env) { w.vars.push(`ENV:${env[1]!.toUpperCase()}`); w.text += env[0]; return i + env[0].length; } + const name = /^\$([A-Za-z_][A-Za-z0-9_]*|[0-9@*#?$!-])/u.exec(text.slice(i)); + if (name) { w.vars.push(name[1]!); w.text += name[0]; return i + name[0].length; } + return i; +} + +/** POSIX single-quoting of one argv element, for turning an argv array back into a command string. */ +export function shellQuote(value: string): string { + return /^[A-Za-z0-9_@%+=:,./-]+$/u.test(value) ? value : `'${value.replace(/'/gu, `'\\''`)}'`; +} + diff --git a/src/main/services/sessionRestorePlan.ts b/src/main/services/sessionRestorePlan.ts new file mode 100644 index 00000000..1e596b9f --- /dev/null +++ b/src/main/services/sessionRestorePlan.ts @@ -0,0 +1,93 @@ +import type { ProviderId, SessionRestoreMode, SessionRestoreNote } from "../../shared/contracts.ts"; +import type { PersistedEnvironmentRef, PersistedTerminalSession } from "./TerminalSessionStore.ts"; +import { canResumeLatestConversation, canResumeThreadById, resumeWithoutIdOpensPicker } from "./terminalLaunch.ts"; + +/** + * How a card starts: a new conversation, the provider's own resume without an id + * (its "latest in this folder" flag, or Codex's resume picker), an exact one, or not at all. + */ +export type ResumeRequest = null | "latest" | { threadId: string }; + +export interface RestoreStep { + record: PersistedTerminalSession; + /** "stopped" comes back as a card with Restart / Continue; nothing is launched. */ + launch: ResumeRequest | "stopped"; + note?: SessionRestoreNote; + /** + * The conversation the card stays tied to: the recorded one when nothing starts or + * it is resumed by id, none when a new conversation starts, so Continue never goes + * back to one that is no longer the card's. + */ + threadId?: string; +} + +/** + * Picks how an agent continues its own conversation: by the id its hook reported + * when there is one. Without an id Codex opens its resume picker, and a "latest in + * this folder" flag is used only when this card is the only card of that CLI in the + * folder; otherwise two cards would continue the same conversation, so it starts + * fresh and says so. + */ +export function chooseResume( + provider: ProviderId, + threadId: string | undefined, + cardsOfProviderInFolder: number +): { resume: ResumeRequest; note?: SessionRestoreNote } { + if (threadId && canResumeThreadById(provider)) return { resume: { threadId } }; + if (resumeWithoutIdOpensPicker(provider)) return { resume: "latest" }; + if (!canResumeLatestConversation(provider)) return { resume: null }; + if (cardsOfProviderInFolder <= 1) return { resume: "latest" }; + return { resume: null, note: "fresh-shared-folder" }; +} + +/** The core's one restore order and rule set, the same for every environment. */ +export function planSessionRestore( + records: readonly PersistedTerminalSession[], + mode: SessionRestoreMode, + context: { + isLiveSession(id: string): boolean; + /** False when the environment's plugin is unavailable or its resume answered stopped. */ + environmentAvailable(environment: PersistedEnvironmentRef, record: PersistedTerminalSession): boolean; + /** False when a plugin named in the saved launch options cannot prepare launches now. */ + launchOptionsAvailable?(options: Record): boolean; + } +): RestoreStep[] { + if (mode === "off") return []; + let kept = records.filter((record) => record.restore); + // A subagent comes back only with its parent; a dropped parent drops its subtree. + for (let changed = true; changed;) { + const ids = new Set(kept.map((record) => record.id)); + const next = kept.filter((record) => record.role !== "subagent" + || ids.has(record.parentSessionId ?? "") || context.isLiveSession(record.parentSessionId ?? "")); + changed = next.length !== kept.length; + kept = next; + } + const byId = new Map(kept.map((record) => [record.id, record])); + const depth = (record: PersistedTerminalSession, seen = new Set()): number => { + const parent = record.parentSessionId ? byId.get(record.parentSessionId) : undefined; + if (!parent || seen.has(parent.id)) return 0; + seen.add(record.id); + return 1 + depth(parent, seen); + }; + const ordered = kept + .map((record, index) => ({ record, index, depth: depth(record) })) + .sort((left, right) => left.depth - right.depth || left.index - right.index) + .map(({ record }) => record); + + return ordered.map((record): RestoreStep => { + const recorded = record.threadId ? { threadId: record.threadId } : {}; + if (record.environment && !context.environmentAvailable(record.environment, record)) { + return { record, launch: "stopped", note: "environment-unavailable", ...recorded }; + } + // Never launch without the contribution the person chose: hold the card with its reason. + if (record.options && context.launchOptionsAvailable?.(record.options) === false) { + return { record, launch: "stopped", note: "plugin-unavailable", ...recorded }; + } + if (record.lastState !== "running") return { record, launch: "stopped", ...recorded }; + if (record.provider === "terminal" || mode === "reopen") return { record, launch: null }; + const peers = kept.filter((candidate) => candidate.provider === record.provider && candidate.cwd === record.cwd); + const { resume, note } = chooseResume(record.provider, record.threadId, peers.length); + return { record, launch: resume, ...(note ? { note } : {}), + ...(resume && typeof resume === "object" ? { threadId: resume.threadId } : {}) }; + }); +} diff --git a/src/main/services/terminalLaunch.ts b/src/main/services/terminalLaunch.ts index 0f4e795c..01870626 100644 --- a/src/main/services/terminalLaunch.ts +++ b/src/main/services/terminalLaunch.ts @@ -1,6 +1,7 @@ import { existsSync } from "node:fs"; import { win32 } from "node:path"; import type { ProviderId } from "../../shared/contracts.ts"; +import { normalizeThreadId } from "../../agent-runtime/runtime-protocol.mjs"; import { openCodeYoloEnvironment } from "./openCodeConfig.ts"; import { providerTerminalBatchCommandLine, @@ -19,8 +20,11 @@ interface LaunchResolutionOptions { fileExists?: (path: string) => boolean; providerCli?: ProviderCliResolution; resumePrevious?: boolean; + resumeThreadId?: string; } +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + const WINDOWS_NATIVE_EXTENSIONS = [".exe", ".com"]; export function resolveTerminalLaunch( @@ -50,8 +54,9 @@ export function resolveTerminalLaunch( : undefined; const providerArgs = [ ...(profile === "yolo" && provider !== "opencode" ? DANGEROUS_ARGUMENTS[provider] : []), - ...agentBrowserArgs, - ...(options.resumePrevious ? RESUME_ARGUMENTS[provider] : []) + // Claude Code keeps only the last inline --settings: a plugin's (after the hooks') would silently drop the hooks. + ...(provider === "claude" ? mergeClaudeInlineSettings(agentBrowserArgs) : agentBrowserArgs), + ...(options.resumePrevious ? resolveResumeArguments(provider, options.resumeThreadId) : []) ]; const combinedEnvironment = { ...providerCli.environment, @@ -72,11 +77,54 @@ export function resolveTerminalLaunch( }; } +function resolveResumeArguments( + provider: Exclude, + resumeThreadId?: string +): string[] { + if (provider === "codex") { + if (resumeThreadId) { + if (!UUID_REGEX.test(resumeThreadId)) { + throw new Error(`Invalid Codex thread ID format: "${resumeThreadId}". Expected a canonical UUID.`); + } + return ["resume", resumeThreadId.toLowerCase()]; + } + return ["resume"]; + } + const byId = RESUME_BY_ID_ARGUMENTS[provider]; + if (byId && resumeThreadId) { + const threadId = normalizeThreadId(provider, resumeThreadId); + if (!threadId) throw new Error(`Invalid ${provider} session ID format: "${resumeThreadId}".`); + return byId(threadId); + } + return RESUME_ARGUMENTS[provider]; +} + +// The same exact resume for the other CLIs whose hook reports that CLI's own session +// id, each checked against its --help: `claude -r, --resume [value]`, +// `opencode -s, --session `. Everything else continues with RESUME_ARGUMENTS. +const RESUME_BY_ID_ARGUMENTS: Partial, (id: string) => string[]>> = { + claude: (id) => ["--resume", id], + opencode: (id) => ["--session", id] +}; + +export function canResumeThreadById(provider: ProviderId): boolean { + return provider === "codex" || (provider !== "terminal" && RESUME_BY_ID_ARGUMENTS[provider] !== undefined); +} + +/** Without an id, Codex opens its own resume picker, so the person chooses; nothing is guessed. */ +export function resumeWithoutIdOpensPicker(provider: ProviderId): boolean { + return provider === "codex"; +} + +/** The CLI has a "latest conversation in this folder" flag. */ +export function canResumeLatestConversation(provider: ProviderId): boolean { + return provider !== "terminal" && provider !== "codex" && RESUME_ARGUMENTS[provider].length > 0; +} + // Per-provider instead of a fallthrough: the old `return ["--continue"]` default would // have handed an unverified flag to whatever provider was added next. A missing entry is // now a compile error. -const RESUME_ARGUMENTS: Record, string[]> = { - codex: ["resume", "--last"], +const RESUME_ARGUMENTS: Record, string[]> = { claude: ["--continue"], qwen: ["--continue"], kimi: ["--continue"], @@ -180,3 +228,84 @@ function findWindowsNativeCommand( } return null; } + +/** + * Claude Code 2.1 applies only the last `--settings` it is given (measured with 2.1.281: a hook in an earlier inline + * JSON never ran). Every inline JSON value is merged into the first one, in order: hook lists are concatenated per + * event, objects such as `env` are merged key by key (later wins), other keys are replaced. A settings file path is + * left alone. + */ +export function mergeClaudeInlineSettings(args: readonly string[]): string[] { + const positions: number[] = []; + for (let index = 0; index < args.length - 1; index++) { + if (args[index] === "--settings" && parseInlineSettings(args[index + 1]!)) positions.push(index); + } + if (positions.length < 2) return [...args]; + const merged: Record = {}; + for (const position of positions) { + for (const [key, value] of Object.entries(parseInlineSettings(args[position + 1]!)!)) { + const current = merged[key]; + if (key === "hooks" && plainObject(current) && plainObject(value)) { + const hooks: Record = { ...current }; + for (const [event, list] of Object.entries(value)) { + const earlier = hooks[event]; + hooks[event] = Array.isArray(earlier) && Array.isArray(list) ? [...earlier, ...list] : list; + } + merged[key] = hooks; + } else if (plainObject(current) && plainObject(value)) merged[key] = { ...current, ...value }; + else merged[key] = value; + } + } + const drop = new Set(positions.slice(1).flatMap((position) => [position, position + 1])); + const next = args.filter((_argument, index) => !drop.has(index)); + next[positions[0]! + 1] = JSON.stringify(merged); + return next; +} + +function parseInlineSettings(value: string): Record | null { + if (!value.trimStart().startsWith("{")) return null; + try { + const parsed: unknown = JSON.parse(value); + return plainObject(parsed) ? parsed : null; + } catch { + return null; + } +} + +function plainObject(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +// What a plugin launch contributor may never append. A trusted plugin already runs as the +// user, so this is not a sandbox: it keeps an ordinary launch from being turned into an +// unattended one behind the profile the person chose, and leaves conversation selection +// to the core's restore rules. Every provider's bypass flag is listed for every provider. +const CORE_OWNED_FLAGS = new Set([ + ...Object.values(DANGEROUS_ARGUMENTS).flat().filter((argument) => argument.startsWith("-")), + "--full-auto", "--ask-for-approval", "--sandbox", "--permission-mode", "--approval-mode", + "--continue", "--resume", "--session", "--last", "--conversation", "--fork-session" +]); +const CORE_OWNED_SHORT_FLAGS: Partial> = { + claude: ["-c", "-r"], + cursor: ["-c", "-r"], + qwen: ["-c", "-r", "-y"], + opencode: ["-c", "-s"], + codex: ["-a", "-s"] +}; +const CORE_OWNED_WORDS = /dangerously|approval_policy|sandbox_mode|bypass/i; +const CORE_OWNED_SUBCOMMANDS: Partial> = { + codex: ["resume", "fork", "exec"] +}; + +/** Claude inline settings keys that decide approvals or the hooks; a plugin's `--settings` may carry e.g. `env` only. */ +const CLAUDE_CORE_SETTINGS = ["permissions", "hooks", "disableAllHooks", "sandbox", "defaultMode", "apiKeyHelper"]; + +export function coreOwnedLaunchArgument(provider: ProviderId, argument: string): boolean { + const flag = argument.split("=", 1)[0]; + const inline = provider === "claude" ? parseInlineSettings(argument) : null; + if (inline && CLAUDE_CORE_SETTINGS.some((key) => key in inline)) return true; + return CORE_OWNED_FLAGS.has(flag) + || Boolean(CORE_OWNED_SHORT_FLAGS[provider]?.includes(flag)) + || Boolean(CORE_OWNED_SUBCOMMANDS[provider]?.includes(argument)) + || CORE_OWNED_WORDS.test(argument); +} diff --git a/src/preload/index.ts b/src/preload/index.ts index f864442f..afe16c9a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -17,8 +17,10 @@ import type { PluginBrowserOpenResponse, PluginCanvasRequest, PluginLauncherRequest, + PluginServiceEvent, PluginStorageChangeEvent, PluginUpdateStatus, + ProviderId, SessionBounds, SessionEvent, SessionRemovedEvent, @@ -98,6 +100,20 @@ const api: CanvasTTYApi = { setHookEnabled: (pluginId: string, hookId: string, enabled: boolean) => ( ipcRenderer.invoke(IPC.pluginsSetHookEnabled, pluginId, hookId, enabled) ), + setNativeCodeTrusted: (pluginId: string, trusted: boolean) => ( + ipcRenderer.invoke(IPC.pluginsSetNativeCodeTrusted, pluginId, trusted) + ), + setDecisionsMayAllow: (pluginId: string, allowed: boolean) => ( + ipcRenderer.invoke(IPC.pluginsSetDecisionsMayAllow, pluginId, allowed) + ), + serviceReport: (pluginId: string) => ipcRenderer.invoke(IPC.pluginsServiceReport, pluginId), + serviceRequest: (pluginId: string, serviceId: string, method: string, params: unknown) => ( + ipcRenderer.invoke(IPC.pluginsServiceRequest, pluginId, serviceId, method, params) + ), + onServiceEvent: (listener: (event: PluginServiceEvent) => void) => subscribe(IPC.pluginsServiceEvent, listener), + launchFieldOptions: (pluginId: string, provider: ProviderId) => ( + ipcRenderer.invoke(IPC.pluginsLaunchFieldOptions, pluginId, provider) + ), uninstall: (pluginId: string) => ipcRenderer.invoke(IPC.pluginsUninstall, pluginId), openCanvas: (pluginId: string, contributionId: string, sourceCanvasInstanceId?: string) => ( ipcRenderer.invoke(IPC.pluginsOpenCanvas, pluginId, contributionId, sourceCanvasInstanceId) @@ -188,12 +204,13 @@ const api: CanvasTTYApi = { list: () => ipcRenderer.invoke(IPC.terminalList), readBuffer: (id: string) => ipcRenderer.invoke(IPC.terminalReadBuffer, id), create: (request: CreateSessionRequest) => ipcRenderer.invoke(IPC.terminalCreate, request), - restart: (id: string) => ipcRenderer.invoke(IPC.terminalRestart, id), + restart: (id: string, options?: { resume?: boolean }) => ipcRenderer.invoke(IPC.terminalRestart, id, options), input: (id: string, data: string) => ipcRenderer.send(IPC.terminalInput, id, data), resize: (id: string, cols: number, rows: number) => ipcRenderer.send(IPC.terminalResize, id, cols, rows), setBounds: (id: string, bounds: SessionBounds) => ipcRenderer.send(IPC.terminalBounds, id, bounds), rename: (id: string, title: string) => ipcRenderer.invoke(IPC.terminalRename, id, title), - dispose: (id: string) => ipcRenderer.invoke(IPC.terminalDispose, id), + setRestore: (id: string, restore: boolean) => ipcRenderer.invoke(IPC.terminalSetRestore, id, restore), + dispose: (id: string, options?: { keepEnvironmentData?: boolean }) => ipcRenderer.invoke(IPC.terminalDispose, id, options), setVisible: (id: string, visible: boolean) => ipcRenderer.send(IPC.terminalSetVisible, id, visible), onData: (listener: (event: TerminalDataEvent) => void) => subscribe(IPC.terminalData, listener), onSession: (listener: (event: SessionEvent) => void) => subscribe(IPC.terminalSession, listener), diff --git a/src/preload/plugin.ts b/src/preload/plugin.ts index 50d3c7fb..a3f28176 100644 --- a/src/preload/plugin.ts +++ b/src/preload/plugin.ts @@ -2,6 +2,7 @@ import { ipcRenderer } from "electron"; const PLUGIN_HOST_INVOKE = "plugins:host-invoke"; const PLUGIN_STORAGE_CHANGED = "plugins:storage-changed"; +const PLUGIN_SERVICE_EVENT = "plugins:service-event"; const pluginId = argument("--canvastty-plugin-id="); const contributionId = argument("--canvastty-contribution-id="); @@ -51,6 +52,12 @@ ipcRenderer.on(PLUGIN_STORAGE_CHANGED, (_event, change: unknown) => { window.postMessage({ source: "canvastty-host", type: "storage-change", key: change.key, value: change.value }, "*"); }); +ipcRenderer.on(PLUGIN_SERVICE_EVENT, (_event, message: unknown) => { + if (!isRecord(message) || message.pluginId !== pluginId) return; + const { serviceId, event, data } = message; + window.postMessage({ source: "canvastty-host", type: "service-event", value: { serviceId, event, data } }, "*"); +}); + function argument(prefix: string): string { const value = process.argv.find((candidate) => candidate.startsWith(prefix))?.slice(prefix.length); if (!value) throw new Error("CanvasTTY plugin window identity is missing."); diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 754bc1ad..f6dfdd06 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -14,6 +14,8 @@ import type { InstalledPlugin, LaunchProfileId, LaunchRole, + PluginLaunchValues, + SessionEnvironmentChoice, LimitsSnapshot, Point, PluginContribution, @@ -41,6 +43,7 @@ import { normalizeExternalUrl } from "../../shared/externalUrl"; import { TitleBar } from "./components/TitleBar"; import { Toast } from "./components/Toast"; import { AgentLaunchDialog } from "./features/launcher/AgentLaunchDialog"; +import { environmentOptions } from "./features/launcher/LaunchOptionsSection"; import { SettingsPanel } from "./features/settings/SettingsPanel"; import { resolveAppearanceSettings } from "./features/settings/appearanceSettings"; import { persistSettingsUpdate } from "./features/settings/persistSettings"; @@ -72,7 +75,7 @@ interface HomeEditDraft { const FALLBACK_SETTINGS: AppSettings = { locale: "ru", - restoreTerminalSessions: false, + sessionRestoreMode: "off", persistCanvasRegions: true, persistStickyNotes: true, palette: "sage", @@ -86,6 +89,7 @@ const FALLBACK_SETTINGS: AppSettings = { radialLauncherItems: [...DEFAULT_RADIAL_LAUNCHER_ITEMS], radialLauncherEnabled: false, agentLifecycleHooksEnabled: true, + baseProtectionEnabled: true, uiScale: DEFAULT_UI_SCALE, canvasColor: "sage", pattern: "dots", @@ -208,7 +212,7 @@ export function App(): React.JSX.Element { const settingsRef = useRef(settings); settingsRef.current = settings; const pluginBrowserOpenQueueRef = useRef(new PluginBrowserOpenQueue()); - const [launchProvider, setLaunchProvider] = useState(null); + const [launchProvider, setLaunchProvider] = useState(null); const [launchPosition, setLaunchPosition] = useState(null); const [settingsOpen, setSettingsOpen] = useState(false); const [homeEditDraft, setHomeEditDraft] = useState(null); @@ -368,7 +372,9 @@ export function App(): React.JSX.Element { profile: LaunchProfileId, cwd: string, requestedCenter?: Point, - role: LaunchRole = "agent" + role: LaunchRole = "agent", + launchOptions?: Record, + environment?: SessionEnvironmentChoice ): Promise => { const currentSettings = settingsRef.current; const position = requestedCenter @@ -391,7 +397,10 @@ export function App(): React.JSX.Element { : { position, size: DEFAULT_SESSION_SIZE }; if (reservation) pendingSessionPlacements.current.push(reservation); try { - const session = await window.canvasTTY.terminal.create({ provider, profile, cwd, position, role }); + const session = await window.canvasTTY.terminal.create({ + provider, profile, cwd, position, role, ...(launchOptions ? { launchOptions } : {}), + ...(environment ? { environment } : {}) + }); sessionsRef.current = upsertSnapshot(sessionsRef.current, session); setSessions((current) => upsertSnapshot(current, session)); setActiveSessionId(session.id); @@ -407,13 +416,20 @@ export function App(): React.JSX.Element { }, [saveSettings]); const openTerminal = useCallback(async (position?: Point): Promise => { + // With a trusted plugin environment for terminals, ask where it runs; otherwise open at once as always. + const installed = await window.canvasTTY.plugins.list().catch(() => plugins); + if (environmentOptions(installed, "terminal").length > 0) { + setLaunchPosition(position ?? null); + setLaunchProvider("terminal"); + return; + } try { await createSession("terminal", "normal", settings.lastDirectory, position); showToast(t(settings.locale, "terminalStarted")); } catch (error) { showToast(error instanceof Error ? error.message : t(settings.locale, "launchFailed")); } - }, [createSession, settings.lastDirectory, settings.locale, showToast]); + }, [createSession, plugins, settings.lastDirectory, settings.locale, showToast]); const openAgent = useCallback((provider: AgentProviderId, position?: Point): void => { if (!agentAvailability?.[provider]) { @@ -431,19 +447,21 @@ export function App(): React.JSX.Element { const launchAgent = useCallback(async ( - provider: AgentProviderId, + provider: ProviderId, profile: LaunchProfileId, cwd: string, - role: LaunchRole + role: LaunchRole, + launchOptions?: Record, + environment?: SessionEnvironmentChoice ): Promise => { - await createSession(provider, profile, cwd, launchPosition ?? undefined, role); + await createSession(provider, profile, cwd, launchPosition ?? undefined, role, launchOptions, environment); setLaunchPosition(null); - showToast(`${t(settings.locale, "sessionStarted")}: ${provider}`); + showToast(provider === "terminal" ? t(settings.locale, "terminalStarted") : `${t(settings.locale, "sessionStarted")}: ${provider}`); }, [createSession, launchPosition, settings.locale, showToast]); - const restartSession = useCallback(async (id: string): Promise => { + const restartSession = useCallback(async (id: string, resume = false): Promise => { try { - await window.canvasTTY.terminal.restart(id); + await window.canvasTTY.terminal.restart(id, { resume }); showToast(t(settings.locale, "sessionRestarted")); } catch (error) { showToast(error instanceof Error ? error.message : t(settings.locale, "restartFailed")); @@ -709,8 +727,8 @@ export function App(): React.JSX.Element { setCamera(focusCamera(settings.browserCanvas.position, settings.browserCanvas.size)); }, [settings.browserCanvas]); - const disposeSession = useCallback((id: string): void => { - void window.canvasTTY.terminal.dispose(id); + const disposeSession = useCallback((id: string, keepEnvironmentData?: boolean): void => { + void window.canvasTTY.terminal.dispose(id, keepEnvironmentData === undefined ? undefined : { keepEnvironmentData }); setSessions((current) => current.filter((session) => session.id !== id)); setActiveSessionId((current) => current === id ? null : current); setRenamingSessionId((current) => current === id ? null : current); @@ -841,6 +859,26 @@ export function App(): React.JSX.Element { } }, [refreshPlugins]); + const setPluginNativeCodeTrusted = useCallback(async (pluginId: string, trusted: boolean): Promise => { + try { + const updated = await window.canvasTTY.plugins.setNativeCodeTrusted(pluginId, trusted); + setPlugins((current) => current.map((plugin) => plugin.manifest.id === pluginId ? updated : plugin)); + } catch (error) { + await refreshPlugins().catch(() => undefined); + throw error; + } + }, [refreshPlugins]); + + const setPluginDecisionsMayAllow = useCallback(async (pluginId: string, allowed: boolean): Promise => { + try { + const updated = await window.canvasTTY.plugins.setDecisionsMayAllow(pluginId, allowed); + setPlugins((current) => current.map((plugin) => plugin.manifest.id === pluginId ? updated : plugin)); + } catch (error) { + await refreshPlugins().catch(() => undefined); + throw error; + } + }, [refreshPlugins]); + const setPluginModules = useCallback(async (pluginId: string, selectedModules: string[]): Promise => { let updated: InstalledPlugin; try { @@ -1214,6 +1252,8 @@ export function App(): React.JSX.Element { onSetPluginModules={setPluginModules} onSetPluginEnabled={setPluginEnabled} onSetPluginHookEnabled={setPluginHookEnabled} + onSetPluginNativeCodeTrusted={setPluginNativeCodeTrusted} + onSetPluginDecisionsMayAllow={setPluginDecisionsMayAllow} onUninstallPlugin={uninstallPlugin} onOpenPluginContribution={openPluginContribution} onToggleHomeWidget={toggleHomeWidget} diff --git a/src/renderer/src/features/launcher/AgentLaunchDialog.tsx b/src/renderer/src/features/launcher/AgentLaunchDialog.tsx index ae57650c..af0c7abd 100644 --- a/src/renderer/src/features/launcher/AgentLaunchDialog.tsx +++ b/src/renderer/src/features/launcher/AgentLaunchDialog.tsx @@ -1,24 +1,36 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import type { AgentProviderId, AppSettings, LaunchProfileId, - LaunchRole + LaunchRole, + PluginLaunchValues, + ProviderId, + SessionEnvironmentChoice } from "../../../../shared/contracts"; import { ProviderIcon } from "../../components/ProviderIcon"; import { UiIcon } from "../../components/UiIcon"; import { t } from "../../lib/i18n"; import { PROVIDERS } from "../../lib/providers"; import { directoryPathFromClipboard } from "../../lib/directoryPathFromClipboard"; +import { LaunchOptionsSection } from "./LaunchOptionsSection"; interface AgentLaunchDialogProps { - provider: AgentProviderId | null; + /** "terminal" opens it only while a plugin environment applies to terminals (folder and Where). */ + provider: ProviderId | null; settings: AppSettings; onClose(): void; onAcknowledge(provider: AgentProviderId): Promise; /** Persists `agentControlEnabled: true`; only ever called from the explicit button. */ onEnableAgentControl(): Promise; - onLaunch(provider: AgentProviderId, profile: LaunchProfileId, cwd: string, role: LaunchRole): Promise; + onLaunch( + provider: ProviderId, + profile: LaunchProfileId, + cwd: string, + role: LaunchRole, + launchOptions?: Record, + environment?: SessionEnvironmentChoice + ): Promise; } export function AgentLaunchDialog({ @@ -35,6 +47,10 @@ export function AgentLaunchDialog({ const [confirmDanger, setConfirmDanger] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + const [launchOptions, setLaunchOptions] = useState>({}); + const changeLaunchOptions = useCallback((options: Record) => setLaunchOptions(options), []); + const [environment, setEnvironment] = useState(null); + const changeEnvironment = useCallback((choice: SessionEnvironmentChoice | null) => setEnvironment(choice), []); const locale = settings.locale; useEffect(() => { @@ -57,8 +73,9 @@ export function AgentLaunchDialog({ if (!provider) return null; - const acknowledged = settings.acknowledgedDangerousProfiles.includes(provider); - const dangerKey = PROVIDERS[provider].dangerKey!; + const isTerminal = provider === "terminal"; + const acknowledged = isTerminal || settings.acknowledgedDangerousProfiles.includes(provider); + const dangerKey = PROVIDERS[provider].dangerKey ?? "confirmLaunch"; // An orchestrator without the endpoint would be a plain session with a // misleading badge, so the launch waits for the explicit enable button. const endpointMissing = role === "orchestrator" && !settings.agentControlEnabled; @@ -107,8 +124,9 @@ export function AgentLaunchDialog({ setBusy(true); setError(null); try { - if (profile === "yolo" && !acknowledged) await onAcknowledge(provider); - await onLaunch(provider, profile, cwd, role); + if (profile === "yolo" && !acknowledged && !isTerminal) await onAcknowledge(provider); + await onLaunch(provider, profile, cwd, role, Object.keys(launchOptions).length > 0 ? launchOptions : undefined, + environment ?? undefined); onClose(); } catch (reason) { setError(reason instanceof Error ? reason.message : t(locale, "launchFailed")); @@ -121,7 +139,7 @@ export function AgentLaunchDialog({
{ if (event.target === event.currentTarget) onClose(); }}> -
+
@@ -144,12 +162,13 @@ export function AgentLaunchDialog({
-
+ {!isTerminal &&
-
+
}
+ {!isTerminal && <> + } @@ -177,6 +197,8 @@ export function AgentLaunchDialog({
)} + + {profile === "yolo" && (
{t(locale, dangerKey)} diff --git a/src/renderer/src/features/launcher/LaunchOptionsSection.tsx b/src/renderer/src/features/launcher/LaunchOptionsSection.tsx new file mode 100644 index 00000000..a84e5ad6 --- /dev/null +++ b/src/renderer/src/features/launcher/LaunchOptionsSection.tsx @@ -0,0 +1,187 @@ +import { useEffect, useState } from "react"; +import type { + InstalledPlugin, + LocaleId, + PluginEnvironmentKind, + PluginLaunchField, + PluginLaunchFieldOptions, + PluginLaunchValues, + ProviderId, + SessionEnvironmentChoice +} from "../../../../shared/contracts"; +import { t } from "../../lib/i18n"; +import { withServiceOptions } from "./launchFieldOptions"; + +interface LaunchOptionPlugin { + pluginId: string; + name: string; + fields: PluginLaunchField[]; +} + +interface EnvironmentOption { + pluginId: string; + name: string; + kind: PluginEnvironmentKind; +} + +/** Plugins whose trusted launch service offers options for this agent. A plain terminal takes none. */ +export function launchOptionPlugins(plugins: readonly InstalledPlugin[], provider: ProviderId): LaunchOptionPlugin[] { + if (provider === "terminal") return []; + return plugins.flatMap((plugin) => { + const launch = plugin.manifest.services?.find((service) => service.launch)?.launch; + if (!plugin.enabled || !plugin.nativeCodeTrusted || !launch) return []; + if (!plugin.manifest.permissions.includes("launch:contribute")) return []; + if (launch.appliesTo && !launch.appliesTo.includes(provider)) return []; + // A policy-only contributor has nothing to choose: it is asked on every launch anyway. + if (launch.policy && launch.fields.length === 0) return []; + return [{ pluginId: plugin.manifest.id, name: plugin.manifest.name, fields: launch.fields }]; + }).sort((left, right) => left.pluginId.localeCompare(right.pluginId)); +} + +/** Environment kinds of trusted plugin services that apply to this provider ("Where"). */ +export function environmentOptions(plugins: readonly InstalledPlugin[], provider: ProviderId): EnvironmentOption[] { + return plugins.flatMap((plugin) => { + const kinds = plugin.manifest.services?.flatMap((service) => service.environments ?? []) ?? []; + if (!plugin.enabled || !plugin.nativeCodeTrusted || kinds.length === 0) return []; + if (!plugin.manifest.permissions.includes("environment:provide")) return []; + return kinds + .filter((kind) => !kind.appliesTo || kind.appliesTo.includes(provider)) + .map((kind) => ({ pluginId: plugin.manifest.id, name: plugin.manifest.name, kind })); + }).sort((left, right) => left.pluginId.localeCompare(right.pluginId)); +} + +function defaults(fields: readonly PluginLaunchField[]): PluginLaunchValues { + return Object.fromEntries(fields.map((field) => [field.key, field.default + ?? (field.kind === "boolean" ? false : field.kind === "select" ? field.options?.[0]?.value ?? "" : "")])); +} + +function FieldInputs({ fields, values, onChange }: { + fields: readonly PluginLaunchField[]; + values: PluginLaunchValues; + onChange(values: PluginLaunchValues): void; +}): React.JSX.Element { + return ( + <> + {fields.map((field) => ( + + ))} + + ); +} + +/** + * The launcher's "Advanced" section: where the session runs (this computer unless the person picks a + * plugin environment), then one block per launch plugin, off until the person chooses it. Only chosen + * plugins' values are returned, and only those plugins prepare the launch. + */ +export function LaunchOptionsSection({ provider, locale, onChange, onEnvironmentChange }: { + provider: ProviderId; + locale: LocaleId; + onChange(options: Record): void; + onEnvironmentChange(environment: SessionEnvironmentChoice | null): void; +}): React.JSX.Element | null { + const [plugins, setPlugins] = useState([]); + const [offered, setOffered] = useState>({}); + const [environments, setEnvironments] = useState([]); + const [chosen, setChosen] = useState>({}); + const [where, setWhere] = useState(null); + + useEffect(() => { + let active = true; + setChosen({}); + setWhere(null); + setOffered({}); + void window.canvasTTY.plugins.list().then((installed) => { + if (!active) return; + const available = launchOptionPlugins(installed, provider); + setPlugins(available); + setEnvironments(environmentOptions(installed, provider)); + // Selects filled by the plugin's service (its accounts, say): asked once per launcher, never blocking it. + for (const plugin of available.filter((entry) => entry.fields.some((field) => field.optionsFrom === "service"))) { + void window.canvasTTY.plugins.launchFieldOptions(plugin.pluginId, provider).then((options) => { + if (active) setOffered((current) => ({ ...current, [plugin.pluginId]: options })); + }).catch(() => undefined); + } + }).catch(() => undefined); + return () => { active = false; }; + }, [provider]); + + useEffect(() => onChange(chosen), [chosen, onChange]); + useEffect(() => onEnvironmentChange(where), [where, onEnvironmentChange]); + + if (plugins.length === 0 && environments.length === 0) return null; + const update = (pluginId: string, values: PluginLaunchValues | null): void => { + setChosen((current) => { + const next = { ...current }; + if (values) next[pluginId] = values; + else delete next[pluginId]; + return next; + }); + }; + const selectedIndex = where + ? environments.findIndex((option) => option.pluginId === where.pluginId && option.kind.kind === where.kind) + : -1; + const selected = selectedIndex >= 0 ? environments[selectedIndex] : undefined; + + return ( +
0 && provider === "terminal"}> + {t(locale, "launchAdvanced")} + {environments.length > 0 && ( +
+ + {selected && where?.options && ( + setWhere({ ...where, options })} /> + )} +
+ )} + {plugins.map((plugin) => { + const values = chosen[plugin.pluginId]; + const fields = withServiceOptions(plugin.fields, offered[plugin.pluginId]); + return ( +
+ + {values && update(plugin.pluginId, next)} />} +
+ ); + })} +
+ ); +} diff --git a/src/renderer/src/features/launcher/launchFieldOptions.ts b/src/renderer/src/features/launcher/launchFieldOptions.ts new file mode 100644 index 00000000..b20db400 --- /dev/null +++ b/src/renderer/src/features/launcher/launchFieldOptions.ts @@ -0,0 +1,11 @@ +import type { PluginLaunchField, PluginLaunchFieldOptions } from "../../../../shared/contracts"; + +/** Declared choices first, then those the plugin's service offered for `optionsFrom: "service"` selects. */ +export function withServiceOptions(fields: readonly PluginLaunchField[], offered: PluginLaunchFieldOptions | undefined): PluginLaunchField[] { + return fields.map((field) => { + const extra = field.optionsFrom === "service" ? offered?.[field.key] : undefined; + if (!extra?.length) return field; + const declared = new Set(field.options?.map((option) => option.value)); + return { ...field, options: [...(field.options ?? []), ...extra.filter((option) => !declared.has(option.value))] }; + }); +} diff --git a/src/renderer/src/features/plugins/PluginFrame.tsx b/src/renderer/src/features/plugins/PluginFrame.tsx index 8d352e06..8e359530 100644 --- a/src/renderer/src/features/plugins/PluginFrame.tsx +++ b/src/renderer/src/features/plugins/PluginFrame.tsx @@ -170,6 +170,16 @@ export function PluginFrame({ postToFrame(frame.current, { source: "canvastty-host", type: "storage-change", key, value }); }), [plugin.manifest.id]); + const hasServices = Boolean(plugin.manifest.services?.length); + useEffect(() => { + if (!hasServices) return; + const pluginId = plugin.manifest.id; + return window.canvasTTY.plugins.onServiceEvent(({ pluginId: owner, serviceId, event, data }) => { + if (owner !== pluginId) return; + postToFrame(frame.current, { source: "canvastty-host", type: "service-event", value: { serviceId, event, data } }); + }); + }, [hasServices, plugin.manifest.id]); + return (