Skip to content

Latest commit

 

History

History
496 lines (379 loc) · 77.2 KB

File metadata and controls

496 lines (379 loc) · 77.2 KB

Runtime-плагины

English · Русский · 简体中文 · Документация

Runtime-плагин CanvasTTY устанавливается из HTTPS GitHub-репозитория. Он может добавить sandboxed web-поверхности и опционально объявить scripts хуков агентов. Он также может объявить долгоживущие сервисы. Web-contributions работают без Node.js. Хуки и сервисы — нативный код: каждый hook script остаётся выключенным, пока пользователь отдельно не включит его в Настройки → Агенты → Хуки, а сервисы плагина — пока он не подтвердит их в Настройки → Агенты → Нативный код расширений.

Модель доверия

Установка плагина разрешает стороннему browser-коду выполняться локально. CanvasTTY уменьшает поверхность риска, но не может сделать неизвестный код доверенным:

  • CanvasTTY скачивает только tar-архив default branch по корневой ссылке GitHub-репозитория и не запускает npm install, build hooks, нативные модули или scripts репозитория во время установки/обновления.
  • В пакете запрещены symlink; лимит — 500 файлов или каталогов / 25 МБ, один отдаваемый ресурс — не больше 8 МБ.
  • Iframe получает opaque sandbox origin, не видит parent DOM, window.canvasTTY и Node.js API.
  • Узкий preload отдельного окна не открывает Node primitives и передаёт те же SDK-запросы через IPC с проверкой plugin/contribution по фактическому URL.
  • Каждый привилегированный SDK-метод требует permission из manifest. Полный список разрешений показывается до подтверждения установки.
  • Sandboxed web-contributions не получают учётные данные провайдеров, PTY buffer, рабочие каталоги, сырые ответы API или доступ к файловой системе.
  • Выключение или удаление плагина сразу прекращает отдачу его ресурсов и закрывает отдельные окна.
  • Сервисы подчиняются тому же правилу, что и хуки, для плагина целиком: установка их не запускает, а обновление, смена modules, выключение или изменённый файл entry снимают подтверждение. Сервисы работают вне процесса; код плагинов не выполняется в main-процессе CanvasTTY.
  • Agent hooks никогда не включаются автоматически. Включённый hook script эквивалентен нативному приложению: он получает payload события агента, выполняется с правами учётной записи пользователя и потенциально видит доступные ей конфиги или credentials. Обновление, смена modules или выключение плагина отзывает все такие разрешения.

CanvasTTY не встраивает произвольные нативные окна ОС. Contribution window — это sandboxed BrowserWindow, которым владеет CanvasTTY. Native reparenting ненадёжен и непереносим между Wayland, macOS, Windows, разными DPI, popup и GPU surfaces.

Структура пакета

В корне репозитория обязателен canvastty.plugin.json. Entry — относительный путь к готовому статическому HTML; inline scripts блокируются plugin CSP.

canvastty.plugin.json
shared/plugin.css
widgets/status.html
widgets/status.js
apps/notes.html
apps/notes.js
windows/focus.html
windows/focus.js
hooks/audit.mjs

Рабочий пример sandboxed web-поверхностей без привилегированного хука: examples/plugins/studio-kit. Минимальный сервис с canvas-приложением, которое его вызывает: examples/plugins/service-echo. Launch contributor: examples/plugins/launch-env, политика запуска: examples/plugins/yolo-guard. Среда сессии (git worktree): examples/plugins/env-worktree. Сервис решений: examples/plugins/deny-rm. Инструмент для агентов, действие на окне и события сессий: examples/plugins/collect-demo. Для IDE доступны JSON Schema manifest и TypeScript declarations SDK.

Manifest v1

{
  "apiVersion": 1,
  "id": "com.example.studio-kit",
  "name": "Studio Kit",
  "version": "1.0.0",
  "description": "Небольшие поверхности CanvasTTY на реальных данных host.",
  "permissions": ["storage", "secrets", "sessions:read", "launcher:open"],
  "hooks": [
    {
      "id": "audit",
      "title": "Локальный журнал аудита",
      "description": "Записывает выбранные события жизненного цикла агента в журнал под управлением пользователя.",
      "entry": "hooks/audit.mjs",
      "providers": ["codex", "claude", "kimi"],
      "events": ["session-start", "permission-request", "session-end"]
    }
  ],
  "settingsContribution": "notes",
  "contributions": [
    {
      "id": "session-status",
      "kind": "home-widget",
      "title": "Session status",
      "entry": "widgets/status.html",
      "defaultSize": { "columns": 4, "rows": 2 }
    },
    {
      "id": "notes",
      "kind": "canvas-app",
      "title": "Notes",
      "entry": "apps/notes.html",
      "defaultSize": { "width": 680, "height": 440 },
      "minSize": { "width": 320, "height": 180 }
    },
    {
      "id": "focus",
      "kind": "window",
      "title": "Focus",
      "entry": "windows/focus.html",
      "defaultSize": { "width": 900, "height": 620 }
    }
  ]
}

ID плагина и contribution — стабильные ключи persistence: после публикации их нельзя переименовывать. Версия использует semantic version. Опциональный settingsContribution ссылается на один canvas-app: CanvasTTY показывает для него отдельное действие Настройки в меню расширений. Каждый установленный home-widget также появляется рядом со встроенными виджетами в разделе Настройки → Оформление → Состав HOME, где он добавляется или удаляется. Опциональный minSize поддерживается для canvas-app и window, не может превышать defaultSize и ограничен снизу размером 240 × 140 px. Для старых manifest сохраняется минимум хоста 320 × 220 px. HOME начинает с просторной логической сетки 16 × 12, сохраняя исходную композицию 12 × 8. В редакторе видимая граница растягивается до 48 × 36 без уменьшения ячеек, а при нехватке места новый виджет расширяет её автоматически. Canvas app использует world-space pixels и участвует в том же snapping, что терминальные карточки.

Поле platforms необязательно; если оно задано, список должен содержать "canvastty", иначе прямая установка или обновление отклоняются. minHostVersion носит информационный характер: витрина помечает плагины для более новой версии host, но не блокирует установку. Старые минимальные версии не считаются несовместимостью.

Необязательные модули

Модульный manifest объявляет проверяемые по целостности coreFiles и до 16 необязательных modules. Для каждого файла задаются path, точный размер bytes и SHA-256. CanvasTTY загружает для предпросмотра только manifest, показывает галочки, размер и разрешения каждого модуля, а затем скачивает только ядро и выбранные модули. Последующее изменение выбора атомарно заменяет установленный пакет и удаляет файлы отключённых модулей. Поле module у contribution скрывает его, если соответствующий модуль не установлен.

Целостность файлов модулей (точный размер в байтах и SHA-256) проверяется по хэшам, объявленным в manifest плагина, а сам manifest загружается с GitHub по TLS без отдельной подписи. Поэтому якорем доверия является GitHub-репозиторий плагина: скомпрометированный репозиторий может опубликовать новый manifest с совпадающими хэшами.

Опциональные хуки агентов

Поле hooks объявляет до 16 JavaScript entries (.js, .mjs, .cjs). Для каждого задаются стабильные id, title, entry, список providers и семантические events: session-start, prompt-submit, permission-request, permission-result, after-tool, stop, session-end. Не поддерживаемые конкретным provider события пропускаются. В modular plugin entry хука должен быть integrity-объявлен в его опциональном module, а без module — в coreFiles. Non-modular пакет обязан содержать файл по проверенному entry path.

Hook-only plugin использует пустой массив contributions и непустой hooks. Установка только копирует и проверяет файлы. Перед включением пользователь должен проверить исходник и репозиторий, затем отдельно подтвердить доверие в Настройки → Агенты → Хуки. Host-owned registry проверяется при каждом вызове, поэтому выключение блокирует последующие запуски даже в уже работающей сессии. Если launch-time bridge provider ещё не установлен, для включения понадобится новая или перезапущенная сессия агента.

Собственная проверка хуков provider остаётся обязательной. Например, Codex может дополнительно попросить проверить launch-time bridge CanvasTTY через свой /hooks. CanvasTTY не передаёт глобальный флаг Codex --dangerously-bypass-hook-trust: включение plugin hook не ослабляет проверку других хуков provider.

Script запускается отдельным процессом из каталога плагина и получает JSON через stdin с полями apiVersion, pluginId, hookId, terminalSessionId, provider, event, providerEvent, payload. Stdout/stderr отбрасываются, время выполнения ограничено, внутренние capability-токены CanvasTTY удаляются из environment. Это защита host internals, а не sandbox: script всё ещё может читать/менять файлы и запускать процессы с обычными правами пользователя.

Сервисы (apiVersion 2)

Манифест с "apiVersion": 2 может объявить до 8 services. Манифесты версии 1 остаются валидными; версия 2 нужна только для services.

"services": [
  { "id": "echo", "title": "Echo", "description": "Отвечает эхом.", "entry": "services/echo.mjs" }
]

У сервиса стабильный id, title, необязательные description и module, и entry с расширением .js, .mjs или .cjs. Entry должен быть собранным одним файлом (например, esbuild): установщик ничего не собирает и не запускает npm install, Electron и node-pty сервису недоступны. В модульном плагине entry должен быть объявлен с хешем в своём module (или в coreFiles), как entry хуков. Когда пользователь доверяет нативному коду плагина, CanvasTTY запоминает SHA-256 entry и проверяет его перед каждым запуском; изменённый файл не запускается, а доверие снимается при следующем старте.

Жизненный цикл: каждый сервис включённого и доверенного плагина работает отдельным процессом (process.execPath с ELECTRON_RUN_AS_NODE=1), рабочая папка — папка плагина. Окружение минимальное: PATH, HOME, пользователь, shell, локаль, временные и XDG-папки, SSH_AUTH_SOCK и системные папки Windows. Ключи провайдеров, токены, NODE_OPTIONS и все CANVASTTY_* удаляются. Неожиданно завершившийся сервис перезапускается через 1, 2, 4, 8, затем 16 с; после более чем 5 неожиданных завершений за 10 минут он остаётся в ошибке, пока доверие не подтвердят заново. Выключение, удаление, обновление, смена модулей, снятие доверия или выход из CanvasTTY останавливают его: сначала уведомление canvastty.shutdown и закрытый stdin, затем SIGTERM, затем SIGKILL. Для сервиса создаётся <userData>/plugin-data/<pluginId>, при удалении плагина папка удаляется. stderr, stdout вне протокола, вызовы log и события жизненного цикла пишутся в ограниченный журнал плагина (последние 300 записей) в Настройки → Агенты → Нативный код расширений.

Протокол: JSON-RPC 2.0 построчно через stdin/stdout, не больше 1 МБ на сообщение в каждую сторону. Более крупный запрос хоста отклоняется, более длинная строка сервиса отбрасывается и попадает в журнал. Первым хост отправляет уведомление canvastty.initialize с { apiVersion: 2, pluginId, serviceId, dataDir, locale, hostVersion }.

При запуске приложения сервисы стартуют только после того, как готовы все методы хоста, которые они могут вызвать (sessions.*, cards.setBadge, secrets.get, …), и до восстановления сохранённых окон: сервис может вызывать их сразу после canvastty.initialize, а подписавшийся на события сессий получает восстановленные окна событиями или в снимке sessions.subscribe.

Запросы от собственных поверхностей плагина приходят с методом и параметрами, которые выбрала поверхность; методы с префиксом canvastty. зарезервированы за хостом. Отвечайте {"jsonrpc":"2.0","id":…,"result":…} или {"jsonrpc":"2.0","id":…,"error":{"code":-32000,"message":"…"}}. Запрос без ответа за 15 с завершается ошибкой таймаута, как и запрос к остановленному, перезапускающемуся или упавшему сервису; одновременно ждут не больше 64 запросов на сервис.

Сервис может вызывать API хоста (это основа, которую расширят следующие точки расширения; всё остальное получает ошибку -32601):

Метод Вид Условие Результат
log { level?: "info" | "warn" | "error", message } запрос или уведомление нет Строка в журнале плагина
storage.get { key } запрос разрешение storage То же изолированное хранилище 64 КБ, что host.storage.get
storage.set { key, value } запрос разрешение storage Запись и уведомление поверхностей плагина
event { event, data } уведомление нет Доставляется открытым поверхностям плагина через host.service.onEvent
redaction.register { values } запрос нет До 32 строк (до 4096 символов, учитываются от 8), которые CanvasTTY маскирует во всём тексте, который один агент читает у другого; хранятся только в памяти
secrets.get { key } запрос разрешение secrets Собственный секрет плагина (то же хранилище, что host.secrets) или null. Значение затем маскируется, как значения redaction.register. Для ключей, которые нужны самому сервису (API-ключ модели, которую он вызывает); никогда не отправляйте его обратно на страницу
sessions.subscribe / sessions.list / sessions.unsubscribe запрос sessions:events События окон и список открытых окон (см. «События сессий»)
sessions.create запрос sessions:launch Запускает окно, которым владеет плагин
sessions.send / sessions.stop запрос sessions:control Только для окон, запущенных этим плагином
cards.setBadge { sessionId, badge } запрос cards:decorate Короткая текстовая метка на любом окне (см. «Метки и действия на окнах»)

Хост привязывает каждый вызов к плагину самого сервиса: сервис не может назвать другой плагин или прочитать секреты другого плагина, а к сессиям обращается только через разрешения sessions:* ниже. Пример service-echo сохраняет токен со своей страницы через host.secrets.set, а его сервис читает его через secrets.get и отвечает только, задан ли он.

Канал UI: sandboxed-поверхности обращаются только к сервисам своего плагина:

const reply = await host.service.request("echo", "echo", { text: "hi" });
host.service.onEvent(({ serviceId, event, data }) => { /* … */ });

Разрешение неявное, если плагин объявил сервис. Хост передаёт непрозрачный JSON и никогда не добавляет credentials. Запрос к неработающему сервису (ещё не доверен, выключен, перезапускается, упал) или по таймауту завершается ошибкой.

Launch contributors (launch:contribute)

Один сервис плагина может объявить блок launch. Его поля появляются в окне запуска агента в разделе Дополнительно, когда нативному коду плагина доверяют; человек включает плагин для одного запуска флажком Использовать имя плагина и задаёт поля. Плагин спрашивают только о запусках, где его выбрали, и о перезапусках и восстановлении этих окон.

"permissions": ["launch:contribute"],
"services": [{
  "id": "launcher", "title": "Launch env", "entry": "services/launcher.mjs",
  "launch": {
    "appliesTo": ["claude"],
    "fields": [
      { "key": "enabled", "label": "Add the variable", "kind": "boolean", "default": true },
      { "key": "greeting", "label": "Value", "kind": "text", "default": "hello", "maxLength": 60 },
      { "key": "mode", "label": "Mode", "kind": "select", "default": "plain",
        "options": [{ "value": "plain", "label": "Plain" }, { "value": "loud", "label": "Loud" }] }
    ]
  }
}]

До 8 полей; kind: boolean, select (1–16 вариантов) или text (до 200 символов или maxLength). appliesTo перечисляет провайдеров-агентов; без него плагин доступен всем агентам. Выбранные значения проверяются по полям, сохраняются в записи сессии окна (до 4 КБ на плагин) и используются при перезапуске и восстановлении. Они не секретны: ключи хранят в secrets плагина, а не в поле.

select с "optionsFrom": "service" дополнительно показывает варианты, которые предлагает сервис, например его собственные учётные записи. При открытии окна запуска CanvasTTY спрашивает сервис canvastty.launch.options { provider, fields: [ключи] } и ждёт не более 3 с; ответ { "<ключ>": [{ value, label }] } добавляет до 64 вариантов на поле после объявленных (они по-прежнему обязательны, и только они видны, если сервис не ответил). Такой список может измениться после сохранения окна, поэтому значение принимается как любой текст до 200 символов без управляющих символов, а canvastty.launch.prepare обязан его проверить и отказать, если такого варианта больше нет.

Оркестраторы передают те же значения в spawn_agent как launchOptions ({ "<pluginId>": { "<ключ>": значение } }); они проверяются так же, как значения окна запуска. Их может выдать инструмент плагина (например, выбранную им учётную запись). Пока запуск дочернего агента ждёт плагинов (параметры запуска, политика запуска, среда), spawn_agent отвечает только после того, как его prompt дошёл до запущенного агента; send_to_agent ждёт так же. Отказ, сбой или отмена запуска завершают вызов ошибкой с причиной и id сессии (окно остаётся); текст отбрасывается и не сохраняется для следующего перезапуска. CLI управления для такого окна отвечает NOT_READY.

Перед запуском агента хост отправляет сервису запрос canvastty.launch.prepare (поверхности его отправить не могут):

{"sessionId":"…","provider":"claude","profile":"normal","role":"agent","cwd":"/project","restoring":false,"resume":false,"options":{"enabled":true,"greeting":"hello","mode":"plain"},"chosen":true,"environment":null}

Ответ: null (ничего не добавлять) или объект с любыми из ключей:

Ключ Предел Действие
env { NAME: value } 32 имени, 8 КБ на значение Добавляется в окружение агента
secretEnv { NAME: secretKey } 16 имён; нужно secrets Хост читает собственный секрет плагина в main-процессе и подставляет его. Значение не попадает ни в сервис, ни в UI и маскируется как <redacted:secret> в тексте этого окна, который читают другие агенты и control CLI (observe, result, screen, причина ошибки)
args [string] 32, до 1024 символов, без управляющих символов Добавляются после аргументов CanvasTTY, перед выбором разговора
files [{ relPath, content }] 16 файлов, 256 КБ, простые относительные пути Пишутся в личную папку этого запуска и удаляются при выходе процесса; {launchFiles} в env и args заменяется на эту папку
thirdPartyModel true — Агент работает на модели не своего поставщика (аккаунт API или Ollama). Профиль auto тогда в этом запуске работает как «только правки», и окно это показывает. Может задавать и политика запуска: отметка только ужесточает запуск
refuse { reason } 240 символов Окно не запускается и показывает причину

Правила хоста, которые никогда не пропускаются:

  • Выбранные плагины опрашиваются параллельно, результаты объединяются в порядке id плагинов. Если два плагина задают одно имя или плагин задаёт имя, которое CanvasTTY задаёт для этого запуска, запуск отклоняется с их названиями. Имена с префиксами CANVASTTY_, ELECTRON_, DYLD_, LD_, а также NODE_OPTIONS, PATH, TERM, COLORTERM зарезервированы.
  • Аргументы, которые отключают подтверждения или выбирают разговор (YOLO-флаги всех провайдеров, --permission-mode, --sandbox, --resume, --continue, --session и подобные), отклоняются: профиль остаётся за человеком, правила восстановления за ядром. Это не песочница: доверенный нативный код и так работает от вашего имени.
  • Claude Code применяет только последний --settings, поэтому встроенный JSON --settings плагина сливается с собственным JSON CanvasTTY (объекты вроде env по ключам, списки хуков дописываются); если он задаёт permissions, hooks, disableAllHooks, sandbox, defaultMode или apiKeyHelper, запуск отклоняется. Проверяются обе формы: --settings <json> и --settings=<json>; файл настроек принимается только как один из собственных файлов запуска вклада ({launchFiles}/…) — CanvasTTY читает его, проверяет так же и передаёт встроенным JSON; любой другой путь к файлу отклоняется. --bare, --safe-mode, --allowedTools, --permission-prompt-tool и --permission-prompts тоже передаёт только CanvasTTY.
  • Нет ответа за 5 с, ошибка, неверный ответ, отсутствующий секрет или выключенный, удалённый либо переставший быть доверенным плагин отклоняют запуск, и причина видна в окне. Агент никогда не запускается без выбранного вклада. Восстановленное окно с недоступным плагином возвращается остановленным с этой причиной и сохраняет запись, пока плагин не вернётся или окно не закроют.
  • Обычный терминал параметров запуска не принимает.

Профили запуска. profile — это normal (по умолчанию), yolo или auto. auto есть только у агентов, чей CLI имеет собственный авторежим, проверенный по --help каждого CLI: Codex --approve-for-me (его собственная проверка в песочнице workspace-write), Claude Code --permission-mode auto с его песочницей ({"sandbox":{"enabled":true,"autoAllowBashIfSandboxed":false}} сливается в единственный --settings) и Grok --permission-mode auto (на песочницу Grok не полагаемся). Базовая защита и сервисы решений отвечают перед ним (Codex, Claude Code). Если хотя бы один вклад ответил thirdPartyModel: true, auto превращается в режим «только правки» того же CLI в той же песочнице (Codex --sandbox workspace-write --ask-for-approval on-request, Claude Code и Grok --permission-mode acceptEdits): собственной проверкой была бы та же модель, а классификатор слабой модели — не граница безопасности.

Доверенная папка. Субагент на этом компьютере, чья папка — та, что человек выбрал для его агента верхнего уровня, или внутри неё, получает "trustedFolder": реальный путь этой папки. CanvasTTY сам отвечает за него на вопрос Codex «Trust this folder?» переопределением -c projects=… только на этот запуск (в ~/.codex ничего не пишется); плагин, который держит собственную папку конфигурации агента (например, CLAUDE_CONFIG_DIR аккаунта), может отметить папку доверенной там. Codex также получает доверие на этот запуск к хукам, которые добавляет сам CanvasTTY (-c hooks.state=…), и не останавливается на «Hooks need review»; хуки проекта или самого человека по-прежнему спрашивают. Плагины не могут передавать -c hooks….

Политики запуска. С "policy": true сервис спрашивают ещё и перед каждым запуском агентов, к которым он относится (создание, перезапуск, восстановление), где человек его не выбрал, с "chosen": false и пустыми options. Такой ответ может быть только null или refuse; всё остальное, нет ответа за 5 с или ошибка — отказ в запуске, так что политика никогда не пропускает запуск из-за сбоя. Каждый canvastty.launch.prepare несёт и "environment": { pluginId, kind } среды окна или null на этом компьютере. Политика без fields в запускателе не показывается. Отзыв доверия к нативному коду плагина убирает его политику.

"launch": { "policy": true, "fields": [] }

Полные примеры: examples/plugins/launch-env (параметры) и examples/plugins/yolo-guard (политика, которая не пускает YOLO вне среды).

Среды сессий (environment:provide)

Среда — это место, где работает окно: git worktree, контейнер, удалённый хост. Плагин может перечислить до 8 видов в environments — в одном сервисе или в нескольких (например, по сервису на модуль); каждый вид уникален в плагине, и на него отвечает сервис, который его перечислил. После доверия нативному коду в разделе Дополнительно лаунчера появляется Где запустить (по умолчанию Этот компьютер) с видами, подходящими провайдеру, и их необязательными fields (те же виды и лимиты, что у полей запуска). Пока какой-то вид подходит терминалам, Открыть терминал открывает тот же лаунчер (папка и «Где запустить»), а не терминал сразу.

"permissions": ["environment:provide"],
"services": [{
  "id": "worktree", "title": "Git worktree", "entry": "services/worktree.mjs",
  "environments": [{
    "kind": "worktree", "label": "Git worktree",
    "description": "A branch in its own folder",
    "appliesTo": ["terminal", "claude"],
    "fields": [{ "key": "branch", "label": "Branch", "kind": "text", "default": "", "maxLength": 80 }]
  }]
}]

CanvasTTY владеет окном, PTY, сохранённой записью и порядком восстановления; сервис отвечает на пять запросов, которые может отправить только хост:

Запрос Параметры Ответ Лимит
canvastty.environment.prepare sessionId, kind, provider, cwd, options { ref, label, cwd? } или { refuse: { reason } }. ref — непрозрачный JSON до 4 КБ, хранится с окном; label (80 символов) — бейдж; cwd (существующая абсолютная папка) становится папкой окна 15 с
canvastty.environment.wrap sessionId, kind, ref, provider, command, args, env, secretEnvNames, cwd { command, args, env?, secretEnv?, cwd? } или { refuse } 5 с
canvastty.environment.resume sessionId, kind, ref { ok: true } или { stopped: { reason } } 10 с
canvastty.environment.release sessionId, kind, ref, keepData, reason (closed или quit) игнорируется 10 с
canvastty.environment.describe sessionId, kind, ref { label, detail? } для бейджа окна и подсказки 3 с
  • prepare вызывается один раз, при первом запуске окна. wrap вызывается перед каждым запуском (создание, перезапуск, восстановление) и превращает то, что хост запустил бы, в то, что работает внутри среды: ssh -tt host …, docker exec -it … или та же программа в другой папке. PTY по-прежнему создаёт хост через node-pty, поэтому прокрутка, статус и оркестрация работают как раньше.
  • Ответ wrap проверяется: command — абсолютный путь к исполняемому файлу или простое имя программы, которое хост находит в PATH; командная строка, относительный путь или синтаксис оболочки отклоняются, ничего не запускается через оболочку. args — массив (256 элементов, до 8 КБ, без NUL). env и secretEnv подчиняются правилам launch contributors: зарезервированные имена и имена, которые CanvasTTY или параметр запуска уже задают для этого запуска, отклоняются. Значения secretEnv берутся из собственных секретов плагина (нужно secrets) и маскируются так же, как секреты запуска.
  • wrap получает переменные самого запуска (от CanvasTTY и выбранных параметров запуска) без зарезервированных имён CANVASTTY_* и без значений секретов; secretEnvNames перечисляет имена, значения которых процесс получит от хоста, чтобы обёртка могла пробросить их по имени (docker exec -e NAME).
  • При восстановлении сначала возобновляются все сохранённые среды, затем запускаются родители, потом дочерние окна. Если плагин выключен, удалён или не доверен либо resume ответил stopped, окно возвращается остановленным с причиной и сохраняет запись; «Перезапуск» снова вызывает resume. Окно никогда не запускается локально вместо среды, а таймаут или ошибка отклоняют запуск без запасного варианта.
  • Пока prepare не выполнен, сохранённая запись окна хранит выбор из окна запуска (плагин, вид, параметры) вместо ref. Если приложение закрылось во время prepare, окно возвращается остановленным с этой причиной, и ничего не готовится и не запускается, пока человек его не перезапустит; перезапуск готовит среду заново с теми же параметрами. Неудачный prepare сохраняется так же. Ответ на prepare, пришедший после закрытия или перезапуска окна или после начала выхода из приложения, не используется и не сохраняется: хост сразу вызывает release с keepData: false и reason: "closed", если ещё работает.
  • При закрытии окна в среде один раз спрашивается «Сохранить данные среды?», затем вызывается release с ответом. Выход из приложения ничего не освобождает (среда вернётся вместе с окном); если сохранение выключено, выход вызывает release с keepData: true и reason: "quit", чтобы остановить вычисления. Плагин не ведёт своего списка сессий и не содержит логики восстановления.

Полный пример: examples/plugins/env-worktree: prepare выполняет git worktree add в папке внутри каталога данных плагина, wrap задаёт папку, resume проверяет, что она существует, describe показывает текущую ветку, release удаляет worktree (и созданную им ветку), если вы не решили её сохранить.

Решения по действиям агентов (decision:provide)

Перед тем как локальный агент выполнит shell-команду или запись файла, CanvasTTY может спросить плагин: запретить, спросить человека или разрешить. Один сервис плагина может объявить decide:

"permissions": ["decision:provide"],
"services": [{
  "id": "guard", "title": "rm -rf guard", "entry": "services/guard.mjs",
  "decide": { "events": ["pre-tool"], "appliesTo": ["claude", "codex"], "timeoutMs": 3000 }
}]

pre-tool — каждый вызов shell-инструмента и записи файла до выполнения, в любом режиме разрешений, включая YOLO: у Claude Code, Codex и Qwen Code через их хук PreToolUse, у OpenCode через плагин CanvasTTY для OpenCode (tool.execute.before). appliesTo ограничивает агентов; без него — все четыре. Хост отправляет canvastty.decide (только хост) и ждёт не больше timeoutMs (от 1000 до 60000; без него 3000). Вызов агента ждёт столько же, поэтому просите больше, только когда ответу это нужно (например, локальная модель читает команду); CanvasTTY настраивает хук каждого окна под самый долгий бюджет сервисов, которые к нему относятся на момент запуска, а сервис, которому доверились позже, получает не больше, чем позволяет окно. Бюджет приходит в запросе как budgetMs:

interface DecisionRequest {
  event: "pre-tool";
  sessionId: string; provider: string; role: "agent" | "orchestrator" | "subagent";
  cwd: string;                 // рабочая папка окна
  agentCwd: string | null;     // текущая папка агента, если CLI её сообщает
  tool: { name: string; kind: "shell" | "edit" | "other"; command: string | null; paths: string[] };
  input: unknown;              // ввод инструмента как есть; null, если больше 40 КБ (truncated)
  truncated: boolean;
  budgetMs: number;            // сколько CanvasTTY ждёт этот ответ
}
// ответ: { verdict: "deny" | "ask" | "allow", reason?: string } или null — нет мнения

Как объединяются ответы, по порядку:

  1. Сначала работает базовая защита (ниже); её запрет окончательный, плагины не спрашиваются.
  2. Побеждает любой deny плагина. Модель читает CanvasTTY plugin "<name>" blocked this tool call (<reason>), поэтому пишите в причине, что сделать вместо этого.
  3. Иначе любой ask: Claude Code спрашивает человека об этом вызове при любом режиме разрешений. Таймаут, ошибка, остановленный сервис или нечитаемый ответ считаются ask, никогда не разрешением.
  4. Иначе allow учитывается только от плагина, которому человек это доверил: второе подтверждение Может разрешать действия агентов под плагином в Настройки → Агенты → Нативный код расширений, снимается вместе с доверием к нативному коду. Тогда Claude Code выполняет вызов без своего вопроса, а на вопрос OpenCode об этом вызове отвечается once. Разрешение никогда не действует для ввода, который был слишком велик, чтобы отправить его целиком.
  5. Иначе ничего: агент продолжает так же, как без CanvasTTY.

Codex и Qwen Code принимают от этого хука только запрет: для них ask и allow оставляют решение собственному режиму разрешений CLI. Удалённые и контейнерные сессии не покрываются (их хук не достаёт до этого компьютера). Хук ставится агентам, запущенным, пока включена базовая защита или применяется плагин решений, поэтому доверенный позже плагин действует только для новых окон. CLI выполняет вызов, если его хук упал, так что это страховка, а не песочница.

Полный пример: examples/plugins/deny-rm: запрещает rm -rf чего-либо на верхнем уровне рабочей папки (rm -rf *, rm -rf src) и не имеет мнения обо всём остальном. Он объявляет timeoutMs: 5000, чтобы показать поле; отвечает сразу.

Инструменты для агентов (tools:agents)

Сервис может предложить агентам до 16 tools. Они появляются в MCP-сервере canvastty_agents как <pluginId>__<name> (точки в id плагина становятся _: com.example.tools + lookup — это com_example_tools__lookup; Anthropic и OpenAI допускают в именах инструментов только буквы, цифры, _ и -, не длиннее 64 символов, а у более длинного имени остаётся начало id и короткий хеш) рядом с собственными инструментами оркестрации CanvasTTY и считаются инструментами самого CanvasTTY: базовая защита их не проверяет (она проверяет только shell и запись файлов).

"permissions": ["tools:agents"],
"services": [{
  "id": "collect", "title": "Diff stat", "entry": "services/collect.mjs",
  "tools": [{
    "name": "diffstat",
    "description": "git diff --stat вашей папки или папки одного из ваших субагентов.",
    "inputSchema": { "type": "object", "properties": { "sessionId": { "type": "string" } }, "additionalProperties": false },
    "roles": ["orchestrator"]
  }]
}]
  • name — [a-z][a-z0-9_]{0,39}, уникальное в плагине; inputSchema — JSON Schema с type: "object" на верхнем уровне (до 8 КБ); roles — orchestrator, agent и/или subagent.
  • Инструмент видят только сессии с указанной ролью и только пока нативному коду плагина доверяют и сервис работает. Список читается при запуске агента, поэтому плагин, которому доверились позже, появится в новых окнах. Оркестраторы получают мост как раньше; окно agent или subagent получает его, только если какой-то инструмент плагина указывает его роль, и видит тогда только инструменты плагинов, никогда — основные инструменты оркестрации. Инструменты плагинов доступны Claude Code, Codex, Qwen Code и OpenCode; Kimi и Hermes используют один файл настроек на все окна и получают только основные инструменты.
  • Вызов приходит сервису как canvastty.tools.call (только от хоста) с { tool, callerSessionId, caller, input }, где caller — сводка вызывающего окна (та же форма, что в событиях сессий). Хост сначала проверяет input: объект, обязательные ключи, типы свойств верхнего уровня, отсутствие лишних ключей при additionalProperties: false; более глубокие проверки — дело плагина.
  • Ответ — { content, isError? }: content — текст или любой JSON (отправляется как JSON-текст). Ответ маскируется реестром секретов и обрезается до 32 тыс. символов; отсутствие ответа за 15 с, ошибка или остановленный сервис дают агенту результат-ошибку и ничего больше. Хост ручается только за id вызывающего: инструмент, который работает с другими сессиями, должен проверять их сам (пример принимает только собственных субагентов вызывающего).

События сессий и окна плагина (sessions:*)

Сервис с sessions:events вызывает sessions.subscribe { ownedOnly? } (заново после каждого запуска). Ответ содержит открытые окна; дальше хост присылает уведомления canvastty.sessions.event:

interface PluginSessionEvent {
  type: "created" | "restored" | "status" | "exited" | "closed";
  owned: boolean;              // окно запустил этот плагин
  session: {
    id: string; provider: string; role: "agent" | "orchestrator" | "subagent"; parentSessionId?: string;
    title: string; status: string; exitCode: number | null; startedAt: number;
    cwd: string;               // папка, которую выбрал человек
    workingDirectory: string;  // где окно работает на самом деле (среда worktree его переносит)
    environment?: { pluginId: string; kind: string; label: string; ref: unknown };
  };
  screen?: string;             // только с sessions:read-screen, в status и exited
}

События содержат только метаданные. С sessions:read-screen (в тексте согласия сказано, что это личные данные) события status и exited добавляют последние 4000 символов вывода окна обычным текстом, замаскированным реестром секретов. sessions.list возвращает те же сводки по запросу.

Управление устроено как в шлюзе agent-control: сервис — один контроллер и владеет только тем, что создал. Владение сохраняется в записи сессии окна, поэтому восстановленное окно по-прежнему принадлежит плагину, который его запустил.

Запрос Условие Действие
sessions.create { provider, cwd, profile?, title?, launchOptions?, environment? } sessions:launch Запускает окно agent через обычный конвейер запуска (включая опции запуска и среды; отказ показывается на окне). Окно видимо и никогда не забирает фокус. Не больше 16 на плагин. Ответ { sessionId }
sessions.send { sessionId, text, submit? } sessions:control Вводит текст (с Enter, если не submit: false) в окно, запущенное этим плагином. Окно, чей запуск ещё готовят плагины, получает его, когда запуск начался; sent равен false, если запуск не состоялся (текст отбрасывается)
sessions.stop { sessionId } sessions:control Закрывает окно, запущенное этим плагином; данные его среды сохраняются

Чужой или неизвестный id получает одну и ту же ошибку, поэтому плагин не может прощупывать другие окна. Удаления и чтения экрана через управление нет.

Метки и действия на окнах (cards:decorate)

Сервис с cards:decorate может поставить метку на любое окно и объявить до 8 cardActions:

"permissions": ["cards:decorate"],
"services": [{
  "id": "collect", "title": "Diff stat", "entry": "services/collect.mjs",
  "cardActions": [{ "id": "show-changes", "title": "Show changes", "when": { "environmentKinds": ["worktree"] } }]
}]
  • cards.setBadge { sessionId, badge: { text, tone?, tooltip? } | null }: text до 24 символов, tone — neutral (по умолчанию), info, warn или error, tooltip до 200 символов; null убирает метку плагина. Не больше 4 меток плагинов на окно. Метки — обычный текст, маскируются как текст агентов и исчезают вместе с окном или при снятии доверия к плагину.
  • Действие появляется в меню параметров окна на каждом окне, которое подходит под when: providers, environmentKinds (среда любого плагина; окно без среды никогда не подходит) и roles, каждое необязательно; должны совпасть все указанные. Выбор отправляет canvastty.cards.invoke { actionId, sessionId, session } (только от хоста; session — сводка выше) и ждёт не больше 15 с. Ответ — { message?, tone? }: сообщение (обычный текст, до 2000 символов, замаскированный) показывается всплывающим уведомлением на окне. Таймаут или ошибка показывают уведомление об ошибке.
  • Никакого HTML: метки, заголовки и сообщения выводятся как текст.

Полный пример — examples/plugins/collect-demo: действие Show changes на окнах в среде worktree (из env-worktree) показывает git diff --stat worktree и ставит метку «N changed», а инструмент collect-demo__diffstat даёт оркестраторам то же для своей папки или папки субагента, о котором плагин узнаёт из событий сессий.

Базовая защита и скрытие секретов (ядро)

Две части безопасности встроены и не требуют плагина:

  • Базовая защита (Настройки → Агенты, включена по умолчанию; человек может её выключить) через тот же хук запрещает sudo и другое повышение прав, передачу скачанного или сгенерированного текста в shell, скачивание с запуском, команды для дисков и форматирования, форк-бомбы, а также запись и удаление вне рабочей папки — включая домашнюю папку, другие проекты и /tmp — и удаление самой рабочей папки. Собственные папки планов и памяти агента (~/.claude/plans, ~/.claude/projects/<project>/memory и то же внутри CLAUDE_CONFIG_DIR запуска) не считаются «вне». Она только запрещает; каждая причина говорит модели, что сделать вместо этого (для записи в /tmp — завести временную папку внутри проекта).
  • Скрытие секретов: весь текст, который CanvasTTY передаёт от одного агента другому (observe_agent, get_agent_result, screen, result и детали ошибок в control CLI), маскируется: ключи провайдеров, которые хранит CanvasTTY, значения secretEnv запуска, значения, зарегистрированные сервисом через redaction.register, в том числе перенесённые терминалом на несколько строк, а также типичные формы ключей (sk-…, GitHub, Slack, AWS, Google, JWT, Bearer …, "apiKey": "…", приватные ключи PEM, длинные случайные строки). Так же маскируются ответы инструментов плагинов, screen в событиях сессий, метки на окнах и сообщения действий.

host.onStorageChange(listener) сообщает всем открытым поверхностям того же плагина — canvas cards, HOME widgets и отдельным окнам — об изменениях через host.storage.set, поэтому нескольким поверхностям не требуется постоянный polling.

Permissions

Permission Возможность SDK Граница данных
storage storage.get, storage.set Изолированное JSON-хранилище, 64 КБ на плагин
secrets secrets.get, secrets.set, secrets.delete; secrets.get сервиса Строковые секреты, зашифрованные через Electron safeStorage; без защищённого хранилища ОС вызов завершается ошибкой. Доверенный сервис читает только секреты своего плагина
sessions:read sessions.list Только ID, provider, title, status, startedAt и exitCode
launch:contribute Блок launch сервиса и canvastty.launch.prepare Может добавлять переменные окружения, аргументы и файлы агентам, запущенным с его опцией; с policy может отказать любому запуску агента
environment:provide environments сервиса и canvastty.environment.* Может создавать место для окон, запущенных в его среде, и менять команду, аргументы, переменные и папку, с которыми они там работают
decision:provide decide сервиса и canvastty.decide Видит команды и записи файлов агентов (с их вводом) до выполнения и может запрещать их или спрашивать человека; для разрешения нужно второе подтверждение
tools:agents tools сервиса и canvastty.tools.call Предлагает инструменты агентам указанных ролей; получает их аргументы и сводку вызывающего окна
sessions:events sessions.subscribe, sessions.list Метаданные окон: провайдер, роль, родитель, заголовок, статус, папки, ref среды; без текста экрана
sessions:read-screen screen в событиях status и exited Конец вывода каждого окна (замаскированный): личные данные
sessions:launch sessions.create Запускает видимые окна агентов через обычный запуск
sessions:control sessions.send, sessions.stop Вводит текст и закрывает только окна, запущенные плагином
cards:decorate cards.setBadge, cardActions сервиса, canvastty.cards.invoke Текстовые метки на окнах и действия в их меню
limits:read limits.get Тот же очищенный LimitsSnapshot, который использует HOME
launcher:open launcher.open Открывает штатную Focus Card или запуск терминала; не обходит пользовательский выбор
external:open external.open Передаёт ОС только явную HTTP(S)-ссылку
browser:open browser.open Открывает только явную HTTP(S)-ссылку во встроенной карточке Browser и её общей browser-сессии, включая localhost
media:library media.* Только выбранные пользователем музыкальные папки; абсолютные пути не раскрываются, аудио отдаётся seekable-потоками canvastty-media://
playlists:read playlists.list, playlists.read Читает .m3u, .m3u8 и .pls в разрешённой музыкальной папке, а .json — только в её Playlists/, до 4 МБ на файл
playlists:write playlists.write Атомарно записывает плейлист в каталог Playlists/ разрешённой папки, до 4 МБ
network browser fetch Разрешает HTTPS и loopback в CSP; учётные данные CanvasTTY не прикрепляются

Permission не открывает generic IPC. Неизвестные методы и permissions отклоняются.

SDK

Подключите host SDK внешним script:

<script src='canvastty-plugin://host/sdk.js'></script>
<script src='./index.js'></script>

SDK создаёт window.CanvasTTYPlugin:

const host = window.CanvasTTYPlugin;

host.onContext(({ appearance, contribution }) => {
  document.documentElement.dataset.palette = appearance.palette;
  document.title = contribution.title;
});

const sessions = await host.request("sessions.list");
await host.storage.set("draft", { text: "Локально для этого плагина" });
const draft = await host.storage.get("draft");
await host.secrets.set("oauth-token", token);
const restoredToken = await host.secrets.get("oauth-token");
await host.request("launcher.open", { provider: "codex" });
await host.canvas.open("notes");
await host.request("window.open", { contributionId: "focus" });
await host.request("browser.open", { url: "http://localhost:9210" });

const library = await host.media.pickLibrary();
if (library) {
  const audio = document.querySelector("audio");
  const tracks = await host.media.scanLibrary(library.id);
  if (audio) audio.src = tracks[0]?.streamUrl ?? "";
  const playlists = await host.playlists.list(library.id);
  const text = playlists[0] ? await host.playlists.read(library.id, playlists[0].id) : "";
  await host.playlists.write(library.id, "favorites.m3u8", text || "#EXTM3U\n");
}

Поддержаны host.getContext, storage.*, secrets.*, sessions.list, limits.get, launcher.open, canvas.open, external.open, browser.open, window.open, media.* и playlists.*. canvas.open открывает или фокусирует canvas-app того же плагина и по возможности ставит его рядом с вызывающей карточкой. browser.open завершается только после создания или фокусировки Browser-card workspace и одной навигации; принимаются лишь нормализованные HTTP(S)-URL, а не текст для поиска, file:, data:, javascript:, about: или URL с учётными данными. window.open может открыть только contribution типа window из того же manifest.

Используйте storage для несекретных JSON-настроек, а secrets — только для OAuth-токенов, API-ключей и других учётных данных. Поддерживается до 32 строковых ключей, 16 КБ на значение и 64 КБ на плагин. Секреты удаляются при uninstall и никогда не сохраняются в plaintext; если ОС не предоставляет защищённое шифрование, вызов явно завершается ошибкой.

Разрешения музыкальных библиотек сохраняются между перезапусками, перечисляются и отзываются только владеющим плагином. Сканирование пропускает symlink и возвращает относительные пути, метаданные и непрозрачные stream URL вместо абсолютного корня библиотеки. При удалении плагина все его разрешения на папки отзываются. Содержимое плейлиста возвращается как записано и не привязано к формату: плеер может использовать стандартные M3U/PLS или собственную JSON-схему; импортированный плейлист сам может содержать абсолютные пути.

Как написать полноценный плеер-плагин

Локальному плееру обычно нужны:

"permissions": ["storage", "media:library", "playlists:read", "playlists:write"]

Добавляйте network только для удалённых каталогов, радио, обложек или стримов; external:open — только для явных ссылок, открываемых в системном браузере; а browser:open — только для явных HTTP(S)-страниц, предназначенных для общей встроенной browser-сессии CanvasTTY. storage предназначен для настроек плеера, избранного, очереди и небольших JSON-метаданных; сами аудиофайлы остаются в выбранных пользователем папках.

Вызов SDK Результат и назначение
host.media.pickLibrary() Открывает системный выбор каталога и сохраняет разрешение; возвращает { id, name } или null при отмене
host.media.listLibraries() Восстанавливает разрешённые этому плагину библиотеки после перезапуска, не раскрывая абсолютные пути
host.media.scanLibrary(libraryId) Рекурсивно возвращает до 20 000 поддерживаемых треков: ID, имя, относительный путь, размер, MIME type и streamUrl
host.media.revokeLibrary(libraryId) Отзывает разрешение этого плагина на выбранную папку
host.playlists.list(libraryId) Перечисляет до 2 000 доступных плейлистов внутри разрешённой библиотеки
host.playlists.read(libraryId, playlistId) Возвращает исходный UTF-8 текст плейлиста размером до 4 МБ
host.playlists.write(libraryId, name, content) Атомарно записывает .m3u, .m3u8, .pls или .json в каталог Playlists/ библиотеки, до 4 МБ

Сканируются аудиофайлы .aac, .flac, .m4a, .mp3, .oga, .ogg, .opus, .wav и .webm. track.streamUrl можно сразу назначить элементу <audio>: host поддерживает byte-range responses, поэтому определение длительности и перемотка работают. Плагин с media:library также может выполнить fetch(track.streamUrl), если байты нужны для разбора метаданных в браузере. Полные overloads методов и result interfaces находятся в plugin-api.d.ts.

Рекомендуемый запуск: вызвать listLibraries(), предложить pickLibrary() только если разрешённых папок ещё нет, просканировать выбранную библиотеку, восстановить очередь и настройки из storage, затем получить и разобрать плейлисты. Отозванную или перемещённую папку показывайте явным состоянием «недоступно» и предложите выбрать её заново.

Context сообщает текущие locale и palette CanvasTTY. Локализация и внутренние стили — ответственность плагина. Плагин не должен выдумывать progress, sessions, status, limits или telemetry.

Установка и управление

  1. Опубликуйте готовый статический пакет в корне публичного GitHub-репозитория.
  2. Откройте Настройки → Плагины.
  3. Вставьте https://github.com/owner/repository и нажмите Проверить.
  4. Прочитайте manifest и permissions, затем подтвердите Установить.
  5. В том же разделе плагин можно включить, выключить или удалить. Его HOME widgets добавляются и удаляются рядом со встроенными виджетами в разделе Оформление → Состав HOME. Если manifest объявляет settingsContribution, карточка плагина также показывает отдельное действие Настройки.
  6. Откройте Настройки → Оформление → Состав HOME и нажмите Редактировать HOME, чтобы двигать плитки, менять их размер или тянуть правый нижний угол границы HOME. Плитка Settings сохраняется как аварийная точка входа; остальные системные и plugin tiles опциональны.

Установщик намеренно не принимает приватные репозитории, ссылки GitHub вида /tree/branch/subdirectory и репозитории, которым нужен build. Публикуйте готовый пакет в корне.

Просмотр и поиск в витрине работают без входа, через публичный поиск GitHub. Вход необязателен и только повышает лимиты поиска GitHub; когда анонимный лимит исчерпан, CanvasTTY показывает, когда можно повторить. Опциональный вход в витрину использует GitHub OAuth Device Flow. Сопровождающий сборку может зарегистрировать OAuth App и включить Device Flow, затем сохранить его публичный client ID в repository variable GitHub Actions CANVASTTY_GITHUB_CLIENT_ID. Официальная сборка встраивает это значение, когда оно настроено; для локальной сборки подходят GITHUB_OAUTH_CLIENT_ID и CANVASTTY_GITHUB_CLIENT_ID, а при запуске любая из них может переопределить встроенный ID. Client secret в приложение не встраивается и не требуется. По умолчанию вход открывает GitHub во встроенном Browser CanvasTTY, а системный браузер остаётся явным fallback. Без client ID интерфейс прямо сообщает, что OAuth недоступен, но проверка и установка по прямой ссылке продолжают работать. Отключение удаляет локальную зашифрованную сессию; при необходимости отдельно отзовите доступ в настройках приложений GitHub.

Чек-лист автора

  • Используйте только structured host data и явные loading/unavailable/error states.
  • Запрашивайте минимальный набор permissions.
  • Держите scripts во внешних файлах; inline script не выполнится.
  • Не рассчитывайте на Node.js, filesystem paths, PTY history, provider tokens или parent DOM.
  • Проверяйте HOME widget в минимальном заявленном размере и при zoom канваса.
  • Проверяйте canvas app в semantic summary ниже 0.5×.
  • Проверяйте одинаковые SDK-вызовы внутри iframe и отдельного окна.
  • При изменении host/example запускайте npm test, npm run typecheck, npm run build.