feat(plugins): plugin indexing module — GitHub showcase, OAuth device flow, metadata/ standard - #16
feat(plugins): plugin indexing module — GitHub showcase, OAuth device flow, metadata/ standard#164444cjtr wants to merge 28 commits into
Conversation
…Y embedded browser
…me) — home-widgets/canvas-apps hit this before main
…patches canvastty:browser-open, App runs full openBrowser(url) flow
…rowser window on canvas - New 'shortcuts' settings section (moved keyboard shortcuts from controls) - browserNewWindow binding (Ctrl+Click) — mouse gesture, soft-validated - Ctrl+left-click on the browser launcher icon opens a new browser window (newTab) without touching open tabs - i18n ru/en, tests updated (239 passed)
… a new card) - BrowserManager owns N BrowserService instances (per-node tabs/viewport/store) - browserCanvases: BrowserCanvasNode[] replaces single browserCanvas (migration from old value -> 'default' node) - IPC: browser:create-node / close-node / nodes; all browser calls take windowId - Renderer renders one BrowserCard per node; plugin browser.open routes via canvastty:browser-open event (App creates node if none) - BrowserManager.open lazily creates default node when none exist - Agent gateway stays bound to default node (agent->node binding is the next step) - tests updated: 239 passed
…, stub) - BrowserActor.agent.browserWindowId (null = default shared browser) - AgentGateway takes BrowserNodeResolver instead of a single core; each connection resolves its bound node's core on auth and routes all commands (execute/subscribe/heartbeat/cursor/disconnect) to it - CreateSessionRequest/RegisterAgentInput/prepareLaunch carry browserWindowId - App: launching an agent binds it to the currently selected browser card, otherwise default — visible via AgentBadges on the card - BROWSER_UNAVAILABLE bridge error when the bound node is gone - tests updated (gateway resolver signature): 239 passed
… links) - SessionMetadata.browserWindowId persisted; terminal:set-browser-binding IPC - AgentLinkGraph: SVG layer inside workspace scene — ports on agent cards (out) and browser cards (in), cubic bezier links, drag port->port to bind, click a link to unbind - App: create/remove link via setBrowserBinding; binding survives restart - CSS: ports, stroke/hit paths, draft line while dragging - typecheck/build clean, 239 tests pass
…inal agents) A) writeGlobalMcpConfig writes mcp-helper into ~/.codex/config.toml and ~/.claude.json so ANY codex/claude launch (CanvasTTY terminal, external terminal, IDE) gets browser tools; mcp-helper reads the gateway address from ~/.config/canvastty/agent-browser-address (written on gateway start) B) every terminal session gets CANVASTTY_TERMINAL_SESSION_ID in env; terminal sessions can be bound to a browser node via the link graph; mcp-helper in guest mode (no capability token) sends the session id, gateway resolves the node through TerminalManager.sessionBrowserBinding - protocol: optional capabilityToken (empty = guest) - tests updated: 239 pass
- AgentProvider is now an open string (format-validated only), so ANY MCP-capable CLI harness passes gateway auth — no code change for new ones - Guest mode: empty terminalSessionId allowed (default node); BrowserCore identity uses agentId as pseudo-session for session-less guests - BROWSER_PROVIDER_COLORS/labels: opencode, hermes, pi + safe fallback for unknown providers; provider labels show the harness name - writeGlobalMcpConfig auto-registers installed harnesses: hermes via 'hermes mcp add' (env before --args — nargs='+' pitfall), opencode via ~/.config/opencode/opencode.json, pi via ~/.config/pi/mcp.json; 30s timeout (slow Electron on Ivy Bridge) - verified end-to-end: hermes mcp test canvastty_browser → Connected, 23 tools discovered; 239 tests pass
Link graph fixes: - SVG layer moved OUT of the 1x1 workspace__scene onto the full workspace; canvas→screen coords via camera (was: 1x1 SVG → ports not clickable) - client→SVG coords via getBoundingClientRect (was: 44px TitleBar offset, draft line rendered below the cursor) - removed setPointerCapture (redirected all pointer events to the source port → drop never fired); drag now listens on window, drop resolves the target via [data-browser-port] Universal hook (works for ANY harness, not just Hermes): - writeAgentBrowserContext(cwd) upserts a marked section into AGENTS.md of the terminal session's cwd, telling the agent it can drive the visible CanvasTTY browser via mcp__canvastty_browser__* tools. AGENTS.md is read by Claude Code, Codex, opencode, Hermes, Cursor, etc. Existing content is preserved; section is idempotent (markers). - called from TerminalManager.create for every session - 3 new tests: create / preserve / idempotent (242 total pass)
resolveCore(null) preferred the hidden 'default' node (created at startup but absent from browserCanvases), so a session-less guest agent opened tabs in an invisible browser while the user watched an empty visible card. Now windowId=null resolves to browserCanvases[0] (first visible node), falling back to default. Bound-node routing (link graph) is unchanged and still takes priority via resolveSessionBrowser.
Harnesses (Hermes and others) filter the environment for MCP subprocesses (_build_safe_env passes only PATH/HOME/XDG_* + config env), so CANVASTTY_TERMINAL_SESSION_ID set by the CanvasTTY pty terminal never reaches mcp-helper. Every agent therefore authenticated as session-less and routed to the same (first visible) node — two agents bound to different browsers still shared one. findTerminalSessionId() walks /proc/<pid>/environ up the process tree (mcp-helper → harness → shell → CanvasTTY pty) and returns the session id the first ancestor carries. Works for ANY harness; no env forwarding needed. Windows: env-only (no /proc). 244 tests pass (2 new: ancestor lookup, filtered-env readIdentity).
Harnesses (Hermes and others) filter the environment for MCP subprocesses (_build_safe_env passes only PATH/HOME/XDG_* + config env), so CANVASTTY_TERMINAL_SESSION_ID set by the CanvasTTY pty terminal never reaches mcp-helper. Every agent therefore authenticated as session-less and routed to the same (first visible) node — two agents bound to different browsers still shared one. findTerminalSessionId() walks /proc/<pid>/environ up the process tree (mcp-helper → harness → shell → CanvasTTY pty) and returns the session id the first ancestor carries. Works for ANY harness; no env forwarding needed. Windows: env-only (no /proc). 245 tests pass (3 new: ancestor lookup, chain test, filtered-env identity).
Ports and links were computed from session.position/bounds.position, which in React only updates on gesture END (liveBounds lives in the card's local state while dragging). Moving a linked window froze the link points in place until drop. Now the graph reads real DOM positions: cards expose data-canvas-node-id, and AgentLinkGraph samples getBoundingClientRect in a requestAnimationFrame loop (signature-checked), converting client coords to SVG space. Ports and bezier links follow the window live during drag, resize, pan and zoom. Drop still resolves via [data-browser-port]. 245 tests pass.
Ports/links now update SVG attributes straight from requestAnimationFrame (skip React state entirely — React re-render added a frame of lag when panning the camera or dragging windows). Geometry writes are diffed against a position cache and the last path d, so static windows cause zero DOM writes; layout reads are cheap and rAF pauses when the tab is hidden. 245 tests pass.
The perf pass split the link into two <path> elements (clickable hit + visible stroke) under different map keys but the rAF loop only updated the hit path, leaving the stroke at 'M 0 0' — links became invisible (click target worked, line didn't render). rAF now diffs and updates both paths. 245 tests pass.
Each card now exposes four ports (top/right/bottom/left). Ports are opacity 0 by default and appear only while the cursor hovers the card (pointermove → elementFromPoint → closest data-canvas-node-id; port circles and link paths carry the attribute too so hovering them keeps the ports visible). During drag all ports light up so the drop target is obvious. Links pick the smartest pair of sides: compare card centers — horizontal if |dx| > |dy|, else vertical — so a line always connects the closest edges and tracks windows live (rAF, no React state). 245 tests pass.
A browser port with an incoming link now starts a 'break' drag (DaVinci-style: pull the wire off the node). The draft line renders red, and releasing anywhere removes the link (onRemoveLink for the bound session). Ports without a link keep their plain drop behavior; cursor shows 'alias' on bound input ports. 245 tests pass.
Dragging a bound browser port now re-drags the EXISTING link (not a hard cut): the agent end stays put, the browser end follows the cursor. On release over another browser's port the link is moved (onCreateLink rewrites browserWindowId); releasing over empty canvas breaks the link (onRemoveLink); releasing back on the same port is a no-op. Draft line starts from the smart agent side. 245 tests pass.
Dragging from an agent port that already has a link now enters relink mode instead of drawing a new connection: the existing line is hidden and its browser end follows the cursor. Release over another browser moves the link, over empty canvas breaks it, back on the same port is a no-op. Links are never duplicated during node-to-node drags. 245 tests pass.
The rAF loop manually restored line opacity (style.opacity = '') after a relink drag ended; an rAF tick could run BEFORE React committed the new link state, flashing the old line for one frame. Now hiding the dragged line is a React class (agent-link--hidden) applied atomically with the commit: rAF only updates geometry and never touches visibility, so a line React decided to remove or redirect can't reappear. 245 tests pass.
The previous fix hid the line via a React class while dragging, but on release the class was removed immediately while onCreateLink/onRemoveLink round-trip through IPC — for 1-2 frames the old line (stale path) was visible again. New pendingHide state keeps the line hidden until the sessions actually update: an effect clears it once no session points at the hidden node (break) or points elsewhere (move). Atomic with the commit that carries the new link state, so the old wire never reappears. 245 tests pass.
A link no longer auto-picks the 'smart' pair of sides; it remembers where the drag started (agent port side) and where it was dropped (browser port side, via data-side) and renders between exactly those two ports. Relink preserves the agent-side anchor and updates only the browser side; breaking the link clears the stored sides. Draft line during a fresh link already followed the start side. 245 tests pass.
|
Замечания по безопасности (проход по диффу на соответствие RFC 9700 / BCP OAuth 2.0) — ни одно не блокирует мерж; 1 и 3 стоит поправить до перевода PR в Ready. 1. 2. Новые IPC-хендлеры не проверяют sender frame. 3. 4. |
ede554a to
22f4a89
Compare
feat(browser): agent-browser nodes on canvas (multi-window, link graph, any harness)
Subtitle 'Пространственный рабочий стол агентов' replaced by a rounded-pill badge '--hui' (border-radius 999px, translucent background, no-drag region). 245 tests pass.
Badge is now yellow (#f2c744, dark text) and the subtitle 'Пространственный рабочий стол агентов' is back next to the title. Selector updated to span:not(.titlebar__badge) so the badge keeps its own colors (last-child selector would have overridden them). 245 tests pass.
Moved the --hui badge next to the app name (title → badge → subtitle). Subtitle styling still applies via span:not(.titlebar__badge). 245 tests pass.
howdeploy
left a comment
There was a problem hiding this comment.
Спасибо за большую и вдумчивую работу, @4444cjtr — витрина плагинов это то, чего проекту не хватало. Видно заботу о лимитах и rate-limit'ах. Но аудит (Codex CLI + мейнтейнерская сторона) нашёл логические баги, которые сломают обещания фичи — нужен раунд правок.
Критично к исправлению (аудит)
- Витрина всегда обрезана до 10 —
PluginManager.ts:1398:mapSearchResults()безусловно стопорится наSEARCH_MAX_RESULTS(10), хотя заявлена пагинация до 1000. UI-пагинация сейчас проверяется только локальными стабами и реальный путь не ловит. Нужно решить: либо честные 10 без пагинации, либо рабочая пагинация. - Обновление плагина неатомарно —
PluginManager.ts:541: для немодульного плагина текущий каталог удаляется ДОrename()(:559-560) — ошибка посередине удаляет рабочую версию без отката; для модульного файлы пишутся поверх существующих (:557,:1779-1785), оставляя смесь версий. Нужен staging + swap + rollback, как вsetModules(). - Обход фильтров при прямой установке —
PluginManager.ts:213:platformsиminHostVersionпроверяются только в витрине (:395-408), аpreviewInstall()по URL их игнорирует. Обновление тоже может заменить совместимую версию несовместимой. Политика должна быть единой для любого источника установки. - Неверная логика
hostMismatch—PluginSettingsSection.tsx:449: сейчасcompareSemver(...) !== 0помечает несовместимым плагин, написанный под БОЛЕЕ СТАРЫЙ хост. Несовместимость — толькоminHostVersion > hostVersion. - Неотменяемый OAuth polling —
GithubAuthService.ts:170:signOut()не отменяет активный device-flow poll (:178-186), вышедший пользователь может получить сохранённые токены обратно (:221-232); повторные нажатия плодят параллельные flow. Нужны отмена/generation-id и единственный активный flow. - Batch limit не применяется —
PluginManager.ts:1559:GITHUB_GRAPHQL_BATCH_LIMIT = 8объявлен, ноgithubGraphqlBatch()строит один запрос на весь вход (:1601-1609). При сотнях репо это отказ API вместо батчинга.
Minors: OAuth-запросы без timeout/AbortSignal (GithubAuthService.ts:149,198,247,277); лишний scope read:user (:147) — по возможности минимизировать; runtime-валидатор не отклоняет неизвестные поля манифеста (PluginManager.ts:807) — «строгая валидация» должна быть строгой.
Вопросы архитектуры (нужно решить до мержа)
- Единый конвейер установки. Витрина и прямой URL должны проходить один и тот же валидированный pipeline (манифест → платформа → версия → лимиты пакета → staging). Сейчас пути расходятся.
- Модель доверия к коду. В 1.2.0 мы пинили редиректы загрузок к api.github.com/raw.githubusercontent.com — сохраняется ли это в новом индексаторе? И достаточна ли CSP песочницы для кода, скачанного из произвольного паблик-репо (архив с main-ветки может поменяться в любой момент)?
- OAuth App перед релизом. Плейсхолдер
DEFAULT_OAUTH_CLIENT_ID— ок как дизайн, но добавь, пожалуйста, в docs пошаговую инструкцию для владельца: создание OAuth App, включение device flow, какие поля куда вписать. Без этого фича нерабочая из коробки. - Стыковка с новым ядром навигации (PR #13, влит в 1.2.2). Плагинные iframe и Browser-сёрфейсы теперь подчиняются общей политике focus/ownership. Проверь, что карточки витрины/установленных и
browser.open-флоу не обходят новую модель владения вводом (hover-delay focus, latch 250 мс и т.д.). - Пересечение с #15. Этот PR включает #15 целиком. План: сначала мержим исправленный #15, затем ты ребейзишь #16 — дифф похудеет, ревью станет проще.
По мелочи из твоего списка «варианты улучшений»: кэш витрины на клиент и фильтры по тегам/автору — разумно отложить; серверную аренду согласен не требовать. Иконки 500–1000 мс — если найдёшь дешёвый способ прелоада манифест-иконок батчем, будет приятно, но не блокер.
После правок — прогоним CI (я одобрю runs) и повторный аудит. Спасибо!
… flow, metadata/ standard Full plugin indexing module on top of upstream v1.2.2 (canvas-navigation input-bridge included from upstream; no conflicts). - GitHub showcase: search canvastty-plugin-* repos, read metadata/ canvastty.plugin.json (legacy root fallback), strict manifest validation, platform filtering (platforms without PLATFORM_ID are dropped), minHostVersion display (green/neutral/orange, no install blocking). - OAuth device flow for GitHub (public client_id placeholder DEFAULT_OAUTH_CLIENT_ID — replace with your own OAuth App). - Pagination (6 installed / 10 showcase per page), pure logic in pluginPagination.ts, unit-tested. - Description limits: 2000 in manifest, 400 shown (manifestDescription). - Rate-limit friendly: metadata cache, GraphQL batch (8 repos/request), raw.githubusercontent fallback without token, icon batch IPC. - SDK browser.open for plugins: PluginFrame dispatches canvastty:browser-open, App opens URL in the single canvas browser (no multi-window nodes). - Tests: plugin-manager (25), plugin-pagination (10), host-version (4) — 83/83 with upstream canvas-navigation suites.
22f4a89 to
db8a32b
Compare
|
Спасибо за обновления. Перенесли ваш финальный целевой commit Продолжение и наши доработки находятся в #17. Там убрана старая расходящаяся история ветки, сохранена архитектура единственного Browser из #15 и закрыты найденные блокеры по атомарности обновлений, OAuth, platform policy, batch-запросам и строгой валидации. Закрываю этот PR как superseded by #17. |
Модуль индексации плагинов — витрина GitHub, OAuth device flow, стандарт metadata/
Полный модуль индексации плагинов: плагины находятся индексацией публичных GitHub-репозиториев, проходят строгую валидацию манифеста,
фильтруются по платформе и версии хоста и показываются в витрине.
Пример готового, шаблонного плагина, прошедшего тесты:
https://github.com/4444cjtr/canvastty-plugin-example
Скриншоты этого плагина в интерфейсе:


Что такое индексация плагинов
Вкладка плагины находит репозитарии индексацией GitHub:
canvastty-plugin-* in:name(только публичные репозитории).metadata/canvastty.plugin.json(legacy-путьcanvastty.plugin.jsonв корнеподдерживается как fallback).
platformsбезcanvastty,исключаются (форк под другую платформу не должен попадать в витрину).
показываются версия, автор, описание (обрезанное), иконка и версия хоста,
под которую написан плагин.
Установка скачивает архив репозитория (ветка
main), повторно валидирует манифести раскладывает файлы в
userData/plugins/<pluginId>/.Ключевые решения и их причины
Стандарт каталога
metadata/Манифест и иконка лежат в
metadata/(metadata/canvastty.plugin.json,metadata/icon.png). Индексатор смотрит только туда — код плагина можно менять безпереиндексации, а метаданные не смешиваются с рантайм-ассетами. Legacy-пути в корне
принимаются как fallback, чтобы существующие плагины продолжали работать.
Фильтрация по платформе (
platforms)Плагин может поддерживать несколько платформ:
["canvastty", "canvastty-superkruto"].Если
platformsобъявлено и не содержит текущую платформу — плагин неиндексируется: форк под другую платформу не должен засорять витрину.
Отсутствие поля = legacy-плагин, совместим со всеми.
Декларация версии хоста (
minHostVersion)minHostVersion— версия CanvasTTY:CanvasTTY:X.Y.Zпод автором.Создано для уменьшения трения юзерэкспириенса. В случаях, когда SDK плагинов обновлен, но автор плагина не обновил репозитарий - юзер узнает об этом без перехода в репо плагина
Обновления плагинов
Из коробки добавлена возможность обновления плагинов. Автообновление при запуске CanvasTTY отброшено, во избежание возможного добавления летенси на старт. Не было добавлено автообновление и при открытии настроек по тем же причинам.
При детекте обновления, в хедере плагина в модуле Установленные плагины - появится кнопка Обновить
Версия берётся из
app.getVersion()и передаётся в renderer через новый IPC-каналapp:version.OAuth device flow (плейсхолдер client_id)
Для поиска в витрине требуется авторизация GitHub (поисковый API GitHub без токена
жёстко лимитирован). Вместо встраивания секрета приложения используем device flow —
client_id публичен по дизайну и должен быть заменён на id собственного OAuth App
владельца репозитория (см. верх этого описания).
Лимит описания: 2000 в репо, 400 в UI
Валидатор принимает до 2000 символов (длинные описания сохраняются в репозитории
целиком), но UI обрезает до 400 символов с многоточием. 400 уже пересекает порог целесообразности. Всё что больше - начинает захломлять интерфейс.
Общий хелпер (
manifestDescription) используется витриной, карточками установленных и превью.Оптимизация запросов (rate limits)
Индексатор бережёт GitHub API:
githubMetadataCache) — владелец/ветка кешируются на сессию.одним GraphQL-вызовом, а не по одному.
raw.githubusercontent.com.plugins:iconпринимает массив URL и возвращаетRecord<url, dataUrl|null>; UI никогда не шлёт по одному запросу на иконку.Поддержка страниц
Установленные: 6 на страницу; витрина: 10 на страницу. Чистая логика живёт в
pluginPagination.ts(без DOM), покрыта юнит-тестами с плагинами-заглушками.Мультистраничность витрины и установленных плагинов уже созданы с якорным фиксом дрейфа страницы, в случаях когда кол-во элементов на витрине/установленных плагинах отличаются
browser.openдля плагиновПлагины могут открыть URL во встроенном браузере канваса через SDK-метод
browser.open(«Открыть в CanvasTTY» на плашках витрины и карточках установленных).Реализация опирается на существующий одиночный браузер CanvasTTY:
PluginFrameдиспатчит событие
canvastty:browser-open,Appоткрывает URL черезbrowser.open(url)и фокусирует камеру.Для создания возможности открыть репозитарий в браузере CanvasTTY, пришлось внести изменение в SDK
Создано как альтернатива открытия репозитария в браузере, для нелюбителей делать 20 альт табов между окнами.
Лимиты
Валидация манифеста
idnameversiondescriptionauthoriconpathpermissionscontributionssettingsContributionПакет (архив репозитория)
storage(на плагин)Индексация и поиск
canvastty-plugin-*Тесты
tests/plugin-manager.test.mjs- валидация манифестов, чтение metadata/,фильтрация платформ, установка/обновление (токен сбрасывается в тестах).
tests/plugin-pagination.test.mjs- границы пагинации, 25/37 плагинов-заглушек,без потерь/дублей.
tests/host-version.test.mjs- сравнение semver / проверка совместимости.Варианты улучшений: