diff --git a/CHANGELOG.md b/CHANGELOG.md index bf3c1d5e..b5fcc6ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,14 @@ [English](CHANGELOG.md) · [Русский](CHANGELOG.ru.md) · [简体中文](CHANGELOG.zh-CN.md) -## Unreleased +## 1.7.0 +- Added pixel terminal skins and agent-generated theme packs from PR #98, with independent Canvas backgrounds and terminal borders. Theme creation now includes inline instructions, labeled upload slots, an example, and explicit preview guidance. Canvas patterns appear before background selection and explain when an image overrides them. +- Restored readable terminal summary tiles when zoomed out, enabled Master artwork automatically for orchestrators, and restored edge/corner resizing for pixel skins without resetting manually chosen or restored sizes. +- Integrated PR #100: startup navigation race fixes, safe provider API-key pasting, recovery from uncaught renderer errors, and protection for CanvasTTY's private control data. PR #100 consolidates the earlier fixes from #96, #97, and #99. +- Updated the SAGE application icon, monochrome title-bar branding, and documentation assets. Fixed the launcher layout when Normal, Auto, and YOLO profiles are available, and made shutdown tolerant of closed stdout/stderr pipes. + +- Base protection now also refuses any use of CanvasTTY's own private data by an agent's shell or file tool: reading, copying or encoding the agent-control token and descriptor, the gateways' connection records, the provider and plugin secret stores, account homes, the GitHub sign-in and prepared launch runs (by any program, interpreter one-liners and heredocs included), and connecting to CanvasTTY's control or runtime sockets (`curl --unix-socket`, `nc -U`, `socat`, a Python socket). The model is told calmly that agents cannot control CanvasTTY this way and to ask the person for an **Orchestrator** launch, which brings the `canvastty_agents` tools. The paths come from the app's own userData folder; the project, the app's settings, other sockets and the bundled control CLI are unaffected. The control endpoint now answers an unauthenticated or malformed request, and an HTTP request (a minimal 403), with the same guidance instead of a bare error, and closes the connection. - Added an **Auto** launch profile for agents whose CLI has a native auto mode, next to Normal (still the default) and YOLO: Codex `--approve-for-me` (its own reviewer in its `workspace-write` sandbox), Claude Code `--permission-mode auto` with its sandbox (`sandbox.enabled`, `autoAllowBashIfSandboxed: false`, merged into the one `--settings`), Grok `--permission-mode auto`; also the control CLI's `create --profile auto` and plugin `sessions.create`. A launch contributor may answer `thirdPartyModel: true` (an API or Ollama account): Auto then runs as the CLI's accept-edits mode in the same sandbox, and the card shows **auto · edits**. Codex no longer stops at "Hooks need review" for the hooks CanvasTTY adds itself (per-run `-c hooks.state`, nothing written to `~/.codex`; plugins cannot pass `-c hooks…`), and a Codex subagent in (or below) the folder the person chose for its orchestrator is not asked to trust it again (per-run `-c projects`); plugins get that folder as `trustedFolder`. Claude Code's «✳» title now reads as idle: a hooked Claude card leaves `needs_approval` only through its hooks, or, when the person declined its prompt, a moment after the answer. Example: `examples/plugins/launch-env` (Local model profile). - 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). diff --git a/CHANGELOG.ru.md b/CHANGELOG.ru.md index 514e150b..b4de8fb1 100644 --- a/CHANGELOG.ru.md +++ b/CHANGELOG.ru.md @@ -2,8 +2,14 @@ [English](CHANGELOG.md) · [Русский](CHANGELOG.ru.md) · [简体中文](CHANGELOG.zh-CN.md) -## Unreleased +## 1.7.0 +- Добавлены пиксельные скины терминалов и создаваемые агентом пакеты тем из PR #98 с независимым выбором фона Canvas и рамок терминалов. В создание темы добавлены краткая инструкция, подписанные ячейки загрузки, пример и пояснения предсмотра. Рисунок Canvas расположен перед выбором фона с пояснением, когда изображение его заменяет. +- Возвращены читаемые плитки терминалов при отдалении, автоматический Master-скин для оркестраторов и ресайз пиксельных окон за края и углы без сброса выбранных или восстановленных размеров. +- Включён PR #100: исправлены гонка навигации при запуске, вставка API-ключей и восстановление после необработанных ошибок интерфейса; защищены приватные управляющие данные CanvasTTY. PR #100 объединяет предыдущие исправления из #96, #97 и #99. +- Обновлены иконка приложения в палитре SAGE, монохромный логотип заголовка и графика документации. Исправлены компоновка запуска с профилями «Обычный», «Авто», YOLO и завершение работы при закрытых stdout/stderr. + +- Базовая защита теперь также запрещает shell- и файловым инструментам агента любые обращения к собственным закрытым данным CanvasTTY: чтение, копирование или кодирование токена и дескриптора agent-control, файлов подключения шлюзов, хранилищ секретов провайдеров и плагинов, домашних папок аккаунтов, входа GitHub и подготовленных запусков (любой программой, включая однострочники интерпретаторов и heredoc), а также подключение к управляющим и runtime-сокетам CanvasTTY (`curl --unix-socket`, `nc -U`, `socat`, сокет Python). Модель спокойно получает объяснение, что так управлять CanvasTTY нельзя, и совет попросить человека запустить её с ролью **Orchestrator**, которая даёт инструменты `canvastty_agents`. Пути берутся из собственной папки userData приложения; проект, настройки приложения, другие сокеты и встроенный CLI управления не затронуты. Управляющий endpoint теперь отвечает на неаутентифицированный или некорректный запрос, а также на HTTP-запрос (минимальный 403) тем же объяснением вместо голой ошибки и закрывает соединение. - Добавлен профиль запуска **Авто** для агентов, у чьего CLI есть собственный авторежим, рядом с «Обычным» (он остаётся по умолчанию) и YOLO: Codex `--approve-for-me` (его собственная проверка в песочнице `workspace-write`), Claude Code `--permission-mode auto` с его песочницей (`sandbox.enabled`, `autoAllowBashIfSandboxed: false`, в единственном `--settings`), Grok `--permission-mode auto`; также `create --profile auto` в CLI управления и `sessions.create` плагинов. Вклад запуска может ответить `thirdPartyModel: true` (аккаунт API или Ollama): тогда «Авто» работает как режим «только правки» того же CLI в той же песочнице, а окно показывает **авто · правки**. Codex больше не останавливается на «Hooks need review» для хуков, которые добавляет сам CanvasTTY (`-c hooks.state` на этот запуск, в `~/.codex` ничего не пишется; плагины не могут передать `-c hooks…`), а субагента Codex в папке, выбранной человеком для его оркестратора (или внутри неё), не спрашивают о доверии к ней снова (`-c projects` на этот запуск); плагины получают эту папку как `trustedFolder`. Заголовок Claude Code «✳» теперь читается как «ожидает»: окно Claude с хуками выходит из `needs_approval` только по хукам, а если человек отклонил запрос — вскоре после ответа. Пример: `examples/plugins/launch-env` (профиль «Local model»). - Добавлены две точки запуска для плагинов учётных записей. Список (`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` (его сервис ещё и читает токен, сохранённый страницей). diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index 1b609785..89ae5eb3 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -2,8 +2,14 @@ [English](CHANGELOG.md) · [Русский](CHANGELOG.ru.md) · [简体中文](CHANGELOG.zh-CN.md) -## Unreleased +## 1.7.0 +- 集成 PR #98 的像素终端皮肤及智能体生成的主题包,画布背景与终端边框可以独立选择。主题创建界面新增简短说明、上传槽位标签、示例和预览提示;画布图案位于背景选择之前,并说明背景图片何时会覆盖图案。 +- 恢复缩小时可读的终端摘要卡片,为编排者自动使用 Master 皮肤,并恢复像素窗口的边缘和角落缩放,不再重置手动调整或恢复的尺寸。 +- 集成 PR #100,修复启动导航竞态、API 密钥粘贴及未捕获界面错误后的恢复,并保护 CanvasTTY 的私有控制数据。该 PR 汇总了 #96、#97 和 #99 的修复。 +- 更新 SAGE 应用图标、单色标题栏标识及文档图片,修复 Normal、Auto、YOLO 启动配置的布局,并允许在 stdout/stderr 已关闭时正常退出。 + +- 基础保护现在还会拒绝智能体的 shell 或文件工具使用 CanvasTTY 自己的私有数据:读取、复制或编码 agent-control 令牌与描述文件、各网关的连接记录、提供商与插件的密钥存储、账户主目录、GitHub 登录信息和已准备的启动运行(任何程序,包括解释器单行命令和 heredoc),以及连接 CanvasTTY 的控制或运行时套接字(`curl --unix-socket`、`nc -U`、`socat`、Python 套接字)。模型会平静地得知智能体不能以这种方式控制 CanvasTTY,并被建议请用户以 **Orchestrator** 角色启动它,从而获得 `canvastty_agents` 工具。路径来自应用自己的 userData 文件夹;项目、应用设置、其他套接字和内置控制 CLI 不受影响。控制端点现在对未认证或格式错误的请求,以及 HTTP 请求(最小的 403),都以相同的指引代替简单错误作答,并关闭连接。 - 为 CLI 自带自动模式的智能体新增 **Auto** 启动配置档,与 Normal(仍为默认)和 YOLO 并列:Codex `--approve-for-me`(其自身审查,位于 `workspace-write` 沙箱),Claude Code `--permission-mode auto` 及其沙箱(`sandbox.enabled`、`autoAllowBashIfSandboxed: false`,合并进唯一的 `--settings`),Grok `--permission-mode auto`;控制 CLI 的 `create --profile auto` 和插件的 `sessions.create` 也支持。启动贡献者可回答 `thirdPartyModel: true`(API 或 Ollama 账户):此时 Auto 以同一沙箱中 CLI 的“仅接受编辑”模式运行,卡片显示 **auto · edits**。Codex 不再因 CanvasTTY 自己添加的 hook 停在 “Hooks need review”(本次运行的 `-c hooks.state`,不写入 `~/.codex`;插件不能传递 `-c hooks…`),位于用户为其编排者所选文件夹(或其子目录)中的 Codex 子智能体不再被再次询问是否信任(本次运行的 `-c projects`);插件以 `trustedFolder` 获得该文件夹。Claude Code 的 «✳» 标题现在表示空闲:带 hook 的 Claude 卡片仅通过 hook 离开 `needs_approval`,或在用户拒绝其提示后稍候离开。示例:`examples/plugins/launch-env`(Local model 配置档)。 - 新增两个供账户插件使用的启动扩展点。启动选项中的 `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`(其服务还会读取页面保存的令牌)。 diff --git a/build/icon.png b/build/icon.png index 5e06877a..f7d8e1dc 100644 Binary files a/build/icon.png and b/build/icon.png differ diff --git a/build/icon.svg b/build/icon.svg new file mode 100644 index 00000000..e8c50d61 --- /dev/null +++ b/build/icon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/docs/README.md b/docs/README.md index bd75d03c..7894c98b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,8 @@ CanvasTTY is a spatial Electron desktop for real local terminals and AI-agent CL | [Built-in browser and audit log](browser.md) | Canvas controls, settings, agent access, website/file boundaries, activity, and persistent redacted audit files | | [Installing, releases, and local data](installing-and-security.md) | Installer formats, unsigned-preview caveats, credential boundaries, and release checks | | [Widget authoring](widget-authoring.md) | Source-level extension paths, visual grammar, process boundaries, and an AI-agent brief | +| [Pixel terminal themes (Russian)](pixel-skin-packs.ru.md) | Ten-PNG ZIP layout, terminal apertures, app import, and local agent CLI | +| [Create a pixel theme with an agent](pixel-skin-agent-start.md) | Zero-context agent brief, exact install ZIP, visual checks, and validation | | [Runtime plugins](plugins.md) | Manifest v1, permissions, HOME widgets, canvas apps, separate windows, player media/playlist APIs, SDK, and install flow | | [Metrics and telemetry](metrics-and-telemetry.md) | Subscription limits, session token usage, source priority, privacy, stale states, and tests | | [Security policy](../SECURITY.md) | Supported release, vulnerability reporting, local data boundaries, plugins, media grants, browser storage, and audit logs | diff --git a/docs/README.ru.md b/docs/README.ru.md index 7833e6dd..8ad3a297 100644 --- a/docs/README.ru.md +++ b/docs/README.ru.md @@ -12,6 +12,8 @@ CanvasTTY — пространственный Electron-десктоп для н | [Встроенный браузер и журнал аудита](browser.ru.md) | Управление на канвасе, настройки, доступ агентов, границы сайтов/файлов, activity и постоянные очищенные audit-файлы | | [Установка, релизы и локальные данные](installing-and-security.ru.md) | Форматы установщиков, оговорки про неподписанное превью, границы доступа к credentials и релизные проверки | | [Создание виджетов](widget-authoring.ru.md) | Способы расширения через исходный код, визуальная грамматика, границы процессов и бриф для AI-агента | +| [Пиксельные темы терминала](pixel-skin-packs.ru.md) | Десять PNG, ZIP, прозрачный проём, импорт и управление агентом | +| [Инструкция агенту по созданию темы](pixel-skin-agent-start.md) | Задание с нулевого контекста, проверка архива и визуальная приёмка | | [Runtime-плагины](plugins.ru.md) | Manifest v1, permissions, HOME widgets, canvas apps, отдельные окна, media/playlist API для плееров, SDK и установка | | [Метрики и телеметрия](metrics-and-telemetry.ru.md) | Лимиты подписок, расход токенов за сессию, приоритет источников, приватность, stale-состояния и тесты | | [Политика безопасности](../SECURITY.ru.md) | Поддерживаемый релиз, сообщения об уязвимостях, локальные данные, плагины, медиапапки, browser storage и audit log | diff --git a/docs/UI_CONTRACT.md b/docs/UI_CONTRACT.md index f8584a66..bb8368a5 100644 --- a/docs/UI_CONTRACT.md +++ b/docs/UI_CONTRACT.md @@ -25,11 +25,15 @@ This contract preserves the approved MVP concept and prevents feature ownership - Click focus has three explicit modes: Off, Single click, and Double click. It is off by default. Selection and its visible outline still work when camera focus is off; a double-click mode never jumps the camera on the first click. Window stacking is independent: an ordinary primary click always raises the pressed terminal, plugin, Browser, or note card. - Selection is currently exclusive across terminals and the built-in Browser: pressing on empty canvas clears it. Input focus is tracked independently so future multi-selection does not have to redefine wheel ownership. - Hover focus is an off-by-default input-focus mode. After a configurable delay (slow `500ms`, normal `250ms`, fast `80ms`), an input-bearing widget under the pointer receives logical focus without changing selection; terminal and native Browser keyboard input follows that focus. Pointer leave only cancels a pending transfer; assigned focus remains until another focusable widget is clicked/hovered or the user clicks outside every widget. Focusable surfaces are terminals, the native Browser, plugin iframe/canvas surfaces, and actually scrollable HOME lists; decorative and action-only tiles do not take focus. -- Go to HOME, Toggle terminal fullscreen, and Rename window are separate user-remappable Controls rows with their own short descriptions. Defaults are `Home`, `Super+F` (`Command+F` on macOS), and `F2`. These actions also accept Mouse3/Mouse4/Mouse5 with optional modifiers. Rename is an inline header edit and does not recreate the PTY. Escape remains terminal input rather than closing fullscreen. A compact canvas hint reflects the persisted bindings immediately, can be hidden, and can be placed in any corner from Appearance. Controls and an expandable section in the hint share a reference of fixed shortcuts, including their terminal/menu context. +- Go to HOME, Toggle terminal fullscreen, and Rename window are separate user-remappable Controls rows with their own short descriptions; Controls has no standalone static shortcut list. Defaults are `Home`, `Super+F` (`Command+F` on macOS), and `F2`. These actions also accept Mouse3/Mouse4/Mouse5 with optional modifiers. Rename is an inline header edit and does not recreate the PTY. Escape remains terminal input rather than closing fullscreen. A compact canvas hint reflects the persisted bindings immediately, can be hidden, and can be placed in any corner from Appearance. Its expandable section contains a reference of fixed shortcuts, including their terminal/menu context. - Snapping is enabled by default and can be disabled without changing existing window bounds. Edge panning remains off by default and exposes slow/normal/fast speed. Wheel direction is configurable separately for terminal scrolling and canvas navigation; canvas inversion applies to both pan axes and historical wheel zoom. Fresh profiles pan on ordinary scroll, while migrated profiles retain ordinary wheel zoom, direction, and sensitivity. ## Visual system +- Canvas image backgrounds and terminal borders are independent persisted choices. Appearance shows pixel backgrounds beside solid Canvas colors, and all pixel border variants remain in Terminal borders. Selecting a solid color clears the image; selecting a border never changes the background. Existing profiles retain their previously coupled background through a one-time migration. +- Pixel theme creation explains the agent brief, ZIP/PNG upload, nine border slots and separate background in place, with a labeled example before upload. Border previews are explicitly marked as examples; their detail/state controls do not change live settings. Master is described as the artwork variant for an orchestrator. +- Orchestrator terminals automatically use Master artwork for built-in and imported pixel skins. Pixel frames retain semantic summary below `0.5×` and resize from every edge and corner. `1200 × 800` is the initial pixel-card size, not a locked size; restored and manually resized bounds are preserved. Switching from non-pixel borders to pixel borders expands existing cards once. + - Flat, large, pastel tiles; strong dark/light contrast; restrained shadows; no ornamental micro-controls or explanatory microcopy around self-evident controls. - Home renders at `1:1` whenever its current persisted boundary fits. Auto-fit uses discrete scale steps down to `0.2×` and integer camera coordinates so borders and dock spacing stay optically even across larger plugin layouts. - System actions use locally vendored SVGs from the official Lucide repository. Do not hand-draw system icons in TSX and do not add an icon runtime package. diff --git a/docs/assets/canvastty-github.png b/docs/assets/canvastty-github.png new file mode 100644 index 00000000..7e98ee8b Binary files /dev/null and b/docs/assets/canvastty-github.png differ diff --git a/docs/assets/canvastty-logo-dark.svg b/docs/assets/canvastty-logo-dark.svg new file mode 100644 index 00000000..ebfb11ec --- /dev/null +++ b/docs/assets/canvastty-logo-dark.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/docs/assets/canvastty-logo.svg b/docs/assets/canvastty-logo.svg new file mode 100644 index 00000000..e8c50d61 --- /dev/null +++ b/docs/assets/canvastty-logo.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/docs/pixel-skin-agent-start.md b/docs/pixel-skin-agent-start.md new file mode 100644 index 00000000..54b96ceb --- /dev/null +++ b/docs/pixel-skin-agent-start.md @@ -0,0 +1,33 @@ +# Create a CanvasTTY pixel theme with an agent + +This is the starting point for an agent with no prior CanvasTTY context. Give the agent [the design-kit repository](https://github.com/teo-nex/CanvasTTY-design-for-agents) or [its offline ZIP](https://github.com/teo-nex/CanvasTTY-design-for-agents/raw/refs/heads/main/CanvasTTY_Agent_Theme_Kit_v1.zip), plus a visual reference. The deliverable is a separate **install ZIP**, not the source-kit ZIP. Read [the full pack specification](pixel-skin-packs.ru.md) and confirm the current contract in `src/main/services/PixelSkinPackRegistry.ts` before generating art. + +## Inputs and output + +- Input: a visual reference or a written art direction, plus optional theme name. The reference is not a ready-to-import frame unless it satisfies the requirements below. +- Output: one ZIP containing **exactly ten final PNG files**. The nine frame files are `minimal_idle.png`, `minimal_working.png`, `minimal_completed.png`, `detailed_idle.png`, `detailed_working.png`, `detailed_completed.png`, `master_idle.png`, `master_working.png`, and `master_completed.png`. The tenth is `background.png`. +- A single containing folder is permitted, but there must be exactly one file for each name. Do not include drafts, alternate versions, nested source/output copies, instructions, or `apertures.json` in the install ZIP. A separate source kit is optional. +- Use `completed`, **not** `done`, in filenames. Completed covers a finished agent turn, whether it succeeded or failed. + +## Artwork contract + +1. Make all nine frames 1536 x 1024 px, PNG RGBA, on the same 3:2 canvas. Keep exterior geometry fixed across levels and states. Do not shift, crop, or rotate the frame between states. +2. The terminal output is live UI underneath the art. Leave a transparent central opening large enough for readable text. Do not bake terminal text, a black terminal panel, the cursor, title, controls, or screenshots into the image. Keep the top-right button plaque clear for native search and close buttons. +3. `minimal` is restrained; `detailed` adds visible decoration; `master` is a distinct, richer F4 view. Every level has three **visually distinguishable** states: calm `idle`, active `working`, and resolved `completed`. Maintain identical geometry within a level. Do not represent state solely with a barely visible dot or a tiny color change. +4. `background.png` is separate wallpaper for the canvas. It is not a replacement for the transparent terminal opening. Do not duplicate it under source and output paths in the install ZIP. +5. For each level report `left`, `right`, `top`, and `bottom` aperture insets as percentages of the full 1536 x 1024 frame. Check that all three states of that level use the same safe opening. The user can enter these values in the import dialog; an agent can pass them in a separate `apertures.json` via `skin-install --apertures`. Do not assume the ZIP auto-configures apertures. + +## Build and verify + +1. Inspect the reference and decide the level/state differences before rendering. Generate or edit the art, then examine all ten final images at their actual resolution. Inspect a contact sheet too, to catch geometry drift and indistinguishable states. +2. Package only the final ten PNGs. Validate the archive from the repository root: + + ```sh + node scripts/validate-pixel-skin-zip.mjs /absolute/path/theme-install.zip + ``` + + This invokes the same ZIP importer as CanvasTTY in an isolated temporary profile. It checks the exact file set and new-theme frame dimensions. A successful structural check does **not** prove the art looks good. +3. Compare the nine frames overlaid, check the transparent opening and button plaque, and preview with live terminal text in CanvasTTY when possible. Report any visual check that could not be done. +4. Deliver the install ZIP, a small preview/contact sheet, the three aperture presets, and a concise description of what changes in each state. Say whether the theme was actually installed and tested in the app; do not infer that from ZIP validation. + +For agent-driven installation in a running CanvasTTY instance, see [the local Agent Control commands](pixel-skin-packs.ru.md#установка-агентом). Installing or changing a theme affects the current user's app; verify the target instance and preserve other installed themes. diff --git a/docs/pixel-skin-packs.ru.md b/docs/pixel-skin-packs.ru.md new file mode 100644 index 00000000..16d1c170 --- /dev/null +++ b/docs/pixel-skin-packs.ru.md @@ -0,0 +1,61 @@ +# Пиксельные темы терминала + +Агенту без контекста проекта передайте [инструкцию по созданию темы](pixel-skin-agent-start.md) и визуальный референс. В настройках CanvasTTY → **Создать пиксельную тему** можно скопировать готовое задание агенту. + +Тема состоит из десяти PNG. Девять изображений образуют матрицу из трёх уровней детализации и трёх состояний; десятое служит фоном CanvasTTY. + +## Геометрия новых тем + +- Все девять рамок: **ровно 1536 × 1024 px, PNG RGBA, пропорция 3:2**. Один и тот же холст без сдвига, обрезки и поворота между состояниями. Не добавляйте вокруг рамки дополнительные поля. +- На CanvasTTY каждой рамке соответствует **фиксированная карточка 1200 × 800 логических px**. Масштаб камеры меняет её размер на экране, но не уровень картинки: настройка выбирает `minimal` или `detailed`, F4 временно включает `master`. +- В каждом уровне координаты внешней рамки, прозрачного проёма, таблички под кнопки и всех неподвижных предметов должны совпадать во всех трёх состояниях. Между уровнями сохраняйте одинаковую внешнюю посадку рамки; меняйте насыщенность декора, а не размер холста. +- Центр проёма должен быть **прозрачным**, чтобы текст настоящего терминала был виден под изображением. Не рисуйте в PNG текст, курсор, чёрный экран терминала или готовые кнопки. Не закрывайте декором проём и зону кнопок. +- `background.png` остаётся отдельным фоном рабочего поля; он не определяет геометрию терминала. Для контрольной проверки накладывайте все девять рамок друг на друга: внешний контур, проём и табличка не должны прыгать. + +Старые ZIP другого разрешения импортируются для совместимости, но могут искажаться при приведении к 1200 × 800. Новые темы готовьте строго по указанному холсту. + +| Уровень | Ожидание | Работа | Завершено | +| --- | --- | --- | --- | +| Минимальный | `minimal_idle.png` | `minimal_working.png` | `minimal_completed.png` | +| Детальный | `detailed_idle.png` | `detailed_working.png` | `detailed_completed.png` | +| Мастер, F4 | `master_idle.png` | `master_working.png` | `master_completed.png` | + +Дополнительный файл: `background.png`. Размещайте десять PNG в ZIP с этими точными именами; вложенная папка допустима. Импорт технически допускает PNG от 320 × 200 до 4096 × 4096, каждый не более 20 МБ; ZIP не более 150 МБ. Для новых рамок применяйте геометрию выше. + +**Установочный ZIP и набор исходников — разные артефакты.** В установочном ZIP оставляйте ровно десять финальных PNG. Не складывайте туда одновременно `source/background.png` и `output/background.png`: импортёр увидит повтор. Имя состояния — только `completed`, не `done`. Исходники, промежуточные рендеры, промпты и скрипты можно отдавать отдельным ZIP. + +Перед передачей архива проверьте его из корня репозитория: + +```sh +node scripts/validate-pixel-skin-zip.mjs /absolute/path/theme-install.zip +``` + +Проверка запускает реальный импортёр в отдельном временном профиле и не устанавливает тему в вашу CanvasTTY. Визуальную посадку проёма и различимость состояний нужно проверить отдельно. + +`idle` означает, что агент сейчас не выполняет ход. `working` включается во время хода. `completed` показывается после явного завершения хода, в том числе при ошибке; следующая работа снова переводит рамку в `working`. Настройка выбирает минимальную или детальную рамку, F4 временно показывает мастер-рамку выбранной темы. + +## Установка в приложении + +В настройках оформления нажмите **Создать пиксельную тему**. Можно выбрать ZIP целиком или назначить десять PNG по отдельности. При необходимости задайте отступы проёма для каждого уровня: `left`, `right`, `top`, `bottom` в процентах изображения. Все три состояния одного уровня используют одни отступы; декор не должен заходить в этот прямоугольник. После установки тема появляется в списке и сохраняется в профиле CanvasTTY. + +## Установка агентом + +Включите локальный Agent Control в CanvasTTY. CLI подключается к локальному дескриптору и не требует управления мышью. Для точной геометрии создайте JSON-файл: + +```json +{ + "minimal": { "left": 10, "right": 10, "top": 16, "bottom": 16 }, + "detailed": { "left": 14, "right": 14, "top": 18, "bottom": 19 }, + "master": { "left": 15, "right": 15, "top": 21, "bottom": 22 } +} +``` + +Из корня проекта: + +```sh +node scripts/canvastty-control.mjs skin-install --archive /absolute/path/theme.zip --name "My theme" --apertures /absolute/path/apertures.json --activate --detail detailed +node scripts/canvastty-control.mjs skin-list +node scripts/canvastty-control.mjs skin-select pixel:THEME-ID --detail minimal +``` + +`--apertures` необязателен; без него применяются консервативные отступы. ID установленной темы возвращается при импорте и доступен через `skin-list`. Команды принимают `--connection` и `--client-file` для выбора конкретного экземпляра CanvasTTY; файлы доступа должны оставаться локальными и приватными. Темы сохраняются между перезапусками. Не кладите в ZIP секреты или личные данные: его содержимое становится частью локальной темы. diff --git a/docs/plugins.md b/docs/plugins.md index bec8181c..40c69a8d 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -139,7 +139,7 @@ Lifecycle: every service of an enabled, trusted plugin runs as its own process ( 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"}} +{"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.7.0"}} ``` At app start, services are started only after every host API they may call (`sessions.*`, `cards.setBadge`, `secrets.get`, …) is ready, and before saved cards are restored: a service can call them as soon as it gets `canvastty.initialize`, and one that subscribes to session events then receives the restored cards as events or in the `sessions.subscribe` snapshot. @@ -386,7 +386,7 @@ The full example is [`examples/plugins/collect-demo`](../examples/plugins/collec 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). +- **Base protection** (Settings → Agents, on by default; the person can turn it off) denies, through the same hook, sudo and other elevation, piping downloaded or generated text into a shell, download-and-run, disk and format commands, fork bombs, and writing or deleting outside the working folder: the home folder, other projects and `/tmp` included, and deleting the working folder itself. An agent's own plan and memory folders (`~/.claude/plans`, `~/.claude/projects//memory`, and the same inside the run's `CLAUDE_CONFIG_DIR`) are not "outside". It also denies any use of CanvasTTY's own private data (from the app's userData folder: the agent-control token and descriptor, the gateways' connection records and sockets, the secret stores, account homes; and the control/runtime socket folders under the temporary folder), by any program, interpreter one-liners and socket clients included; the reason points the model at an **Orchestrator** launch and the `canvastty_agents` tools. The bundled control CLI may name its descriptor. It only ever denies; each reason tells the model what to do instead (a write to `/tmp` suggests a scratch folder inside the project). - **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). Plugin tool answers, `screen` in session events, card badges and card action messages are masked the same way. host.onStorageChange(listener) notifies every live contribution of the same plugin — canvases, HOME widgets, and separate windows — of writes made through host.storage.set, avoiding polling when a plugin coordinates several surfaces. diff --git a/docs/plugins.zh-CN.md b/docs/plugins.zh-CN.md index dafd0a97..7abb4b1d 100644 --- a/docs/plugins.zh-CN.md +++ b/docs/plugins.zh-CN.md @@ -365,7 +365,7 @@ interface PluginSessionEvent { 两项安全功能内置,无需插件: -- **基础保护**(设置 → Agents → Base protection,默认开启;用户可以关闭)通过同一个 hook 拒绝:sudo 及其他提权、把下载或生成的文本管道给 shell、下载后直接运行、磁盘和格式化命令、fork 炸弹,以及在工作文件夹之外写入或删除(包括主目录、其他项目和 `/tmp`),以及删除工作文件夹本身。agent 自己的计划和记忆文件夹(`~/.claude/plans`、`~/.claude/projects//memory`,以及本次运行 `CLAUDE_CONFIG_DIR` 中的相同位置)不算"外部"。它只会拒绝;每条原因都告诉模型应当改做什么(写入 `/tmp` 时建议在项目内建立临时文件夹)。 +- **基础保护**(设置 → Agents → Base protection,默认开启;用户可以关闭)通过同一个 hook 拒绝:sudo 及其他提权、把下载或生成的文本管道给 shell、下载后直接运行、磁盘和格式化命令、fork 炸弹,以及在工作文件夹之外写入或删除(包括主目录、其他项目和 `/tmp`),以及删除工作文件夹本身。agent 自己的计划和记忆文件夹(`~/.claude/plans`、`~/.claude/projects//memory`,以及本次运行 `CLAUDE_CONFIG_DIR` 中的相同位置)不算"外部"。它还会拒绝任何程序(包括解释器单行命令和套接字客户端)使用 CanvasTTY 自己的私有数据(来自应用的 userData 文件夹:agent-control 令牌与描述文件、各网关的连接记录与套接字、密钥存储、账户主目录;以及临时文件夹下的控制/运行时套接字文件夹);拒绝原因会引导模型改用 **Orchestrator** 启动和 `canvastty_agents` 工具。内置控制 CLI 可以引用它的描述文件。它只会拒绝;每条原因都告诉模型应当改做什么(写入 `/tmp` 时建议在项目内建立临时文件夹)。 - **密钥遮蔽**:CanvasTTY 从一个 agent 交给另一个 agent 的所有文本(`observe_agent`、`get_agent_result`,以及 control CLI 的 `screen`、`result` 和失败详情)都会被遮蔽:CanvasTTY 保存的服务商密钥、启动时的 `secretEnv` 值、服务通过 `redaction.register` 注册的值(包括被终端折行拆开的情况),以及常见密钥形式(`sk-…`、GitHub、Slack、AWS、Google、JWT、`Bearer …`、`"apiKey": "…"`、PEM 私钥、长随机串)。插件工具的回答、会话事件中的 `screen`、卡片标记和卡片动作消息也以同样方式遮蔽。 host.onStorageChange(listener) 会把 host.storage.set 的写入通知给同一插件的所有活动界面——画布卡片、HOME 小组件和独立窗口——从而避免轮询。 diff --git a/examples/terminal-skins/foundry-seven/README.md b/examples/terminal-skins/foundry-seven/README.md new file mode 100644 index 00000000..7b787df3 --- /dev/null +++ b/examples/terminal-skins/foundry-seven/README.md @@ -0,0 +1,5 @@ +# Foundry Seven + +A dark, instrument-grade bezel in graphite and aged bronze. Brushed metal edges, a fine calibration rail, and enamel-like controls add depth while keeping the terminal surface clear. The treatment is static, so reduced-motion settings need no override; keyboard focus remains visible. + +Skin ID: `custom:foundry-seven` (`foundry-seven` in the manifest). diff --git a/examples/terminal-skins/foundry-seven/manifest.json b/examples/terminal-skins/foundry-seven/manifest.json new file mode 100644 index 00000000..cae0cc10 --- /dev/null +++ b/examples/terminal-skins/foundry-seven/manifest.json @@ -0,0 +1,6 @@ +{ + "schemaVersion": 1, + "id": "foundry-seven", + "name": "Foundry Seven", + "kind": "terminal-border" +} diff --git a/examples/terminal-skins/foundry-seven/skin.css b/examples/terminal-skins/foundry-seven/skin.css new file mode 100644 index 00000000..dab71543 --- /dev/null +++ b/examples/terminal-skins/foundry-seven/skin.css @@ -0,0 +1,49 @@ +/* Foundry Seven: a warm, instrument-grade graphite and bronze bezel. */ +.terminal-card { + border: 3px solid transparent; + border-radius: 7px; + background: linear-gradient(var(--terminal-background, #202430), var(--terminal-background, #202430)) padding-box, + repeating-linear-gradient(135deg, #ead5a2 0 1px, #987547 1px 2px, #57442c 2px 4px, #b48d53 4px 6px) border-box; + box-shadow: inset 0 1px #fff0c655, inset 0 0 0 1px #17191a, + 0 0 0 1px #322719, 0 4px 0 #211a12, 0 14px 28px #000a; +} + +.terminal-card .terminal-card__header { + color: #f1e5cd; + background: repeating-linear-gradient(90deg, #fff0c514 0 1px, transparent 1px 11px) top left / 100% 2px no-repeat, + repeating-linear-gradient(90deg, #d4ad6c 0 1px, transparent 1px 9px) bottom left / 100% 3px no-repeat, + linear-gradient(180deg, #655239 0, #37332b 12%, #242624 28%, #171b1c 78%, #111312 96%, #947044 100%); + box-shadow: inset 0 1px #f7e4bd77, inset 0 -1px #090b0b; +} + +.terminal-card .terminal-card__surface { + border-top: 1px solid #735632; + box-shadow: inset 0 1px #0d1011; +} + +.terminal-card .terminal-card__actions button { + color: #f4dfb4; + border: 1px solid #a17b45; + border-radius: 4px; + background: linear-gradient(145deg, #716044 0, #40382b 42%, #1b1e1e 100%); + box-shadow: inset 0 1px #f5dda078, inset 0 -1px #111312, 0 1px 2px #000a; +} + +.terminal-card .terminal-card__actions button:hover { + color: #fff3d6; + border-color: #e1bd7b; + background: linear-gradient(145deg, #907347 0, #51432e 42%, #242625 100%); +} + +.terminal-card .terminal-card__actions button:focus-visible { + outline: 2px solid #ffe3a2; + outline-offset: 1px; + box-shadow: 0 0 0 3px #17191a, inset 0 1px #f5dda078; +} + +.terminal-card:is(:focus-within, .terminal-card--selected) { + outline: 2px solid #f3d28e; + outline-offset: 3px; + box-shadow: 0 0 0 1px #17130e, 0 0 0 5px #aa8048, + 0 4px 0 #211a12, 0 14px 28px #000a; +} diff --git a/package-lock.json b/package-lock.json index 5dcf52db..ef3e024f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "canvastty", - "version": "1.5.2", + "version": "1.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "canvastty", - "version": "1.5.2", + "version": "1.7.0", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -24,6 +24,7 @@ "react": "19.2.8", "react-dom": "19.2.8", "secure-remote-password": "0.3.1", + "unzipper": "^0.12.3", "yaml": "2.9.0" }, "devDependencies": { @@ -4352,7 +4353,6 @@ "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", - "dev": true, "license": "MIT" }, "node_modules/boolean": { @@ -4727,7 +4727,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true, "license": "MIT" }, "node_modules/cross-dirname": { @@ -5044,7 +5043,6 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", - "dev": true, "license": "BSD-3-Clause", "dependencies": { "readable-stream": "^2.0.2" @@ -6040,7 +6038,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, "node_modules/inquirer": { @@ -6148,7 +6145,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true, "license": "MIT" }, "node_modules/isbinaryfile": { @@ -6943,7 +6939,6 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", - "dev": true, "license": "MIT" }, "node_modules/node-pty": { @@ -7256,7 +7251,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true, "license": "MIT" }, "node_modules/progress": { @@ -7403,7 +7397,6 @@ "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, "license": "MIT", "dependencies": { "core-util-is": "~1.0.0", @@ -7641,7 +7634,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, "license": "MIT" }, "node_modules/safer-buffer": { @@ -7835,7 +7827,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.1.0" @@ -8105,15 +8096,14 @@ } }, "node_modules/unzipper": { - "version": "0.12.5", - "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.5.tgz", - "integrity": "sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==", - "dev": true, + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.3.tgz", + "integrity": "sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA==", "license": "MIT", "dependencies": { "bluebird": "~3.7.2", "duplexer2": "~0.1.4", - "fs-extra": "11.3.1", + "fs-extra": "^11.2.0", "graceful-fs": "^4.2.2", "node-int64": "^0.4.0" } @@ -8122,7 +8112,6 @@ "version": "11.3.1", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.1.tgz", "integrity": "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==", - "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -8175,7 +8164,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, "license": "MIT" }, "node_modules/vite": { diff --git a/package.json b/package.json index 318fea19..a02cbd38 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "canvastty", - "version": "1.5.2", + "version": "1.7.0", "private": true, "description": "A spatial desktop for local AI agents and real terminal sessions.", "desktopName": "CanvasTTY", @@ -54,6 +54,7 @@ "react": "19.2.8", "react-dom": "19.2.8", "secure-remote-password": "0.3.1", + "unzipper": "^0.12.3", "yaml": "2.9.0" }, "devDependencies": { diff --git a/scripts/canvastty-control.mjs b/scripts/canvastty-control.mjs index 5e2eb343..2aceae1d 100644 --- a/scripts/canvastty-control.mjs +++ b/scripts/canvastty-control.mjs @@ -106,11 +106,12 @@ export async function controlRequest({ connectionPath, clientPath, method, param export function parseArguments(argv) { const options = {}; const positional = []; - const flags = new Set(["--connection", "--client-file", "--request-id", "--cwd", "--title", "--provider", "--profile", "--prompt-file", "--text", "--after", "--choice", "--revision"]); + const flags = new Set(["--connection", "--client-file", "--request-id", "--cwd", "--title", "--provider", "--profile", "--prompt-file", "--text", "--after", "--choice", "--revision", "--archive", "--name", "--apertures", "--detail"]); for (let i = 0; i < argv.length; i++) { const arg = argv[i]; if (arg === "--json") continue; if (arg === "--yolo") { options.yolo = true; continue; } + if (arg === "--activate") { options.activate = true; continue; } if (arg === "--help" || arg === "-h") { options.help = true; continue; } if (arg.startsWith("--")) { if (!flags.has(arg) || i + 1 === argv.length) throw new Error("Unknown option or missing option value."); @@ -139,6 +140,9 @@ result [--after ] interrupt choose --choice --revision dismiss --revision +skin-list +skin-install --archive [--name ] [--apertures ] [--activate] [--detail minimal|detailed] +skin-select [--detail minimal|detailed] Global options: --connection --client-file --request-id --json @@ -153,14 +157,16 @@ export async function runCli(argv) { const { options, positional } = parseArguments(argv); if (options.help) return { help: HELP }; const [method, sessionId] = positional; - if (!["create", "list", "status", "screen", "send", "result", "interrupt", "choose", "dismiss"].includes(method)) throw new Error("Unknown command; see --help."); - if (positional.length !== (["create", "list"].includes(method) ? 1 : 2)) throw new Error("Unexpected or missing positional argument."); + if (!["create", "list", "status", "screen", "send", "result", "interrupt", "choose", "dismiss", "skin-list", "skin-install", "skin-select"].includes(method)) throw new Error("Unknown command; see --help."); + if (positional.length !== (["create", "list", "skin-list", "skin-install"].includes(method) ? 1 : 2)) throw new Error("Unexpected or missing positional argument."); const allowed = new Set(["connection", "client-file", "request-id", ...(method === "create" ? ["cwd", "title", "provider", "profile", "yolo"] : []), ...(method === "send" ? ["prompt-file", "text"] : []), ...(method === "result" ? ["after"] : []), ...(method === "choose" ? ["choice", "revision"] : []), ...(method === "dismiss" ? ["revision"] : [])]); + if (method === "skin-install") ["archive", "name", "apertures", "activate", "detail"].forEach((key) => allowed.add(key)); + if (method === "skin-select") allowed.add("detail"); if (Object.keys(options).some((key) => !allowed.has(key))) throw new Error("Option does not apply to this command."); - let params = method === "list" ? {} : { sessionId }; + let params = ["list", "skin-list", "skin-install"].includes(method) ? {} : { sessionId }; if (method === "create") { if (!options.cwd) throw new Error("create requires --cwd."); if (options.yolo && options.profile && options.profile !== "yolo") throw new Error("Conflicting launch profiles."); @@ -184,11 +190,23 @@ export async function runCli(argv) { if (!options.revision) throw new Error("dismiss requires the observed --revision."); params.revision = options.revision; } + if (method === "skin-install") { + if (!options.archive) throw new Error("skin-install requires --archive."); + if (options.detail && !options.activate) throw new Error("--detail requires --activate."); + const apertures = options.apertures ? JSON.parse(await readFile(resolve(options.apertures), "utf8")) : undefined; + params = { archivePath: resolve(options.archive), ...(options.name ? { name: options.name } : {}), + ...(apertures ? { apertures } : {}), ...(options.activate ? { activate: true } : {}), + ...(options.detail ? { detail: options.detail } : {}) }; + } + if (method === "skin-select") { + params = { skinId: sessionId, ...(options.detail ? { detail: options.detail } : {}) }; + } const connectionPath = resolve(options.connection || defaultConnectionPath()); const clientPath = resolve(options["client-file"] || join(dirname(connectionPath), "controller.json")); const requestId = options["request-id"] || randomUUID(); try { - const result = await controlRequest({ connectionPath, clientPath, method, params, requestId }); + const result = await controlRequest({ connectionPath, clientPath, method, params, requestId, + timeoutMs: method === "skin-install" ? 120_000 : 8_000 }); return { requestId, result }; } catch (error) { error.requestId = requestId; throw error; } } diff --git a/scripts/validate-pixel-skin-zip.mjs b/scripts/validate-pixel-skin-zip.mjs new file mode 100644 index 00000000..6a8bbb3d --- /dev/null +++ b/scripts/validate-pixel-skin-zip.mjs @@ -0,0 +1,42 @@ +import { createRequire } from "node:module"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { basename, join } from "node:path"; +import { tmpdir } from "node:os"; +import { PixelSkinPackRegistry, PIXEL_SKIN_SLOTS } from "../src/main/services/PixelSkinPackRegistry.ts"; + +const require = createRequire(import.meta.url); +const unzipper = require("unzipper"); + +async function main() { + const archivePath = process.argv[2]; + if (!archivePath || process.argv.length !== 3) { + throw new Error("Usage: node scripts/validate-pixel-skin-zip.mjs /absolute/path/theme-install.zip"); + } + const archive = await readFile(archivePath); + const directory = await unzipper.Open.buffer(archive); + const actual = directory.files.filter((entry) => entry.type === "File") + .map((entry) => basename(entry.path.replace(/\\/g, "/"))).sort(); + const expected = PIXEL_SKIN_SLOTS.map((slot) => `${slot}.png`).sort(); + if (actual.length !== expected.length || actual.some((filename, index) => filename !== expected[index])) { + throw new Error(`Install ZIP must contain exactly: ${expected.join(", ")}`); + } + + const profile = await mkdtemp(join(tmpdir(), "canvastty-skin-validate-")); + try { + const registry = new PixelSkinPackRegistry(profile); + await registry.initialize(); + const pack = await registry.installZip(archive, "ZIP validation"); + const manifest = JSON.parse(await readFile(join(profile, "pixel-skins", pack.id.slice("pixel:".length), "manifest.json"), "utf8")); + if (manifest.width !== 1536 || manifest.height !== 1024) { + throw new Error(`New theme frames must be 1536x1024; found ${manifest.width}x${manifest.height}.`); + } + process.stdout.write("Valid CanvasTTY install ZIP: ten PNGs, 1536x1024 frames, importer accepted.\n"); + } finally { + await rm(profile, { recursive: true, force: true }); + } +} + +main().catch((error) => { + process.stderr.write(`Invalid CanvasTTY install ZIP: ${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +}); diff --git a/src/agent-browser/mcp-helper.mjs b/src/agent-browser/mcp-helper.mjs index 611d062a..dd25435a 100644 --- a/src/agent-browser/mcp-helper.mjs +++ b/src/agent-browser/mcp-helper.mjs @@ -568,13 +568,15 @@ function response(id, result) { return { jsonrpc: "2.0", id: id ?? null, result }; } -function errorResponse(id, error) { +export function errorResponse(id, error) { return { jsonrpc: "2.0", id: id ?? null, error: { code: Number.isInteger(error?.code) ? error.code : -32603, - message: Number.isInteger(error?.code) ? error.message : "Internal error" + message: error instanceof BridgeClientError + ? `CanvasTTY browser: ${error.code}: ${error.message}` + : Number.isInteger(error?.code) ? error.message : "Internal error" } }; } diff --git a/src/main/index.ts b/src/main/index.ts index 2a9358ef..e2ffe0fb 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,3 +1,5 @@ +import "./stdio"; +import appIcon from "../../build/icon.png?asset"; import { ipcMain } from "electron"; import { randomUUID } from "node:crypto"; import { isAbsolute } from "node:path"; @@ -15,6 +17,8 @@ import { } from "../shared/contracts"; import { registerIpc } from "./ipc/registerIpc"; import { SettingsStore } from "./services/SettingsStore"; +import { SkinRegistry } from "./services/SkinRegistry"; +import { PixelSkinPackRegistry } from "./services/PixelSkinPackRegistry"; import { TerminalManager, reachesObservers, reachesRenderer } from "./services/TerminalManager"; import { TerminalRendererOutbox } from "./services/TerminalRendererOutbox"; import { AgentControlGateway } from "./services/agent-control/AgentControlGateway"; @@ -31,6 +35,7 @@ import { LaunchPipeline } from "./services/LaunchPipeline"; import { EnvironmentRegistry } from "./services/EnvironmentRegistry"; import { DecisionHooks } from "./services/DecisionHooks"; import { SecretRedactionRegistry } from "./services/safety/SecretRedaction"; +import { canvasTtyPrivateData } from "./services/safety/baseProtection"; import { PluginAgentTools } from "./services/PluginAgentTools"; import { PluginSessions } from "./services/PluginSessions"; import { PluginCards } from "./services/PluginCards"; @@ -173,11 +178,13 @@ if (!hasSingleInstanceLock) app.quit(); /** * Creates the shell window and starts loading the startup page into it. The - * page load is not awaited: services start next to it, and the application - * surface may replace the page before it finished (see startApplication). + * page load is not awaited here: services start next to it, and startApplication + * waits for it to settle before it loads the application surface. */ function createWindow(): { window: BrowserWindow; startupPage: StartupPageLoad } { + if (process.platform === "darwin" && !app.isPackaged) app.dock?.setIcon(appIcon); const window = new BrowserWindow({ + icon: appIcon, width: 1440, height: 900, minWidth: 920, @@ -241,28 +248,30 @@ function createWindow(): { window: BrowserWindow; startupPage: StartupPageLoad } } }); - const startupPage: StartupPageLoad = { failure: null, superseded: false }; - window.loadURL(startupPageUrl({ locale: app.getLocale(), isMacOS: process.platform === "darwin" })).catch((error) => { - // A close during this load aborts the navigation (ERR_ABORTED / ERR_FAILED). - // That is a quit, not a failed startup; the application surface replacing a - // page that was still loading aborts it the same way. Neither is a failure; - // a real error on a live window is kept for startApplication to report. - if (shellWindowGone(window)) { - console.warn("CanvasTTY startup page load stopped: its window is gone, the application is closing.", error); - return; - } - if (!startupPage.superseded) startupPage.failure = error; - }); + const startupPage: StartupPageLoad = window + .loadURL(startupPageUrl({ locale: app.getLocale(), isMacOS: process.platform === "darwin" })) + .then( + () => null, + (error: unknown) => { + // A close during this load aborts the navigation (ERR_ABORTED / ERR_FAILED). + // That is a quit, not a failed startup; a real error on a live window is + // handed to startApplication to report. + if (shellWindowGone(window)) { + console.warn("CanvasTTY startup page load stopped: its window is gone, the application is closing.", error); + return null; + } + return error ?? new Error("The startup page did not load."); + } + ); return { window, startupPage }; } -/** The startup page load of a fresh shell window, as startApplication sees it. */ -interface StartupPageLoad { - /** A real load error of the page, reported as a failed startup. */ - failure: unknown; - /** Set once the application surface starts loading: aborting the page is expected then. */ - superseded: boolean; -} +/** + * The startup page load of a fresh shell window: it settles with the load error + * to report as a failed startup, or null once the page loaded (or its window is + * gone). It never rejects. + */ +type StartupPageLoad = Promise; /** * True when the shell window is on its way out: its close was requested (the @@ -292,6 +301,10 @@ async function initializeServices(): Promise { const userDataPath = app.getPath("userData"); const settings = new SettingsStore(userDataPath, app.getLocale(), process.platform, providerCliAvailability(providerClis)); await settings.load(); + const terminalBorderSkins = new SkinRegistry(userDataPath); + await terminalBorderSkins.initialize(); + const pixelSkinPacks = new PixelSkinPackRegistry(userDataPath); + await pixelSkinPacks.initialize(); pluginManager = new PluginManager(userDataPath); await pluginManager.load(); // Secrets this app knows are masked in every text one agent reads from another (EP-8). @@ -328,7 +341,8 @@ async function initializeServices(): Promise { 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 + session: (sessionId) => terminalManager?.decisionContext(sessionId) ?? null, + privateData: canvasTtyPrivateData(userDataPath) }); pluginManager.setServiceObserver(async (specs) => { await pluginServices!.sync(specs); @@ -424,6 +438,7 @@ async function initializeServices(): Promise { terminalManager?.applyProviderSignal(terminalSessionId, { kind: "lifecycle", state: signal.state, + event: signal.event, ...(signal.turnId ? { requestId: signal.turnId } : {}), ...(signal.threadId ? { threadId: signal.threadId } : {}) }); @@ -606,8 +621,11 @@ async function initializeServices(): Promise { ? join(process.resourcesPath, "agent-browser", WINDOWS_PIPE_HOST_FILENAME) : join(app.getAppPath(), "build", "windows-agent-pipe-host", WINDOWS_PIPE_HOST_FILENAME) : undefined; - const gateway = new AgentControlGateway({ userDataPath, terminals: terminalManager, + const gateway = new AgentControlGateway({ userDataPath, terminals: terminalManager, pixelSkinPacks, settings, lifecycleEnabled: () => Boolean(runtimeGateway) && settings.get().agentLifecycleHooksEnabled, + onSettingsChanged: (updated) => { + if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send(IPC.settingsChanged, updated); + }, windowsHostPath }); agentControl = gateway; try { @@ -671,6 +689,8 @@ async function initializeServices(): Promise { protocol.handle("canvastty-media", (request) => pluginMediaService!.protocolResponse(request)); observeMainWindowState = registerIpc({ settings, + terminalBorderSkins, + pixelSkinPacks, providerClis, recheckProviderClis: async () => { providerClis!.refresh(); @@ -827,8 +847,14 @@ async function startApplication(): Promise { if (!servicesReady) await initializeServices(); if (shutdownRunning || shutdownComplete || shellWindowGone(window)) return; if (startupPage) { - if (startupPage.failure) throw startupPage.failure; - startupPage.superseded = true; + // The application surface must not replace a page that is still loading: + // Chromium can report that page's ERR_ABORTED after the next navigation has + // started, and Electron's loadFile/loadURL promise takes the first main-frame + // load failure it sees as its own, so startup would fail with the startup + // page's abort. The page usually settles before services are up. + const failure = await startupPage; + if (shutdownRunning || shutdownComplete || shellWindowGone(window)) return; + if (failure !== null) throw failure; } initializeUpdater(); await loadApplication(window); @@ -1092,6 +1118,7 @@ async function openPluginWindow(pluginId: string, contributionId: string): Promi const window = new BrowserWindow({ width: contribution.defaultSize.width, + icon: appIcon, height: contribution.defaultSize.height, minWidth: contribution.minSize?.width ?? 320, minHeight: contribution.minSize?.height ?? 220, diff --git a/src/main/ipc/registerIpc.ts b/src/main/ipc/registerIpc.ts index 3a3574e6..035be79b 100644 --- a/src/main/ipc/registerIpc.ts +++ b/src/main/ipc/registerIpc.ts @@ -6,10 +6,15 @@ import type { AgentCliAvailability, BrowserCommand, CanvasNavigationPointerBindingInput, + CustomTerminalBorderSkinId, CreateSessionRequest, PluginBrowserOpenResponse, PluginCanvasRequest, PluginLaunchFieldOptions, + PixelSkinPackInstallRequest, + PixelSkinZipInstallRequest, + PixelSkinSlot, + PixelTerminalBorderSkinId, ProviderId, ProviderSecretId, SessionBounds @@ -18,6 +23,8 @@ import { IPC, PROVIDER_SECRET_IDS, isProviderId } from "../../shared/contracts"; import { isCanvasNavigationMouseButton } from "../../shared/canvasNavigation"; import { createWindowStateObserver, readWindowState } from "../windowState"; import type { SettingsStore } from "../services/SettingsStore"; +import { isCustomTerminalBorderSkinId, type SkinRegistry } from "../services/SkinRegistry"; +import { isPixelSkinSlot, isPixelTerminalBorderSkinId, type PixelSkinPackRegistry } from "../services/PixelSkinPackRegistry"; import { providerCliAvailability, type ProviderCliRegistry } from "../services/providerCliRegistry"; import type { TerminalManager } from "../services/TerminalManager"; import type { LimitsService } from "../services/LimitsService"; @@ -37,6 +44,8 @@ import { readHomeMedia } from "../services/homeMedia"; interface Dependencies { settings: SettingsStore; + terminalBorderSkins: SkinRegistry; + pixelSkinPacks: PixelSkinPackRegistry; providerClis: ProviderCliRegistry; recheckProviderClis(): Promise<{ availability: AgentCliAvailability; settings: AppSettings }>; terminals: TerminalManager; @@ -72,6 +81,8 @@ interface Dependencies { export function registerIpc({ settings, + terminalBorderSkins, + pixelSkinPacks, providerClis, recheckProviderClis, terminals, @@ -124,6 +135,40 @@ export function registerIpc({ return app.getVersion(); }); ipcMain.handle(IPC.settingsGet, () => settings.get()); + ipcMain.handle(IPC.terminalBorderSkinsList, (event) => { + assertMainRenderer(event, getMainWindow); + return terminalBorderSkins.list(); + }); + ipcMain.handle(IPC.terminalBorderSkinsGet, (event, id: unknown) => { + assertMainRenderer(event, getMainWindow); + if (!isCustomTerminalBorderSkinId(id)) throw new Error("Custom terminal skin ID is invalid."); + return terminalBorderSkins.get(id as CustomTerminalBorderSkinId); + }); + terminalBorderSkins.onChanged(() => { + const window = getMainWindow(); + if (window && !window.isDestroyed()) window.webContents.send(IPC.terminalBorderSkinsChanged); + }); + ipcMain.handle(IPC.pixelSkinsList, (event) => { + assertMainRenderer(event, getMainWindow); + return pixelSkinPacks.list(); + }); + ipcMain.handle(IPC.pixelSkinsInstall, (event, request: PixelSkinPackInstallRequest) => { + assertMainRenderer(event, getMainWindow); + return pixelSkinPacks.install(request); + }); + ipcMain.handle(IPC.pixelSkinsInstallZip, (event, request: PixelSkinZipInstallRequest) => { + assertMainRenderer(event, getMainWindow); + return pixelSkinPacks.installZip(request.archive, request.name, request.apertures); + }); + ipcMain.handle(IPC.pixelSkinsReadAsset, (event, id: unknown, slot: unknown) => { + assertMainRenderer(event, getMainWindow); + if (!isPixelTerminalBorderSkinId(id) || !isPixelSkinSlot(slot)) return null; + return pixelSkinPacks.readAsset(id as PixelTerminalBorderSkinId, slot as PixelSkinSlot); + }); + pixelSkinPacks.onChanged(() => { + const window = getMainWindow(); + if (window && !window.isDestroyed()) window.webContents.send(IPC.pixelSkinsChanged); + }); ipcMain.handle(IPC.agentsAvailability, (event) => { assertMainRenderer(event, getMainWindow); return providerCliAvailability(providerClis); diff --git a/src/main/services/DecisionHooks.ts b/src/main/services/DecisionHooks.ts index 32b3e7e9..1ebdedb1 100644 --- a/src/main/services/DecisionHooks.ts +++ b/src/main/services/DecisionHooks.ts @@ -3,6 +3,7 @@ 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 type { PrivateData } from "./safety/commandFacts.ts"; import { DEFAULT_DECIDE_TIMEOUT_MS } from "../../agent-runtime/runtime-protocol.mjs"; /** A trusted plugin service that declared `decide` (PluginManager.decisionServices). */ @@ -34,6 +35,8 @@ export interface DecisionHooksDependencies { call(pluginId: string, serviceId: string, method: "canvastty.decide", params: unknown, timeoutMs: number): Promise; session(sessionId: string): DecisionSession | null; home?: string; + /** CanvasTTY's own tokens, secret stores and sockets (canvasTtyPrivateData of its userData folder). */ + privateData?: PrivateData; timeoutMs?: number; } @@ -101,7 +104,8 @@ export class DecisionHooks { root: session.cwd, commandCwd: request.cwd, home, - agentRoots: [join(home, ".claude"), ...session.configDirs] + agentRoots: [join(home, ".claude"), ...session.configDirs], + ...(this.deps.privateData ? { privateData: this.deps.privateData } : {}) }); if (base) return { behavior: "deny", message: base.message }; } diff --git a/src/main/services/PixelSkinPackRegistry.ts b/src/main/services/PixelSkinPackRegistry.ts new file mode 100644 index 00000000..c3dc76ef --- /dev/null +++ b/src/main/services/PixelSkinPackRegistry.ts @@ -0,0 +1,296 @@ +import { randomUUID } from "node:crypto"; +import { createRequire } from "node:module"; +import { lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { DEFAULT_PIXEL_SKIN_APERTURES } from "../../shared/contracts.ts"; +import type { + PixelSkinAperture, + PixelSkinApertures, + PixelSkinPackInstallRequest, + PixelSkinPackSummary, + PixelSkinSlot, + PixelTerminalBorderSkinId +} from "../../shared/contracts"; + +export const PIXEL_SKIN_SLOTS = [ + "minimal_idle", "minimal_working", "minimal_completed", + "detailed_idle", "detailed_working", "detailed_completed", + "master_idle", "master_working", "master_completed", "background" +] as const satisfies readonly PixelSkinSlot[]; + +const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); +const MAX_IMAGE_BYTES = 20 * 1024 * 1024; +export const MAX_PIXEL_SKIN_ARCHIVE_BYTES = 150 * 1024 * 1024; +const MAX_PACK_BYTES = MAX_PIXEL_SKIN_ARCHIVE_BYTES; +const MAX_PACKS = 100; +const MAX_ARCHIVE_ENTRIES = 100; +const ID_PATTERN = /^pixel:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +const CRC_TABLE = Uint32Array.from({ length: 256 }, (_, index) => { + let value = index; + for (let bit = 0; bit < 8; bit += 1) value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1; + return value >>> 0; +}); +const require = createRequire(import.meta.url); +const unzipper = require("unzipper") as { + Open: { buffer(data: Buffer): Promise<{ files: Array<{ + path: string; + type: string; + uncompressedSize: number; + stream(): AsyncIterable; + }> }> }; +}; + +function pngChunkCrc(bytes: Buffer, start: number, end: number): number { + let crc = 0xffffffff; + for (let offset = start; offset < end; offset += 1) crc = CRC_TABLE[(crc ^ bytes[offset]) & 0xff] ^ (crc >>> 8); + return (crc ^ 0xffffffff) >>> 0; +} + +interface PackManifest { + schemaVersion: 1; + id: PixelTerminalBorderSkinId; + name: string; + width: number; + height: number; + aperture: PixelSkinAperture; + apertures: PixelSkinApertures; +} + +const DEFAULT_APERTURE: PixelSkinAperture = { left: 8, right: 8, top: 17, bottom: 13 }; + +function validApertures(value: unknown, fallback: PixelSkinAperture): PixelSkinApertures { + if (value === undefined) return { minimal: fallback, detailed: fallback, master: fallback }; + if (!value || typeof value !== "object") throw new Error("Terminal apertures are invalid."); + const apertures = value as Record; + if (Object.keys(apertures).length !== 3 || ["minimal", "detailed", "master"].some((level) => !(level in apertures))) { + throw new Error("Terminal apertures are invalid."); + } + return { + minimal: validAperture(apertures.minimal), + detailed: validAperture(apertures.detailed), + master: validAperture(apertures.master) + }; +} + +function validAperture(value: unknown): PixelSkinAperture { + if (value === undefined) return DEFAULT_APERTURE; + if (!value || typeof value !== "object") throw new Error("Terminal aperture is invalid."); + const aperture = value as Record; + for (const edge of ["left", "right", "top", "bottom"] as const) { + if (typeof aperture[edge] !== "number" || !Number.isFinite(aperture[edge]) + || aperture[edge] < 2 || aperture[edge] > 35) throw new Error("Terminal aperture is invalid."); + } + if ((aperture.left as number) + (aperture.right as number) > 55 + || (aperture.top as number) + (aperture.bottom as number) > 55) { + throw new Error("Terminal aperture leaves too little room for output."); + } + return aperture as unknown as PixelSkinAperture; +} + +export function isPixelTerminalBorderSkinId(value: unknown): value is PixelTerminalBorderSkinId { + return typeof value === "string" && ID_PATTERN.test(value); +} + +export function isPixelSkinSlot(value: unknown): value is PixelSkinSlot { + return typeof value === "string" && (PIXEL_SKIN_SLOTS as readonly string[]).includes(value); +} + +function parsePng(data: Uint8Array): { width: number; height: number } { + if (!(data instanceof Uint8Array) || data.byteLength < 57 || data.byteLength > MAX_IMAGE_BYTES) { + throw new Error("PNG file size is invalid."); + } + const bytes = Buffer.from(data.buffer, data.byteOffset, data.byteLength); + if (!bytes.subarray(0, 8).equals(PNG_SIGNATURE)) throw new Error("Only PNG images are accepted."); + let offset = 8; + let width = 0; + let height = 0; + let hasImageData = false; + let ended = false; + while (offset + 12 <= bytes.length) { + const length = bytes.readUInt32BE(offset); + const end = offset + 12 + length; + if (length > MAX_IMAGE_BYTES || end > bytes.length) throw new Error("PNG chunks are malformed."); + if (pngChunkCrc(bytes, offset + 4, offset + 8 + length) !== bytes.readUInt32BE(offset + 8 + length)) { + throw new Error("PNG checksum is invalid."); + } + const kind = bytes.toString("ascii", offset + 4, offset + 8); + if (offset === 8) { + if (kind !== "IHDR" || length !== 13) throw new Error("PNG header is malformed."); + width = bytes.readUInt32BE(offset + 8); + height = bytes.readUInt32BE(offset + 12); + if (width < 320 || height < 200 || width > 4096 || height > 4096) { + throw new Error("PNG dimensions must be between 320x200 and 4096x4096."); + } + } + if (kind === "IDAT") hasImageData = true; + if (kind === "IEND") { + if (length !== 0 || end !== bytes.length) throw new Error("PNG ending is malformed."); + ended = true; + break; + } + offset = end; + } + if (!hasImageData || !ended) throw new Error("PNG image data is incomplete."); + return { width, height }; +} + +function validName(value: unknown): string { + if (typeof value !== "string") throw new Error("Theme name is required."); + const name = value.trim(); + if (!name || name.length > 64 || /[\x00-\x1f\x7f]/.test(name)) throw new Error("Theme name is invalid."); + return name; +} + +export class PixelSkinPackRegistry { + private readonly root: string; + private readonly packs = new Map(); + private readonly listeners = new Set<() => void>(); + + constructor(userDataPath: string) { + this.root = join(userDataPath, "pixel-skins"); + } + + async initialize(): Promise { + await mkdir(this.root, { recursive: true }); + const entries = await readdir(this.root, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory() || entry.isSymbolicLink()) continue; + const id = `pixel:${entry.name}`; + if (!isPixelTerminalBorderSkinId(id)) continue; + try { + const path = join(this.root, entry.name); + const manifestPath = join(path, "manifest.json"); + if (!(await lstat(manifestPath)).isFile()) continue; + const raw = await readFile(manifestPath); + if (raw.length > 4096) continue; + const manifest = JSON.parse(raw.toString("utf8")) as Partial; + if (manifest.schemaVersion !== 1 || manifest.id !== id) continue; + const name = validName(manifest.name); + const aperture = validAperture(manifest.aperture); + const apertures = validApertures(manifest.apertures, aperture); + if (!Number.isInteger(manifest.width) || !Number.isInteger(manifest.height)) continue; + const files = await Promise.all(PIXEL_SKIN_SLOTS.map(async (slot) => { + const stat = await lstat(join(path, `${slot}.png`)); + return stat.isFile() && !stat.isSymbolicLink() && stat.size <= MAX_IMAGE_BYTES; + })); + if (files.every(Boolean)) this.packs.set(id, { + schemaVersion: 1, id, name, aperture, apertures, + width: manifest.width as number, height: manifest.height as number + }); + } catch { + // A malformed user pack is unavailable; it must not prevent app startup. + } + } + } + + list(): PixelSkinPackSummary[] { + return [...this.packs.values()].map(({ id, name, aperture, apertures }) => ({ id, name, aperture, apertures })); + } + + onChanged(listener: () => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + async installZip(archive: Uint8Array, name: string, apertures?: PixelSkinApertures): Promise { + if (!(archive instanceof Uint8Array) || archive.byteLength < 22 || archive.byteLength > MAX_PACK_BYTES) { + throw new Error("Theme ZIP file size is invalid."); + } + const directory = await unzipper.Open.buffer(Buffer.from(archive.buffer, archive.byteOffset, archive.byteLength)); + if (directory.files.length > MAX_ARCHIVE_ENTRIES) throw new Error("Theme ZIP has too many entries."); + const entries = new Map(); + for (const entry of directory.files) { + if (entry.type !== "File") continue; + const filename = entry.path.replace(/\\/g, "/").split("/").at(-1) ?? ""; + if (!filename.endsWith(".png")) continue; + const slot = filename.slice(0, -4); + if (!isPixelSkinSlot(slot)) continue; + if (entries.has(slot)) throw new Error(`Theme ZIP contains duplicate ${filename}.`); + if (entry.uncompressedSize < 1 || entry.uncompressedSize > MAX_IMAGE_BYTES) { + throw new Error(`${filename} exceeds the PNG size limit.`); + } + entries.set(slot, entry); + } + if (entries.size !== PIXEL_SKIN_SLOTS.length) { + throw new Error("Theme ZIP needs minimal/detailed/master idle, working, completed PNGs and background.png."); + } + const files = {} as Record; + let total = 0; + for (const slot of PIXEL_SKIN_SLOTS) { + const chunks: Buffer[] = []; + let size = 0; + for await (const chunk of entries.get(slot)!.stream()) { + size += chunk.length; + if (size > MAX_IMAGE_BYTES || total + size > MAX_PACK_BYTES) throw new Error(`${slot}.png exceeds the PNG size limit.`); + chunks.push(chunk); + } + total += size; + files[slot] = Buffer.concat(chunks, size); + } + return this.install({ name, apertures, files }); + } + + async install(request: PixelSkinPackInstallRequest): Promise { + if (!request || typeof request !== "object" || !request.files || typeof request.files !== "object") { + throw new Error("Theme package is invalid."); + } + if (this.packs.size >= MAX_PACKS) throw new Error("Too many pixel themes are installed."); + const name = validName(request.name); + const aperture = validAperture(request.aperture); + const apertures = request.apertures + ? validApertures(request.apertures, aperture) + : request.aperture + ? validApertures(undefined, aperture) + : validApertures(DEFAULT_PIXEL_SKIN_APERTURES, aperture); + const files = request.files as Record; + if (Object.keys(files).length !== PIXEL_SKIN_SLOTS.length + || Object.keys(files).some((slot) => !isPixelSkinSlot(slot))) { + throw new Error("A pixel theme needs exactly nine terminal PNGs and one background PNG."); + } + let width = 0; + let height = 0; + let total = 0; + for (const slot of PIXEL_SKIN_SLOTS) { + const data = files[slot]; + if (!(data instanceof Uint8Array)) throw new Error(`${slot} must be a PNG image.`); + total += data.byteLength; + if (total > MAX_PACK_BYTES) throw new Error("Theme package is too large."); + const dimensions = parsePng(data); + if (slot !== "background") { + if (!width) ({ width, height } = dimensions); + else if (width !== dimensions.width || height !== dimensions.height) { + throw new Error("All nine terminal frames must have the same dimensions."); + } + } + } + const id = `pixel:${randomUUID()}` as PixelTerminalBorderSkinId; + const manifest: PackManifest = { schemaVersion: 1, id, name, width, height, aperture, apertures }; + await mkdir(this.root, { recursive: true }); + const temporary = await mkdtemp(join(this.root, ".install-")); + try { + for (const slot of PIXEL_SKIN_SLOTS) { + await writeFile(join(temporary, `${slot}.png`), files[slot] as Uint8Array, { flag: "wx" }); + } + await writeFile(join(temporary, "manifest.json"), JSON.stringify(manifest), { flag: "wx" }); + await rename(temporary, join(this.root, id.slice("pixel:".length))); + } catch (error) { + await rm(temporary, { recursive: true, force: true }); + throw error; + } + this.packs.set(id, manifest); + for (const listener of this.listeners) listener(); + return { id, name, aperture, apertures }; + } + + async readAsset(id: PixelTerminalBorderSkinId, slot: PixelSkinSlot): Promise { + if (!isPixelTerminalBorderSkinId(id) || !isPixelSkinSlot(slot) || !this.packs.has(id)) return null; + const path = join(this.root, id.slice("pixel:".length), `${slot}.png`); + try { + const stat = await lstat(path); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_IMAGE_BYTES) return null; + return await readFile(path); + } catch { + return null; + } + } +} diff --git a/src/main/services/ProviderSecretsService.ts b/src/main/services/ProviderSecretsService.ts index 70de552c..b7936d4e 100644 --- a/src/main/services/ProviderSecretsService.ts +++ b/src/main/services/ProviderSecretsService.ts @@ -60,6 +60,9 @@ export class ProviderSecretsService { private async mutate(mutation: (values: Record) => void): Promise { const operation = async (): Promise => { + if (!this.encryption.isAvailable()) { + throw new Error("Secure provider storage is unavailable on this system."); + } const values = await this.read(); mutation(values); const keys = Object.keys(values); @@ -83,9 +86,6 @@ export class ProviderSecretsService { } private async read(): Promise> { - if (!this.encryption.isAvailable()) { - throw new Error("Secure provider storage is unavailable on this system."); - } let encrypted: Buffer; try { encrypted = await readFile(this.root); @@ -93,6 +93,9 @@ export class ProviderSecretsService { if (isMissingFile(error)) return {}; throw error; } + if (!this.encryption.isAvailable()) { + throw new Error("Secure provider storage is unavailable on this system."); + } try { const plaintext = this.encryption.decrypt(encrypted); if (Buffer.byteLength(plaintext) > MAX_SECRET_PAYLOAD_BYTES) throw new Error("Secret payload is too large."); diff --git a/src/main/services/SettingsStore.ts b/src/main/services/SettingsStore.ts index 38947bdf..d2c45495 100644 --- a/src/main/services/SettingsStore.ts +++ b/src/main/services/SettingsStore.ts @@ -8,10 +8,12 @@ import type { ApiProfile, ApiProfileProtocol, AppSettings, + AppSkinId, BrowserCanvasState, CanvasLauncherItemId, CanvasRegion, CanvasColorId, + CanvasBackgroundId, CanvasOverlayPlacement, CanvasWheelCaptureMode, CanvasPatternId, @@ -27,16 +29,19 @@ import type { MinimapInteractionMode, PaletteId, PluginCanvasInstance, + PixelSkinPreferredDetail, ProviderSecretId, RadialLauncherItemId, SessionRestoreMode, SessionRowColorMode, ShortcutBindings, StickyNote, + TerminalBorderSkinId, ZoomSensitivity } from "../../shared/contracts"; import { API_PROFILE_PROTOCOLS, + BUNDLED_CANVAS_BACKGROUND_IDS, CANVAS_LAUNCHER_ITEMS, DEFAULT_CANVAS_LAUNCHER_ITEMS, DEFAULT_HOME_ACCENT_COLORS, @@ -57,6 +62,7 @@ import { UI_SCALE_MIN, UI_SCALE_STEP } from "../../shared/contracts.ts"; +import { isTerminalBorderSkinId } from "./SkinRegistry.ts"; import { canvasNavigationPlatform, defaultCanvasWheelBinding, @@ -72,6 +78,7 @@ const HOME_ACCENT_PRESETS = new Set(["classic", "warm", "coo 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 APP_SKINS = new Set(["classic", "atelier", "signal", "greenhouse", "midnight"]); const MEDIA_FITS = new Set(["cover", "contain"]); // 21: the on/off "restoreTerminalSessions" became sessionRestoreMode (off / reopen / continue). const SETTINGS_VERSION = 21; @@ -163,6 +170,7 @@ export class SettingsStore { || !("baseProtectionEnabled" in source) || !("uiScale" in source) || !("canvasColor" in source) + || !("canvasBackground" in source) || !("minimapPlacement" in source) || !("minimapInteractionMode" in source) || !("shortcutHintsPlacement" in source) @@ -350,7 +358,12 @@ function createDefaults(systemLocale: string, platform: CanvasNavigationPlatform baseProtectionEnabled: true, uiScale: DEFAULT_UI_SCALE, canvasColor: "sage", + canvasBackground: "none", pattern: "dots", + terminalBorderSkin: "classic", + terminalSkinDetail: "detailed", + terminalSkinAnimationEnabled: true, + appSkin: "classic", snapToGrid: true, invertTerminalWheel: true, invertCanvasWheel: false, @@ -504,6 +517,16 @@ export function normalizeSettings( : CANVAS_COLORS.has(canvasColorCandidate as CanvasColorId) ? canvasColorCandidate as CanvasColorId : fallback.canvasColor; + // Preserve the visible background once when loading a profile from before independent backgrounds. + const canvasBackgroundCandidate = source.canvasBackground === undefined + ? source.terminalBorderSkin + : source.canvasBackground; + const canvasBackground: CanvasBackgroundId = canvasBackgroundCandidate === "none" + || (BUNDLED_CANVAS_BACKGROUND_IDS as readonly unknown[]).includes(canvasBackgroundCandidate) + || (typeof canvasBackgroundCandidate === "string" && canvasBackgroundCandidate.startsWith("pixel:") + && isTerminalBorderSkinId(canvasBackgroundCandidate)) + ? canvasBackgroundCandidate as CanvasBackgroundId + : fallback.canvasBackground ?? "none"; return { locale: LOCALES.has(source.locale as LocaleId) ? source.locale as LocaleId : fallback.locale, @@ -538,9 +561,22 @@ export function normalizeSettings( : fallback.baseProtectionEnabled ?? true, uiScale: normalizeUiScale(source.uiScale, fallback.uiScale ?? DEFAULT_UI_SCALE), canvasColor, + canvasBackground, pattern: PATTERNS.has(source.pattern as CanvasPatternId) ? source.pattern as CanvasPatternId : fallback.pattern, + terminalBorderSkin: isTerminalBorderSkinId(source.terminalBorderSkin) + ? source.terminalBorderSkin as TerminalBorderSkinId + : fallback.terminalBorderSkin, + terminalSkinDetail: source.terminalSkinDetail === "minimal" || source.terminalSkinDetail === "detailed" + ? source.terminalSkinDetail as PixelSkinPreferredDetail + : fallback.terminalSkinDetail, + terminalSkinAnimationEnabled: typeof source.terminalSkinAnimationEnabled === "boolean" + ? source.terminalSkinAnimationEnabled + : fallback.terminalSkinAnimationEnabled, + appSkin: APP_SKINS.has(source.appSkin as AppSkinId) + ? source.appSkin as AppSkinId + : fallback.appSkin, snapToGrid: typeof source.snapToGrid === "boolean" ? source.snapToGrid : fallback.snapToGrid, invertTerminalWheel: typeof source.invertTerminalWheel === "boolean" ? source.invertTerminalWheel diff --git a/src/main/services/SkinRegistry.ts b/src/main/services/SkinRegistry.ts new file mode 100644 index 00000000..c7a4eb6a --- /dev/null +++ b/src/main/services/SkinRegistry.ts @@ -0,0 +1,707 @@ +import { createHash } from "node:crypto"; +import { watch as watchFileSystem, type Dirent, type FSWatcher } from "node:fs"; +import { constants as fsConstants } from "node:fs"; +import { lstat, mkdir, open, readdir, realpath } from "node:fs/promises"; +import { isAbsolute, join, relative, resolve, sep } from "node:path"; +import type { + CustomTerminalBorderSkinId, + TerminalBorderSkinListItem, + TerminalBorderSkinManifest, + TerminalBorderSkinReadResult +} from "../../shared/contracts"; + +const MAX_SKINS = 100; +const MAX_MANIFEST_BYTES = 4 * 1024; +const MAX_CSS_BYTES = 64 * 1024; +const MAX_SKIN_NAME_LENGTH = 64; +const MAX_SKIN_SLUG_LENGTH = 48; +const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const CUSTOM_SKIN_ID_PATTERN = /^custom:([a-z0-9]+(?:-[a-z0-9]+)*)$/; +const BORDER_PROPERTIES = /^(?:border(?:-(?:top|right|bottom|left))?(?:-(?:color|style|width))?|border-radius|border-image(?:-(?:source|slice|width|outset|repeat))?|outline(?:-(?:color|style|width|offset))?|box-shadow|background(?:-(?:color|image|position|size|repeat|origin|clip))?|color|opacity)$/; +const DANGEROUS_CSS = /(?:@import|\burl\s*\(|\bexpression\s*\(|\b(?:javascript|vbscript|file|data)\s*:|(?:https?:)?\/\/|-moz-binding|(?:^|[;{\s])behavior\s*:|@namespace|@font-face|@document|@supports|@media|paint\s*\(|element\s*\()/i; + +interface LoadedSkin { + id: CustomTerminalBorderSkinId; + name: string; + revision: string; + css: string; +} + +interface SkinState { + good?: LoadedSkin; + error?: string; +} + +interface WatchedDirectory { + watcher: FSWatcher; + dev: number; + ino: number; +} + +interface RootWatcherHandle { + close(): void; +} + +type RootWatcherFactory = ( + path: string, + onChange: () => void, + onError: (error?: unknown) => void +) => RootWatcherHandle; + +export interface SkinRegistryOptions { + /** Injectable so watcher install and runtime failures can be tested deterministically. */ + rootWatcherFactory?: RootWatcherFactory; + /** Retry timing is configurable for focused tests; production values are bounded below. */ + rootRetryBaseMs?: number; + rootRetryMaxMs?: number; +} + +const DEFAULT_ROOT_RETRY_BASE_MS = 250; +const DEFAULT_ROOT_RETRY_MAX_MS = 30_000; +const MAX_ROOT_RETRY_EXPONENT = 16; + +const nativeRootWatcherFactory: RootWatcherFactory = (path, onChange, onError) => { + const watcher = watchFileSystem(path, () => onChange()); + watcher.on("error", onError); + return watcher; +}; + +/** The slug remains deliberately narrower than the shared template-string type. */ +export function isCustomTerminalBorderSkinId(value: unknown): value is CustomTerminalBorderSkinId { + if (typeof value !== "string") return false; + const match = CUSTOM_SKIN_ID_PATTERN.exec(value); + return Boolean(match && match[1].length <= MAX_SKIN_SLUG_LENGTH); +} + +function isSkinSlug(value: string): boolean { + return value.length <= MAX_SKIN_SLUG_LENGTH && SLUG_PATTERN.test(value); +} + +export function isTerminalBorderSkinId(value: unknown): value is import("../../shared/contracts").TerminalBorderSkinId { + return value === "classic" + || value === "minimal" + || value === "glass" + || value === "cyber" + || value === "nord" + || value === "gradient" + || value === "cybercore" + || value === "titanium" + || value === "retro" + || value === "sakura" + || value === "matrix" + || value === "forest-cabin" + || value === "gold-black" + || value === "cat" + || value === "gothic-eclipse" + || (typeof value === "string" && /^pixel:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(value)) + || isCustomTerminalBorderSkinId(value); +} + +/** + * Loads user-authored, local-only terminal border skins from userData/skins. + * CSS is a restricted stylesheet; it is never evaluated as JavaScript. + */ +export class SkinRegistry { + private readonly requestedRoot: string; + private root = ""; + private readonly entries = new Map(); + private readonly listeners = new Set<() => void>(); + private readonly directoryWatchers = new Map(); + private readonly rootWatcherFactory: RootWatcherFactory; + private readonly rootRetryBaseMs: number; + private readonly rootRetryMaxMs: number; + private rootWatcher?: RootWatcherHandle; + private rootRetryTimer?: ReturnType; + private rootRetryAttempt = 0; + private debounceTimer?: ReturnType; + private scanInProgress = false; + private scanAgain = false; + private initialized = false; + private disposed = false; + private rootError?: string; + + constructor(userDataPath: string, options: SkinRegistryOptions = {}) { + this.requestedRoot = join(userDataPath, "skins"); + this.rootWatcherFactory = options.rootWatcherFactory ?? nativeRootWatcherFactory; + this.rootRetryBaseMs = boundedRetryDelay(options.rootRetryBaseMs, DEFAULT_ROOT_RETRY_BASE_MS); + this.rootRetryMaxMs = Math.max( + this.rootRetryBaseMs, + boundedRetryDelay(options.rootRetryMaxMs, DEFAULT_ROOT_RETRY_MAX_MS) + ); + } + + async initialize(): Promise { + if (this.initialized) return; + try { + await mkdir(this.requestedRoot, { recursive: true }); + const rootStat = await lstat(this.requestedRoot); + if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) { + throw new Error("The terminal skin directory must be a real directory."); + } + this.root = await realpath(this.requestedRoot); + } catch (error) { + this.rootError = safeErrorMessage(error); + this.initialized = true; + return; + } + this.installRootWatcher(); + try { + await this.scan(false); + } catch (error) { + this.rootError = safeErrorMessage(error); + this.entries.clear(); + } + this.initialized = true; + } + + list(): TerminalBorderSkinListItem[] { + if (this.rootError) { + return [{ + id: "custom:skin-registry", + status: "error", + error: this.rootError + }]; + } + return [...this.entries.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([slug, state]) => { + if (state.error) { + return { + id: `custom:${slug}` as CustomTerminalBorderSkinId, + ...(state.good ? { name: state.good.name, revision: state.good.revision } : {}), + status: "error" as const, + error: state.error + }; + } + if (!state.good) return undefined; + return { + id: state.good.id, + name: state.good.name, + revision: state.good.revision, + status: "ready" as const + }; + }) + .filter((item): item is TerminalBorderSkinListItem => Boolean(item)); + } + + get(id: CustomTerminalBorderSkinId): TerminalBorderSkinReadResult { + if (!isCustomTerminalBorderSkinId(id)) { + throw new Error("Custom terminal skin ID is invalid."); + } + if (this.rootError) return { id, status: "error", error: this.rootError }; + const slug = id.slice("custom:".length); + const state = this.entries.get(slug); + if (state?.good) { + // Keep serving the last valid CSS during a malformed edit. list() carries + // the current error so the renderer can show that a newer edit was rejected. + return { ...state.good, status: "ready" }; + } + return { + id, + status: "error", + error: state?.error ?? "Terminal border skin was not found." + }; + } + + onChanged(listener: () => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + dispose(): void { + this.disposed = true; + if (this.debounceTimer) clearTimeout(this.debounceTimer); + if (this.rootRetryTimer) clearTimeout(this.rootRetryTimer); + this.rootRetryTimer = undefined; + this.rootWatcher?.close(); + this.rootWatcher = undefined; + for (const watched of this.directoryWatchers.values()) watched.watcher.close(); + this.directoryWatchers.clear(); + this.listeners.clear(); + } + + private installRootWatcher(): void { + if (this.disposed || !this.root || this.rootWatcher) return; + let watcher: RootWatcherHandle | undefined; + let failedBeforeAssignment = false; + try { + watcher = this.rootWatcherFactory( + this.root, + () => { + if (this.disposed || this.rootWatcher !== watcher) return; + this.rootRetryAttempt = 0; + this.scheduleScan(); + }, + () => { + if (!watcher) { + failedBeforeAssignment = true; + return; + } + this.handleRootWatcherFailure(watcher); + } + ); + if (this.disposed || failedBeforeAssignment) { + watcher.close(); + if (!this.disposed) this.scheduleRootWatcherRetry(); + return; + } + this.rootWatcher = watcher; + } catch { + this.rootWatcher = undefined; + this.scheduleRootWatcherRetry(); + } + } + + private handleRootWatcherFailure(watcher: RootWatcherHandle): void { + if (this.disposed || this.rootWatcher !== watcher) return; + this.rootWatcher = undefined; + watcher.close(); + this.scheduleScan(); + this.scheduleRootWatcherRetry(); + } + + private scheduleRootWatcherRetry(): void { + if (this.disposed || !this.root || this.rootRetryTimer) return; + const exponent = Math.min(this.rootRetryAttempt, MAX_ROOT_RETRY_EXPONENT); + const delay = Math.min(this.rootRetryBaseMs * (2 ** exponent), this.rootRetryMaxMs); + this.rootRetryAttempt = Math.min(this.rootRetryAttempt + 1, MAX_ROOT_RETRY_EXPONENT); + this.rootRetryTimer = setTimeout(() => { + this.rootRetryTimer = undefined; + if (this.disposed) return; + this.installRootWatcher(); + // Even if the watcher cannot be reinstalled, bounded retries continue + // to discover folder additions and removals without requiring restart. + void this.scan(true).catch(() => undefined); + }, delay); + } + + private scheduleScan(): void { + if (this.disposed || (!this.initialized && !this.root)) return; + if (this.debounceTimer) clearTimeout(this.debounceTimer); + this.debounceTimer = setTimeout(() => { + this.debounceTimer = undefined; + void this.scan(true).catch(() => undefined); + }, 80); + } + + private async scan(notify: boolean): Promise { + if (this.scanInProgress) { + this.scanAgain = true; + return; + } + this.scanInProgress = true; + try { + do { + this.scanAgain = false; + await this.scanOnce(notify); + } while (this.scanAgain); + } finally { + this.scanInProgress = false; + if (this.scanAgain) { + this.scanAgain = false; + this.scheduleScan(); + } + } + } + + private async scanOnce(notify: boolean): Promise { + const children = await readdir(this.root, { withFileTypes: true }); + this.rootError = undefined; + const slugs = children + .map((entry) => entry.name) + .filter(isSkinSlug) + .sort(); + await this.refreshDirectoryWatchers(children); + + const previous = new Map(this.entries); + const next = new Map(); + for (const [index, slug] of slugs.entries()) { + if (index >= MAX_SKINS) { + next.set(slug, { error: `Only the first ${MAX_SKINS} terminal skins can be loaded.` }); + continue; + } + const oldState = this.entries.get(slug); + try { + next.set(slug, { good: await this.loadSkin(slug) }); + } catch (error) { + next.set(slug, { + ...(oldState?.good ? { good: oldState.good } : {}), + error: safeErrorMessage(error) + }); + } + } + const changed = !sameEntries(previous, next); + this.entries.clear(); + for (const [slug, state] of next) this.entries.set(slug, state); + + if (notify && changed && this.listeners.size > 0) { + for (const listener of this.listeners) listener(); + } + } + + private async refreshDirectoryWatchers(children: Dirent[]): Promise { + const available = new Set(children + .filter((child) => isSkinSlug(child.name) && child.isDirectory()) + .map((child) => child.name) + .sort() + .slice(0, MAX_SKINS)); + for (const slug of available) { + try { + const directory = resolve(this.root, slug); + const directoryStat = await lstat(directory); + if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink() || await realpath(directory) !== directory) continue; + const existing = this.directoryWatchers.get(slug); + if (existing?.dev === directoryStat.dev && existing.ino === directoryStat.ino) continue; + existing?.watcher.close(); + this.directoryWatchers.delete(slug); + const watcher = watchFileSystem(directory, () => this.scheduleScan()); + watcher.on("error", () => { + watcher.close(); + if (this.directoryWatchers.get(slug)?.watcher === watcher) this.directoryWatchers.delete(slug); + this.scheduleScan(); + }); + this.directoryWatchers.set(slug, { watcher, dev: directoryStat.dev, ino: directoryStat.ino }); + } catch { + // Invalid or disappearing folders will be surfaced by the scanner. + } + } + for (const [slug, watched] of this.directoryWatchers) { + if (available.has(slug)) continue; + watched.watcher.close(); + this.directoryWatchers.delete(slug); + } + } + + private async loadSkin(slug: string): Promise { + if (!isSkinSlug(slug)) throw new Error("Skin folder name is invalid."); + const directory = resolve(this.root, slug); + const directoryRelative = relative(this.root, directory); + if (!directoryRelative || directoryRelative.startsWith(`..${sep}`) || directoryRelative === ".." || isAbsolute(directoryRelative)) { + throw new Error("Skin path escapes the skin directory."); + } + const directoryStat = await lstat(directory); + if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) { + throw new Error("Skin folder must be a real directory, not a symlink."); + } + if (await realpath(directory) !== directory) { + throw new Error("Skin folder resolves outside the skin directory."); + } + + const manifestPath = join(directory, "manifest.json"); + const manifestText = await readBoundedUtf8File(manifestPath, MAX_MANIFEST_BYTES, "Skin manifest"); + const manifest = parseManifest(manifestText, slug); + const cssPath = join(directory, "skin.css"); + const css = await readBoundedUtf8File(cssPath, MAX_CSS_BYTES, "Skin CSS"); + validateSkinCss(css); + + const id = `custom:${slug}` as CustomTerminalBorderSkinId; + const revision = createHash("sha256").update(manifest.name).update("\0").update(css).digest("hex").slice(0, 16); + return { id, name: manifest.name, revision, css }; + } +} + +function boundedRetryDelay(value: number | undefined, fallback: number): number { + if (value === undefined || !Number.isFinite(value)) return fallback; + return Math.min(DEFAULT_ROOT_RETRY_MAX_MS, Math.max(10, Math.floor(value))); +} + +async function readBoundedUtf8File(path: string, maxBytes: number, label: string): Promise { + const before = await lstat(path); + if (before.isSymbolicLink() || !before.isFile()) throw new Error(`${label} must be a regular file, not a symlink.`); + if (before.size > maxBytes) throw new Error(`${label} exceeds the ${maxBytes}-byte limit.`); + + let flags = fsConstants.O_RDONLY; + if (typeof fsConstants.O_NOFOLLOW === "number") flags |= fsConstants.O_NOFOLLOW; + const handle = await open(path, flags); + try { + const opened = await handle.stat(); + if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino) { + throw new Error(`${label} changed while it was being opened.`); + } + if (opened.size > maxBytes) throw new Error(`${label} exceeds the ${maxBytes}-byte limit.`); + + const buffer = Buffer.alloc(maxBytes + 1); + let total = 0; + while (total <= maxBytes) { + const { bytesRead } = await handle.read(buffer, total, Math.min(4096, buffer.length - total), null); + if (bytesRead === 0) break; + total += bytesRead; + } + if (total > maxBytes) throw new Error(`${label} exceeds the ${maxBytes}-byte limit.`); + try { + return new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, total)); + } catch { + throw new Error(`${label} must use valid UTF-8.`); + } + } finally { + await handle.close(); + } +} + +function parseManifest(text: string, slug: string): TerminalBorderSkinManifest { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + throw new Error("Skin manifest is not valid JSON."); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Skin manifest must be a JSON object."); + } + const value = parsed as Record; + const keys = Object.keys(value).sort(); + if ( + keys.length !== 4 + || keys.join(",") !== "id,kind,name,schemaVersion" + || value.schemaVersion !== 1 + || value.kind !== "terminal-border" + || value.id !== slug + || typeof value.name !== "string" + || value.name.trim().length === 0 + || value.name.trim().length > MAX_SKIN_NAME_LENGTH + || /[\u0000-\u001f\u007f]/.test(value.name) + ) { + throw new Error("Skin manifest fields are invalid or do not match the folder name."); + } + return { + schemaVersion: 1, + id: slug, + name: value.name.trim(), + kind: "terminal-border" + }; +} + +function validateSkinCss(css: string): void { + if (Buffer.byteLength(css, "utf8") === 0) throw new Error("Skin CSS is empty."); + if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(css)) { + throw new Error("Skin CSS contains unsupported control characters."); + } + if (css.includes("<")) throw new Error("Skin CSS cannot contain HTML markup."); + if (css.includes("\\")) throw new Error("Skin CSS escapes are not supported."); + + const normalized = stripCssComments(css); + if (DANGEROUS_CSS.test(normalized)) throw new Error("Skin CSS contains a forbidden import, URL, or executable CSS feature."); + if (normalized.trim().length === 0) throw new Error("Skin CSS is empty."); + + let cursor = 0; + let ruleCount = 0; + while (cursor < normalized.length) { + while (/\s/.test(normalized[cursor] ?? "")) cursor += 1; + if (cursor >= normalized.length) break; + + const openBrace = findCssDelimiter(normalized, cursor, "{"); + if (openBrace < 0) throw new Error("Skin CSS contains an incomplete rule."); + const selectorText = normalized.slice(cursor, openBrace).trim(); + if (!selectorText || selectorText.startsWith("@") || selectorText.includes("@") || selectorText.includes(";")) { + throw new Error("Skin CSS at-rules and malformed selectors are not supported."); + } + for (const selector of splitTopLevel(selectorText, ",")) { + validateScopedSelector(selector); + } + + const closeBrace = findCssBlockEnd(normalized, openBrace + 1); + const body = normalized.slice(openBrace + 1, closeBrace); + validateDeclarations(body); + ruleCount += 1; + if (ruleCount > 256) throw new Error("Skin CSS contains too many rules."); + cursor = closeBrace + 1; + } + if (ruleCount === 0) throw new Error("Skin CSS does not contain any rules."); +} + +function stripCssComments(value: string): string { + let result = ""; + let quote = ""; + for (let index = 0; index < value.length; index += 1) { + const character = value[index]; + if (quote) { + result += character; + if (character === quote && value[index - 1] !== "\\") quote = ""; + continue; + } + if (character === "'" || character === '"') { + quote = character; + result += character; + continue; + } + if (character === "/" && value[index + 1] === "*") { + const end = value.indexOf("*/", index + 2); + if (end < 0) throw new Error("Skin CSS contains an unclosed comment."); + index = end + 1; + continue; + } + result += character; + } + if (quote) throw new Error("Skin CSS contains an unclosed string."); + return result; +} + +function findCssDelimiter(value: string, start: number, delimiter: string): number { + let quote = ""; + let parentheses = 0; + let brackets = 0; + for (let index = start; index < value.length; index += 1) { + const character = value[index]; + if (quote) { + if (character === quote) quote = ""; + continue; + } + if (character === "'" || character === '"') quote = character; + else if (character === "(") parentheses += 1; + else if (character === ")") parentheses -= 1; + else if (character === "[") brackets += 1; + else if (character === "]") brackets -= 1; + else if (character === delimiter && parentheses === 0 && brackets === 0) return index; + else if (character === "}" && parentheses === 0 && brackets === 0) { + throw new Error("Skin CSS contains an unexpected closing brace."); + } + if (parentheses < 0 || brackets < 0) throw new Error("Skin CSS contains unbalanced delimiters."); + } + if (quote || parentheses !== 0 || brackets !== 0) throw new Error("Skin CSS contains unbalanced delimiters."); + return -1; +} + +function findCssBlockEnd(value: string, start: number): number { + let quote = ""; + let parentheses = 0; + let brackets = 0; + for (let index = start; index < value.length; index += 1) { + const character = value[index]; + if (quote) { + if (character === quote) quote = ""; + continue; + } + if (character === "'" || character === '"') quote = character; + else if (character === "(") parentheses += 1; + else if (character === ")") parentheses -= 1; + else if (character === "[") brackets += 1; + else if (character === "]") brackets -= 1; + else if (character === "{") throw new Error("Nested CSS rules are not supported."); + else if (character === "}" && parentheses === 0 && brackets === 0) return index; + if (parentheses < 0 || brackets < 0) throw new Error("Skin CSS contains unbalanced delimiters."); + } + throw new Error("Skin CSS contains an unclosed rule."); +} + +function splitTopLevel(value: string, delimiter: string): string[] { + const result: string[] = []; + let start = 0; + let quote = ""; + let parentheses = 0; + let brackets = 0; + for (let index = 0; index < value.length; index += 1) { + const character = value[index]; + if (quote) { + if (character === quote) quote = ""; + continue; + } + if (character === "'" || character === '"') quote = character; + else if (character === "(") parentheses += 1; + else if (character === ")") parentheses -= 1; + else if (character === "[") brackets += 1; + else if (character === "]") brackets -= 1; + else if (character === delimiter && parentheses === 0 && brackets === 0) { + result.push(value.slice(start, index)); + start = index + 1; + } + } + result.push(value.slice(start)); + return result; +} + +function validateScopedSelector(selector: string): void { + const normalized = selector.trim(); + if (!normalized.startsWith(".terminal-card")) { + throw new Error("Every skin selector must start inside .terminal-card."); + } + const next = normalized[".terminal-card".length]; + if (next && !/[.#:[\s>+~]/.test(next)) { + throw new Error("Skin selector does not target the .terminal-card scope."); + } + if (/:global\b|:host\b|::slotted\b|::part\b|::backdrop\b|::view-transition\b/i.test(normalized)) { + throw new Error("Skin selector cannot escape the .terminal-card scope."); + } + if (hasTopLevelColumnCombinator(normalized)) { + throw new Error("Skin selector cannot use a column combinator outside the .terminal-card scope."); + } + if (hasTopLevelSiblingCombinator(normalized)) { + throw new Error("Skin selector cannot use sibling combinators outside the .terminal-card scope."); + } +} + +function hasTopLevelColumnCombinator(selector: string): boolean { + let parentheses = 0; + let brackets = 0; + let quote = ""; + for (let index = 0; index < selector.length - 1; index += 1) { + const character = selector[index]; + if (quote) { + if (character === quote) quote = ""; + continue; + } + if (character === "'" || character === '"') quote = character; + else if (character === "(") parentheses += 1; + else if (character === ")") parentheses -= 1; + else if (character === "[") brackets += 1; + else if (character === "]") brackets -= 1; + else if (character === "|" && selector[index + 1] === "|" && parentheses === 0 && brackets === 0) return true; + } + return false; +} + +function hasTopLevelSiblingCombinator(selector: string): boolean { + let parentheses = 0; + let brackets = 0; + let quote = ""; + for (const character of selector) { + if (quote) { + if (character === quote) quote = ""; + continue; + } + if (character === "'" || character === '"') quote = character; + else if (character === "(") parentheses += 1; + else if (character === ")") parentheses -= 1; + else if (character === "[") brackets += 1; + else if (character === "]") brackets -= 1; + else if (parentheses === 0 && brackets === 0) { + if (character === "+" || character === "~") return true; + } + } + return false; +} + +function validateDeclarations(body: string): void { + const declarations = splitTopLevel(body, ";"); + let count = 0; + for (const declaration of declarations) { + const trimmed = declaration.trim(); + if (!trimmed) continue; + const colon = trimmed.indexOf(":"); + if (colon <= 0) throw new Error("Skin CSS contains an invalid declaration."); + const property = trimmed.slice(0, colon).trim().toLowerCase(); + const value = trimmed.slice(colon + 1).trim(); + if (!/^(?:--[a-z0-9_-]+|[a-z][a-z0-9-]*)$/.test(property) || !value) { + throw new Error("Skin CSS contains an invalid declaration."); + } + if (!property.startsWith("--") && !BORDER_PROPERTIES.test(property)) { + throw new Error(`Skin CSS property '${property}' is not allowed for terminal border skins.`); + } + if (/!\s*important/i.test(value)) throw new Error("Skin CSS cannot use !important."); + count += 1; + } + if (count === 0) throw new Error("Skin CSS rules must contain at least one declaration."); +} + +function safeErrorMessage(error: unknown): string { + if (error instanceof Error && !("code" in error) && error.message.length <= 240) return error.message; + return "Skin files could not be read or validated."; +} + +function sameEntries(left: Map, right: Map): boolean { + if (left.size !== right.size) return false; + for (const [slug, state] of left) { + const other = right.get(slug); + if (!other || state.error !== other.error || state.good?.revision !== other.good?.revision) return false; + } + return true; +} diff --git a/src/main/services/TerminalManager.ts b/src/main/services/TerminalManager.ts index 8baa23e0..bbf27b30 100644 --- a/src/main/services/TerminalManager.ts +++ b/src/main/services/TerminalManager.ts @@ -161,6 +161,7 @@ type LaunchContribution = Extract; export interface ProviderLifecycleSignal { kind: "lifecycle"; state: "idle" | "working" | "needs_approval"; + event?: string; requestId?: string; threadId?: string; } @@ -593,6 +594,7 @@ export class TerminalManager { session.resumeOnLaunch = resume; session.metadata.startedAt = Date.now(); session.metadata.status = initialSessionStatus(session.metadata.provider); + session.metadata.turnCompleted = false; session.metadata.exitCode = null; session.metadata.failureDetails = null; this.emitSession(session.metadata); @@ -649,6 +651,7 @@ export class TerminalManager { failureOrigin = "user"; } else { session.metadata.status = initialSessionStatus(session.metadata.provider); + session.metadata.turnCompleted = false; session.metadata.exitCode = null; session.metadata.failureDetails = null; if (launched.process) this.bindProcess(id, session, launched.process); @@ -805,8 +808,11 @@ export class TerminalManager { } const nextStatus = signal.state; - if (session.metadata.status === nextStatus) return; + const completed = nextStatus === "idle" && ["Stop", "StopFailure", "StopCancelled"].includes(signal.event ?? ""); + const nextTurnCompleted = nextStatus === "working" ? false : completed || Boolean(session.metadata.turnCompleted); + if (session.metadata.status === nextStatus && Boolean(session.metadata.turnCompleted) === nextTurnCompleted) return; session.metadata.status = nextStatus; + session.metadata.turnCompleted = nextTurnCompleted; this.emitSession(session.metadata); } diff --git a/src/main/services/agent-control/AgentControlGateway.ts b/src/main/services/agent-control/AgentControlGateway.ts index ef124d86..3c1fcdb0 100644 --- a/src/main/services/agent-control/AgentControlGateway.ts +++ b/src/main/services/agent-control/AgentControlGateway.ts @@ -1,23 +1,26 @@ import { createHash, randomBytes } from "node:crypto"; -import { chmod, mkdir, mkdtemp, realpath, rename, rm, writeFile } from "node:fs/promises"; +import { chmod, lstat, mkdir, mkdtemp, readFile, realpath, rename, rm, writeFile } from "node:fs/promises"; import { createServer, type Server } from "node:net"; import { tmpdir } from "node:os"; -import { isAbsolute, join } from "node:path"; +import { basename, isAbsolute, join } from "node:path"; import { lazyRequire } from "../../lazyRequire.ts"; import { NdjsonLineReader } from "../../../agent-runtime/ndjson.mjs"; import { MAX_UNIX_SOCKET_PATH_BYTES, closeServer, listenOnEndpoint, tokenDigest, tokenMatches } from "../gatewaySocket.ts"; -import type { CreateSessionRequest, SessionMetadata, SessionSnapshot, TerminalBufferSnapshot } from "../../../shared/contracts.ts"; +import type { AppSettings, CreateSessionRequest, PixelSkinApertures, SessionMetadata, SessionSnapshot, TerminalBufferSnapshot } from "../../../shared/contracts.ts"; import { IPC } from "../../../shared/contracts.ts"; import type { RuntimeLifecycleSignal } from "../agent-runtime/RuntimeGateway.ts"; import { WindowsPipeHostTransport, type AgentGatewaySocket } from "../agent-browser/WindowsPipeHostTransport.ts"; import { controlCapabilities, isControlProvider } from "./controlCapabilities.ts"; import { hasAutoMode, isLaunchProfile } from "../../../shared/autoMode.ts"; +import { MAX_PIXEL_SKIN_ARCHIVE_BYTES, type PixelSkinPackRegistry } from "../PixelSkinPackRegistry.ts"; +import type { SettingsStore } from "../SettingsStore.ts"; // Headless terminals are created on demand; the module loads with the first one. const xterm = lazyRequire("@xterm/headless"); const MAX_REQUEST_BYTES = 128 * 1024; const MAX_RESPONSE_BYTES = 256 * 1024; +const BUILT_IN_PIXEL_SKINS = ["sakura", "matrix", "forest-cabin", "gold-black", "cat", "gothic-eclipse"]; const MAX_RECEIPTS = 4096; // Refusals that happen before anything is written: a retry with the same // request id must be performed again instead of replaying the refusal. @@ -27,6 +30,13 @@ const MAX_TRANSPORT_RESTART_ATTEMPTS = 3; const TRANSPORT_RESTART_BASE_DELAY_MS = 500; const MAX_TEXT = 16_000; const ID = /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$/; +/** + * What a caller that is not CanvasTTY's control CLI reads (an unauthenticated, malformed or HTTP request): why it + * was refused and what to do instead. Stable, and free of protocol details, file names and paths. + */ +export const CONTROL_REFUSAL_MESSAGE = "CanvasTTY refused this request: this endpoint only accepts requests from sessions CanvasTTY itself launched as orchestrators, and guessing its protocol will not work. If you are an agent and need other agents, ask the person to start you from CanvasTTY's launcher with the Orchestrator role: you will then get the canvastty_agents tools (spawn_agent, list_routes, wait_for_agent and the rest)."; +/** An HTTP request line (curl, a browser, an HTTP/2 preface): answered with a minimal 403 instead of NDJSON. */ +const HTTP_REQUEST_LINE = /^[A-Z]{3,10} \S{1,4096} HTTP\/\d(?:\.\d)?\r?$/; const SECRET = /^[a-f0-9]{64}$/; interface TerminalPort { @@ -47,7 +57,8 @@ interface ControlRequest { instanceId: string; token: string; controller: string; - method: "create" | "list" | "status" | "screen" | "send" | "result" | "interrupt" | "choose" | "dismiss"; + method: "create" | "list" | "status" | "screen" | "send" | "result" | "interrupt" | "choose" | "dismiss" + | "skin-list" | "skin-install" | "skin-select"; params: Record; } @@ -74,6 +85,9 @@ interface OwnedSession { export interface AgentControlGatewayOptions { userDataPath: string; terminals: TerminalPort; + pixelSkinPacks?: PixelSkinPackRegistry; + settings?: SettingsStore; + onSettingsChanged?(settings: AppSettings): void; lifecycleEnabled(): boolean; platform?: NodeJS.Platform; windowsHostPath?: string; @@ -270,7 +284,7 @@ export class AgentControlGateway { this.sockets.add(socket); const lines = new NdjsonLineReader({ maxLineBytes: MAX_REQUEST_BYTES }); let handled = false; - const timer = setTimeout(() => socket.destroy(), 10_000); + let timer = setTimeout(() => socket.destroy(), 10_000); timer.unref(); socket.setNoDelay(true); socket.on("error", () => socket.destroy()); @@ -283,6 +297,24 @@ export class AgentControlGateway { socket.write(data); } catch { socket.destroy(); } }; + // A refusal is answered once and the connection closed, so a caller is not left waiting for the timeout. + const close = (): void => { + // A Unix socket half-closes after the reply is flushed; the Windows relay has no end(), so it is dropped shortly. + const end = (socket as { end?: () => void }).end; + if (typeof end === "function") end.call(socket); + else setTimeout(() => socket.destroy(), 250).unref(); + }; + const refuse = (line: Buffer): void => { + if (socket.destroyed) return; + if (HTTP_REQUEST_LINE.test(line.subarray(0, 4200).toString("latin1"))) { + const body = Buffer.from(`${CONTROL_REFUSAL_MESSAGE}\n`); + socket.write(Buffer.concat([Buffer.from("HTTP/1.1 403 Forbidden\r\nContent-Type: text/plain; charset=utf-8\r\n" + + `Content-Length: ${body.length}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n`), body])); + } else { + reply({ v: 1, ok: false, error: { code: "INVALID_REQUEST", message: CONTROL_REFUSAL_MESSAGE } }); + } + close(); + }; socket.on("data", (chunk) => { if (handled) return; let line: Buffer | undefined; @@ -291,7 +323,12 @@ export class AgentControlGateway { handled = true; let request: ControlRequest; try { request = this.parse(JSON.parse(line.toString("utf8"))); } - catch { reply({ v: 1, ok: false, error: { code: "INVALID_REQUEST", message: "Invalid or unauthenticated control request." } }); return; } + catch { refuse(line); return; } + if (request.method === "skin-install") { + clearTimeout(timer); + timer = setTimeout(() => socket.destroy(), 120_000); + timer.unref(); + } void this.dispatch(request).then( (result) => reply({ v: 1, id: request.id, ok: true, result }), (error: unknown) => reply({ v: 1, id: request.id, ok: false, error: { @@ -307,7 +344,8 @@ export class AgentControlGateway { || value.v !== 1 || typeof value.id !== "string" || !ID.test(value.id) || value.instanceId !== this.instanceId || typeof value.token !== "string" || !SECRET.test(value.token) || typeof value.controller !== "string" || !SECRET.test(value.controller) - || !["create", "list", "status", "screen", "send", "result", "interrupt", "choose", "dismiss"].includes(String(value.method)) + || !["create", "list", "status", "screen", "send", "result", "interrupt", "choose", "dismiss", + "skin-list", "skin-install", "skin-select"].includes(String(value.method)) || !record(value.params)) throw new Error("Invalid envelope"); if (!tokenMatches(value.token, this.tokenHash)) throw new Error("Invalid credential"); return value as unknown as ControlRequest; @@ -315,7 +353,7 @@ export class AgentControlGateway { private async dispatch(request: ControlRequest): Promise { const owner = hash(request.controller); - const mutating = ["create", "send", "interrupt", "choose", "dismiss"].includes(request.method); + const mutating = ["create", "send", "interrupt", "choose", "dismiss", "skin-install", "skin-select"].includes(request.method); if (!mutating) return this.perform(owner, request); const key = `${owner}:${request.id}`; const digest = hash(JSON.stringify([request.method, Object.entries(request.params).sort(([a], [b]) => a.localeCompare(b))])); @@ -352,6 +390,9 @@ export class AgentControlGateway { private async perform(owner: string, request: ControlRequest): Promise { if (this.closed) throw new ControlError("CLOSED", "Agent control is shutting down."); const params = request.params; + if (request.method === "skin-list" || request.method === "skin-install" || request.method === "skin-select") { + return this.performSkinOperation(request.method, params); + } if (request.method === "create") { fields(params, ["provider", "cwd", "title", "profile"]); if (!isControlProvider(params.provider) || !isLaunchProfile(params.profile)) throw new ControlError("INVALID_PARAMS", "Specify an agent provider (codex, claude, qwen, kimi, opencode, hermes, grok, omp, pi) and an explicit normal, yolo or auto launch profile."); @@ -482,6 +523,57 @@ export class AgentControlGateway { private redactTurn(turn: Turn): Turn { return turn.result ? { ...turn, result: { ...turn.result, text: this.redact(turn.result.text) } } : turn; } + + private async performSkinOperation(method: "skin-list" | "skin-install" | "skin-select", params: Record): Promise { + const packs = this.options.pixelSkinPacks; + const settings = this.options.settings; + if (!packs || !settings) throw new ControlError("NOT_SUPPORTED", "Pixel theme control is unavailable."); + if (method === "skin-list") { + fields(params, []); + const current = settings.get(); + return { builtIn: BUILT_IN_PIXEL_SKINS, + installed: packs.list(), activeId: current.terminalBorderSkin, detail: current.terminalSkinDetail }; + } + if (method === "skin-install") { + fields(params, ["archivePath", "name", "activate", "detail", "apertures"]); + const archivePath = string(params.archivePath, 4096, "archivePath"); + if (!isAbsolute(archivePath)) throw new ControlError("INVALID_PARAMS", "archivePath must be absolute."); + const stat = await lstat(archivePath).catch(() => { throw new ControlError("INVALID_PARAMS", "Theme ZIP does not exist."); }); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_PIXEL_SKIN_ARCHIVE_BYTES) { + throw new ControlError("INVALID_PARAMS", "Theme ZIP must be a regular file smaller than 150 MB."); + } + if (params.activate !== undefined && typeof params.activate !== "boolean") throw new ControlError("INVALID_PARAMS", "activate must be boolean."); + const detail = this.skinDetail(params.detail); + if (params.detail !== undefined && !params.activate) throw new ControlError("INVALID_PARAMS", "detail requires activate: true."); + const name = params.name === undefined ? basename(archivePath).replace(/\.zip$/i, "") : string(params.name, 64, "name"); + let pack; + try { pack = await packs.installZip(await readFile(archivePath), name, + params.apertures as PixelSkinApertures | undefined); } + catch (error) { throw new ControlError("INVALID_PACK", error instanceof Error ? error.message : "Invalid theme ZIP."); } + if (params.activate) { + const updated = await settings.update({ terminalBorderSkin: pack.id, ...(detail ? { terminalSkinDetail: detail } : {}) }); + this.options.onSettingsChanged?.(updated); + } + return { pack, active: Boolean(params.activate) }; + } + fields(params, ["skinId", "detail"]); + const skinId = string(params.skinId, 128, "skinId"); + if (!BUILT_IN_PIXEL_SKINS.includes(skinId) + && !packs.list().some((pack) => pack.id === skinId)) { + throw new ControlError("INVALID_PARAMS", "Unknown pixel theme ID."); + } + const detail = this.skinDetail(params.detail); + const updated = await settings.update({ terminalBorderSkin: skinId as AppSettings["terminalBorderSkin"], + ...(detail ? { terminalSkinDetail: detail } : {}) }); + this.options.onSettingsChanged?.(updated); + return { activeId: updated.terminalBorderSkin, detail: updated.terminalSkinDetail }; + } + + private skinDetail(value: unknown): "minimal" | "detailed" | undefined { + if (value === undefined) return undefined; + if (value !== "minimal" && value !== "detailed") throw new ControlError("INVALID_PARAMS", "detail must be minimal or detailed."); + return value; + } } export function codexComposerReady(screen: string): boolean { diff --git a/src/main/services/safety/baseProtection.ts b/src/main/services/safety/baseProtection.ts index a6f81ed6..6e6381c5 100644 --- a/src/main/services/safety/baseProtection.ts +++ b/src/main/services/safety/baseProtection.ts @@ -1,6 +1,7 @@ import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { isPathInside } from '../../../agent-runtime/path-inside.mjs'; -import { analyzeAction, commandFromArgv, realish, type HardFacts, type ToolAction } from './commandFacts.ts'; +import { analyzeAction, commandFromArgv, realish, type HardFacts, type PrivateData, 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 @@ -9,11 +10,12 @@ import { analyzeAction, commandFromArgv, realish, type HardFacts, type ToolActio * model: local rules only, no git, no network. */ -const BASE_DENY_RULES = ['elevation', 'pipe-to-shell', 'download-exec', 'disk', 'fork-bomb', 'delete-outside', 'write-outside'] as const; +const BASE_DENY_RULES = ['app-private', '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> = { + 'app-private': 'CanvasTTY blocked this: it reads CanvasTTY\'s own access tokens or secret stores, or talks to its control socket. Agents can\'t control CanvasTTY this way, and guessing its protocol will not work. If you need other agents, ask the person to start you from CanvasTTY\'s launcher with the Orchestrator role: you will then get the canvastty_agents tools (spawn_agent, list_routes, wait_for_agent and the rest). Otherwise continue your task without controlling CanvasTTY.', 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.', @@ -77,8 +79,24 @@ export function actionFromHook(toolName: string, toolInput: unknown, preview: st return { kind: 'edit', command: null, commandCwd: null, paths: path ? [path] : [] }; } +/** + * CanvasTTY's own private data under its userData folder (the app passes `app.getPath('userData')`; tests pass a + * temporary folder): the control token and descriptor, the gateways' connection records and sockets, the secret + * stores, the per-account homes and the prepared launch runs. Its settings and layouts are not listed. + */ +export function canvasTtyPrivateData(userDataPath: string): PrivateData { + const names = ['agent-control', join('browser', 'runtime'), join('lifecycle', 'runtime'), join('orchestration', 'runtime'), + 'provider-secrets.bin', 'plugin-secrets', 'account-homes', 'github-oauth.json', 'launch-runs']; + return { + appRoots: [userDataPath], + paths: names.map(name => join(userDataPath, name)), + markers: ['agent-control', 'provider-secrets', 'plugin-secrets', 'account-homes', 'github-oauth'] + }; +} + /** The first deny rule a set of facts breaks, in a fixed order. */ export function denyRule(facts: HardFacts): BaseDenyRule | null { + if (facts.appPrivate) return 'app-private'; if (facts.elevation) return 'elevation'; if (facts.pipeToShell) return 'pipe-to-shell'; if (facts.downloadExec) return 'download-exec'; @@ -97,6 +115,8 @@ export function denyRule(facts: HardFacts): BaseDenyRule | null { export function checkBaseProtection(input: { toolName: string; toolInput: unknown; preview?: string | null; root: string; commandCwd?: string | null; home?: string; agentRoots?: readonly string[]; + /** CanvasTTY's own private data (canvasTtyPrivateData); its socket folders are known without it. */ + privateData?: PrivateData; }): BaseVerdict | null { try { const action = actionFromHook(input.toolName, input.toolInput, input.preview ?? null); @@ -105,7 +125,8 @@ export function checkBaseProtection(input: { 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 } : {}) + ...(input.agentRoots ? { agentRoots: input.agentRoots } : {}), + ...(input.privateData ? { privateData: input.privateData } : {}) }); const rule = denyRule(facts); if (!rule) return null; diff --git a/src/main/services/safety/commandFacts.ts b/src/main/services/safety/commandFacts.ts index 58bc9104..aefd9477 100644 --- a/src/main/services/safety/commandFacts.ts +++ b/src/main/services/safety/commandFacts.ts @@ -7,8 +7,9 @@ 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). + * needs are computed: elevation, a pipe into a shell, download-and-run, disk commands, a fork bomb, writes or + * deletes outside the working folder (deleting the folder itself included), and any use of CanvasTTY's own private + * data (its access tokens, secret stores and control sockets). */ /** One tool call, source-neutral: a shell command or the files a file tool writes. */ @@ -43,8 +44,18 @@ export interface HardFacts { deletesOutside: boolean; /** Absolute targets written outside the working folder (for the temporary-folder advice). */ outsideWrites: string[]; + /** Names, reads or connects to CanvasTTY's own private data: tokens, secret stores, control/runtime sockets. */ + appPrivate: boolean; } +/** + * CanvasTTY's own private data, as the running app knows it (its userData folder differs per platform and per + * profile, so it is passed in, never guessed). `appRoots`: the app's data folders; `paths`: the private files and + * folders in them (tokens, connection records, secret stores, account homes); `markers`: names that, together with + * an app root's own name, identify those paths inside interpreter code that builds a path piece by piece. + */ +export interface PrivateData { appRoots: readonly string[]; paths: readonly string[]; markers: readonly string[] } + // --------------------------------------------------------------------------- // Paths // --------------------------------------------------------------------------- @@ -70,14 +81,26 @@ const DEVICE = /^(?:\/dev\/(?:r?disk\d|sd[a-z]|hd[a-z]|nvme\d|mmcblk\d|xvd[a-z]| 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[] } +export interface PathContext { + root: string; rootReal: string; home: string; temp: string; agentRoots: string[]; + /** CanvasTTY's private paths and data folders (as given and resolved), and the temporary folders its sockets live in. */ + privatePaths: string[]; appRoots: string[]; appNames: string[]; markers: string[]; tempRoots: 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. */ -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))) }; +function pathContext(root: string, home = homedir(), agentRoots?: readonly string[], privateData?: PrivateData): PathContext { + const both = (paths: readonly string[]): string[] => [...new Set(paths.filter(path => path && isAbsolute(path)).flatMap(path => [resolve(path), realish(resolve(path))]))]; + const temp = tmpdir(); + return { + root, rootReal: realish(resolve(root)), home, temp, agentRoots: (agentRoots ?? [join(home, '.claude')]).map(dir => realish(resolve(dir))), + privatePaths: both(privateData?.paths ?? []), appRoots: both(privateData?.appRoots ?? []), + appNames: [...new Set((privateData?.appRoots ?? []).map(path => basename(path).toLowerCase()).filter(name => name.length >= 4))], + markers: (privateData?.markers ?? []).map(marker => marker.toLowerCase()).filter(marker => marker.length >= 6), + tempRoots: both([temp, '/tmp', '/private/tmp', '/var/tmp']) + }; } const AGENT_SERVICE_DIR = /^(?:plans|projects[\\/][^\\/]+[\\/]memory)(?:[\\/]|$)/u; @@ -193,9 +216,11 @@ interface Acc { ctx: PathContext; writes: Target[]; deletes: Target[]; - flags: { elevation: boolean; pipeToShell: boolean; downloadExec: boolean; disk: boolean; forkBomb: boolean }; + flags: { elevation: boolean; pipeToShell: boolean; downloadExec: boolean; disk: boolean; forkBomb: boolean; appPrivate: boolean }; depth: number; budget: number; + /** Paths still to be checked against CanvasTTY's private data (each costs a realpath). */ + privateBudget: number; } interface Stdin { pipeIn: boolean; heredoc: string | null } @@ -227,6 +252,7 @@ function analyzeSegment(segment: Segment, cwd: string | null, acc: Acc, download if (/^[A-Za-z_][A-Za-z0-9_]*=/u.test(first.text) || LEADING_RESERVED.has(first.text)) words.shift(); else break; } + checkPrivate(segment, words, cwd, acc); for (const word of segment.words) inspectWord(word, acc); for (const redirect of segment.redirects) { if (redirect.fdDup || !redirect.target) continue; @@ -437,6 +463,7 @@ function runInterpreter(program: string, argWords: Word[], cwd: string | null, a 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 (args[i + 1] !== undefined && codeNamesPrivate(args[i + 1]!, acc.ctx)) acc.flags.appPrivate = 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; } @@ -449,9 +476,148 @@ function runInterpreter(program: string, argWords: Word[], cwd: string | null, a return cwd; } if (stdin.pipeIn) acc.flags.pipeToShell = true; + // A program read from a heredoc (`python3 - < privateHit(text, cwd, acc, false)) || codeNamesPrivate(stdin.heredoc, acc.ctx))) acc.flags.appPrivate = true; return cwd; } +// --------------------------------------------------------------------------- +// CanvasTTY's own private data +// --------------------------------------------------------------------------- + +/** The folders CanvasTTY's gateways put their sockets in, under a temporary folder (`ctty-control-XXXX`, …). */ +const SOCKET_DIR = /^ctty-(?:control|runtime|orch|user|\d+)-/u; +/** Variables that carry a control descriptor, a gateway address or a capability. */ +const PRIVATE_ENV = /^(?:ENV:)?CANVASTTY_(?:CONTROL_CONNECTION|[A-Z_]*_(?:CAPABILITY|ADDRESS))$/u; +/** CanvasTTY's own control CLI reads its descriptor itself: an orchestrator may name it. */ +const CONTROL_CLI = /^canvastty-control(?:\.mjs)?$/u; +/** Programs that walk folders by themselves: naming a folder that holds private data reads it. */ +const RECURSIVE = new Set(['rg', 'ag', 'ack', 'find', 'fd', 'tar', 'bsdtar', 'zip', '7z', 'rsync', 'ditto', 'scp', 'rclone']); +const GLOB_CHAR = /[*?[]/u; +const MAX_PRIVATE_CHECKS = 256; +const MAX_SCANNED_TEXT = 64 * 1024; + +const parts = (path: string): string[] => path.split(/[\\/]+/u).filter(Boolean); + +/** A glob component (`token-*`, `ctty-control-????`) against one real name. */ +function componentMatches(pattern: string, name: string): boolean { + if (!GLOB_CHAR.test(pattern)) return pattern === name; + let source = ''; + for (let i = 0; i < pattern.length; i++) { + const char = pattern[i]!; + if (char === '*') source += '.*'; + else if (char === '?') source += '.'; + else if (char === '[') { + const close = pattern.indexOf(']', i + 2); + if (close < 0) { source += '\\['; continue; } + source += `[${pattern.slice(i + 1, close).replace(/^!/u, '^').replace(/[\\\]]/gu, '\\$&')}]`; + i = close; + } else source += char.replace(/[.+^${}()|\\\]]/gu, '\\$&'); + } + try { return new RegExp(`^${source}$`, 'u').test(name); } catch { return true; } +} + +/** + * Whether one path (absolute, maybe a glob) is CanvasTTY's private data: inside a private path, a folder of the app + * that holds one when the command walks folders, or a gateway's socket folder under a temporary folder. + */ +function privatePath(abs: string, glob: boolean, recursive: boolean, ctx: PathContext): boolean { + if (!glob) { + // The project, and the agent's own config folder (an account home it was launched with), are its own. + if (isPathInside(ctx.rootReal, abs) || ctx.agentRoots.some(dir => isPathInside(dir, abs))) return false; + if (ctx.privatePaths.some(path => isPathInside(path, abs))) return true; + if (recursive && ctx.privatePaths.some(path => isPathInside(abs, path)) && ctx.appRoots.some(root => isPathInside(root, abs))) return true; + return ctx.tempRoots.some(temp => isPathInside(temp, abs, { allowRoot: false }) && SOCKET_DIR.test(parts(relative(temp, abs))[0] ?? '')); + } + // A glob: the folders before its first wildcard resolved, the rest matched name by name. + const all = parts(abs); + const first = all.findIndex(part => GLOB_CHAR.test(part)); + const prefix = realish((abs.startsWith('/') ? '/' : '') + all.slice(0, first).join('/')); + const pattern = [...parts(prefix), ...all.slice(first)]; + const matchesFrom = (target: string[]): number => { + let i = 0; + while (i < pattern.length && i < target.length && componentMatches(pattern[i]!, target[i]!)) i++; + return i; + }; + for (const path of ctx.privatePaths) { + const target = parts(path); + const matched = matchesFrom(target); + if (matched >= target.length) return true; + if (matched === pattern.length && recursive && ctx.appRoots.some(root => parts(root).length <= pattern.length)) return true; + } + return ctx.tempRoots.some(temp => { + const target = parts(temp); + if (matchesFrom(target) < target.length || pattern.length <= target.length) return false; + const next = pattern[target.length]!; + return SOCKET_DIR.test(next) || next.startsWith('ctty') && GLOB_CHAR.test(next); + }); +} + +/** Expands `~`, $HOME and $TMPDIR in a path found inside a word or in code; null when it is not a path. */ +function codePath(text: string, ctx: PathContext): string | null { + let value = text.trim().replace(/^file:\/\//iu, '/'); + value = value.replace(/^(?:\$HOME|\$\{HOME\})(?=[\\/]|$)/u, ctx.home).replace(/^(?:\$TMPDIR|\$\{TMPDIR\})(?=[\\/]|$)/u, ctx.temp); + if (value === '~' || value.startsWith('~/')) value = ctx.home + value.slice(1); + return value.startsWith('/') && value.length > 1 ? value : null; +} + +/** Paths inside a word or a program text: after `=` or `:` (`--unix-socket=P`, `UNIX-CONNECT:P`), quoted strings, bare tokens. */ +function privateCandidates(text: string, ctx: PathContext): string[] { + if (text.length > MAX_SCANNED_TEXT) text = text.slice(0, MAX_SCANNED_TEXT); + const found = new Set(); + const add = (value: string | undefined): void => { const path = value ? codePath(value, ctx) : null; if (path && found.size < 64) found.add(path); }; + for (const match of text.matchAll(/[=:]((?:~|\$\{?(?:HOME|TMPDIR)\}?|\/)[^\s,;'"`()<>|&]*)/gu)) add(match[1]); + for (const match of text.matchAll(/(['"`])([^'"`\n]{1,4096}?)\1/gu)) add(match[2]); + for (const token of text.split(/[\s,;()[\]{}<>|&'"`=]+/u)) if (/^(?:~|\$\{?(?:HOME|TMPDIR)\}?|\/)/u.test(token)) add(token); + return [...found]; +} + +/** Interpreter code that builds a private path from pieces: an app folder's name together with a private name. */ +function codeNamesPrivate(code: string, ctx: PathContext): boolean { + const lower = code.slice(0, MAX_SCANNED_TEXT).toLowerCase(); + if (/ctty-(?:control|runtime|orch)-/u.test(lower)) return true; + return ctx.appNames.some(name => lower.includes(name)) && ctx.markers.some(marker => lower.includes(marker)); +} + +function privateHit(text: string, cwd: string | null, acc: Acc, recursive: boolean, glob = GLOB_CHAR.test(text)): boolean { + if (--acc.privateBudget < 0) return false; + if (!isAbsolute(text) && cwd === null) return false; + return privatePath(glob ? resolve(cwd ?? acc.ctx.rootReal, text) : realish(resolve(cwd ?? acc.ctx.rootReal, text)), glob, recursive, acc.ctx); +} + +/** The command is CanvasTTY's control CLI (`canvastty-control.mjs …`, `node "$CANVASTTY_CONTROL_CLI" …`). */ +function runsControlCli(words: readonly Word[]): boolean { + const cli = (word: Word | undefined): boolean => Boolean(word && (word.vars.length === 1 && word.vars[0] === 'CANVASTTY_CONTROL_CLI' && /^\$\{?CANVASTTY_CONTROL_CLI\}?$/u.test(word.text) || !word.vars.length && !word.substitution && CONTROL_CLI.test(programName(word.text)))); + if (cli(words[0])) return true; + if (!words[0] || !INTERPRETERS.has(programName(words[0].text))) return false; + return cli(words.slice(1).find(word => !word.text.startsWith('-'))); +} + +/** + * One segment against CanvasTTY's private data: every word (and each path inside it), every redirection, and the + * variables that carry a descriptor or a capability. Whatever program reads, copies, encodes or connects to them, + * the command uses them. CanvasTTY's own control CLI is the one program that may name its descriptor. + */ +function checkPrivate(segment: Segment, words: Word[], cwd: string | null, acc: Acc): void { + if (acc.flags.appPrivate || acc.privateBudget <= 0) return; + if (runsControlCli(words)) return; + const recursive = words.some(word => RECURSIVE.has(programName(word.text)) || /^(?:-[a-zA-Z]*[rR][a-zA-Z]*|--recursive|--archive)$/u.test(word.text)) + || programName(words[0]?.text ?? '') === 'cp' && words.some(word => /^-[a-zA-Z]*a/u.test(word.text)); + const targets = [...segment.words, ...segment.redirects.filter(redirect => !redirect.fdDup && redirect.target).map(redirect => redirect.target!)]; + for (const word of targets) { + if (word.vars.some(name => PRIVATE_ENV.test(name))) { acc.flags.appPrivate = true; return; } + if (word.substitution) continue; + const text = expand(word, cwd, acc.ctx); + if (text === null || text === '') continue; + const candidates = new Set([text, ...(text.length > 1 && /[=:'"\s]/u.test(text) ? privateCandidates(text, acc.ctx) : [])]); + for (const candidate of candidates) { + if (!privateHit(candidate, cwd, acc, recursive, candidate === text ? word.glob : GLOB_CHAR.test(candidate))) continue; + acc.flags.appPrivate = true; + return; + } + } +} + 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)); @@ -843,13 +1009,17 @@ function gitEffect(sub: string, rest: readonly string[]): 'read' | 'write' | 'de /** 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 } }; +export function analyzeAction(action: ToolAction, root: string, options: { home?: string; agentRoots?: readonly string[]; privateData?: PrivateData } = {}): HardFacts { + const ctx = pathContext(root, options.home, options.agentRoots, options.privateData); + const acc: Acc = { ctx, writes: [], deletes: [], depth: 0, budget: 64, privateBudget: MAX_PRIVATE_CHECKS, + flags: { elevation: false, pipeToShell: false, downloadExec: false, disk: false, forkBomb: false, appPrivate: 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))); + else if (action.kind === 'edit') { + acc.writes.push(...action.paths.map(path => resolveTarget(path, cwd, ctx))); + if (action.paths.some(path => { const text = codePath(path, ctx) ?? path; return privateHit(text, cwd, acc, false, false); })) acc.flags.appPrivate = true; + } const outside = acc.writes.filter(t => t.where === 'outside' && !t.device && !(t.abs && isAgentServicePath(t.abs, ctx))); return { ...acc.flags, diff --git a/src/main/stdio.ts b/src/main/stdio.ts new file mode 100644 index 00000000..e648e842 --- /dev/null +++ b/src/main/stdio.ts @@ -0,0 +1,7 @@ +// A desktop app can outlive the launcher that reads its output pipes. +export function handleStdioError(error: NodeJS.ErrnoException): void { + if (error.code !== "EPIPE") throw error; +} + +process.stdout.on("error", handleStdioError); +process.stderr.on("error", handleStdioError); diff --git a/src/preload/index.ts b/src/preload/index.ts index 9b4aa659..459101d2 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -12,6 +12,7 @@ import type { CanvasNavigationOverrideStateEvent, CanvasNavigationPointerBindingInput, CanvasTTYApi, + CustomTerminalBorderSkinId, CreateSessionRequest, PluginBrowserOpenRequest, PluginBrowserOpenResponse, @@ -22,6 +23,10 @@ import type { PluginStorageChangeEvent, PluginUpdateStatus, ProviderId, + PixelSkinPackInstallRequest, + PixelSkinZipInstallRequest, + PixelSkinSlot, + PixelTerminalBorderSkinId, SessionBounds, SessionEvent, SessionRemovedEvent, @@ -69,7 +74,20 @@ const api: CanvasTTYApi = { }, settings: { get: () => ipcRenderer.invoke(IPC.settingsGet), - update: (patch: Partial) => ipcRenderer.invoke(IPC.settingsUpdate, patch) + update: (patch: Partial) => ipcRenderer.invoke(IPC.settingsUpdate, patch), + onChanged: (listener: (settings: AppSettings) => void) => subscribe(IPC.settingsChanged, listener) + }, + skins: { + list: () => ipcRenderer.invoke(IPC.terminalBorderSkinsList), + get: (id: CustomTerminalBorderSkinId) => ipcRenderer.invoke(IPC.terminalBorderSkinsGet, id), + onChanged: (listener: () => void) => subscribe(IPC.terminalBorderSkinsChanged, listener) + }, + pixelSkins: { + list: () => ipcRenderer.invoke(IPC.pixelSkinsList), + install: (request: PixelSkinPackInstallRequest) => ipcRenderer.invoke(IPC.pixelSkinsInstall, request), + installZip: (request: PixelSkinZipInstallRequest) => ipcRenderer.invoke(IPC.pixelSkinsInstallZip, request), + readAsset: (id: PixelTerminalBorderSkinId, slot: PixelSkinSlot) => ipcRenderer.invoke(IPC.pixelSkinsReadAsset, id, slot), + onChanged: (listener: () => void) => subscribe(IPC.pixelSkinsChanged, listener) }, agents: { availability: () => ipcRenderer.invoke(IPC.agentsAvailability), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 4444ce49..b60d087e 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -40,6 +40,10 @@ import { DEFAULT_SHORTCUTS } from "../../shared/contracts"; import { normalizeExternalUrl } from "../../shared/externalUrl"; +import { + createTerminalBorderSkinStyleController, + type TerminalBorderSkinStyleController +} from "./lib/skinStyles"; import { TitleBar } from "./components/TitleBar"; import { Toast } from "./components/Toast"; import { AgentLaunchDialog } from "./features/launcher/AgentLaunchDialog"; @@ -50,6 +54,9 @@ import { persistSettingsUpdate } from "./features/settings/persistSettings"; import { PluginBrowserOpenQueue } from "./features/plugins/PluginBrowserOpenQueue"; import { TerminalLinkDialog } from "./features/terminal/TerminalLinkDialog"; import { WorkspaceCanvas } from "./features/workspace/WorkspaceCanvas"; +import { isPixelSkinThemeId } from "./features/skins/skinCatalog"; +import { isPixelSkinPackId } from "./features/skins/SkinAssets"; +import { expandedPixelSkinCardBounds, PIXEL_SKIN_CARD_SIZE } from "./features/skins/pixelSkinCardGeometry"; import type { LimitsLoadState } from "./features/home/homeModel"; import { t } from "./lib/i18n"; import { AGENT_PROVIDERS, LIMIT_PROVIDERS } from "./lib/providers"; @@ -92,7 +99,12 @@ const FALLBACK_SETTINGS: AppSettings = { baseProtectionEnabled: true, uiScale: DEFAULT_UI_SCALE, canvasColor: "sage", + canvasBackground: "none", pattern: "dots", + terminalBorderSkin: "classic", + terminalSkinDetail: "detailed", + terminalSkinAnimationEnabled: true, + appSkin: "classic", snapToGrid: true, invertTerminalWheel: true, invertCanvasWheel: false, @@ -143,6 +155,28 @@ const EMPTY_BROWSER_SNAPSHOT: BrowserSnapshot = { const DEFAULT_FOCUS_ZOOM = 0.92; const PLUGIN_CANVAS_FOCUS_ZOOM = 1; +function TerminalBorderSkinStyleHost({ skinId }: { skinId: AppSettings["terminalBorderSkin"] }): null { + const controllerRef = useRef(null); + const activeSkinIdRef = useRef(skinId); + activeSkinIdRef.current = skinId; + + useEffect(() => { + const controller = createTerminalBorderSkinStyleController(window.canvasTTY.skins, document); + controllerRef.current = controller; + controller.setActive(activeSkinIdRef.current); + return () => { + controller.dispose(); + if (controllerRef.current === controller) controllerRef.current = null; + }; + }, []); + + useEffect(() => { + controllerRef.current?.setActive(skinId); + }, [skinId]); + + return null; +} + function customHomeAccentStyle(colors: HomeAccentColors): React.CSSProperties { const launcherTile = mixHexWithWhite(colors.launcher, 0.62); return { @@ -261,6 +295,9 @@ export function App(): React.JSX.Element { }); const settingsRequest = window.canvasTTY.settings.get(); + const unsubscribeSettings = window.canvasTTY.settings.onChanged((next) => { + if (active) setSettings(next); + }); const availabilityRequest = window.canvasTTY.agents.availability(); const sessionsRequest = window.canvasTTY.terminal.list().then((loadedSessions) => { if (active) setSessions((current) => mergeSessionSnapshots(current, loadedSessions)); @@ -291,6 +328,7 @@ export function App(): React.JSX.Element { active = false; unsubscribeSession(); unsubscribeRemoved(); + unsubscribeSettings(); }; }, [showToast]); @@ -391,8 +429,11 @@ export function App(): React.JSX.Element { environment?: SessionEnvironmentChoice ): Promise => { const currentSettings = settingsRef.current; + const pixelSkin = isPixelSkinThemeId(currentSettings.terminalBorderSkin) + || isPixelSkinPackId(currentSettings.terminalBorderSkin); + const cardSize = pixelSkin ? PIXEL_SKIN_CARD_SIZE : DEFAULT_SESSION_SIZE; const position = requestedCenter - ? centeredWindowPosition(requestedCenter, DEFAULT_SESSION_SIZE) + ? centeredWindowPosition(requestedCenter, cardSize) : findNearHomeSessionPosition( { position: { x: 0, y: 0 }, size: homeGridPixelSize(currentSettings.homeGridSize) }, [ @@ -402,26 +443,28 @@ export function App(): React.JSX.Element { ...(currentSettings.browserCanvas ? [currentSettings.browserCanvas] : []), ...pendingSessionPlacements.current ], - DEFAULT_SESSION_SIZE + cardSize ); // Reserve the slot until the async create finishes, so fast parallel launches // cannot both choose the same free position before React renders either card. const reservation: SessionBounds | null = requestedCenter ? null - : { position, size: DEFAULT_SESSION_SIZE }; + : { position, size: cardSize }; if (reservation) pendingSessionPlacements.current.push(reservation); try { 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)); + const sizedSession = pixelSkin ? { ...session, size: { ...cardSize } } : session; + if (pixelSkin) window.canvasTTY.terminal.setBounds(session.id, { position, size: sizedSession.size }); + sessionsRef.current = upsertSnapshot(sessionsRef.current, sizedSession); + setSessions((current) => upsertSnapshot(current, sizedSession)); setActiveSessionId(session.id); await saveSettings({ lastDirectory: cwd }); isHomeCamera.current = false; - setCamera(focusCamera(position, session.size)); - return session; + setCamera(focusCamera(position, sizedSession.size)); + return sizedSession; } finally { if (reservation) { pendingSessionPlacements.current = pendingSessionPlacements.current.filter((item) => item !== reservation); @@ -534,6 +577,22 @@ export function App(): React.JSX.Element { } }, [fullscreenSessionId]); + const previousBorderSkin = useRef(null); + useEffect(() => { + if (!ready) return; + const previous = previousBorderSkin.current; + previousBorderSkin.current = settings.terminalBorderSkin; + // Expand only when entering pixel styling; preserve restored and manually resized bounds. + if (previous === null || previous === settings.terminalBorderSkin + || isPixelSkinThemeId(previous) || isPixelSkinPackId(previous) + || !(isPixelSkinThemeId(settings.terminalBorderSkin) + || isPixelSkinPackId(settings.terminalBorderSkin))) return; + const expanded = expandedPixelSkinCardBounds(sessions); + for (const { id, bounds } of expanded) changeSessionBounds(id, bounds); + const active = expanded.find(({ id }) => id === activeSessionId); + if (active && !isHomeCamera.current) setCamera(focusCamera(active.bounds.position, active.bounds.size)); + }, [activeSessionId, changeSessionBounds, ready, sessions, settings.terminalBorderSkin]); + const changePluginCanvasBounds = useCallback((id: string, bounds: SessionBounds): void => { const pluginCanvas = settingsRef.current.pluginCanvas.map((instance) => instance.id === id ? { ...instance, position: bounds.position, size: bounds.size } @@ -1143,7 +1202,8 @@ export function App(): React.JSX.Element { }, [agentAvailability, homeEditDraft, settings]); return ( -
+
+
{!ready &&
{t(settings.locale, "loading")}
} diff --git a/src/renderer/src/assets/theme-backgrounds/cat.png b/src/renderer/src/assets/theme-backgrounds/cat.png new file mode 100644 index 00000000..1e12215b Binary files /dev/null and b/src/renderer/src/assets/theme-backgrounds/cat.png differ diff --git a/src/renderer/src/assets/theme-backgrounds/forest-cabin.png b/src/renderer/src/assets/theme-backgrounds/forest-cabin.png new file mode 100644 index 00000000..ed0c7f01 Binary files /dev/null and b/src/renderer/src/assets/theme-backgrounds/forest-cabin.png differ diff --git a/src/renderer/src/assets/theme-backgrounds/gold-black.png b/src/renderer/src/assets/theme-backgrounds/gold-black.png new file mode 100644 index 00000000..b79a15fd Binary files /dev/null and b/src/renderer/src/assets/theme-backgrounds/gold-black.png differ diff --git a/src/renderer/src/assets/theme-backgrounds/gothic-eclipse.png b/src/renderer/src/assets/theme-backgrounds/gothic-eclipse.png new file mode 100644 index 00000000..a9efaff5 Binary files /dev/null and b/src/renderer/src/assets/theme-backgrounds/gothic-eclipse.png differ diff --git a/src/renderer/src/assets/theme-backgrounds/matrix.png b/src/renderer/src/assets/theme-backgrounds/matrix.png new file mode 100644 index 00000000..308773d9 Binary files /dev/null and b/src/renderer/src/assets/theme-backgrounds/matrix.png differ diff --git a/src/renderer/src/assets/theme-backgrounds/sakura.png b/src/renderer/src/assets/theme-backgrounds/sakura.png new file mode 100644 index 00000000..1810c0c0 Binary files /dev/null and b/src/renderer/src/assets/theme-backgrounds/sakura.png differ diff --git a/src/renderer/src/components/TitleBar.tsx b/src/renderer/src/components/TitleBar.tsx index 95e86f88..ed998bdd 100644 --- a/src/renderer/src/components/TitleBar.tsx +++ b/src/renderer/src/components/TitleBar.tsx @@ -1,6 +1,6 @@ import type { LocaleId, WindowState } from "../../../shared/contracts"; import appManifest from "../../../../package.json"; -import { ProviderIcon } from "./ProviderIcon"; +import appLogo from "../../../../docs/assets/canvastty-logo-dark.svg?url"; import { UiIcon } from "./UiIcon"; import { t } from "../lib/i18n"; @@ -36,7 +36,7 @@ export function TitleBar({ locale, windowState, onWindowStateChange }: TitleBarP