From af08060ec8d246ae277b341a017e8df991cf9219 Mon Sep 17 00:00:00 2001 From: cododel Date: Sat, 22 Aug 2026 19:16:58 +0400 Subject: [PATCH 1/4] feat(home): expose failed session details --- docs/UI_CONTRACT.md | 2 +- docs/UI_CONTRACT.ru.md | 2 +- docs/UI_CONTRACT.zh-CN.md | 2 +- docs/issues/README.md | 18 +++++ ...cos-builds-fail-to-detect-provider-clis.md | 55 ++++++++++++++++ src/main/services/TerminalManager.ts | 8 ++- src/main/services/cliEnvironment.ts | 1 + src/main/services/terminalFailureDetails.ts | 28 ++++++++ src/renderer/src/components/UiIcon.tsx | 2 + src/renderer/src/features/home/HomeZone.tsx | 66 ++++++++++++++----- src/renderer/src/lib/i18n.ts | 6 ++ src/renderer/src/styles/app.css | 15 ++++- src/shared/contracts.ts | 1 + tests/cli-environment.test.mjs | 9 +++ tests/home-model.test.mjs | 1 + tests/home-session-error-tooltip.test.mjs | 25 +++++++ tests/terminal-failure-details.test.mjs | 61 +++++++++++++++++ tests/terminal-lifecycle.test.mjs | 8 +++ 18 files changed, 289 insertions(+), 21 deletions(-) create mode 100644 docs/issues/README.md create mode 100644 docs/issues/packaged-macos-builds-fail-to-detect-provider-clis.md create mode 100644 src/main/services/terminalFailureDetails.ts create mode 100644 tests/home-session-error-tooltip.test.mjs create mode 100644 tests/terminal-failure-details.test.mjs diff --git a/docs/UI_CONTRACT.md b/docs/UI_CONTRACT.md index ae9c24b..918d45a 100644 --- a/docs/UI_CONTRACT.md +++ b/docs/UI_CONTRACT.md @@ -8,7 +8,7 @@ This contract preserves the approved MVP concept and prevents feature ownership - HOME uses fixed-size `82 × 72` logical cells with `18px` gaps so enlarging the page never makes existing widgets smaller. Fresh profiles start at `16 × 12`, while the original arrangement remains in the upper-left `12 × 8` area and leaves explicit spare space for plugins. Its persisted boundary and cell grid are shown only in Edit HOME, where the bottom-right corner resizes the area up to the generous `48 × 36` safety ceiling. - The wide left tile contains the user-selected subset of the real Codex, Claude, Kimi, OpenCode Go, and Grok Build limit rows; all five are visible by default. Visible rows divide the tile height equally, while four or five rows switch to a compact density that keeps every icon, countdown, and usage rail inside the default tile. An empty selection shows an explicit empty state. Display selection does not disable background limit reads and never offers providers without a real adapter. Each row prefers the provider's longest real default quota window (the weekly window when exposed), and falls back to another real window only when needed. It shows the countdown to that window's `resetsAt` and the matching usage rail. Values below one day use `HH:MM`; longer values use `Nд HHч`/`Nd HHh`. Window length stays in accessible metadata; unavailable data is labeled and has no fake reset or percentage. A short yellow rail means the last valid snapshot is shown because its latest refresh failed; it is not selection or usage progress. -- The right tile is the only session list. Its viewport shows three rows and scrolls when more real sessions exist; it never discards rows. Each row shows provider mark, localized semantic state, and identity. Session duration and limit-style progress rails never appear here. +- The right tile is the only session list. Its viewport shows three rows and scrolls when more real sessions exist; it never discards rows. Each row shows provider mark, localized semantic state, and identity. Hovering a failed row's error mark exposes its bounded, terminal-control-free final PTY output, including a supplied traceback, in an accessible panel; its Copy action copies that displayed output only. When the PTY emitted no visible output, the panel instead states that fact with the actual exit code. It never invents a reason or derives the state from terminal text. Session duration and limit-style progress rails never appear here. - The clock is the dominant middle tile and renders only `HH:MM`. The adjacent media tile is an autonomous widget: click to pick or replace an image/GIF; remove from the widget itself. - The bottom dock always contains Terminal and Browser, plus the agent providers enabled in Settings. Its equal-width columns are derived from the buttons that are actually visible, so disabling an agent never leaves an empty slot. All current agents are enabled for fresh and migrated profiles; hiding a launcher never stops or removes an existing session. Settings is a separate tile. Browser opens or focuses the one built-in browser card and never launches an external browser. Agent badges never overlay the Browser launcher icon. - Every default tile except Settings may be hidden. Settings remains the recovery entry point. Edit HOME mode shows the full cell grid and exact HOME boundary, hides all terminal and canvas-plugin windows, and keeps its changes as a draft until Save. Tiles move without overlap and may temporarily cross any HOME edge; Save is disabled until every tile is fully inside. Every tile edge and corner resizes it while preserving the opposite edge, with visible cues only at the top-left and bottom-right corners. The boundary can grow or shrink without crossing placed widgets. Adding a widget automatically grows HOME when the current boundary is full. diff --git a/docs/UI_CONTRACT.ru.md b/docs/UI_CONTRACT.ru.md index 6c35e6d..3082472 100644 --- a/docs/UI_CONTRACT.ru.md +++ b/docs/UI_CONTRACT.ru.md @@ -8,7 +8,7 @@ - HOME использует фиксированные логические ячейки `82 × 72` с промежутками `18px`, поэтому увеличение страницы не уменьшает существующие виджеты. Новый профиль начинает с `16 × 12`; исходная композиция остаётся в верхней левой области `12 × 8`, оставляя явный запас для плагинов. Сохраняемые граница и сетка видны только в Edit HOME, где правый нижний угол меняет область до безопасного потолка `48 × 36`. - Широкая левая плитка содержит выбранное пользователем подмножество настоящих строк лимитов Codex, Claude, Kimi, OpenCode Go и Grok Build; по умолчанию видны все пять. Видимые строки делят высоту плитки поровну, а при четырёх или пяти строках переходят в компактную плотность, чтобы каждая иконка, countdown и usage rail оставались внутри дефолтной плитки. Пустой выбор показывает явное пустое состояние. Настройка отображения не отключает фоновые запросы лимитов и не предлагает провайдеров без реального adapter. Каждая строка предпочитает самое длинное настоящее default quota window провайдера (weekly, если доступно) и только при необходимости переключается на другое реальное окно. Она показывает countdown до `resetsAt` этого окна и соответствующий usage rail. Значения короче суток используют `HH:MM`, длиннее — `Nд HHч`/`Nd HHh`. Длина окна остаётся в accessible metadata; недоступные данные подписаны и не получают выдуманный reset или percentage. Короткая жёлтая полоска означает, что после неудачного свежего обновления показан последний валидный snapshot; это не выбор строки и не progress использования. -- Правая плитка — единственный список сессий. Viewport показывает три строки и прокручивается при большем числе реальных сессий, не отбрасывая их. Каждая строка показывает mark провайдера, локализованный semantic state и identity. Session duration и progress rails в стиле лимитов здесь не появляются. +- Правая плитка — единственный список сессий. Viewport показывает три строки и прокручивается при большем числе реальных сессий, не отбрасывая их. Каждая строка показывает mark провайдера, локализованный semantic state и identity. Hover на error mark неудачной сессии открывает доступную панель с ограниченным финальным выводом PTY без terminal-control последовательностей, включая переданный traceback; действие Copy копирует только этот показанный вывод. Если PTY не передал видимого вывода, панель сообщает этот факт с фактическим кодом завершения. UI не выдумывает причину и не определяет state по тексту терминала. Session duration и progress rails в стиле лимитов здесь не появляются. - Часы — доминирующая средняя плитка и показывают только `HH:MM`. Соседняя media tile автономна: нажмите, чтобы выбрать/заменить image/GIF; удаление находится в самом виджете. - Нижний dock всегда содержит Terminal и Browser, а также включённых в Settings агентских провайдеров. Его равные по ширине колонки вычисляются из реально видимых кнопок, поэтому отключённый агент не оставляет пустую ячейку. Для новых и мигрированных профилей изначально включены все текущие агенты; скрытие launcher-кнопки не останавливает и не удаляет существующую сессию. Settings — отдельная плитка. Browser открывает или фокусирует единственную встроенную карточку браузера и никогда не запускает внешний браузер. Agent badges не накладываются на launcher-иконку Browser. - Любую default tile, кроме Settings, можно скрыть. Settings остаётся recovery entry point. Edit HOME показывает полную сетку и точную границу HOME, скрывает terminal/canvas-plugin windows и хранит изменения как draft до Save. Плитки двигаются без overlap и могут временно пересекать любую границу HOME; Save выключен, пока каждая плитка не окажется целиком внутри. Любая грань/угол меняет размер с сохранением противоположной грани; видимые подсказки есть только в верхнем левом и нижнем правом углах. Граница растёт/сжимается, не пересекая размещённые widgets. Добавление виджета автоматически расширяет HOME, если текущая область заполнена. diff --git a/docs/UI_CONTRACT.zh-CN.md b/docs/UI_CONTRACT.zh-CN.md index 3907a2c..d58463d 100644 --- a/docs/UI_CONTRACT.zh-CN.md +++ b/docs/UI_CONTRACT.zh-CN.md @@ -8,7 +8,7 @@ - HOME 使用固定的 `82 × 72` 逻辑 cell 与 `18px` 间距,因此扩大页面不会让已有 widget 变小。新 profile 从 `16 × 12` 开始;原布局保留在左上角 `12 × 8` 区域,并为插件留下明确空间。持久化边界与 cell grid 只在 Edit HOME 中显示;右下角可将区域调整到 `48 × 36` 的安全上限。 - 左侧宽 tile 只包含用户选择的 Codex、Claude、Kimi、OpenCode Go 与 Grok Build 真实限额行;默认显示全部五个。可见行等分 tile 高度;四行或五行时切换为紧凑密度,确保每个图标、倒计时和 usage rail 都留在默认 tile 内。空选择显示明确的空状态。显示设置不会停止后台读取限额,也不会提供没有真实 adapter 的服务商。每行优先显示服务商最长的真实默认配额窗口(若提供则为 weekly),仅在必要时 fallback 到另一个真实窗口。它显示该窗口 `resetsAt` 的倒计时及对应 usage rail。短于一天使用 `HH:MM`,更长时间使用 `Nд HHч`/`Nd HHh`。窗口长度保留在 accessible metadata 中;不可用数据必须明确标记,不得伪造 reset 或 percentage。短黄色竖线表示最新刷新失败、当前显示的是最后一个有效 snapshot;它不是选择状态或 usage progress。 -- 右侧 tile 是唯一的 session list。Viewport 显示三行,真实会话更多时滚动,并且不丢弃任何行。每行显示 provider mark、本地化 semantic state 与 identity。这里不显示 session duration 或 limit-style progress rail。 +- 右侧 tile 是唯一的 session list。Viewport 显示三行,真实会话更多时滚动,并且不丢弃任何行。每行显示 provider mark、本地化 semantic state 与 identity。Hover 失败行的 error mark 会打开 accessible panel,显示经过长度限制、去除 terminal-control 序列的最终 PTY 输出(包括提供的 traceback);Copy action 只复制该显示输出。若 PTY 没有可见输出,面板会如实显示这一点及实际退出码。UI 不会编造原因,也不会从 terminal text 推断 state。这里不显示 session duration 或 limit-style progress rail。 - Clock 是主导的中间 tile,只渲染 `HH:MM`。旁边的 media tile 是自治 widget:点击选择或替换 image/GIF;删除操作留在 widget 内。 - 底部 dock 始终包含 Terminal 与 Browser,以及 Settings 中启用的 agent provider。等宽列数由实际可见按钮计算,因此禁用 agent 不会留下空白单元格。新建和迁移后的 profile 默认启用所有当前 agent;隐藏 launcher 按钮不会停止或删除现有 session。Settings 是独立 tile。Browser 打开或聚焦唯一的内置浏览器卡片,绝不启动外部浏览器。Agent badge 不会覆盖 Browser launcher 图标。 - 除 Settings 外,所有默认 tile 都可隐藏;Settings 保留为恢复入口。Edit HOME 显示完整 cell grid 与精确 HOME 边界,隐藏所有 terminal/canvas-plugin window,并在 Save 前把变化保留为 draft。Tile 移动时不得 overlap,可暂时跨越任意 HOME 边缘;只有全部 tile 完全位于边界内时才可 Save。任意 edge/corner 均可 resize 并保持对边不动;可见提示只放在左上与右下 corner。边界可在不穿过已摆放 widget 的前提下扩大或缩小。空间不足时添加 widget 会自动扩大 HOME。 diff --git a/docs/issues/README.md b/docs/issues/README.md new file mode 100644 index 0000000..c555918 --- /dev/null +++ b/docs/issues/README.md @@ -0,0 +1,18 @@ +# Repository Technical-Debt Issues + +This directory stores deferred, independently resumable engineering work close to the code it affects. +It is not a duplicate product tracker and not an archive for every observation. + +Create an Issue when evidence proves material work that should not widen the current task. Include a +stable locator, context, root cause or explicit hypothesis, deferral reason, recommended direction, +resume condition, and verification boundary. Add one linked code TODO when a stable seam benefits from +an in-place warning. + +## Format + +Use a descriptive English kebab-case filename. Write the document as a self-contained bug report or +deferred-work record without tracker metadata in the body; status, priority, assignment, and dates +belong to the issue tracker that imports the document. + +Before removing a resolved document, extract unique current behavior, significant decisions, or +repeatable operational knowledge into their canonical owner. diff --git a/docs/issues/packaged-macos-builds-fail-to-detect-provider-clis.md b/docs/issues/packaged-macos-builds-fail-to-detect-provider-clis.md new file mode 100644 index 0000000..a43609a --- /dev/null +++ b/docs/issues/packaged-macos-builds-fail-to-detect-provider-clis.md @@ -0,0 +1,55 @@ +# Packaged macOS builds fail to detect installed provider CLIs + +## Bug + +Codex launches in development but the installed macOS app reports `Codex · CLI not found` and +creates an empty `Failed` terminal. + +In the reproduced case, Codex was installed at `/opt/homebrew/bin/codex`, but the Finder-launched +app did not inherit that directory in `PATH`. Adding it to the main-process environment restored +Codex launch and limits. + +Expected: CLI availability must be identical in development and packaged builds. If a CLI is +unavailable, `(!)` must show a copyable diagnostic even when the PTY is empty. + +## Reproduction + +1. Install Codex at `/opt/homebrew/bin/codex`. +2. Confirm it works through `npm run dev`. +3. Build, install, and launch the macOS `.app` from Finder. +4. Launch Codex from HOME. + +## Root cause + +Provider CLI discovery has no single owner: + +- POSIX terminal launch delegates command lookup to `node-pty`. +- `LimitsService` and `ProviderLaunch` perform separate command lookups. +- Provider smoke tests launch development Electron with the caller's `PATH`, not the packaged app + with a Finder-like environment. + +The current PATH augmentation fixes known Homebrew locations but does not guarantee that all +consumers use the same executable or produce useful errors. + +## Proposed solution + +- Resolve every provider CLI once at startup to either an absolute executable or a structured + `cli-not-found` result containing the paths checked. +- Search in this order: test override, input `PATH`, platform defaults, then known user/provider + directories. Validate that the selected file is executable. Do not use a login shell as the primary + resolver. +- Pass the same resolved executable to terminal launch, limits, and agent-browser integration. +- Fail before creating the PTY when resolution fails. Show the structured error in `(!)` and make + the complete text copyable. +- Add a packaged macOS smoke test with a minimal Finder-like `PATH`, no credentials, and no network. + +The initial implementation may require restarting CanvasTTY after installing a new CLI. + +## Acceptance criteria + +- [ ] Terminal launch, limits, and agent-browser integration use one resolved executable. +- [ ] A packaged macOS app finds supported Homebrew CLIs with a minimal `PATH`. +- [ ] Missing or non-executable CLIs report the paths actually checked. +- [ ] `(!)` and Copy work when the PTY produced no output. +- [ ] HOME limits and agent launch cannot disagree about `cli-not-found`. +- [ ] Unit tests and a packaged smoke test cover successful and failed resolution. diff --git a/src/main/services/TerminalManager.ts b/src/main/services/TerminalManager.ts index 2eba496..b61ec41 100644 --- a/src/main/services/TerminalManager.ts +++ b/src/main/services/TerminalManager.ts @@ -21,6 +21,7 @@ import type { } from "./agent-browser/AgentBrowserBridge.ts"; import { AGENT_BROWSER_ENV } from "./agent-browser/AgentBrowserBridge.ts"; import { tryPtyOperation } from "./ptySafety.ts"; +import { terminalFailureDetails } from "./terminalFailureDetails.ts"; import { resolveTerminalLaunch } from "./terminalLaunch.ts"; const MAX_SCROLLBACK_CHARS = 240_000; @@ -82,7 +83,8 @@ export class TerminalManager { size: DEFAULT_TERMINAL_SIZE, status: "idle", startedAt: Date.now(), - exitCode: null + exitCode: null, + failureDetails: null }; const session: ManagedSession = { @@ -118,6 +120,7 @@ export class TerminalManager { session.metadata.status = "idle"; session.metadata.startedAt = Date.now(); session.metadata.exitCode = null; + session.metadata.failureDetails = null; this.bindProcess(id, session, launched.process); this.emitSession(session.metadata); return snapshot(session); @@ -247,6 +250,9 @@ export class TerminalManager { this.flushOutput(id, current); current.metadata.exitCode = exitCode; current.metadata.status = exitCode === 0 ? "done" : "failed"; + current.metadata.failureDetails = exitCode === 0 + ? null + : terminalFailureDetails(current.bufferChunks.slice(current.bufferStart).join("")); current.agentBrowser?.cleanup(); current.agentBrowser = null; this.emitSession(current.metadata); diff --git a/src/main/services/cliEnvironment.ts b/src/main/services/cliEnvironment.ts index d2e4c6a..00acf64 100644 --- a/src/main/services/cliEnvironment.ts +++ b/src/main/services/cliEnvironment.ts @@ -18,6 +18,7 @@ export function augmentCliPath( path.join(homeDirectory, ".npm-global", "bin"), path.join(homeDirectory, ".bun", "bin"), path.join(homeDirectory, ".cargo", "bin"), + ...(platform === "darwin" ? ["/opt/homebrew/bin", "/usr/local/bin"] : []), ...(platform === "win32" ? [path.join(homeDirectory, "AppData", "Roaming", "npm")] : []) ].filter(directoryExists); diff --git a/src/main/services/terminalFailureDetails.ts b/src/main/services/terminalFailureDetails.ts new file mode 100644 index 0000000..01654bc --- /dev/null +++ b/src/main/services/terminalFailureDetails.ts @@ -0,0 +1,28 @@ +const MAX_FAILURE_DETAILS_CHARS = 8_000; +const MAX_FAILURE_DETAILS_LINES = 48; + +export function terminalFailureDetails(buffer: string): string | null { + const lines = stripTerminalControls(buffer) + .split("\n") + .map((line) => line.trimEnd()) + .filter((line) => line.trim().length > 0); + if (lines.length === 0) return null; + + const tracebackStart = lines.reduce( + (lastMatch, line, index) => line === "Traceback (most recent call last):" ? index : lastMatch, + -1 + ); + const details = lines.slice(tracebackStart >= 0 ? tracebackStart : -MAX_FAILURE_DETAILS_LINES).join("\n"); + return details.length <= MAX_FAILURE_DETAILS_CHARS + ? details + : `…${details.slice(-(MAX_FAILURE_DETAILS_CHARS - 1))}`; +} + +function stripTerminalControls(value: string): string { + return value + .replace(/\r\n?/g, "\n") + .replace(/\u001B\][\s\S]*?(?:\u0007|\u001B\\)/g, "") + .replace(/\u001B(?:\[[0-?]*[ -/]*[@-~]|[@-_][ -/]*[@-~])/g, "") + .replace(/[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g, "") + .replace(/\t/g, " "); +} diff --git a/src/renderer/src/components/UiIcon.tsx b/src/renderer/src/components/UiIcon.tsx index 76f57ea..f78693a 100644 --- a/src/renderer/src/components/UiIcon.tsx +++ b/src/renderer/src/components/UiIcon.tsx @@ -28,6 +28,7 @@ export type UiIconName = | "minimize" | "maximize" | "restore" + | "copy" | "folder" | "browser" | "terminal" @@ -57,6 +58,7 @@ const ICONS: Record = { minimize: minimizeIcon, maximize: maximizeIcon, restore: copyIcon, + copy: copyIcon, folder: folderIcon, browser: browserIcon, terminal: terminalIcon, diff --git a/src/renderer/src/features/home/HomeZone.tsx b/src/renderer/src/features/home/HomeZone.tsx index cc74c41..250081b 100644 --- a/src/renderer/src/features/home/HomeZone.tsx +++ b/src/renderer/src/features/home/HomeZone.tsx @@ -293,22 +293,58 @@ export function HomeZone({
{t(locale, "noActiveSessions")}
) : home.sessionRows.map((session) => { const statusIcon = sessionStatusIcon(session.status); + const failureDetails = session.status === "failed" + ? session.failureDetails ?? `${t(locale, "failureOutputUnavailable")}${session.exitCode ?? "unknown"}` + : null; + const failureTooltipId = failureDetails ? `session-failure-${session.id}` : undefined; return ( - +
+ + {failureDetails && ( + <> + +
+ {failureDetails} + +
+ + )} +
); })} diff --git a/src/renderer/src/lib/i18n.ts b/src/renderer/src/lib/i18n.ts index d1582d1..c76c847 100644 --- a/src/renderer/src/lib/i18n.ts +++ b/src/renderer/src/lib/i18n.ts @@ -288,6 +288,9 @@ const ru = { statusNeedsApproval: "Требуется решение", statusDone: "Завершено", statusFailed: "Ошибка", + showErrorDetails: "Показать детали ошибки", + copyErrorDetails: "Скопировать детали ошибки", + failureOutputUnavailable: "Терминал не передал вывод. Код завершения: ", moveHint: "Тяните пустой канвас · Ctrl/Command + скролл масштабирует", loading: "Готовим рабочее пространство…" } as const; @@ -580,6 +583,9 @@ const en: Record = { statusNeedsApproval: "Needs approval", statusDone: "Done", statusFailed: "Failed", + showErrorDetails: "Show error details", + copyErrorDetails: "Copy error details", + failureOutputUnavailable: "The terminal produced no output. Exit code: ", moveHint: "Drag the empty canvas · Ctrl/Command + scroll to zoom", loading: "Preparing your workspace…" }; diff --git a/src/renderer/src/styles/app.css b/src/renderer/src/styles/app.css index 3609e9e..65aa755 100644 --- a/src/renderer/src/styles/app.css +++ b/src/renderer/src/styles/app.css @@ -109,7 +109,7 @@ button { border: 0; } .limit-row__track { position: relative; width: 100%; height: 6px; overflow: hidden; border-radius: 999px; background: rgba(255,255,255,.12); } .limit-row__fill { position: absolute; inset: 0 auto 0 0; max-width: 100%; border-radius: inherit; background: rgba(248,247,241,.78); } .limit-row__empty { overflow: hidden; color: var(--text-muted-on-dark); font-size: 13px; font-weight: 800; text-overflow: ellipsis; white-space: nowrap; } -.limit-row--stale::before, .limit-row--error::before, .usage-row:has(.ui-icon--attention)::before, .usage-row:has(.ui-icon--error)::before { content: ""; position: absolute; left: 0; top: 10px; bottom: 10px; width: 3px; border-radius: 999px; } +.limit-row--stale::before, .limit-row--error::before, .usage-row:has(.ui-icon--attention)::before, .usage-row-wrap:has(.ui-icon--error) .usage-row::before { content: ""; position: absolute; left: 0; top: 10px; bottom: 10px; width: 3px; border-radius: 999px; } .limit-row--stale::before, .usage-row:has(.ui-icon--attention)::before { background: var(--yellow); } .limit-row--error::before, .usage-row:has(.ui-icon--error)::before { background: var(--danger); } .limit-row--loading .provider-icon { opacity: .65; } @@ -123,12 +123,23 @@ button { border: 0; } @keyframes spin { to { transform: rotate(360deg); } } .usage-list { padding: 13px 9px 13px 13px; display: grid; grid-auto-rows: 68px; align-content: start; gap: 9px; overflow-y: auto; scrollbar-gutter: stable; scrollbar-width: thin; scrollbar-color: rgba(255,255,255,.28) transparent; background: var(--surface); } +.home-widget-slot:has(.usage-row__failure-trigger:hover), .home-widget-slot:has(.usage-row__failure-trigger:focus-visible), .home-widget-slot:has(.usage-row__failure-tooltip:hover), .home-widget-slot:has(.usage-row__failure-tooltip:focus-within) { z-index: 2; } +.usage-list:has(.usage-row__failure-trigger:hover), .usage-list:has(.usage-row__failure-trigger:focus-visible), .usage-list:has(.usage-row__failure-tooltip:hover), .usage-list:has(.usage-row__failure-tooltip:focus-within) { overflow: visible; } .home-empty { min-height: 222px; display: grid; place-items: center; padding: 22px; border-radius: 12px; color: var(--text-muted-on-dark); background: var(--surface-soft); text-align: center; font-size: 13px; line-height: 1.45; } -.usage-row { position: relative; min-height: 68px; padding: 8px 14px; display: grid; grid-template-columns: 46px minmax(0, 1fr) 28px; align-items: center; gap: 13px; border-radius: 12px; color: var(--text); background: var(--surface-soft); cursor: pointer; text-align: left; transition: background .15s ease; } +.usage-row-wrap { position: relative; min-width: 0; min-height: 68px; } +.usage-row-wrap:has(.usage-row__failure-trigger:hover), .usage-row-wrap:has(.usage-row__failure-trigger:focus-visible), .usage-row-wrap:has(.usage-row__failure-tooltip:hover), .usage-row-wrap:has(.usage-row__failure-tooltip:focus-within) { z-index: 1; } +.usage-row { position: relative; width: 100%; min-height: 68px; padding: 8px 14px; display: grid; grid-template-columns: 46px minmax(0, 1fr) 28px; align-items: center; gap: 13px; border-radius: 12px; color: var(--text); background: var(--surface-soft); cursor: pointer; text-align: left; transition: background .15s ease; } .usage-row:hover { background: #4c4c5c; } .usage-row__copy { min-width: 0; display: flex; flex-direction: column; gap: 4px; } .usage-row__copy strong { overflow: hidden; font-size: 18px; line-height: 1; text-overflow: ellipsis; white-space: nowrap; } .usage-row__copy span { overflow: hidden; color: var(--text-muted-on-dark); font-size: 11px; font-weight: 800; text-overflow: ellipsis; white-space: nowrap; } +.usage-row__failure-trigger { position: absolute; top: 50%; right: 14px; width: 30px; height: 30px; display: grid; place-items: center; padding: 0; border-radius: 7px; color: rgba(255,255,255,.88); background: transparent; cursor: help; transform: translateY(-50%); } +.usage-row__failure-trigger:hover, .usage-row__failure-trigger:focus-visible { color: white; background: rgba(255,255,255,.1); } +.usage-row__failure-tooltip { position: absolute; top: calc(100% + 6px); right: 0; width: min(520px, calc(100vw - 32px)); display: none; grid-template-columns: minmax(0, 1fr) 30px; gap: 8px; align-items: start; padding: 10px; border: 1px solid rgba(255,255,255,.14); border-radius: 10px; color: white; background: #292936; box-shadow: var(--shadow-md); } +.usage-row__failure-trigger:hover + .usage-row__failure-tooltip, .usage-row__failure-trigger:focus-visible + .usage-row__failure-tooltip, .usage-row__failure-tooltip:hover, .usage-row__failure-tooltip:focus-within { display: grid; } +.usage-row__failure-details { max-height: 260px; overflow: auto; color: rgba(255,255,255,.84); font: 700 11px/1.4 var(--font-mono); white-space: pre; } +.usage-row__failure-copy { width: 30px; height: 30px; display: grid; place-items: center; border-radius: 7px; color: rgba(255,255,255,.78); background: rgba(255,255,255,.1); cursor: pointer; } +.usage-row__failure-copy:hover, .usage-row__failure-copy:focus-visible { color: white; background: rgba(255,255,255,.2); } .usage-row > .ui-icon--working { animation: spin 1.7s linear infinite; } .launcher-dock { padding: 8px; display: grid; grid-template-columns: repeat(var(--launcher-columns, 2), minmax(0, 1fr)); gap: 8px; border: 0; border-radius: 16px; background: var(--home-launcher-dock); } .launcher-button, .settings-button { display: grid; place-items: center; border-radius: 12px; color: var(--home-launcher-text); background: var(--home-launcher-tile); cursor: pointer; transition: background .15s ease, filter .15s ease; } diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index 03e279a..6b40eed 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -161,6 +161,7 @@ export interface SessionMetadata { status: SessionStatus; startedAt: number; exitCode: number | null; + failureDetails: string | null; } export interface SessionSnapshot extends SessionMetadata { diff --git a/tests/cli-environment.test.mjs b/tests/cli-environment.test.mjs index 50ff3f0..6dae0a2 100644 --- a/tests/cli-environment.test.mjs +++ b/tests/cli-environment.test.mjs @@ -34,3 +34,12 @@ test("does not duplicate CLI directories already present in PATH", () => { assert.equal(environment.PATH.split(":").filter((entry) => entry === kimiBin).length, 1); }); + +test("adds Homebrew directories for macOS apps launched outside an interactive shell", () => { + const environment = { PATH: "/usr/bin:/bin" }; + const homebrewBin = "/opt/homebrew/bin"; + + augmentCliPath(environment, "/Users/Kisa", "darwin", (directory) => directory === homebrewBin); + + assert.equal(environment.PATH, `/usr/bin:/bin:${homebrewBin}`); +}); diff --git a/tests/home-model.test.mjs b/tests/home-model.test.mjs index c819c75..eed47aa 100644 --- a/tests/home-model.test.mjs +++ b/tests/home-model.test.mjs @@ -50,6 +50,7 @@ function session(id, status, startedAt) { status, startedAt, exitCode: status === "done" ? 0 : status === "failed" ? 1 : null, + failureDetails: null, buffer: "" }; } diff --git a/tests/home-session-error-tooltip.test.mjs b/tests/home-session-error-tooltip.test.mjs new file mode 100644 index 0000000..3aed4ad --- /dev/null +++ b/tests/home-session-error-tooltip.test.mjs @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const homeZonePath = new URL("../src/renderer/src/features/home/HomeZone.tsx", import.meta.url); +const appStylesPath = new URL("../src/renderer/src/styles/app.css", import.meta.url); + +test("Home exposes failed session details from the error mark with a Copy action", async () => { + const source = await readFile(homeZonePath, "utf8"); + + assert.match(source, /session\.failureDetails \?\? `\$\{t\(locale, "failureOutputUnavailable"\)\}\$\{session\.exitCode \?\? "unknown"\}`/); + assert.match(source, /className="usage-row__failure-tooltip"/); + assert.match(source, /className="usage-row__failure-trigger"/); + assert.match(source, //); + assert.match(source, /window\.canvasTTY\.clipboard\.writeText\(failureDetails\)/); + assert.match(source, //); +}); + +test("Home keeps error details visible while the trigger is hovered or keyboard-focused", async () => { + const styles = await readFile(appStylesPath, "utf8"); + + assert.match(styles, /\.usage-row__failure-trigger:hover \+ \.usage-row__failure-tooltip/); + assert.match(styles, /\.usage-row__failure-trigger:focus-visible \+ \.usage-row__failure-tooltip/); + assert.match(styles, /\.usage-row__failure-tooltip:hover/); +}); diff --git a/tests/terminal-failure-details.test.mjs b/tests/terminal-failure-details.test.mjs new file mode 100644 index 0000000..e9cd6b0 --- /dev/null +++ b/tests/terminal-failure-details.test.mjs @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { terminalFailureDetails } from "../src/main/services/terminalFailureDetails.ts"; + +test("keeps a traceback-sized final PTY excerpt and removes terminal control sequences", () => { + const details = terminalFailureDetails([ + "\u001b[?25lstarting\u001b[?25h", + "first context", + "second context", + "third context", + "fourth context", + "fifth context", + "sixth context", + "error: Codex executable was not found\r\n" + ].join("\n")); + + assert.equal(details, [ + "starting", + "first context", + "second context", + "third context", + "fourth context", + "fifth context", + "sixth context", + "error: Codex executable was not found" + ].join("\n")); +}); + +test("returns no details when the failed PTY wrote no visible output", () => { + assert.equal(terminalFailureDetails("\u001b[2J\u0007"), null); +}); + +test("bounds the tooltip payload to the final 8,000 characters", () => { + const details = terminalFailureDetails("x".repeat(9_000)); + + assert.equal(details?.length, 8_000); + assert.ok(details?.startsWith("…")); +}); + +test("preserves traceback indentation", () => { + const details = terminalFailureDetails("Traceback (most recent call last):\n File \"agent.py\", line 8, in \n run()\nRuntimeError: launch failed"); + + assert.equal(details, "Traceback (most recent call last):\n File \"agent.py\", line 8, in \n run()\nRuntimeError: launch failed"); +}); + +test("starts at the final traceback instead of showing the echoed launch command", () => { + const details = terminalFailureDetails([ + "> printf 'Traceback (most recent call last):\\n...'; exit 1", + "Traceback (most recent call last):", + " File \"agent.py\", line 8, in ", + " run()", + "RuntimeError: launch failed" + ].join("\n")); + + assert.equal(details, [ + "Traceback (most recent call last):", + " File \"agent.py\", line 8, in ", + " run()", + "RuntimeError: launch failed" + ].join("\n")); +}); diff --git a/tests/terminal-lifecycle.test.mjs b/tests/terminal-lifecycle.test.mjs index 0b2930a..9086db0 100644 --- a/tests/terminal-lifecycle.test.mjs +++ b/tests/terminal-lifecycle.test.mjs @@ -114,11 +114,19 @@ test("an exited PTY can restart in place without recreating its xterm card", asy assert.match(manager, /restart\(id: string\): SessionSnapshot/); assert.match(manager, /session\.metadata\.exitCode === null/); assert.match(manager, /session\.metadata\.status = "idle"/); + assert.match(manager, /session\.metadata\.failureDetails = null/); assert.match(manager, /this\.bindProcess\(id, session, launched\.process\)/); assert.match(card, /shouldRestartExitedTerminal\(event, sessionExited\.current\)/); assert.match(card, /onRestart\(session\.id\)/); }); +test("failed PTYs preserve their final sanitized output as failure details", async () => { + const source = await readFile(terminalManagerPath, "utf8"); + + assert.match(source, /terminalFailureDetails\(current\.bufferChunks\.slice\(current\.bufferStart\)\.join\(""\)\)/); + assert.match(source, /current\.metadata\.failureDetails = exitCode === 0/); +}); + function effectDependenciesContaining(source, marker) { const markerIndex = source.indexOf(marker); assert.notEqual(markerIndex, -1, `Could not find ${marker}`); From 3aa17e944173f3043665b696a506de1543a014b4 Mon Sep 17 00:00:00 2001 From: cododel Date: Sat, 22 Aug 2026 20:10:49 +0400 Subject: [PATCH 2/4] fix(cli): centralize provider executable resolution --- .github/workflows/release.yml | 3 + docs/ARCHITECTURE.md | 2 +- docs/ARCHITECTURE.ru.md | 2 +- docs/ARCHITECTURE.zh-CN.md | 2 +- ...cos-builds-fail-to-detect-provider-clis.md | 55 --- package.json | 1 + scripts/smoke-packaged-cli-resolution.mjs | 122 ++++++ src/main/index.ts | 73 ++-- src/main/services/LimitsService.ts | 68 +++- src/main/services/TerminalManager.ts | 70 +++- .../services/agent-browser/ProviderLaunch.ts | 32 +- .../services/browser/ProviderElectronSmoke.ts | 24 +- src/main/services/cliEnvironment.ts | 26 -- src/main/services/providerCliRegistry.ts | 370 ++++++++++++++++++ src/main/services/terminalLaunch.ts | 172 ++------ tests/agent-browser-helper.test.mjs | 24 +- tests/agent-browser-provider-launch.test.mjs | 37 +- tests/cli-environment.test.mjs | 45 --- tests/provider-cli-integration.test.mjs | 74 ++++ tests/provider-cli-registry.test.mjs | 134 +++++++ tests/terminal-launch.test.mjs | 91 +++++ tests/terminal-lifecycle.test.mjs | 6 +- tests/windows-terminal-launch.test.mjs | 234 ----------- 23 files changed, 1092 insertions(+), 575 deletions(-) delete mode 100644 docs/issues/packaged-macos-builds-fail-to-detect-provider-clis.md create mode 100644 scripts/smoke-packaged-cli-resolution.mjs delete mode 100644 src/main/services/cliEnvironment.ts create mode 100644 src/main/services/providerCliRegistry.ts delete mode 100644 tests/cli-environment.test.mjs create mode 100644 tests/provider-cli-integration.test.mjs create mode 100644 tests/provider-cli-registry.test.mjs create mode 100644 tests/terminal-launch.test.mjs delete mode 100644 tests/windows-terminal-launch.test.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8e9f7d8..0cf63a7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -51,6 +51,9 @@ jobs: app="$(find release -type d -name 'CanvasTTY.app' -print -quit)" test -n "$app" codesign --verify --deep --strict --verbose=4 "$app" + - name: Smoke-test packaged macOS CLI resolution + if: runner.os == 'macOS' + run: npm run smoke:cli-resolution:mac - name: Install AppImage runtime dependency if: runner.os == 'Linux' run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libfuse2t64 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1b426c3..c98aa79 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -42,7 +42,7 @@ Electron main process - `src/main/services/browser/` contains the browser kernel. `BrowserStore` atomically persists only tab order, active tab, and safe restore URLs. `BrowserPolicyService` centralizes URL, permission, download, and upload rules; validated uploads are copied through an already-open no-follow file descriptor into private staging before Chromium sees them. `BrowserAutomationService` attaches Electron's internal debugger to the existing live tab without a remote-debugging port. `BrowserCommandDispatcher` adds revisions, revision-bound refs, mutation request deduplication, per-tab FIFO mutation lanes, bounded concurrency, typed errors, and redacted fail-closed audit for agent mutations. - `src/main/services/agent-browser/` exposes the kernel only through an authenticated user-local Unix socket (`0600`) or Windows named pipe. The Windows pipe is created by the bundled native host with a protected DACL containing only the exact current-user SID and rejects remote clients. Each agent PTY receives a one-use bootstrap capability through its child environment. A successful authentication rotates it to a session-scoped reconnect capability held only in helper memory; duplicate bootstrap authentication is accepted only while the same `connectionId` is already live, and every capability is revoked when the PTY ends. The bundled stdio MCP helper is the only protocol adapter; no TCP listener, cookie/storage endpoint, arbitrary evaluation tool, or raw CDP surface exists. - `TerminalManager` injects the MCP helper per launch without leaving permanent provider configuration. Claude Code and Codex receive CLI arguments; OpenCode receives a merged launch-only `OPENCODE_CONFIG_CONTENT` entry plus one scoped browser-tool permission; Kimi uses its per-run MCP configuration when supported. Older Kimi versions receive a compare-and-swap temporary CanvasTTY entry and one exact permission rule with an atomic recovery journal. Hermes receives a temporary `mcp_servers.canvastty_browser` entry in `HERMES_HOME/config.yaml` (defaulting to `~/.hermes/config.yaml` on POSIX or `%LOCALAPPDATA%\hermes\config.yaml` on Windows); sensitive capability values stay as child-environment placeholders. Temporary Kimi and Hermes configuration remains until the final owning PTY session ends, then exact original bytes are restored when safe. A journal repairs an interrupted Hermes launch at the next CanvasTTY startup, while compare-and-swap checks preserve concurrent user edits. Unrelated MCP entries, credentials, and file/shell permissions are preserved. OpenCode YOLO is a launch-only inline override, and Hermes YOLO passes its native `--yolo` flag; neither changes persistent permission settings. -- `src/main/services/cliEnvironment.ts` supplements the graphical-session `PATH` with existing per-user CLI directories before any provider process is spawned. It never reads shell startup scripts. +- `src/main/services/providerCliRegistry.ts` is the single owner of provider CLI discovery. During main-process startup it creates one immutable snapshot for Codex, Claude, Kimi, OpenCode, Hermes, and Grok Build by checking smoke-only overrides, the inherited `PATH`, platform defaults, and known per-user/provider directories in that order. Available entries retain an absolute executable, launcher kind, and supplemented child `PATH`; POSIX entries must be executable files and Windows entries must be supported native or batch launchers. `TerminalManager`, `LimitsService`, agent-browser probes, and provider smoke tests consume that same snapshot and never repeat command lookup. Missing entries produce a failed session with copyable checked-path diagnostics before PTY or temporary browser configuration creation, and the matching HOME limit stays `cli-not-found`. CanvasTTY never reads shell startup scripts, and installing or moving a CLI requires restarting the app. The primary `BrowserWindow` is created and shown with a lightweight local startup page before settings, plugins, media, and IPC services initialize. Successful initialization replaces that page with the trusted renderer; bootstrap failures replace it with a visible error page and retain a native-dialog fallback. The main process holds Electron's single-instance lock and restores/focuses the existing window when another launch is attempted. diff --git a/docs/ARCHITECTURE.ru.md b/docs/ARCHITECTURE.ru.md index 5eb1b9a..7654ac9 100644 --- a/docs/ARCHITECTURE.ru.md +++ b/docs/ARCHITECTURE.ru.md @@ -37,7 +37,7 @@ Electron main process - `src/main/services/PluginMediaService.ts` сохраняет разрешения только после нативного выбора папки, скрывает абсолютные пути, пропускает symlinks и отдаёт аудио с HTTP Range. Чтение плейлистов остаётся внутри разрешённых библиотек; ограниченная атомарная запись разрешена только в `Playlists/`. - `src/main/services/BrowserService.ts` владеет вкладками встроенного браузера в `WebContentsView`. Удалённые страницы используют отдельный persistent partition с выключенным Node, включёнными context isolation/sandbox и отклонением website permissions по умолчанию. Это core service, а не возможность runtime-плагина. - `TerminalManager` подмешивает MCP helper, не оставляя постоянных изменений в provider-конфигах. Claude Code и Codex получают CLI arguments, OpenCode — объединённый launch-only `OPENCODE_CONFIG_CONTENT` с одной scoped browser-tool permission, Kimi — per-run MCP config или временную запись с compare-and-swap и recovery journal для старых версий. Hermes получает временную запись `mcp_servers.canvastty_browser` в `HERMES_HOME/config.yaml` (по умолчанию `~/.hermes/config.yaml` в POSIX или `%LOCALAPPDATA%\hermes\config.yaml` в Windows); чувствительные capability-значения остаются ссылками на окружение дочернего процесса. Временная конфигурация Kimi и Hermes живёт до завершения последней владеющей PTY-сессии, после чего исходные байты точно восстанавливаются, если файл не менялся параллельно. Journal восстанавливает Hermes после прерванного запуска при следующем старте CanvasTTY, а compare-and-swap сохраняет одновременные пользовательские изменения. Сторонние MCP-записи, credentials и file/shell permissions не затрагиваются. OpenCode YOLO существует только как inline override текущего запуска, а Hermes получает штатный `--yolo`; постоянные permission-настройки не меняются. -- `src/main/services/cliEnvironment.ts` дополняет `PATH` графической сессии существующими пользовательскими каталогами CLI до запуска любого процесса провайдера. Shell startup scripts не читаются. +- `src/main/services/providerCliRegistry.ts` — единственный владелец обнаружения provider CLI. При запуске main-процесса он создаёт один неизменяемый snapshot для Codex, Claude, Kimi, OpenCode, Hermes и Grok Build, последовательно проверяя smoke-only overrides, унаследованный `PATH`, системные каталоги платформы и известные пользовательские/provider-каталоги. Доступная запись хранит абсолютный executable, тип launcher-а и дополненный дочерний `PATH`; POSIX-кандидат обязан быть исполняемым файлом, а Windows-кандидат — поддерживаемым native или batch launcher-ом. `TerminalManager`, `LimitsService`, agent-browser probes и provider smoke используют один и тот же snapshot и не повторяют поиск команды. Недоступный CLI создаёт failed-сессию с копируемой диагностикой проверенных путей до создания PTY или временной browser-конфигурации, а соответствующий HOME limit остаётся `cli-not-found`. CanvasTTY не читает shell startup scripts; после установки или перемещения CLI приложение нужно перезапустить. Основной `BrowserWindow` создаётся и показывается с лёгкой локальной стартовой страницей до инициализации settings, plugins, media и IPC. Успешная инициализация заменяет её доверенным renderer; bootstrap failure показывает видимую error page и сохраняет fallback на native dialog. Main process удерживает single-instance lock и восстанавливает/фокусирует существующее окно при повторном запуске. diff --git a/docs/ARCHITECTURE.zh-CN.md b/docs/ARCHITECTURE.zh-CN.md index 1341c73..e612fdb 100644 --- a/docs/ARCHITECTURE.zh-CN.md +++ b/docs/ARCHITECTURE.zh-CN.md @@ -37,7 +37,7 @@ Electron main process - `src/main/services/PluginMediaService.ts` 仅在原生目录选择后保存授权,隐藏绝对路径,跳过 symlink,并以 HTTP Range 提供音频。Playlist 读取限制在授权媒体库内;写入受大小限制,并且只能原子写入 `Playlists/`。 - `src/main/services/BrowserService.ts` 管理内置浏览器的 `WebContentsView` tab。远程页面使用独立 persistent partition,禁用 Node,启用 context isolation/sandbox,并默认拒绝网站权限。这是 core service,不是 runtime 插件能力。 - `TerminalManager` 注入 MCP helper 时不会留下永久的服务商配置变更。Claude Code 与 Codex 使用 CLI 参数;OpenCode 使用合并后的、仅本次启动有效的 `OPENCODE_CONFIG_CONTENT` 和一条 scoped browser-tool 权限;Kimi 使用 per-run MCP 配置,旧版本则使用带 compare-and-swap 与 recovery journal 的临时配置。Hermes 会在 `HERMES_HOME/config.yaml` 中获得临时 `mcp_servers.canvastty_browser` 配置项(POSIX 默认路径为 `~/.hermes/config.yaml`,Windows 默认路径为 `%LOCALAPPDATA%\hermes\config.yaml`),敏感 capability 值仍以子进程环境变量占位符保存。Kimi 与 Hermes 的临时配置会保留到最后一个所属 PTY 会话结束;若文件未被并发修改,则精确恢复原始字节。若 Hermes 启动意外中断,journal 会在 CanvasTTY 下次启动时修复配置,compare-and-swap 则保留用户的并发修改。其他 MCP 配置项、凭据和文件/shell 权限不会受影响。OpenCode YOLO 仅使用本次启动的 inline override,Hermes 则使用原生 `--yolo` 参数;两者都不修改持久权限设置。 -- `src/main/services/cliEnvironment.ts` 会在启动任何服务商进程前,用现有用户 CLI 目录补充图形会话的 `PATH`,且不会读取 shell startup script。 +- `src/main/services/providerCliRegistry.ts` 是服务商 CLI 发现的唯一职责边界。main 进程启动时,它按 smoke-only override、继承的 `PATH`、平台默认目录、已知用户/服务商目录的顺序,为 Codex、Claude、Kimi、OpenCode、Hermes 与 Grok Build 创建一个不可变快照。可用条目保存绝对 executable、launcher 类型以及补充后的子进程 `PATH`;POSIX 候选必须是可执行文件,Windows 候选必须是受支持的 native 或 batch launcher。`TerminalManager`、`LimitsService`、agent-browser probe 与 provider smoke 共用该快照,不再各自查找命令。CLI 不可用时,系统会在创建 PTY 或临时 browser 配置之前生成 failed session,并提供可复制的已检查路径诊断;对应的 HOME limit 同样保持 `cli-not-found`。CanvasTTY 不读取 shell startup script;安装或移动 CLI 后必须重启应用。 主 `BrowserWindow` 在 settings、plugins、media 和 IPC 服务初始化之前创建并显示轻量本地启动页。初始化成功后替换为可信 renderer;bootstrap 失败后替换为可见错误页,并保留原生对话框 fallback。主进程持有 Electron single-instance lock;再次启动时恢复并聚焦已有窗口。 diff --git a/docs/issues/packaged-macos-builds-fail-to-detect-provider-clis.md b/docs/issues/packaged-macos-builds-fail-to-detect-provider-clis.md deleted file mode 100644 index a43609a..0000000 --- a/docs/issues/packaged-macos-builds-fail-to-detect-provider-clis.md +++ /dev/null @@ -1,55 +0,0 @@ -# Packaged macOS builds fail to detect installed provider CLIs - -## Bug - -Codex launches in development but the installed macOS app reports `Codex · CLI not found` and -creates an empty `Failed` terminal. - -In the reproduced case, Codex was installed at `/opt/homebrew/bin/codex`, but the Finder-launched -app did not inherit that directory in `PATH`. Adding it to the main-process environment restored -Codex launch and limits. - -Expected: CLI availability must be identical in development and packaged builds. If a CLI is -unavailable, `(!)` must show a copyable diagnostic even when the PTY is empty. - -## Reproduction - -1. Install Codex at `/opt/homebrew/bin/codex`. -2. Confirm it works through `npm run dev`. -3. Build, install, and launch the macOS `.app` from Finder. -4. Launch Codex from HOME. - -## Root cause - -Provider CLI discovery has no single owner: - -- POSIX terminal launch delegates command lookup to `node-pty`. -- `LimitsService` and `ProviderLaunch` perform separate command lookups. -- Provider smoke tests launch development Electron with the caller's `PATH`, not the packaged app - with a Finder-like environment. - -The current PATH augmentation fixes known Homebrew locations but does not guarantee that all -consumers use the same executable or produce useful errors. - -## Proposed solution - -- Resolve every provider CLI once at startup to either an absolute executable or a structured - `cli-not-found` result containing the paths checked. -- Search in this order: test override, input `PATH`, platform defaults, then known user/provider - directories. Validate that the selected file is executable. Do not use a login shell as the primary - resolver. -- Pass the same resolved executable to terminal launch, limits, and agent-browser integration. -- Fail before creating the PTY when resolution fails. Show the structured error in `(!)` and make - the complete text copyable. -- Add a packaged macOS smoke test with a minimal Finder-like `PATH`, no credentials, and no network. - -The initial implementation may require restarting CanvasTTY after installing a new CLI. - -## Acceptance criteria - -- [ ] Terminal launch, limits, and agent-browser integration use one resolved executable. -- [ ] A packaged macOS app finds supported Homebrew CLIs with a minimal `PATH`. -- [ ] Missing or non-executable CLIs report the paths actually checked. -- [ ] `(!)` and Copy work when the PTY produced no output. -- [ ] HOME limits and agent launch cannot disagree about `cli-not-found`. -- [ ] Unit tests and a packaged smoke test cover successful and failed resolution. diff --git a/package.json b/package.json index c5bd306..dd4e5f8 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "smoke:browser:providers": "node scripts/smoke-browser-providers.mjs --deterministic", "smoke:browser:provider-helper": "node scripts/smoke-browser-providers.mjs --direct", "smoke:browser:providers:live": "node scripts/smoke-browser-providers.mjs --all", + "smoke:cli-resolution:mac": "node scripts/smoke-packaged-cli-resolution.mjs", "smoke:appimage": "node scripts/smoke-appimage.mjs", "preview": "electron-vite preview", "audit:secrets": "node scripts/audit-secrets.mjs", diff --git a/scripts/smoke-packaged-cli-resolution.mjs b/scripts/smoke-packaged-cli-resolution.mjs new file mode 100644 index 0000000..b4731e8 --- /dev/null +++ b/scripts/smoke-packaged-cli-resolution.mjs @@ -0,0 +1,122 @@ +import { spawn } from "node:child_process"; +import { access, chmod, mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +if (process.platform !== "darwin") { + throw new Error("The packaged CLI-resolution smoke requires macOS."); +} + +const PROVIDERS = ["codex", "claude", "kimi", "opencode", "hermes", "grok"]; +const READY_MARKER = "CANVASTTY_CLI_RESOLUTION_SMOKE_READY "; +const MAX_OUTPUT_BYTES = 256 * 1024; +const root = await mkdtemp(join(tmpdir(), "canvastty-cli-resolution-")); + +try { + const appPath = await packagedApplication(); + const executable = join(appPath, "Contents", "MacOS", "CanvasTTY"); + const platformRoot = join(root, "platform-root"); + const home = join(root, "home"); + const homebrewBin = join(platformRoot, "opt", "homebrew", "bin"); + await Promise.all([ + mkdir(homebrewBin, { recursive: true, mode: 0o700 }), + mkdir(home, { recursive: true, mode: 0o700 }) + ]); + for (const provider of PROVIDERS) { + const path = join(homebrewBin, provider); + await writeFile(path, "#!/bin/sh\nexit 0\n", { mode: 0o700 }); + } + + const available = await runSmoke(executable, platformRoot, home, "available"); + for (const provider of PROVIDERS) { + const resolution = available[provider]; + if (resolution?.state !== "available") { + throw new Error(`${provider} was not available in the packaged startup snapshot.`); + } + if (resolution.executable !== join(homebrewBin, provider)) { + throw new Error(`${provider} resolved an unexpected executable: ${resolution.executable}`); + } + } + + await chmod(join(homebrewBin, "codex"), 0o600); + const unavailable = await runSmoke(executable, platformRoot, home, "unavailable"); + if (unavailable.codex?.state !== "unavailable") { + throw new Error("Non-executable Codex was not rejected in the packaged startup snapshot."); + } + const rejected = unavailable.codex.checked.find((candidate) => ( + candidate.path === join(homebrewBin, "codex") && candidate.result === "not-executable" + )); + if (!rejected || !unavailable.codex.diagnostic.includes(`${rejected.path}: not-executable`)) { + throw new Error("Packaged unavailable diagnostics did not preserve the rejected Codex path."); + } + + process.stdout.write("Packaged CLI resolution smoke passed.\n"); +} finally { + await rm(root, { recursive: true, force: true }); +} + +async function findApplication(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + for (const entry of entries) { + const path = join(directory, entry.name); + if (entry.isDirectory() && entry.name === "CanvasTTY.app") return path; + if (entry.isDirectory()) { + const nested = await findApplication(path).catch(() => null); + if (nested) return nested; + } + } + throw new Error(`CanvasTTY.app was not found below ${directory}.`); +} + +async function packagedApplication() { + const preferred = join(process.cwd(), "release", "mac-arm64", "CanvasTTY.app"); + try { + await access(preferred); + return preferred; + } catch { + return findApplication(join(process.cwd(), "release")); + } +} + +async function runSmoke(executable, platformRoot, home, label) { + const userData = join(root, `user-data-${label}`); + await mkdir(userData, { recursive: true, mode: 0o700 }); + const child = spawn(executable, [`--user-data-dir=${userData}`, "--disable-gpu"], { + env: { + PATH: "/usr/bin:/bin", + HOME: home, + CANVASTTY_CLI_RESOLUTION_SMOKE: "1", + CANVASTTY_CLI_RESOLUTION_SMOKE_ROOT: platformRoot, + CANVASTTY_CLI_RESOLUTION_SMOKE_HOME: home + }, + stdio: ["ignore", "pipe", "pipe"] + }); + let output = ""; + const consume = (chunk) => { + output = `${output}${chunk.toString("utf8")}`; + if (Buffer.byteLength(output) > MAX_OUTPUT_BYTES) child.kill("SIGKILL"); + }; + child.stdout.on("data", consume); + child.stderr.on("data", consume); + const result = await new Promise((resolveExit, reject) => { + const timer = setTimeout(() => { + child.kill("SIGKILL"); + reject(new Error(`Packaged CLI-resolution smoke timed out.\n${output.slice(-16_384)}`)); + }, 30_000); + child.once("error", (error) => { + clearTimeout(timer); + reject(error); + }); + child.once("exit", (code, signal) => { + clearTimeout(timer); + resolveExit({ code, signal }); + }); + }); + if (result.code !== 0 || result.signal !== null) { + throw new Error(`Packaged smoke exited unsuccessfully (code=${result.code}, signal=${result.signal}).\n${output.slice(-16_384)}`); + } + const markerIndex = output.indexOf(READY_MARKER); + if (markerIndex === -1) throw new Error(`Packaged smoke marker was missing.\n${output.slice(-16_384)}`); + const line = output.slice(markerIndex + READY_MARKER.length).split(/\r?\n/u, 1)[0]; + return JSON.parse(line); +} diff --git a/src/main/index.ts b/src/main/index.ts index dfa4609..5d1a33e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -5,7 +5,10 @@ import { registerIpc } from "./ipc/registerIpc"; import { SettingsStore } from "./services/SettingsStore"; import { TerminalManager } from "./services/TerminalManager"; import { LimitsService } from "./services/LimitsService"; -import { augmentCliPath } from "./services/cliEnvironment"; +import { + createProviderCliRegistry, + type ProviderCliRegistry +} from "./services/providerCliRegistry"; import { PluginManager } from "./services/PluginManager"; import { GithubAuthService } from "./services/GithubAuthService"; import { PluginMediaService } from "./services/PluginMediaService"; @@ -72,6 +75,7 @@ let canvasNavigationInput: CanvasNavigationInputController | null = null; let agentGateway: AgentGateway | null = null; let agentBrowserBridge: AgentBrowserBridge | null = null; let agentBrowserHelper: StdioHelperLaunch | null = null; +let providerClis: ProviderCliRegistry | null = null; const pluginWindows = new Map(); let servicesReady = false; let startupRunning = false; @@ -119,7 +123,7 @@ async function createWindow(): Promise { } async function initializeServices(): Promise { - augmentCliPath(); + providerClis = buildProviderCliRegistry(); // Recovery is independent of gateway availability: interrupted provider config // overlays must be restored before any new terminal can launch, including on Windows. const hermesHomeDirectory = resolveHermesHomeDirectory(); @@ -179,12 +183,10 @@ async function initializeServices(): Promise { }; agentBrowserBridge = new AgentBrowserBridge(agentGateway, { helper: agentBrowserHelper, + providerClis, runtimeDirectory, hermesHomeDirectory, - kimiHomeDirectory, - ...(process.env.CANVASTTY_PROVIDER_SMOKE_KIMI_COMMAND - ? { kimiCommand: process.env.CANVASTTY_PROVIDER_SMOKE_KIMI_COMMAND } - : {}) + kimiHomeDirectory }); } else { console.warn(WINDOWS_AGENT_GATEWAY_UNAVAILABLE); @@ -194,8 +196,8 @@ async function initializeServices(): Promise { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send(channel, payload); } - }, agentBrowserBridge ?? undefined); - limitsService = new LimitsService(app.getVersion()); + }, providerClis, agentBrowserBridge ?? undefined); + limitsService = new LimitsService(providerClis, app.getVersion()); pluginManager = new PluginManager(app.getPath("userData")); await pluginManager.load(); githubAuth = new GithubAuthService(app.getPath("userData")); @@ -282,23 +284,7 @@ async function loadApplication(window: BrowserWindow): Promise { helper: agentBrowserHelper, cwd: process.env.CANVASTTY_PROVIDER_SMOKE_CWD || app.getPath("temp"), targets, - commands: { - ...(process.env.CANVASTTY_PROVIDER_SMOKE_KIMI_COMMAND - ? { kimi: process.env.CANVASTTY_PROVIDER_SMOKE_KIMI_COMMAND } - : {}), - ...(process.env.CANVASTTY_PROVIDER_SMOKE_CLAUDE_COMMAND - ? { claude: process.env.CANVASTTY_PROVIDER_SMOKE_CLAUDE_COMMAND } - : {}), - ...(process.env.CANVASTTY_PROVIDER_SMOKE_CODEX_COMMAND - ? { codex: process.env.CANVASTTY_PROVIDER_SMOKE_CODEX_COMMAND } - : {}), - ...(process.env.CANVASTTY_PROVIDER_SMOKE_OPENCODE_COMMAND - ? { opencode: process.env.CANVASTTY_PROVIDER_SMOKE_OPENCODE_COMMAND } - : {}), - ...(process.env.CANVASTTY_PROVIDER_SMOKE_HERMES_COMMAND - ? { hermes: process.env.CANVASTTY_PROVIDER_SMOKE_HERMES_COMMAND } - : {}) - } + providerClis: providerClis! }); console.log("CANVASTTY_PROVIDER_SMOKE_READY"); app.quit(); @@ -321,6 +307,12 @@ async function startApplication(): Promise { try { if (!window) window = await createWindow(); + if (process.env.CANVASTTY_CLI_RESOLUTION_SMOKE === "1") { + const registry = buildProviderCliRegistry(); + console.log(`CANVASTTY_CLI_RESOLUTION_SMOKE_READY ${JSON.stringify(registry.snapshot())}`); + app.quit(); + return; + } if (!servicesReady) await initializeServices(); await loadApplication(window); } catch (error) { @@ -335,6 +327,37 @@ async function startApplication(): Promise { } } +function buildProviderCliRegistry(): ProviderCliRegistry { + const providerSmoke = process.env.CANVASTTY_PROVIDER_SMOKE; + const smokeOverrides = providerSmoke ? { + ...(process.env.CANVASTTY_PROVIDER_SMOKE_KIMI_COMMAND + ? { kimi: process.env.CANVASTTY_PROVIDER_SMOKE_KIMI_COMMAND } + : {}), + ...(process.env.CANVASTTY_PROVIDER_SMOKE_CLAUDE_COMMAND + ? { claude: process.env.CANVASTTY_PROVIDER_SMOKE_CLAUDE_COMMAND } + : {}), + ...(process.env.CANVASTTY_PROVIDER_SMOKE_CODEX_COMMAND + ? { codex: process.env.CANVASTTY_PROVIDER_SMOKE_CODEX_COMMAND } + : {}), + ...(process.env.CANVASTTY_PROVIDER_SMOKE_OPENCODE_COMMAND + ? { opencode: process.env.CANVASTTY_PROVIDER_SMOKE_OPENCODE_COMMAND } + : {}), + ...(process.env.CANVASTTY_PROVIDER_SMOKE_HERMES_COMMAND + ? { hermes: process.env.CANVASTTY_PROVIDER_SMOKE_HERMES_COMMAND } + : {}) + } : undefined; + const resolutionSmoke = process.env.CANVASTTY_CLI_RESOLUTION_SMOKE === "1"; + return createProviderCliRegistry({ + ...(smokeOverrides ? { overrides: smokeOverrides } : {}), + ...(resolutionSmoke && process.env.CANVASTTY_CLI_RESOLUTION_SMOKE_ROOT + ? { platformRoot: process.env.CANVASTTY_CLI_RESOLUTION_SMOKE_ROOT } + : {}), + ...(resolutionSmoke && process.env.CANVASTTY_CLI_RESOLUTION_SMOKE_HOME + ? { homeDirectory: process.env.CANVASTTY_CLI_RESOLUTION_SMOKE_HOME } + : {}) + }); +} + async function showStartupFailure(window: BrowserWindow, error: unknown): Promise { const detail = error instanceof Error ? error.stack ?? error.message : String(error); console.error("CanvasTTY startup failed.", error); diff --git a/src/main/services/LimitsService.ts b/src/main/services/LimitsService.ts index d5badfe..b057a21 100644 --- a/src/main/services/LimitsService.ts +++ b/src/main/services/LimitsService.ts @@ -11,6 +11,11 @@ import type { LimitsSnapshot, ProviderLimitsSnapshot } from "../../shared/contracts"; +import { + providerChildProcessLaunch, + type AvailableProviderCli, + type ProviderCliRegistry +} from "./providerCliRegistry.ts"; const CACHE_TTL_MS = 60_000; const REQUEST_TIMEOUT_MS = 15_000; @@ -45,7 +50,8 @@ interface PendingRequest { export class LimitsService { private readonly codex: CodexAppServerClient; - private readonly kimi = new KimiWebUsageClient(); + private readonly kimi: KimiWebUsageClient; + private readonly providerClis: ProviderCliRegistry; private readonly clientVersion: string; private cache: CacheEntry | null = null; private inFlight: Promise | null = null; @@ -56,9 +62,11 @@ export class LimitsService { private lastGoodGrok: Extract | null = null; private disposed = false; - constructor(clientVersion = "unknown") { + constructor(providerClis: ProviderCliRegistry, clientVersion = "unknown") { + this.providerClis = providerClis; this.clientVersion = clientVersion; - this.codex = new CodexAppServerClient(clientVersion); + this.codex = new CodexAppServerClient(availableCli(providerClis, "codex"), clientVersion); + this.kimi = new KimiWebUsageClient(availableCli(providerClis, "kimi")); } async get(): Promise { @@ -102,6 +110,9 @@ export class LimitsService { } private async loadCodex(checkedAt: number): Promise { + if (this.providerClis.get("codex").state === "unavailable") { + return unavailable("codex", "codex-app-server", "cli-not-found", checkedAt); + } if (this.disposed) { return unavailable("codex", "codex-app-server", "protocol-error", checkedAt); } @@ -135,6 +146,9 @@ export class LimitsService { } private async loadClaude(checkedAt: number): Promise { + if (this.providerClis.get("claude").state === "unavailable") { + return unavailable("claude", "claude-usage-api", "cli-not-found", checkedAt); + } try { const raw = await readClaudeUsage(this.clientVersion); const windows = normalizeClaudeLimits(raw); @@ -164,6 +178,9 @@ export class LimitsService { } private async loadKimi(checkedAt: number): Promise { + if (this.providerClis.get("kimi").state === "unavailable") { + return unavailable("kimi", "kimi-usage-api", "cli-not-found", checkedAt); + } try { const raw = await this.kimi.readUsage(); const windows = normalizeKimiLimits(raw); @@ -193,6 +210,9 @@ export class LimitsService { } private async loadOpenCode(checkedAt: number): Promise { + if (this.providerClis.get("opencode").state === "unavailable") { + return unavailable("opencode", "opencode-go-usage-api", "cli-not-found", checkedAt); + } try { const raw = await readOpenCodeGoUsage(this.clientVersion); const windows = normalizeOpenCodeGoLimits(raw); @@ -222,6 +242,9 @@ export class LimitsService { } private async loadGrok(checkedAt: number): Promise { + if (this.providerClis.get("grok").state === "unavailable") { + return unavailable("grok", "grok-billing-api", "cli-not-found", checkedAt); + } try { const raw = await readGrokUsage(this.clientVersion); const windows = normalizeGrokLimits(raw); @@ -388,7 +411,16 @@ function cleanSecret(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } +function availableCli( + registry: ProviderCliRegistry, + provider: LimitProviderId +): AvailableProviderCli | null { + const resolution = registry.get(provider); + return resolution.state === "available" ? resolution : null; +} + class KimiWebUsageClient { + private readonly cli: AvailableProviderCli | null; private child: ChildProcessWithoutNullStreams | null = null; private ready: Promise | null = null; private baseUrl: string | null = null; @@ -396,6 +428,10 @@ class KimiWebUsageClient { private buffer = ""; private disposed = false; + constructor(cli: AvailableProviderCli | null) { + this.cli = cli; + } + async readUsage(): Promise { await this.ensureConnected(); if (!this.baseUrl || !this.token) throw new LimitsAdapterError("protocol-error"); @@ -424,12 +460,19 @@ class KimiWebUsageClient { } private async startChild(): Promise { + if (!this.cli) throw new LimitsAdapterError("cli-not-found"); const port = await reserveLoopbackPort(); + const launch = providerChildProcessLaunch( + this.cli, + ["web", "--no-open", "--port", String(port), "--log-level", "silent"] + ); let child: ChildProcessWithoutNullStreams; try { - child = spawn("kimi", ["web", "--no-open", "--port", String(port), "--log-level", "silent"], { + child = spawn(launch.command, launch.args, { shell: false, - stdio: ["pipe", "pipe", "pipe"] + env: { ...process.env, ...launch.environment }, + stdio: ["pipe", "pipe", "pipe"], + ...(launch.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}) }); } catch { throw new LimitsAdapterError("cli-not-found"); @@ -535,9 +578,11 @@ class CodexAppServerClient { private nextId = 1; private buffer = ""; private disposed = false; + private readonly cli: AvailableProviderCli | null; private readonly clientVersion: string; - constructor(clientVersion: string) { + constructor(cli: AvailableProviderCli | null, clientVersion: string) { + this.cli = cli; this.clientVersion = clientVersion; } @@ -570,11 +615,18 @@ class CodexAppServerClient { } private startChild(): void { + if (!this.cli) throw new LimitsAdapterError("cli-not-found"); + const launch = providerChildProcessLaunch( + this.cli, + ["app-server", "--listen", "stdio://"] + ); let child: ChildProcessWithoutNullStreams; try { - child = spawn("codex", ["app-server", "--listen", "stdio://"], { + child = spawn(launch.command, launch.args, { shell: false, - stdio: ["pipe", "pipe", "pipe"] + env: { ...process.env, ...launch.environment }, + stdio: ["pipe", "pipe", "pipe"], + ...(launch.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}) }); } catch { throw new LimitsAdapterError("cli-not-found"); diff --git a/src/main/services/TerminalManager.ts b/src/main/services/TerminalManager.ts index b61ec41..4ba2215 100644 --- a/src/main/services/TerminalManager.ts +++ b/src/main/services/TerminalManager.ts @@ -23,6 +23,7 @@ import { AGENT_BROWSER_ENV } from "./agent-browser/AgentBrowserBridge.ts"; import { tryPtyOperation } from "./ptySafety.ts"; import { terminalFailureDetails } from "./terminalFailureDetails.ts"; import { resolveTerminalLaunch } from "./terminalLaunch.ts"; +import type { ProviderCliRegistry, UnavailableProviderCli } from "./providerCliRegistry.ts"; const MAX_SCROLLBACK_CHARS = 240_000; const OUTPUT_BATCH_MS = 16; @@ -32,7 +33,7 @@ const MAX_TERMINAL_SIZE = { width: 1_600, height: 1_100 }; interface ManagedSession { metadata: SessionMetadata; - process: IPty; + process: IPty | null; bufferChunks: string[]; bufferStart: number; bufferLength: number; @@ -55,10 +56,16 @@ type Emit = ( export class TerminalManager { private readonly sessions = new Map(); private readonly emit: Emit; + private readonly providerClis: ProviderCliRegistry; private readonly agentBrowser?: AgentBrowserLaunchCoordinator; - constructor(emit: Emit, agentBrowser?: AgentBrowserLaunchCoordinator) { + constructor( + emit: Emit, + providerClis: ProviderCliRegistry, + agentBrowser?: AgentBrowserLaunchCoordinator + ) { this.emit = emit; + this.providerClis = providerClis; this.agentBrowser = agentBrowser; } @@ -71,7 +78,6 @@ export class TerminalManager { assertDirectory(request.cwd); const id = randomUUID(); - const launched = this.spawnProcess(id, request.provider, request.profile, request.cwd); const metadata: SessionMetadata = { id, provider: request.provider, @@ -86,6 +92,8 @@ export class TerminalManager { exitCode: null, failureDetails: null }; + const launched = this.spawnProcess(id, request.provider, request.profile, request.cwd); + if (launched.failure) applyLaunchFailure(metadata, launched.failure); const session: ManagedSession = { metadata, @@ -98,7 +106,7 @@ export class TerminalManager { agentBrowser: launched.agentBrowser }; this.sessions.set(id, session); - this.bindProcess(id, session, launched.process); + if (launched.process) this.bindProcess(id, session, launched.process); this.emitSession(metadata); return snapshot(session); @@ -117,11 +125,15 @@ export class TerminalManager { ); session.process = launched.process; session.agentBrowser = launched.agentBrowser; - session.metadata.status = "idle"; session.metadata.startedAt = Date.now(); - session.metadata.exitCode = null; - session.metadata.failureDetails = null; - this.bindProcess(id, session, launched.process); + if (launched.failure) { + applyLaunchFailure(session.metadata, launched.failure); + } else { + session.metadata.status = "idle"; + session.metadata.exitCode = null; + session.metadata.failureDetails = null; + if (launched.process) this.bindProcess(id, session, launched.process); + } this.emitSession(session.metadata); return snapshot(session); } @@ -129,17 +141,19 @@ export class TerminalManager { input(id: string, data: string): void { if (typeof data !== "string" || data.length === 0) return; const session = this.sessions.get(id); - if (!session || session.metadata.exitCode !== null) return; - tryPtyOperation(() => session.process.write(data)); + if (!session || session.metadata.exitCode !== null || !session.process) return; + const process = session.process; + tryPtyOperation(() => process.write(data)); } resize(id: string, cols: number, rows: number): void { if (!Number.isFinite(cols) || !Number.isFinite(rows)) return; const session = this.sessions.get(id); - if (!session || session.metadata.exitCode !== null) return; + if (!session || session.metadata.exitCode !== null || !session.process) return; const safeCols = Math.max(20, Math.min(400, Math.floor(cols))); const safeRows = Math.max(5, Math.min(200, Math.floor(rows))); - tryPtyOperation(() => session.process.resize(safeCols, safeRows)); + const process = session.process; + tryPtyOperation(() => process.resize(safeCols, safeRows)); } setBounds(id: string, bounds: SessionBounds): void { @@ -185,10 +199,12 @@ export class TerminalManager { this.flushOutput(id, session); this.sessions.delete(id); session.agentBrowser?.cleanup(); - try { - session.process.kill(); - } catch (error) { - console.warn(`PTY ${id} could not be killed cleanly.`, error); + if (session.process) { + try { + session.process.kill(); + } catch (error) { + console.warn(`PTY ${id} could not be killed cleanly.`, error); + } } this.emit(IPC.terminalRemoved, { id }); } @@ -208,7 +224,15 @@ export class TerminalManager { provider: ProviderId, profile: CreateSessionRequest["profile"], cwd: string - ): { process: IPty; agentBrowser: PreparedAgentBrowserPtyLaunch | null } { + ): { + process: IPty | null; + agentBrowser: PreparedAgentBrowserPtyLaunch | null; + failure: UnavailableProviderCli | null; + } { + const providerCli = provider === "terminal" ? undefined : this.providerClis.get(provider); + if (providerCli?.state === "unavailable") { + return { process: null, agentBrowser: null, failure: providerCli }; + } const agentBrowser = provider === "terminal" || provider === "grok" ? null : this.agentBrowser?.prepareLaunch({ terminalSessionId: id, provider, cwd }) ?? null; @@ -216,7 +240,8 @@ export class TerminalManager { const baseEnvironment = terminalEnvironment(); const browserEnvironment = agentBrowser?.environment ?? {}; const launch = resolveTerminalLaunch(provider, profile, agentBrowser?.args ?? [], { - environment: { ...baseEnvironment, ...browserEnvironment } + environment: { ...baseEnvironment, ...browserEnvironment }, + ...(providerCli ? { providerCli } : {}) }); return { process: pty.spawn(launch.command, launch.args, { @@ -226,7 +251,8 @@ export class TerminalManager { cwd, env: { ...baseEnvironment, ...browserEnvironment, ...launch.environment } }), - agentBrowser + agentBrowser, + failure: null }; } catch (error) { agentBrowser?.cleanup(); @@ -279,6 +305,12 @@ export class TerminalManager { } } +function applyLaunchFailure(metadata: SessionMetadata, failure: UnavailableProviderCli): void { + metadata.status = "failed"; + metadata.exitCode = 127; + metadata.failureDetails = failure.diagnostic; +} + export function terminalEnvironment( source: Readonly> = process.env ): Record { diff --git a/src/main/services/agent-browser/ProviderLaunch.ts b/src/main/services/agent-browser/ProviderLaunch.ts index a19d1c3..588a089 100644 --- a/src/main/services/agent-browser/ProviderLaunch.ts +++ b/src/main/services/agent-browser/ProviderLaunch.ts @@ -32,6 +32,12 @@ import { resolveHermesHomeDirectory } from "../hermesConfig.ts"; import { openCodeBrowserEnvironment } from "../openCodeConfig.ts"; +import { + providerChildProcessLaunch, + type AvailableProviderCli, + type ProviderCliRegistry, + type ProviderCliResolution +} from "../providerCliRegistry.ts"; const KIMI_RULE_PATTERN = `mcp__${MCP_SERVER_NAME}__*`; const CLAUDE_RULE_PATTERN = `mcp__${MCP_SERVER_NAME}__*`; @@ -54,11 +60,11 @@ export interface PreparedProviderLaunch { export interface ProviderLaunchOptions { helper: StdioHelperLaunch; + providerClis: ProviderCliRegistry; hermesHomeDirectory?: string; kimiHomeDirectory?: string; runtimeDirectory: string; - kimiCommand?: string; - probeKimiPerRunConfig?: (command: string) => boolean; + probeKimiPerRunConfig?: (cli: AvailableProviderCli) => boolean; environment?: Readonly>; } @@ -69,10 +75,11 @@ interface ConfigurationLockHooks { export class ProviderLaunchAdapters { private readonly options: ProviderLaunchOptions; + private readonly providerClis: ProviderCliRegistry; private readonly hermesHomeDirectory: string; private readonly kimiHomeDirectory: string; - private readonly kimiCommand: string; - private readonly probe: (command: string) => boolean; + private readonly kimiCli: ProviderCliResolution; + private readonly probe: (cli: AvailableProviderCli) => boolean; private readonly environment: Readonly>; private kimiSupportsPerRunConfig: boolean | null = null; private kimiConfiguration: KimiTemporaryConfiguration | null = null; @@ -83,16 +90,19 @@ export class ProviderLaunchAdapters { constructor(options: ProviderLaunchOptions) { validateStdioHelperLaunch(options.helper); this.options = options; + this.providerClis = options.providerClis; this.hermesHomeDirectory = options.hermesHomeDirectory ?? resolveHermesHomeDirectory(); this.kimiHomeDirectory = validateKimiHomeDirectory( options.kimiHomeDirectory ?? join(homedir(), ".kimi-code") ); - this.kimiCommand = options.kimiCommand ?? "kimi"; + this.kimiCli = options.providerClis.get("kimi"); this.probe = options.probeKimiPerRunConfig ?? probeKimiPerRunMcpConfig; this.environment = options.environment ?? process.env; } prepare(provider: AgentProvider, connectionId: string): PreparedProviderLaunch { + const providerCli = this.providerClis.get(provider); + if (providerCli.state === "unavailable") throw new Error(providerCli.diagnostic); if (provider === "claude") { return { args: claudeMcpArgs(this.options.helper), @@ -153,8 +163,9 @@ export class ProviderLaunchAdapters { } private prepareKimi(connectionId: string): PreparedProviderLaunch { + if (this.kimiCli.state === "unavailable") throw new Error(this.kimiCli.diagnostic); if (this.kimiSupportsPerRunConfig === null) { - this.kimiSupportsPerRunConfig = this.probe(this.kimiCommand); + this.kimiSupportsPerRunConfig = this.probe(this.kimiCli); KimiTemporaryConfiguration.recover(this.kimiHomeDirectory); } const supportsPerRun = this.kimiSupportsPerRunConfig; @@ -249,12 +260,15 @@ export function codexMcpArgs(helper: StdioHelperLaunch): string[] { return ["-c", `${prefix}={${table}}`]; } -export function probeKimiPerRunMcpConfig(command = "kimi"): boolean { - const result = spawnSync(command, ["--help"], { +export function probeKimiPerRunMcpConfig(cli: AvailableProviderCli): boolean { + const launch = providerChildProcessLaunch(cli, ["--help"]); + const result = spawnSync(launch.command, launch.args, { encoding: "utf8", + env: { ...process.env, ...launch.environment }, timeout: 3_000, maxBuffer: 256 * 1024, - windowsHide: true + windowsHide: true, + ...(launch.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}) }); if (result.error || result.status !== 0) return false; return `${result.stdout ?? ""}\n${result.stderr ?? ""}`.includes("--mcp-config-file"); diff --git a/src/main/services/browser/ProviderElectronSmoke.ts b/src/main/services/browser/ProviderElectronSmoke.ts index 054d823..9ac3cef 100644 --- a/src/main/services/browser/ProviderElectronSmoke.ts +++ b/src/main/services/browser/ProviderElectronSmoke.ts @@ -7,6 +7,10 @@ import type { import { AGENT_BROWSER_ENV } from "../agent-browser/AgentBrowserBridge.ts"; import type { StdioHelperLaunch } from "../agent-browser/ProviderLaunch.ts"; import type { AgentProvider } from "../agent-browser/protocol.ts"; +import { + providerChildProcessLaunch, + type ProviderCliRegistry +} from "../providerCliRegistry.ts"; import { APPROVED_BROWSER_TOOL_NAMES, MCP_SERVER_NAME, @@ -28,7 +32,7 @@ export interface ProviderElectronSmokeOptions { helper: StdioHelperLaunch; cwd: string; targets: ProviderSmokeTarget[]; - commands?: Partial>; + providerClis: ProviderCliRegistry; } export async function runProviderElectronSmoke(options: ProviderElectronSmokeOptions): Promise { @@ -97,15 +101,21 @@ async function runProviderCli( options: ProviderElectronSmokeOptions, provider: AgentProvider ): Promise { + const resolution = options.providerClis.get(provider); + if (resolution.state === "unavailable") throw new Error(resolution.diagnostic); const launch = freshLaunch(options.bridge, provider, options.cwd, "cli"); - const command = options.commands?.[provider] ?? provider; const args = providerSmokeArguments(provider, launch.args, options.cwd); + const providerLaunch = providerChildProcessLaunch(resolution, args); try { - const transcript = await runBounded(command, args, { + const transcript = await runBounded(providerLaunch.command, providerLaunch.args, { cwd: options.cwd, - environment: freshEnvironment(launch), + environment: { + ...freshEnvironment(launch), + ...providerLaunch.environment + }, timeoutMs: provider === "kimi" ? DETERMINISTIC_TIMEOUT_MS : LIVE_TIMEOUT_MS, - redactions: Object.values(launch.environment) + redactions: Object.values(launch.environment), + windowsVerbatimArguments: providerLaunch.windowsVerbatimArguments ?? false }); assertProviderTranscript(provider, transcript.stdout); } finally { @@ -372,6 +382,7 @@ interface BoundedRunOptions { environment: NodeJS.ProcessEnv; timeoutMs: number; redactions: string[]; + windowsVerbatimArguments: boolean; } async function runBounded( @@ -384,7 +395,8 @@ async function runBounded( env: options.environment, stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32", - windowsHide: true + windowsHide: true, + windowsVerbatimArguments: options.windowsVerbatimArguments }); let stdout = ""; let stderr = ""; diff --git a/src/main/services/cliEnvironment.ts b/src/main/services/cliEnvironment.ts deleted file mode 100644 index 00acf64..0000000 --- a/src/main/services/cliEnvironment.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { existsSync } from "node:fs"; -import { homedir } from "node:os"; -import { posix, win32 } from "node:path"; - -export function augmentCliPath( - environment: NodeJS.ProcessEnv = process.env, - homeDirectory = homedir(), - platform = process.platform, - directoryExists: (directory: string) => boolean = existsSync -): void { - const pathDelimiter = platform === "win32" ? ";" : ":"; - const path = platform === "win32" ? win32 : posix; - const pathKey = Object.keys(environment).find((key) => key.toLowerCase() === "path") ?? "PATH"; - const currentEntries = (environment[pathKey] ?? "").split(pathDelimiter).filter(Boolean); - const userCliDirectories = [ - path.join(homeDirectory, ".kimi-code", "bin"), - path.join(homeDirectory, ".local", "bin"), - path.join(homeDirectory, ".npm-global", "bin"), - path.join(homeDirectory, ".bun", "bin"), - path.join(homeDirectory, ".cargo", "bin"), - ...(platform === "darwin" ? ["/opt/homebrew/bin", "/usr/local/bin"] : []), - ...(platform === "win32" ? [path.join(homeDirectory, "AppData", "Roaming", "npm")] : []) - ].filter(directoryExists); - - environment[pathKey] = [...new Set([...currentEntries, ...userCliDirectories])].join(pathDelimiter); -} diff --git a/src/main/services/providerCliRegistry.ts b/src/main/services/providerCliRegistry.ts new file mode 100644 index 0000000..36ec1f8 --- /dev/null +++ b/src/main/services/providerCliRegistry.ts @@ -0,0 +1,370 @@ +import { accessSync, constants, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { posix, win32 } from "node:path"; +import type { AgentProviderId } from "../../shared/contracts.ts"; + +export const PROVIDER_CLI_IDS: readonly AgentProviderId[] = Object.freeze([ + "codex", + "claude", + "kimi", + "opencode", + "hermes", + "grok" +]); + +export type ProviderCliLauncher = "native" | "batch"; +export type ProviderCliRejectionReason = "missing" | "not-file" | "not-executable" | "unsupported-launcher"; + +export interface ProviderCliCheck { + path: string; + result: "selected" | ProviderCliRejectionReason; +} + +export interface AvailableProviderCli { + state: "available"; + provider: AgentProviderId; + executable: string; + launcher: ProviderCliLauncher; + commandPrompt?: string; + environment: Readonly>; + checked: readonly ProviderCliCheck[]; +} + +export interface UnavailableProviderCli { + state: "unavailable"; + provider: AgentProviderId; + reason: "cli-not-found"; + checked: readonly ProviderCliCheck[]; + diagnostic: string; +} + +export type ProviderCliResolution = AvailableProviderCli | UnavailableProviderCli; + +export interface ProviderCliRegistry { + get(provider: AgentProviderId): ProviderCliResolution; + snapshot(): Readonly>; +} + +interface ProviderCliRegistryOptions { + platform?: NodeJS.Platform; + environment?: Readonly; + homeDirectory?: string; + startupDirectory?: string; + overrides?: Partial>; + platformRoot?: string; + inspectCandidate?: (path: string, platform: NodeJS.Platform) => ProviderCliRejectionReason | null; + directoryExists?: (path: string) => boolean; +} + +const WINDOWS_NATIVE_EXTENSIONS = [".exe", ".com"] as const; +const WINDOWS_BATCH_EXTENSIONS = [".cmd", ".bat"] as const; + +export function createProviderCliRegistry(options: ProviderCliRegistryOptions = {}): ProviderCliRegistry { + const platform = options.platform ?? process.platform; + const environment = options.environment ?? process.env; + const homeDirectory = options.homeDirectory ?? homedir(); + const startupDirectory = options.startupDirectory ?? process.cwd(); + const inspectCandidate = options.inspectCandidate ?? inspectProviderCandidate; + const directoryExists = options.directoryExists ?? isDirectory; + const pathKey = environmentPathKey(environment); + const inputDirectories = pathEntries(environment[pathKey], platform, startupDirectory); + const platformDirectories = defaultPlatformDirectories(platform, options.platformRoot); + const sharedDirectories = sharedUserDirectories(platform, environment, homeDirectory); + const childDirectories = uniquePaths( + [...inputDirectories, ...platformDirectories, ...sharedDirectories].filter(directoryExists), + platform + ); + const childPath = childDirectories.join(platform === "win32" ? ";" : ":"); + const resolutions = Object.fromEntries(PROVIDER_CLI_IDS.map((provider) => { + const providerDirectories = uniquePaths([ + ...inputDirectories, + ...platformDirectories, + ...knownProviderDirectories(provider, platform, environment, homeDirectory), + ...sharedDirectories + ], platform); + return [provider, resolveProviderCli({ + provider, + platform, + environment, + override: normalizeOverride(options.overrides?.[provider], platform, startupDirectory), + directories: providerDirectories, + pathKey, + childPath, + inspectCandidate + })]; + })) as Record; + + for (const provider of PROVIDER_CLI_IDS) Object.freeze(resolutions[provider]); + Object.freeze(resolutions); + return Object.freeze({ + get(provider: AgentProviderId): ProviderCliResolution { + return resolutions[provider]; + }, + snapshot(): Readonly> { + return resolutions; + } + }); +} + +interface ResolveProviderCliInput { + provider: AgentProviderId; + platform: NodeJS.Platform; + environment: Readonly; + override?: string; + directories: string[]; + pathKey: string; + childPath: string; + inspectCandidate: (path: string, platform: NodeJS.Platform) => ProviderCliRejectionReason | null; +} + +function resolveProviderCli(input: ResolveProviderCliInput): ProviderCliResolution { + const candidates = input.override + ? [input.override, ...providerCandidates(input.provider, input.directories, input.platform)] + : providerCandidates(input.provider, input.directories, input.platform); + const checked: ProviderCliCheck[] = []; + + for (const candidate of uniquePaths(candidates, input.platform)) { + const absoluteCandidate = candidate; + const rejection = input.inspectCandidate(absoluteCandidate, input.platform); + if (rejection) { + checked.push({ path: absoluteCandidate, result: rejection }); + continue; + } + const launcher = launcherKind(absoluteCandidate, input.platform); + if (!launcher) { + checked.push({ path: absoluteCandidate, result: "unsupported-launcher" }); + continue; + } + const commandPrompt = launcher === "batch" + ? resolveWindowsCommandPrompt(input.environment, input.inspectCandidate) + : undefined; + if (launcher === "batch" && !commandPrompt) { + checked.push({ path: absoluteCandidate, result: "unsupported-launcher" }); + continue; + } + checked.push({ path: absoluteCandidate, result: "selected" }); + const environmentPath = childPathWithExecutable(input.childPath, absoluteCandidate, input.platform); + return Object.freeze({ + state: "available", + provider: input.provider, + executable: absoluteCandidate, + launcher, + ...(commandPrompt ? { commandPrompt } : {}), + environment: Object.freeze({ [input.pathKey]: environmentPath }), + checked: freezeChecks(checked) + }); + } + + const unavailable: UnavailableProviderCli = { + state: "unavailable", + provider: input.provider, + reason: "cli-not-found", + checked: freezeChecks(checked), + diagnostic: providerCliDiagnostic(input.provider, checked) + }; + return Object.freeze(unavailable); +} + +export function providerCliDiagnostic(provider: AgentProviderId, checked: readonly ProviderCliCheck[]): string { + const paths = checked.length === 0 + ? " (no candidate paths were available)" + : checked.map((candidate) => ` - ${candidate.path}: ${candidate.result}`).join("\n"); + return [ + `${providerLabel(provider)} CLI was not found.`, + "CanvasTTY resolves provider CLIs once at startup; install the CLI and restart CanvasTTY.", + "Checked paths:", + paths + ].join("\n"); +} + +export interface ProviderChildProcessLaunch { + command: string; + args: string[]; + environment: Readonly>; + windowsVerbatimArguments?: boolean; +} + +export function providerChildProcessLaunch( + resolution: AvailableProviderCli, + args: string[] +): ProviderChildProcessLaunch { + if (resolution.launcher === "native") { + return { command: resolution.executable, args, environment: resolution.environment }; + } + if (!resolution.commandPrompt) throw new Error("A Windows batch provider requires cmd.exe."); + return { + command: resolution.commandPrompt, + args: ["/d", "/s", "/c", windowsBatchShellCommand(resolution.executable, args)], + environment: resolution.environment, + windowsVerbatimArguments: true + }; +} + +export function providerTerminalBatchCommandLine(executable: string, args: string[]): string { + return `/d /s /c ${windowsBatchShellCommand(executable, args)}`; +} + +function windowsBatchShellCommand(executable: string, args: string[]): string { + const shellCommand = [escapeCommandPromptCommand(executable), ...args.map(escapeCommandPromptArgument)].join(" "); + return `"${shellCommand}"`; +} + +const COMMAND_PROMPT_META_CHARACTERS = /([()\][%!^"`<>&|;, *?])/g; + +function escapeCommandPromptCommand(value: string): string { + return value.replace(COMMAND_PROMPT_META_CHARACTERS, "^$1"); +} + +function escapeCommandPromptArgument(value: string): string { + let escaped = value.replace(/(?=(\\+?)?)\1"/g, "$1$1\\\""); + escaped = escaped.replace(/(?=(\\+?)?)\1$/, "$1$1"); + return `"${escaped}"`.replace(COMMAND_PROMPT_META_CHARACTERS, "^$1"); +} + +function providerCandidates(provider: AgentProviderId, directories: string[], platform: NodeJS.Platform): string[] { + const path = platform === "win32" ? win32 : posix; + const extensions = platform === "win32" + ? [...WINDOWS_NATIVE_EXTENSIONS, ...WINDOWS_BATCH_EXTENSIONS] + : [""]; + return directories.flatMap((directory) => extensions.map((extension) => path.join(directory, `${provider}${extension}`))); +} + +function launcherKind(path: string, platform: NodeJS.Platform): ProviderCliLauncher | null { + if (platform !== "win32") return "native"; + const extension = win32.extname(path).toLowerCase(); + if (WINDOWS_NATIVE_EXTENSIONS.includes(extension as (typeof WINDOWS_NATIVE_EXTENSIONS)[number])) return "native"; + if (WINDOWS_BATCH_EXTENSIONS.includes(extension as (typeof WINDOWS_BATCH_EXTENSIONS)[number])) return "batch"; + return null; +} + +function inspectProviderCandidate(path: string, platform: NodeJS.Platform): ProviderCliRejectionReason | null { + let stats; + try { + stats = statSync(path); + } catch { + return "missing"; + } + if (!stats.isFile()) return "not-file"; + if (platform !== "win32") { + try { + accessSync(path, constants.X_OK); + } catch { + return "not-executable"; + } + } + return null; +} + +function defaultPlatformDirectories(platform: NodeJS.Platform, platformRoot = "/"): string[] { + if (platform === "darwin") { + return [posix.join(platformRoot, "opt", "homebrew", "bin"), posix.join(platformRoot, "usr", "local", "bin")]; + } + if (platform === "win32") return []; + return [posix.join(platformRoot, "usr", "local", "bin"), posix.join(platformRoot, "usr", "bin"), posix.join(platformRoot, "bin")]; +} + +function knownProviderDirectories( + provider: AgentProviderId, + platform: NodeJS.Platform, + environment: Readonly, + homeDirectory: string +): string[] { + const path = platform === "win32" ? win32 : posix; + const directories: string[] = []; + if (platform === "win32" && provider === "codex") { + const localAppData = environment.LOCALAPPDATA ?? path.join(homeDirectory, "AppData", "Local"); + directories.push(path.join(localAppData, "Programs", "OpenAI", "Codex", "bin")); + } + if (provider === "kimi") directories.push(path.join(homeDirectory, ".kimi-code", "bin")); + if (provider === "grok") directories.push(path.join(homeDirectory, ".grok", "bin")); + return directories; +} + +function sharedUserDirectories( + platform: NodeJS.Platform, + environment: Readonly, + homeDirectory: string +): string[] { + const path = platform === "win32" ? win32 : posix; + const directories = [ + path.join(homeDirectory, ".local", "bin"), + path.join(homeDirectory, ".npm-global", "bin"), + path.join(homeDirectory, ".bun", "bin"), + path.join(homeDirectory, ".cargo", "bin") + ]; + if (platform === "win32") { + const appData = environment.APPDATA ?? path.join(homeDirectory, "AppData", "Roaming"); + directories.push(path.join(appData, "npm")); + } + return directories; +} + +function resolveWindowsCommandPrompt( + environment: Readonly, + inspectCandidate: ResolveProviderCliInput["inspectCandidate"] +): string | null { + const configured = environment.ComSpec || environment.COMSPEC; + if (configured && inspectCandidate(configured, "win32") === null) return configured; + const systemRoot = environment.SystemRoot || environment.WINDIR; + if (!systemRoot) return null; + const candidate = win32.join(systemRoot, "System32", "cmd.exe"); + return inspectCandidate(candidate, "win32") === null ? candidate : null; +} + +function pathEntries(value: string | undefined, platform: NodeJS.Platform, startupDirectory: string): string[] { + if (!value) return []; + const path = platform === "win32" ? win32 : posix; + return value.split(platform === "win32" ? ";" : ":") + .map((entry) => entry.trim().replace(/^"|"$/g, "")) + .filter(Boolean) + .map((entry) => path.isAbsolute(entry) ? path.normalize(entry) : path.resolve(startupDirectory, entry)); +} + +function normalizeOverride( + override: string | undefined, + platform: NodeJS.Platform, + startupDirectory: string +): string | undefined { + if (!override) return undefined; + const path = platform === "win32" ? win32 : posix; + return path.isAbsolute(override) ? path.normalize(override) : path.resolve(startupDirectory, override); +} + +function childPathWithExecutable(childPath: string, executable: string, platform: NodeJS.Platform): string { + const path = platform === "win32" ? win32 : posix; + const entries = childPath ? childPath.split(platform === "win32" ? ";" : ":") : []; + return uniquePaths([...entries, path.dirname(executable)], platform).join(platform === "win32" ? ";" : ":"); +} + +function freezeChecks(checks: ProviderCliCheck[]): readonly ProviderCliCheck[] { + checks.forEach(Object.freeze); + return Object.freeze(checks); +} + +function uniquePaths(paths: string[], platform: NodeJS.Platform): string[] { + const path = platform === "win32" ? win32 : posix; + const seen = new Set(); + return paths.filter((entry) => { + const normalized = platform === "win32" ? path.normalize(entry).toLowerCase() : path.normalize(entry); + if (seen.has(normalized)) return false; + seen.add(normalized); + return true; + }); +} + +function environmentPathKey(environment: Readonly): string { + return Object.keys(environment).find((key) => key.toLowerCase() === "path") ?? "PATH"; +} + +function isDirectory(path: string): boolean { + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} + +function providerLabel(provider: AgentProviderId): string { + if (provider === "opencode") return "OpenCode"; + return `${provider[0].toUpperCase()}${provider.slice(1)}`; +} diff --git a/src/main/services/terminalLaunch.ts b/src/main/services/terminalLaunch.ts index 16b600d..c3f02e5 100644 --- a/src/main/services/terminalLaunch.ts +++ b/src/main/services/terminalLaunch.ts @@ -1,8 +1,11 @@ import { existsSync } from "node:fs"; -import { homedir } from "node:os"; import { win32 } from "node:path"; import type { ProviderId } from "../../shared/contracts.ts"; import { openCodeYoloEnvironment } from "./openCodeConfig.ts"; +import { + providerTerminalBatchCommandLine, + type ProviderCliResolution +} from "./providerCliRegistry.ts"; export interface TerminalLaunch { command: string; @@ -13,12 +16,11 @@ export interface TerminalLaunch { interface LaunchResolutionOptions { platform?: NodeJS.Platform; environment?: Readonly; - homeDirectory?: string; fileExists?: (path: string) => boolean; + providerCli?: ProviderCliResolution; } const WINDOWS_NATIVE_EXTENSIONS = [".exe", ".com"]; -const WINDOWS_SCRIPT_EXTENSIONS = [".cmd", ".bat"]; export function resolveTerminalLaunch( provider: ProviderId, @@ -36,36 +38,35 @@ export function resolveTerminalLaunch( : { command: environment.SHELL || "/bin/bash", args: ["-l"] }; } + const providerCli = options.providerCli; + if (!providerCli || providerCli.provider !== provider) { + throw new Error(`${provider} CLI resolution was not provided.`); + } + if (providerCli.state === "unavailable") throw new Error(providerCli.diagnostic); + const launchEnvironment = profile === "yolo" && provider === "opencode" - ? openCodeYoloEnvironment(environment) + ? openCodeYoloEnvironment({ ...environment, ...providerCli.environment }) : undefined; const providerArgs = [ ...(profile === "yolo" && provider !== "opencode" ? dangerousArguments(provider) : []), ...agentBrowserArgs ]; - if (platform !== "win32") { - return { - command: provider, - args: providerArgs, - ...(launchEnvironment ? { environment: launchEnvironment } : {}) - }; - } - - const homeDirectory = options.homeDirectory ?? homedir(); - const resolved = resolveWindowsProvider(provider, environment, homeDirectory, fileExists); - if (!WINDOWS_SCRIPT_EXTENSIONS.includes(win32.extname(resolved).toLowerCase())) { + const combinedEnvironment = { + ...providerCli.environment, + ...launchEnvironment + }; + if (providerCli.launcher === "native") { return { - command: resolved, + command: providerCli.executable, args: providerArgs, - ...(launchEnvironment ? { environment: launchEnvironment } : {}) + environment: combinedEnvironment }; } - - const commandPrompt = resolveWindowsCommandPrompt(environment, fileExists); + if (!providerCli.commandPrompt) throw new Error("A Windows batch provider requires cmd.exe."); return { - command: commandPrompt, - args: windowsBatchCommandLine(resolved, providerArgs), - ...(launchEnvironment ? { environment: launchEnvironment } : {}) + command: providerCli.commandPrompt, + args: providerTerminalBatchCommandLine(providerCli.executable, providerArgs), + environment: combinedEnvironment }; } @@ -94,145 +95,42 @@ function resolveWindowsShell( } } - const modernPowerShell = findWindowsCommand( - "pwsh", - environment, - fileExists, - WINDOWS_NATIVE_EXTENSIONS - ); - if (modernPowerShell) { - return { command: modernPowerShell, args: ["-NoLogo", "-NoProfile"] }; - } + const modernPowerShell = findWindowsNativeCommand("pwsh", environment, fileExists); + if (modernPowerShell) return { command: modernPowerShell, args: ["-NoLogo", "-NoProfile"] }; return { command: resolveWindowsCommandPrompt(environment, fileExists), args: ["/d"] }; } -function resolveWindowsProvider( - provider: Exclude, - environment: Readonly, - homeDirectory: string, - fileExists: (path: string) => boolean -): string { - const knownDirectories = knownProviderDirectories(provider, environment, homeDirectory); - const native = findWindowsCommand(provider, environment, fileExists, WINDOWS_NATIVE_EXTENSIONS) - ?? findInDirectories(provider, knownDirectories, fileExists, WINDOWS_NATIVE_EXTENSIONS); - if (native) return native; - - const script = findWindowsCommand(provider, environment, fileExists, WINDOWS_SCRIPT_EXTENSIONS) - ?? findInDirectories(provider, knownDirectories, fileExists, WINDOWS_SCRIPT_EXTENSIONS); - if (script) return script; - - throw new Error( - `${providerLabel(provider)} CLI was not found on Windows. Install it, then restart CanvasTTY. ` - + `Checked PATH and these directories: ${knownDirectories.join(", ")}. ` - + "Supported launchers: .exe, .com, .cmd, and .bat." - ); -} - -function knownProviderDirectories( - provider: Exclude, - environment: Readonly, - homeDirectory: string -): string[] { - const directories: string[] = []; - if (provider === "codex") { - const localAppData = environment.LOCALAPPDATA ?? win32.join(homeDirectory, "AppData", "Local"); - directories.push(win32.join(localAppData, "Programs", "OpenAI", "Codex", "bin")); - } - if (provider === "kimi") directories.push(win32.join(homeDirectory, ".kimi-code", "bin")); - if (provider === "grok") directories.push(win32.join(homeDirectory, ".grok", "bin")); - directories.push(win32.join(homeDirectory, ".local", "bin")); - const roamingAppData = environment.APPDATA ?? win32.join(homeDirectory, "AppData", "Roaming"); - directories.push(win32.join(roamingAppData, "npm")); - return uniqueWindowsPaths(directories); -} - function resolveWindowsCommandPrompt( environment: Readonly, fileExists: (path: string) => boolean ): string { const configured = environment.ComSpec || environment.COMSPEC; if (configured && fileExists(configured)) return configured; - - const fromPath = findWindowsCommand( - "cmd", - environment, - fileExists, - WINDOWS_NATIVE_EXTENSIONS - ); + const fromPath = findWindowsNativeCommand("cmd", environment, fileExists); if (fromPath) return fromPath; - const systemRoot = environment.SystemRoot || environment.WINDIR; const systemCommandPrompt = systemRoot ? win32.join(systemRoot, "System32", "cmd.exe") : null; if (systemCommandPrompt && fileExists(systemCommandPrompt)) return systemCommandPrompt; throw new Error("No supported Windows shell was found (PowerShell, pwsh, or cmd.exe)."); } -function findWindowsCommand( +function findWindowsNativeCommand( command: string, environment: Readonly, - fileExists: (path: string) => boolean, - extensions: string[] -): string | null { - return findInDirectories(command, windowsPathEntries(environment), fileExists, extensions); -} - -function findInDirectories( - command: string, - directories: string[], - fileExists: (path: string) => boolean, - extensions: string[] + fileExists: (path: string) => boolean ): string | null { + const pathKey = Object.keys(environment).find((key) => key.toLowerCase() === "path"); + if (!pathKey) return null; + const directories = (environment[pathKey] ?? "") + .split(";") + .map((entry) => entry.trim().replace(/^"|"$/g, "")) + .filter(Boolean); for (const directory of directories) { - for (const extension of extensions) { + for (const extension of WINDOWS_NATIVE_EXTENSIONS) { const candidate = win32.join(directory, `${command}${extension}`); if (fileExists(candidate)) return candidate; } } return null; } - -function windowsPathEntries(environment: Readonly): string[] { - const pathKey = Object.keys(environment).find((key) => key.toLowerCase() === "path"); - if (!pathKey) return []; - return uniqueWindowsPaths( - (environment[pathKey] ?? "") - .split(";") - .map((entry) => entry.trim().replace(/^"|"$/g, "")) - .filter(Boolean) - ); -} - -function uniqueWindowsPaths(paths: string[]): string[] { - const seen = new Set(); - return paths.filter((path) => { - const key = win32.normalize(path).toLowerCase(); - if (seen.has(key)) return false; - seen.add(key); - return true; - }); -} - -function providerLabel(provider: Exclude): string { - if (provider === "opencode") return "OpenCode"; - return `${provider[0].toUpperCase()}${provider.slice(1)}`; -} - -function windowsBatchCommandLine(command: string, args: string[]): string { - const shellCommand = [escapeCommandPromptCommand(command), ...args.map(escapeCommandPromptArgument)].join(" "); - return `/d /s /c "${shellCommand}"`; -} - -const COMMAND_PROMPT_META_CHARACTERS = /([()\][%!^"`<>&|;, *?])/g; - -function escapeCommandPromptCommand(value: string): string { - return value.replace(COMMAND_PROMPT_META_CHARACTERS, "^$1"); -} - -function escapeCommandPromptArgument(value: string): string { - // Follow the same two-stage escaping used by cross-spawn for cmd.exe wrappers: - // first Windows argv quoting, then protection from command prompt metacharacters. - let escaped = value.replace(/(?=(\\+?)?)\1"/g, "$1$1\\\""); - escaped = escaped.replace(/(?=(\\+?)?)\1$/, "$1$1"); - return `"${escaped}"`.replace(COMMAND_PROMPT_META_CHARACTERS, "^$1"); -} diff --git a/tests/agent-browser-helper.test.mjs b/tests/agent-browser-helper.test.mjs index 63f4e08..d2ac030 100644 --- a/tests/agent-browser-helper.test.mjs +++ b/tests/agent-browser-helper.test.mjs @@ -6,6 +6,22 @@ import { join } from "node:path"; import test from "node:test"; import { parse as parseYaml } from "yaml"; +const providerClis = Object.freeze({ + get(provider) { + return Object.freeze({ + state: "available", + provider, + executable: `/resolved/${provider}`, + launcher: "native", + environment: Object.freeze({ PATH: "/resolved:/usr/bin" }), + checked: Object.freeze([{ path: `/resolved/${provider}`, result: "selected" }]) + }); + }, + snapshot() { + throw new Error("Agent browser helper tests do not need a complete snapshot."); + } +}); + import { APPROVED_BROWSER_TOOL_NAMES, TOOL_DEFINITIONS @@ -159,7 +175,10 @@ test("PTY bridge keeps the one-time capability in child env only and honors the }; const bridge = new AgentBrowserBridge(gateway, { helper: { command: "/usr/bin/node", args: ["/app/mcp-helper.mjs"] }, - runtimeDirectory: "/tmp/canvastty-runtime" + providerClis, + runtimeDirectory: "/tmp/canvastty-runtime", + hermesHomeDirectory: "/tmp/canvastty-helper-hermes", + kimiHomeDirectory: "/tmp/canvastty-helper-kimi" }); const launch = bridge.prepareLaunch({ terminalSessionId: "terminal-id", @@ -219,8 +238,10 @@ test("PTY bridge retains temporary Kimi configuration until session cleanup", as }; const bridge = new AgentBrowserBridge(gateway, { helper: { command: "/usr/bin/node", args: ["/app/mcp-helper.mjs"] }, + providerClis, runtimeDirectory: join(root, "runtime"), kimiHomeDirectory, + hermesHomeDirectory: join(root, "hermes-home"), probeKimiPerRunConfig: () => false }); const launch = bridge.prepareLaunch({ @@ -262,6 +283,7 @@ test("PTY bridge gives Hermes environment placeholders and restores config on cl }; const bridge = new AgentBrowserBridge(gateway, { helper: { command: "/usr/bin/node", args: ["/app/mcp-helper.mjs"] }, + providerClis, runtimeDirectory: join(root, "runtime"), hermesHomeDirectory, kimiHomeDirectory: join(root, "kimi-home") diff --git a/tests/agent-browser-provider-launch.test.mjs b/tests/agent-browser-provider-launch.test.mjs index 99c9565..653f70c 100644 --- a/tests/agent-browser-provider-launch.test.mjs +++ b/tests/agent-browser-provider-launch.test.mjs @@ -33,6 +33,22 @@ const helper = Object.freeze({ env: { ELECTRON_RUN_AS_NODE: "1" } }); +const providerClis = Object.freeze({ + get(provider) { + return Object.freeze({ + state: "available", + provider, + executable: `/resolved/${provider}`, + launcher: "native", + environment: Object.freeze({ PATH: "/resolved:/usr/bin" }), + checked: Object.freeze([{ path: `/resolved/${provider}`, result: "selected" }]) + }); + }, + snapshot() { + throw new Error("Provider launch tests do not need a complete snapshot."); + } +}); + async function fixture(t, prefix) { const root = await mkdtemp(join(tmpdir(), prefix)); t.after(() => rm(root, { recursive: true, force: true })); @@ -147,7 +163,10 @@ test("codexMcpArgs returns one complete table that replaces a same-name global s test("OpenCode receives one per-launch MCP override without losing existing inline config", () => { const adapters = new ProviderLaunchAdapters({ helper, + providerClis, runtimeDirectory: "/tmp/canvastty-unused-runtime", + hermesHomeDirectory: "/tmp/canvastty-unused-hermes", + kimiHomeDirectory: "/tmp/canvastty-unused-kimi", environment: { OPENCODE_CONFIG_CONTENT: JSON.stringify({ model: "opencode/kimi-k3", @@ -178,7 +197,10 @@ test("OpenCode receives one per-launch MCP override without losing existing inli test("OpenCode browser launch rejects malformed inline config instead of replacing it", () => { const adapters = new ProviderLaunchAdapters({ helper, + providerClis, runtimeDirectory: "/tmp/canvastty-unused-runtime", + hermesHomeDirectory: "/tmp/canvastty-unused-hermes", + kimiHomeDirectory: "/tmp/canvastty-unused-kimi", environment: { OPENCODE_CONFIG_CONTENT: "not-json" } }); assert.throws(() => adapters.prepare("opencode", "connection-opencode"), /must contain valid JSON/u); @@ -321,7 +343,9 @@ test("Hermes launch configuration is shared until the final session exits", asyn await writeFile(paths.config, original, { mode: 0o600 }); const adapters = new ProviderLaunchAdapters({ helper, + providerClis, hermesHomeDirectory: home, + kimiHomeDirectory: join(home, "kimi"), runtimeDirectory: join(home, "runtime") }); @@ -372,7 +396,9 @@ test("helper environment is validated before argv or filesystem artifacts are cr const runtimeDirectory = join(root, `runtime-${index}`); assert.throws(() => new ProviderLaunchAdapters({ helper: invalidHelper, + providerClis, kimiHomeDirectory: home, + hermesHomeDirectory: join(root, `hermes-adapter-${index}`), runtimeDirectory, probeKimiPerRunConfig: () => false }), entry.message); @@ -683,18 +709,19 @@ test("ProviderLaunchAdapters uses only injected temp Kimi paths and reference-co const probed = []; const adapters = new ProviderLaunchAdapters({ helper, + providerClis, kimiHomeDirectory: home, + hermesHomeDirectory: join(root, "hermes-home"), runtimeDirectory, - kimiCommand: "never-launched-kimi", - probeKimiPerRunConfig: (command) => { - probed.push(command); + probeKimiPerRunConfig: (cli) => { + probed.push(cli.executable); return true; } }); const first = adapters.prepare("kimi", "connection/one"); const second = adapters.prepare("kimi", "connection-two"); - assert.deepEqual(probed, ["never-launched-kimi"]); + assert.deepEqual(probed, ["/resolved/kimi"]); assert.equal(first.args[0], "--mcp-config-file"); assert.equal(second.args[0], "--mcp-config-file"); assert.equal(first.args[1].startsWith(runtimeDirectory), true); @@ -716,7 +743,9 @@ test("ProviderLaunchAdapters fallback adds and removes only temporary Kimi state const home = join(root, "kimi-home"); const adapters = new ProviderLaunchAdapters({ helper, + providerClis, kimiHomeDirectory: home, + hermesHomeDirectory: join(root, "hermes-home"), runtimeDirectory: join(root, "runtime"), probeKimiPerRunConfig: () => false }); diff --git a/tests/cli-environment.test.mjs b/tests/cli-environment.test.mjs deleted file mode 100644 index 6dae0a2..0000000 --- a/tests/cli-environment.test.mjs +++ /dev/null @@ -1,45 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import { augmentCliPath } from "../src/main/services/cliEnvironment.ts"; - -test("adds existing Linux user CLI directories without replacing desktop PATH", () => { - const kimiBin = "/test-home/.kimi-code/bin"; - const environment = { PATH: "/usr/local/bin:/usr/bin" }; - - augmentCliPath(environment, "/test-home", "linux", (directory) => directory === kimiBin); - - assert.equal(environment.PATH, `/usr/local/bin:/usr/bin:${kimiBin}`); -}); - -test("uses Windows PATH casing, separators, and user directories", () => { - const kimiBin = "C:\\Users\\Kisa\\.kimi-code\\bin"; - const npmBin = "C:\\Users\\Kisa\\AppData\\Roaming\\npm"; - const environment = { Path: "C:\\Windows\\System32;C:\\Program Files\\nodejs" }; - const existing = new Set([kimiBin, npmBin]); - - augmentCliPath(environment, "C:\\Users\\Kisa", "win32", (directory) => existing.has(directory)); - - assert.equal( - environment.Path, - `C:\\Windows\\System32;C:\\Program Files\\nodejs;${kimiBin};${npmBin}` - ); - assert.equal("PATH" in environment, false); -}); - -test("does not duplicate CLI directories already present in PATH", () => { - const kimiBin = "/test-home/.kimi-code/bin"; - const environment = { PATH: `${kimiBin}:/usr/bin` }; - - augmentCliPath(environment, "/test-home", "linux", (directory) => directory === kimiBin); - - assert.equal(environment.PATH.split(":").filter((entry) => entry === kimiBin).length, 1); -}); - -test("adds Homebrew directories for macOS apps launched outside an interactive shell", () => { - const environment = { PATH: "/usr/bin:/bin" }; - const homebrewBin = "/opt/homebrew/bin"; - - augmentCliPath(environment, "/Users/Kisa", "darwin", (directory) => directory === homebrewBin); - - assert.equal(environment.PATH, `/usr/bin:/bin:${homebrewBin}`); -}); diff --git a/tests/provider-cli-integration.test.mjs b/tests/provider-cli-integration.test.mjs new file mode 100644 index 0000000..1948e12 --- /dev/null +++ b/tests/provider-cli-integration.test.mjs @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { LimitsService } from "../src/main/services/LimitsService.ts"; +import { TerminalManager } from "../src/main/services/TerminalManager.ts"; + +function unavailable(provider) { + const checked = [{ path: `/missing/${provider}`, result: "missing" }]; + return Object.freeze({ + state: "unavailable", + provider, + reason: "cli-not-found", + checked, + diagnostic: `${provider} CLI was not found.\nChecked paths:\n - /missing/${provider}: missing` + }); +} + +function unavailableRegistry() { + return { + get: unavailable, + snapshot() { + return Object.fromEntries( + ["codex", "claude", "kimi", "opencode", "hermes", "grok"].map((provider) => [provider, unavailable(provider)]) + ); + } + }; +} + +test("missing provider creates a failed session without PTY or browser configuration", () => { + let browserPrepareCalls = 0; + const events = []; + const manager = new TerminalManager( + (channel, payload) => events.push({ channel, payload }), + unavailableRegistry(), + { + prepareLaunch() { + browserPrepareCalls += 1; + throw new Error("Browser configuration must not be touched for a missing CLI."); + } + } + ); + + const session = manager.create({ + provider: "codex", + cwd: process.cwd(), + profile: "normal", + position: { x: 10, y: 20 } + }); + + assert.equal(session.status, "failed"); + assert.equal(session.exitCode, 127); + assert.match(session.failureDetails, /\/missing\/codex: missing/u); + assert.equal(session.buffer, ""); + assert.equal(browserPrepareCalls, 0); + assert.equal(events.length, 1); + assert.equal(events[0].payload.session.failureDetails, session.failureDetails); + manager.disposeAll(); +}); + +test("limits short-circuit every missing provider to cli-not-found", async () => { + const service = new LimitsService(unavailableRegistry(), "test"); + try { + const snapshot = await service.get(); + assert.deepEqual( + snapshot.providers.map(({ provider, state, reason }) => ({ provider, state, reason })), + ["codex", "claude", "kimi", "opencode", "grok"].map((provider) => ({ + provider, + state: "unavailable", + reason: "cli-not-found" + })) + ); + } finally { + service.dispose(); + } +}); diff --git a/tests/provider-cli-registry.test.mjs b/tests/provider-cli-registry.test.mjs new file mode 100644 index 0000000..4a115fc --- /dev/null +++ b/tests/provider-cli-registry.test.mjs @@ -0,0 +1,134 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + createProviderCliRegistry, + providerChildProcessLaunch +} from "../src/main/services/providerCliRegistry.ts"; + +function inspection(results) { + return (path) => results.has(path) ? results.get(path) : "missing"; +} + +test("Finder-like macOS PATH resolves Codex from the Homebrew platform default", () => { + const codex = "/opt/homebrew/bin/codex"; + const registry = createProviderCliRegistry({ + platform: "darwin", + environment: { PATH: "/usr/bin:/bin" }, + homeDirectory: "/test-home", + inspectCandidate: inspection(new Map([[codex, null]])), + directoryExists: (path) => ["/usr/bin", "/bin", "/opt/homebrew/bin"].includes(path) + }); + + const resolution = registry.get("codex"); + assert.equal(resolution.state, "available"); + assert.equal(resolution.executable, codex); + assert.equal(resolution.launcher, "native"); + assert.equal(resolution.environment.PATH, "/usr/bin:/bin:/opt/homebrew/bin"); +}); + +test("override wins over PATH and fallback candidates", () => { + const override = "/fixtures/codex"; + const fromPath = "/tools/codex"; + const registry = createProviderCliRegistry({ + platform: "linux", + environment: { PATH: "/tools" }, + overrides: { codex: override }, + inspectCandidate: inspection(new Map([[override, null], [fromPath, null]])), + directoryExists: () => true + }); + + const resolution = registry.get("codex"); + assert.equal(resolution.state, "available"); + assert.equal(resolution.executable, override); + assert.deepEqual(resolution.checked, [{ path: override, result: "selected" }]); +}); + +test("relative PATH entries are frozen as absolute startup paths", () => { + const codex = "/workspace/tools/codex"; + const registry = createProviderCliRegistry({ + platform: "linux", + environment: { PATH: "tools:/usr/bin" }, + startupDirectory: "/workspace", + inspectCandidate: inspection(new Map([[codex, null]])), + directoryExists: () => true + }); + + const resolution = registry.get("codex"); + assert.equal(resolution.state, "available"); + assert.equal(resolution.executable, codex); + assert.match(resolution.environment.PATH, /^\/workspace\/tools:/u); +}); + +test("unavailable diagnostics preserve missing and rejected candidate evidence", () => { + const notFile = "/tools/codex"; + const notExecutable = "/opt/homebrew/bin/codex"; + const registry = createProviderCliRegistry({ + platform: "darwin", + environment: { PATH: "/tools" }, + homeDirectory: "/test-home", + inspectCandidate: inspection(new Map([ + [notFile, "not-file"], + [notExecutable, "not-executable"] + ])), + directoryExists: () => true + }); + + const resolution = registry.get("codex"); + assert.equal(resolution.state, "unavailable"); + assert.match(resolution.diagnostic, /\/tools\/codex: not-file/u); + assert.match(resolution.diagnostic, /\/opt\/homebrew\/bin\/codex: not-executable/u); + assert.match(resolution.diagnostic, /restart CanvasTTY/u); +}); + +test("Windows native launch preserves the resolved executable and child PATH", () => { + const codex = "D:\\Tools\\codex.exe"; + const registry = createProviderCliRegistry({ + platform: "win32", + environment: { Path: "D:\\Tools;C:\\Windows\\System32" }, + homeDirectory: "C:\\Users\\Kisa", + inspectCandidate: inspection(new Map([[codex, null]])), + directoryExists: () => true + }); + + const resolution = registry.get("codex"); + assert.equal(resolution.state, "available"); + const launch = providerChildProcessLaunch(resolution, ["app-server"]); + assert.deepEqual(launch, { + command: codex, + args: ["app-server"], + environment: { Path: resolution.environment.Path } + }); +}); + +test("Windows batch launch uses the startup-resolved command prompt", () => { + const claude = "C:\\Users\\Kisa\\AppData\\Roaming\\npm\\claude.cmd"; + const commandPrompt = "C:\\Windows\\System32\\cmd.exe"; + const registry = createProviderCliRegistry({ + platform: "win32", + environment: { APPDATA: "C:\\Users\\Kisa\\AppData\\Roaming", ComSpec: commandPrompt }, + homeDirectory: "C:\\Users\\Kisa", + inspectCandidate: inspection(new Map([[claude, null], [commandPrompt, null]])), + directoryExists: () => true + }); + + const resolution = registry.get("claude"); + assert.equal(resolution.state, "available"); + assert.equal(resolution.launcher, "batch"); + const launch = providerChildProcessLaunch(resolution, ["--bridge"]); + assert.equal(launch.command, commandPrompt); + assert.deepEqual(launch.args.slice(0, 3), ["/d", "/s", "/c"]); + assert.match(launch.args[3], /claude\.cmd/u); + assert.equal(launch.windowsVerbatimArguments, true); +}); + +test("registry snapshot and provider resolutions are immutable", () => { + const registry = createProviderCliRegistry({ + platform: "linux", + environment: {}, + inspectCandidate: () => "missing", + directoryExists: () => false + }); + + assert.equal(Object.isFrozen(registry.snapshot()), true); + assert.equal(Object.isFrozen(registry.get("codex")), true); +}); diff --git a/tests/terminal-launch.test.mjs b/tests/terminal-launch.test.mjs new file mode 100644 index 0000000..dadcf63 --- /dev/null +++ b/tests/terminal-launch.test.mjs @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { resolveTerminalLaunch } from "../src/main/services/terminalLaunch.ts"; + +function available(provider, executable, options = {}) { + return { + state: "available", + provider, + executable, + launcher: options.launcher ?? "native", + ...(options.commandPrompt ? { commandPrompt: options.commandPrompt } : {}), + environment: options.environment ?? { PATH: "/resolved/bin:/usr/bin" }, + checked: [{ path: executable, result: "selected" }] + }; +} + +test("Windows terminal selects built-in PowerShell", () => { + const powershell = "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"; + const launch = resolveTerminalLaunch("terminal", "normal", [], { + platform: "win32", + environment: { SystemRoot: "C:\\Windows" }, + fileExists: (path) => path === powershell + }); + assert.deepEqual(launch, { command: powershell, args: ["-NoLogo", "-NoProfile"] }); +}); + +test("Unix terminal keeps the configured login shell", () => { + assert.deepEqual( + resolveTerminalLaunch("terminal", "normal", [], { + platform: "linux", + environment: { SHELL: "/usr/bin/zsh" } + }), + { command: "/usr/bin/zsh", args: ["-l"] } + ); +}); + +test("provider launch uses the registry executable and scoped YOLO arguments", () => { + const providerCli = available("codex", "/opt/homebrew/bin/codex"); + const launch = resolveTerminalLaunch("codex", "yolo", ["--bridge"], { + platform: "darwin", + environment: { PATH: "/usr/bin:/bin" }, + providerCli + }); + assert.deepEqual(launch, { + command: "/opt/homebrew/bin/codex", + args: ["--dangerously-bypass-approvals-and-sandbox", "--bridge"], + environment: { PATH: "/resolved/bin:/usr/bin" } + }); +}); + +test("OpenCode merges YOLO config with the registry child environment", () => { + const providerCli = available("opencode", "/test-home/.local/bin/opencode"); + const launch = resolveTerminalLaunch("opencode", "yolo", [], { + platform: "darwin", + environment: { OPENCODE_CONFIG_CONTENT: JSON.stringify({ theme: "system" }) }, + providerCli + }); + assert.equal(launch.command, providerCli.executable); + assert.equal(JSON.parse(launch.environment.OPENCODE_CONFIG_CONTENT).permission, "allow"); + assert.equal(launch.environment.PATH, providerCli.environment.PATH); +}); + +test("Windows batch provider is routed through the registry command prompt", () => { + const commandPrompt = "C:\\Windows\\System32\\cmd.exe"; + const providerCli = available( + "claude", + "C:\\Users\\Kisa\\AppData\\Roaming\\npm\\claude.cmd", + { launcher: "batch", commandPrompt, environment: { Path: "C:\\resolved" } } + ); + const launch = resolveTerminalLaunch("claude", "normal", ["--bridge"], { + platform: "win32", + providerCli + }); + assert.equal(launch.command, commandPrompt); + assert.match(launch.args, /^\/d \/s \/c /u); + assert.match(launch.args, /claude\.cmd/u); +}); + +test("unavailable provider reports the structured diagnostic before PTY launch", () => { + const providerCli = { + state: "unavailable", + provider: "kimi", + reason: "cli-not-found", + checked: [{ path: "/opt/homebrew/bin/kimi", result: "missing" }], + diagnostic: "Kimi CLI was not found.\nChecked paths:\n - /opt/homebrew/bin/kimi: missing" + }; + assert.throws( + () => resolveTerminalLaunch("kimi", "normal", [], { providerCli }), + /\/opt\/homebrew\/bin\/kimi: missing/u + ); +}); diff --git a/tests/terminal-lifecycle.test.mjs b/tests/terminal-lifecycle.test.mjs index 9086db0..3facef1 100644 --- a/tests/terminal-lifecycle.test.mjs +++ b/tests/terminal-lifecycle.test.mjs @@ -101,8 +101,8 @@ test("late input and resize events are guarded after PTY exit", async () => { const source = await readFile(terminalManagerPath, "utf8"); assert.match(source, /session\.metadata\.exitCode !== null/); - assert.match(source, /tryPtyOperation\(\(\) => session\.process\.write\(data\)\)/); - assert.match(source, /tryPtyOperation\(\(\) => session\.process\.resize\(safeCols, safeRows\)\)/); + assert.match(source, /tryPtyOperation\(\(\) => process\.write\(data\)\)/); + assert.match(source, /tryPtyOperation\(\(\) => process\.resize\(safeCols, safeRows\)\)/); }); test("an exited PTY can restart in place without recreating its xterm card", async () => { @@ -115,7 +115,7 @@ test("an exited PTY can restart in place without recreating its xterm card", asy assert.match(manager, /session\.metadata\.exitCode === null/); assert.match(manager, /session\.metadata\.status = "idle"/); assert.match(manager, /session\.metadata\.failureDetails = null/); - assert.match(manager, /this\.bindProcess\(id, session, launched\.process\)/); + assert.match(manager, /if \(launched\.process\) this\.bindProcess\(id, session, launched\.process\)/); assert.match(card, /shouldRestartExitedTerminal\(event, sessionExited\.current\)/); assert.match(card, /onRestart\(session\.id\)/); }); diff --git a/tests/windows-terminal-launch.test.mjs b/tests/windows-terminal-launch.test.mjs deleted file mode 100644 index f78d88d..0000000 --- a/tests/windows-terminal-launch.test.mjs +++ /dev/null @@ -1,234 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import { resolveTerminalLaunch } from "../src/main/services/terminalLaunch.ts"; - -function existing(...paths) { - const entries = new Set(paths.map((path) => path.toLowerCase())); - return (path) => entries.has(path.toLowerCase()); -} - -test("Windows terminal selects the built-in PowerShell instead of /bin/bash", () => { - const powershell = "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"; - const launch = resolveTerminalLaunch("terminal", "normal", [], { - platform: "win32", - environment: { SystemRoot: "C:\\Windows" }, - fileExists: existing(powershell) - }); - - assert.deepEqual(launch, { - command: powershell, - args: ["-NoLogo", "-NoProfile"] - }); -}); - -test("Windows terminal falls back to the configured command prompt", () => { - const commandPrompt = "D:\\Windows\\System32\\cmd.exe"; - const launch = resolveTerminalLaunch("terminal", "normal", [], { - platform: "win32", - environment: { ComSpec: commandPrompt }, - fileExists: existing(commandPrompt) - }); - - assert.deepEqual(launch, { command: commandPrompt, args: ["/d"] }); -}); - -test("Unix terminal keeps the configured login shell", () => { - assert.deepEqual( - resolveTerminalLaunch("terminal", "normal", [], { - platform: "linux", - environment: { SHELL: "/usr/bin/zsh" } - }), - { command: "/usr/bin/zsh", args: ["-l"] } - ); -}); - -test("OpenCode uses its native TUI and applies YOLO through per-run inline config", () => { - assert.deepEqual( - resolveTerminalLaunch("opencode", "normal", [], { - platform: "linux", - environment: {} - }), - { command: "opencode", args: [] } - ); - - const launch = resolveTerminalLaunch("opencode", "yolo", [], { - platform: "linux", - environment: { - OPENCODE_CONFIG_CONTENT: JSON.stringify({ model: "opencode/kimi-k3", theme: "system" }) - } - }); - assert.equal(launch.command, "opencode"); - assert.deepEqual(launch.args, []); - assert.deepEqual(JSON.parse(launch.environment.OPENCODE_CONFIG_CONTENT), { - model: "opencode/kimi-k3", - theme: "system", - permission: "allow" - }); -}); - -test("OpenCode YOLO preserves a browser MCP inline override", () => { - const existing = { - mcp: { - canvastty_browser: { - type: "local", - command: ["helper", "--stdio"], - enabled: true - } - }, - permission: { canvastty_browser: "allow" } - }; - const launch = resolveTerminalLaunch("opencode", "yolo", [], { - platform: "linux", - environment: { OPENCODE_CONFIG_CONTENT: JSON.stringify(existing) } - }); - const config = JSON.parse(launch.environment.OPENCODE_CONFIG_CONTENT); - assert.deepEqual(config.mcp, existing.mcp); - assert.equal(config.permission, "allow"); -}); - -test("OpenCode refuses to replace malformed inline config", () => { - assert.throws( - () => resolveTerminalLaunch("opencode", "yolo", [], { - platform: "linux", - environment: { OPENCODE_CONFIG_CONTENT: "not-json" } - }), - /must contain valid JSON/u - ); -}); - -test("Hermes uses its native interactive CLI and scopes YOLO to the launch", () => { - assert.deepEqual( - resolveTerminalLaunch("hermes", "normal", [], { - platform: "linux", - environment: {} - }), - { command: "hermes", args: [] } - ); - assert.deepEqual( - resolveTerminalLaunch("hermes", "yolo", [], { - platform: "linux", - environment: {} - }), - { command: "hermes", args: ["--yolo"] } - ); -}); - -test("Grok Build uses its native TUI and scopes automatic approval to YOLO", () => { - assert.deepEqual( - resolveTerminalLaunch("grok", "normal", [], { platform: "linux", environment: {} }), - { command: "grok", args: [] } - ); - assert.deepEqual( - resolveTerminalLaunch("grok", "yolo", [], { platform: "linux", environment: {} }), - { command: "grok", args: ["--always-approve"] } - ); -}); - -test("Windows Codex resolves an absolute native executable and preserves bridge arguments", () => { - const codex = "C:\\Users\\dev\\AppData\\Local\\Programs\\OpenAI\\Codex\\bin\\codex.exe"; - const launch = resolveTerminalLaunch("codex", "yolo", ["--bridge", "C:\\runtime path\\helper"], { - platform: "win32", - homeDirectory: "C:\\Users\\dev", - environment: { LOCALAPPDATA: "C:\\Users\\dev\\AppData\\Local" }, - fileExists: existing(codex) - }); - - assert.deepEqual(launch, { - command: codex, - args: [ - "--dangerously-bypass-approvals-and-sandbox", - "--bridge", - "C:\\runtime path\\helper" - ] - }); -}); - -test("Windows provider lookup supports a native executable from PATH", () => { - const kimi = "D:\\Tools\\kimi.exe"; - const launch = resolveTerminalLaunch("kimi", "normal", [], { - platform: "win32", - homeDirectory: "C:\\Users\\dev", - environment: { Path: '"D:\\Tools";C:\\Windows' }, - fileExists: existing(kimi) - }); - - assert.deepEqual(launch, { command: kimi, args: [] }); -}); - -test("Windows provider lookup resolves an OpenCode npm shim", () => { - const opencode = "C:\\Users\\dev\\AppData\\Roaming\\npm\\opencode.cmd"; - const commandPrompt = "C:\\Windows\\System32\\cmd.exe"; - const launch = resolveTerminalLaunch("opencode", "normal", [], { - platform: "win32", - homeDirectory: "C:\\Users\\dev", - environment: { - APPDATA: "C:\\Users\\dev\\AppData\\Roaming", - ComSpec: commandPrompt - }, - fileExists: existing(opencode, commandPrompt) - }); - - assert.deepEqual(launch, { - command: commandPrompt, - args: '/d /s /c "C:\\Users\\dev\\AppData\\Roaming\\npm\\opencode.cmd"' - }); -}); - -test("Windows provider lookup resolves a native Hermes launcher", () => { - const hermes = "C:\\Users\\dev\\.local\\bin\\hermes.exe"; - const launch = resolveTerminalLaunch("hermes", "yolo", ["--bridge"], { - platform: "win32", - homeDirectory: "C:\\Users\\dev", - environment: {}, - fileExists: existing(hermes) - }); - - assert.deepEqual(launch, { - command: hermes, - args: ["--yolo", "--bridge"] - }); -}); - -test("Windows provider lookup respects PATH before known fallback directories", () => { - const preferred = "D:\\Tools\\codex.exe"; - const fallback = "C:\\Users\\dev\\.local\\bin\\codex.exe"; - const launch = resolveTerminalLaunch("codex", "normal", [], { - platform: "win32", - homeDirectory: "C:\\Users\\dev", - environment: { Path: "D:\\Tools" }, - fileExists: existing(preferred, fallback) - }); - - assert.equal(launch.command, preferred); -}); - -test("Windows batch provider shims are launched through cmd.exe", () => { - const claude = "C:\\Users\\dev user\\AppData\\Roaming\\npm\\claude.cmd"; - const commandPrompt = "C:\\Windows\\System32\\cmd.exe"; - const launch = resolveTerminalLaunch("claude", "normal", ["--bridge"], { - platform: "win32", - homeDirectory: "C:\\Users\\dev user", - environment: { - APPDATA: "C:\\Users\\dev user\\AppData\\Roaming", - ComSpec: commandPrompt - }, - fileExists: existing(claude, commandPrompt) - }); - - assert.deepEqual(launch, { - command: commandPrompt, - args: '/d /s /c "C:\\Users\\dev^ user\\AppData\\Roaming\\npm\\claude.cmd ^"--bridge^""' - }); -}); - -test("missing Windows providers report an actionable error before node-pty", () => { - assert.throws( - () => resolveTerminalLaunch("kimi", "normal", [], { - platform: "win32", - homeDirectory: "C:\\Users\\dev", - environment: {}, - fileExists: () => false - }), - /Kimi CLI was not found on Windows\. Install it, then restart CanvasTTY\. Checked PATH and these directories:.*Supported launchers: \.exe, \.com, \.cmd, and \.bat\./u - ); -}); From bb7a101f2a82771c9b1e91ddef1c82f285137cab Mon Sep 17 00:00:00 2001 From: cododel Date: Sat, 22 Aug 2026 20:43:03 +0400 Subject: [PATCH 3/4] fix(ci): decouple direct provider smoke from cli installation --- scripts/smoke-browser-providers.mjs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/scripts/smoke-browser-providers.mjs b/scripts/smoke-browser-providers.mjs index 265f120..2d68358 100644 --- a/scripts/smoke-browser-providers.mjs +++ b/scripts/smoke-browser-providers.mjs @@ -19,6 +19,11 @@ const smokeRoot = await mkdtemp(join(process.platform === "win32" ? tmpdir() : " const userDataPath = join(smokeRoot, "electron"); const kimiHome = join(smokeRoot, "kimi-home"); const workDirectory = join(smokeRoot, "work"); +// The direct smoke validates only the helper and its one-time capability; it +// must not depend on a provider CLI being installed on the CI runner. +const directPreflightCommand = targets.includes("direct") && !targets.includes("codex") + ? process.env.CANVASTTY_PROVIDER_SMOKE_CODEX_COMMAND ?? process.execPath + : undefined; let child; let output = ""; let fakeApi = null; @@ -52,7 +57,10 @@ try { KIMI_CODE_NO_AUTO_UPDATE: "1", KIMI_DISABLE_CRON: "1", CANVASTTY_PROVIDER_SMOKE: targets.join(","), - CANVASTTY_PROVIDER_SMOKE_CWD: workDirectory + CANVASTTY_PROVIDER_SMOKE_CWD: workDirectory, + ...(directPreflightCommand + ? { CANVASTTY_PROVIDER_SMOKE_CODEX_COMMAND: directPreflightCommand } + : {}) }, stdio: ["ignore", "pipe", "pipe"] }); From 3d324ba21f0f8db2e1ebe4eb9158e5242f860708 Mon Sep 17 00:00:00 2001 From: cododel Date: Sat, 22 Aug 2026 20:55:16 +0400 Subject: [PATCH 4/4] fix(ci): stabilize browser wheel smoke sequencing --- src/main/services/browser/BrowserElectronSmoke.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/services/browser/BrowserElectronSmoke.ts b/src/main/services/browser/BrowserElectronSmoke.ts index 97604d6..8eef741 100644 --- a/src/main/services/browser/BrowserElectronSmoke.ts +++ b/src/main/services/browser/BrowserElectronSmoke.ts @@ -4,8 +4,10 @@ import { join } from "node:path"; import { BrowserWindow, webContents } from "electron"; import type { BrowserActor, BrowserCommand, BrowserElementRef, BrowserResult } from "../../../shared/contracts.ts"; import type { BrowserService } from "../BrowserService.ts"; +import { BROWSER_CANVAS_WHEEL_IDLE_MS } from "./BrowserCanvasFreeze.ts"; const READY_TIMEOUT_MS = 12_000; +const WHEEL_IDLE_SETTLE_MS = BROWSER_CANVAS_WHEEL_IDLE_MS * 2; const SENTINEL = "canvastty-secret-must-not-leak"; export async function runBrowserElectronSmoke( @@ -570,7 +572,9 @@ async function wheelRelayCount( } async function waitForWheelIdle(): Promise { - await new Promise((resolve) => setTimeout(resolve, 320)); + // The page preload and main process expire ownership independently. Allow + // both event loops a complete idle interval before starting a new sequence. + await new Promise((resolve) => setTimeout(resolve, WHEEL_IDLE_SETTLE_MS)); } async function waitUntil(check: () => Promise): Promise {