From 6f16b7644d937554aa4d107708e35dcf7219dd21 Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Tue, 29 Sep 2026 05:51:39 +0000 Subject: [PATCH 1/5] fix(startup): load the application surface only after the startup page settled Startup could fail with "ERR_ABORTED (-3) loading 'data:text/html...'" when the machine was busy. Since services start while the startup page loads, the application surface could start its navigation while the page was still committing or loading in its own renderer process. The application surface then committed first, and the page's ERR_ABORTED arrived afterwards, while the loadFile promise was still waiting. Electron's loadURL/loadFile promise takes the first main-frame did-fail-load it sees as its own, so the application load rejected with the startup page's abort and startup showed the failure page. Services still start while the page loads. The application surface now waits until the page load has settled, which it usually has by the time services are up. A close during the page load is still a quiet quit, and a real page error on a live window still fails startup. Measured with 40 sequential hidden launches per build, each helper process (renderer, GPU, utility) paused at random for 20 to 300 ms during the first navigations: origin/main failed 4 and 6 of 40, the same loop with this change 0 of 80. The build before "perf(startup): start services while the startup page loads" had 0 of 40. Without pauses all builds start 40 of 40; the median time to a ready window goes from 342 to 379 ms. --- src/main/index.ts | 54 ++++++++++++++++++-------------- tests/startup-lifecycle.test.mjs | 45 +++++++++++++++++++------- 2 files changed, 65 insertions(+), 34 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 2a9358ef..b2ae2c74 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -173,8 +173,8 @@ if (!hasSingleInstanceLock) app.quit(); /** * Creates the shell window and starts loading the startup page into it. The - * page load is not awaited: services start next to it, and the application - * surface may replace the page before it finished (see startApplication). + * page load is not awaited here: services start next to it, and startApplication + * waits for it to settle before it loads the application surface. */ function createWindow(): { window: BrowserWindow; startupPage: StartupPageLoad } { const window = new BrowserWindow({ @@ -241,28 +241,30 @@ function createWindow(): { window: BrowserWindow; startupPage: StartupPageLoad } } }); - const startupPage: StartupPageLoad = { failure: null, superseded: false }; - window.loadURL(startupPageUrl({ locale: app.getLocale(), isMacOS: process.platform === "darwin" })).catch((error) => { - // A close during this load aborts the navigation (ERR_ABORTED / ERR_FAILED). - // That is a quit, not a failed startup; the application surface replacing a - // page that was still loading aborts it the same way. Neither is a failure; - // a real error on a live window is kept for startApplication to report. - if (shellWindowGone(window)) { - console.warn("CanvasTTY startup page load stopped: its window is gone, the application is closing.", error); - return; - } - if (!startupPage.superseded) startupPage.failure = error; - }); + const startupPage: StartupPageLoad = window + .loadURL(startupPageUrl({ locale: app.getLocale(), isMacOS: process.platform === "darwin" })) + .then( + () => null, + (error: unknown) => { + // A close during this load aborts the navigation (ERR_ABORTED / ERR_FAILED). + // That is a quit, not a failed startup; a real error on a live window is + // handed to startApplication to report. + if (shellWindowGone(window)) { + console.warn("CanvasTTY startup page load stopped: its window is gone, the application is closing.", error); + return null; + } + return error ?? new Error("The startup page did not load."); + } + ); return { window, startupPage }; } -/** The startup page load of a fresh shell window, as startApplication sees it. */ -interface StartupPageLoad { - /** A real load error of the page, reported as a failed startup. */ - failure: unknown; - /** Set once the application surface starts loading: aborting the page is expected then. */ - superseded: boolean; -} +/** + * The startup page load of a fresh shell window: it settles with the load error + * to report as a failed startup, or null once the page loaded (or its window is + * gone). It never rejects. + */ +type StartupPageLoad = Promise; /** * True when the shell window is on its way out: its close was requested (the @@ -827,8 +829,14 @@ async function startApplication(): Promise { if (!servicesReady) await initializeServices(); if (shutdownRunning || shutdownComplete || shellWindowGone(window)) return; if (startupPage) { - if (startupPage.failure) throw startupPage.failure; - startupPage.superseded = true; + // The application surface must not replace a page that is still loading: + // Chromium can report that page's ERR_ABORTED after the next navigation has + // started, and Electron's loadFile/loadURL promise takes the first main-frame + // load failure it sees as its own, so startup would fail with the startup + // page's abort. The page usually settles before services are up. + const failure = await startupPage; + if (shutdownRunning || shutdownComplete || shellWindowGone(window)) return; + if (failure !== null) throw failure; } initializeUpdater(); await loadApplication(window); diff --git a/tests/startup-lifecycle.test.mjs b/tests/startup-lifecycle.test.mjs index 89faf867..6772fa5c 100644 --- a/tests/startup-lifecycle.test.mjs +++ b/tests/startup-lifecycle.test.mjs @@ -36,11 +36,15 @@ test("closing during service startup stops renderer loading without a failure di assert.equal(failures, 1, "a real error on a live window still reaches the failure page"); }); -test("services start while the startup page is still loading, and only a real page error fails startup", async () => { +test("services start while the startup page loads, and the application surface waits until the page settled", async () => { const source = await readFile(mainPath, "utf8"); const start = source.slice(source.indexOf("async function startApplication"), source.indexOf("function buildProviderCliRegistry")); const events = []; - let page; + const turn = () => new Promise((resolve) => setImmediate(resolve)); + let settlePage; + // The startup page load as createWindow hands it over: it settles with the error to report, or null. + const pendingPage = () => new Promise((resolve) => { settlePage = resolve; }); + let gone = false; const context = { startupRunning: false, shutdownRunning: false, @@ -48,22 +52,41 @@ test("services start while the startup page is still loading, and only a real pa servicesReady: false, mainWindow: null, process: { env: {} }, - shellWindowGone: () => false, - // The page load never settles here: startup must not wait for it. - createWindow: () => { page = { failure: null, superseded: false }; events.push("window"); return { window: {}, startupPage: page }; }, - initializeServices: async () => { events.push(`services superseded=${page.superseded}`); }, + shellWindowGone: () => gone, + createWindow: () => { events.push("window"); return { window: {}, startupPage: pendingPage() }; }, + initializeServices: async () => { events.push("services"); }, initializeUpdater: () => events.push("updater"), - loadApplication: async () => { events.push(`app superseded=${page.superseded}`); }, + loadApplication: async () => { events.push("app"); }, showStartupFailure: async (_window, error) => { events.push(`failure ${error.message}`); } }; const startApplication = runInNewContext(`${stripTypeScriptTypes(start)}; startApplication`, context); - await startApplication(); - assert.deepEqual(events, ["window", "services superseded=false", "updater", "app superseded=true"]); + // Services do not wait for the page. The application surface does: a page replaced while it is still loading + // reports its ERR_ABORTED late, and Electron's loadFile promise takes that failure as its own. + let startup = startApplication(); + await turn(); + assert.deepEqual(events, ["window", "services"]); + events.push("page settled"); + settlePage(null); + await startup; + assert.deepEqual(events, ["window", "services", "page settled", "updater", "app"]); + + // A real page error on a live window fails startup. events.length = 0; - context.initializeServices = async () => { page.failure = new Error("startup page failed"); }; + context.createWindow = () => { events.push("window"); return { window: {}, startupPage: Promise.resolve(new Error("startup page failed")) }; }; await startApplication(); - assert.deepEqual(events, ["window", "failure startup page failed"]); + assert.deepEqual(events, ["window", "services", "failure startup page failed"]); + + // A close while the page is still loading ends startup quietly once the page settles. + events.length = 0; + context.createWindow = () => { events.push("window"); return { window: {}, startupPage: pendingPage() }; }; + startup = startApplication(); + await turn(); + gone = true; + settlePage(null); + await startup; + assert.deepEqual(events, ["window", "services"]); + assert.equal(context.startupRunning, false); }); test("dependencies only some paths need are not imported when the main process starts", async () => { From 479048818cab5ac8ec1b8fbc8095d9ac98c856a7 Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:49:34 +0000 Subject: [PATCH 2/5] fix(settings): read a pasted provider key before its state update runs Pasting a MiniMax API key into Settings -> Agents -> Provider API keys turned the window black. The key field's onChange read event.currentTarget.value inside the setDrafts((current) => ...) updater. React runs that updater later, during render, whenever an earlier update of the component is still pending (a paste over a character already in the field, the second keystroke of fast typing). By then the event has finished dispatching and currentTarget is null, so the render threw "Cannot read properties of null (reading 'value')" and React unmounted the whole root. The renderer process stayed alive with an empty #root. Key length and shape are not the cause: a 120-character key pasted over a pending edit breaks the same way. The handler now reads the value while the event dispatches and passes it to the updater. No other renderer updater reads an event (checked with an AST scan over src/renderer). Tests: tests/provider-secrets-settings.test.mjs bundles the real component against a React stand-in whose updaters run after dispatch and pastes fake sk-cp-/sk-api- keys of 120 to 10 000 characters, with a trailing newline and space, over a pending edit (fails before this change with the same TypeError); a line-level guard keeps event reads out of state updaters in the renderer. --- .../settings/ProviderSecretsSettings.tsx | 7 +- tests/provider-secrets-settings.test.mjs | 122 ++++++++++++++++++ 2 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 tests/provider-secrets-settings.test.mjs diff --git a/src/renderer/src/features/settings/ProviderSecretsSettings.tsx b/src/renderer/src/features/settings/ProviderSecretsSettings.tsx index 56a10e2e..1f6fcec1 100644 --- a/src/renderer/src/features/settings/ProviderSecretsSettings.tsx +++ b/src/renderer/src/features/settings/ProviderSecretsSettings.tsx @@ -84,7 +84,12 @@ export function ProviderSecretsSettings({ locale }: ProviderSecretsSettingsProps spellCheck={false} placeholder={secretId} aria-label={`${SECRET_LABELS[secretId]} ${secretId}`} - onChange={(event) => setDrafts((current) => ({ ...current, [secretId]: event.currentTarget.value }))} + onChange={(event) => { + // Read the value while the event is dispatching: React may run the updater + // later (a paste over a pending edit), when currentTarget is already null. + const value = event.currentTarget.value; + setDrafts((current) => ({ ...current, [secretId]: value })); + }} onKeyDown={(event) => { if (event.key === "Enter") void save(secretId); }} diff --git a/tests/provider-secrets-settings.test.mjs b/tests/provider-secrets-settings.test.mjs new file mode 100644 index 00000000..91f71b34 --- /dev/null +++ b/tests/provider-secrets-settings.test.mjs @@ -0,0 +1,122 @@ +import assert from "node:assert/strict"; +import { readFile, readdir } from "node:fs/promises"; +import { join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { build } from "esbuild"; + +// The provider key fields in Settings, driven the way React drives them. React runs a functional state +// updater later than the change handler whenever an earlier update of that component is still pending +// (the second keystroke, or a paste over text already in the field). By then the synthetic event has +// finished dispatching and `event.currentTarget` is null. An updater that reads the event then throws +// during render, and React unmounts the whole application: the window turns black. +// +// This harness bundles the real component against a minimal React stand-in whose state updaters always +// run after dispatch, which is the ordering that broke the MiniMax key field in the packaged app. +const FAKE_REACT = ` +let state = []; +let cursor = 0; +const queue = []; +export function useState(initial) { + const index = cursor++; + if (!(index in state)) state[index] = typeof initial === "function" ? initial() : initial; + return [state[index], (update) => { queue.push([index, update]); }]; +} +export function useEffect() {} +export function jsx(type, props) { return { type, props }; } +export const jsxs = jsx; +export const Fragment = "fragment"; +export function __render(component, props) { cursor = 0; return component(props); } +export function __flush() { + for (const [index, update] of queue.splice(0)) state[index] = typeof update === "function" ? update(state[index]) : update; +} +export function __reset() { state = []; cursor = 0; queue.length = 0; } +export default { useState, useEffect }; +`; + +const root = fileURLToPath(new URL("..", import.meta.url)); +const { outputFiles } = await build({ + stdin: { + contents: 'export { ProviderSecretsSettings } from "./src/renderer/src/features/settings/ProviderSecretsSettings.tsx"; export { __render, __flush, __reset } from "react";', + resolveDir: root, + loader: "ts" + }, + jsx: "automatic", + bundle: true, + platform: "node", + format: "esm", + write: false, + plugins: [{ + name: "fake-react", + setup(builder) { + builder.onResolve({ filter: /^react(\/jsx-runtime)?$/ }, () => ({ path: "react", namespace: "fake-react" })); + builder.onLoad({ filter: /.*/, namespace: "fake-react" }, () => ({ contents: FAKE_REACT, loader: "js" })); + } + }] +}); +const { ProviderSecretsSettings, __render, __flush, __reset } = await import( + `data:text/javascript;base64,${Buffer.from(outputFiles[0].contents).toString("base64")}` +); + +function findAll(node, predicate, found = []) { + if (Array.isArray(node)) { for (const child of node) findAll(child, predicate, found); return found; } + if (!node || typeof node !== "object") return found; + if (predicate(node)) found.push(node); + findAll(node.props?.children, predicate, found); + return found; +} +const keyField = (tree) => findAll(tree, (node) => node.type === "input" && /MINIMAX_API_KEY/u.test(node.props["aria-label"] ?? ""))[0]; + +/** One React change event: currentTarget is the field while the handler runs, null once dispatch ends. */ +function change(field, value) { + const element = { value }; + const event = { currentTarget: element, target: element }; + field.props.onChange(event); + event.currentTarget = null; +} + +// MiniMax keys are `sk-cp-` / `sk-api-` + a long [A-Za-z0-9_-] body. These are FAKE keys of that shape. +function fakeKey(length, prefix = "sk-cp-") { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-"; + let seed = length; + let out = prefix; + while (out.length < length) { seed = (seed * 1103515245 + 12345) >>> 0; out += alphabet[(seed >>> 8) % alphabet.length]; } + return out; +} + +test("pasting over a provider key draft keeps the settings rendered, for keys of any length", () => { + const keys = [ + fakeKey(120), fakeKey(200, "sk-api-"), fakeKey(250), fakeKey(500), fakeKey(2_000), fakeKey(10_000), + `${fakeKey(180)}\n`, `${fakeKey(180)} ` + ]; + __reset(); + const props = { locale: "en" }; + let tree = __render(ProviderSecretsSettings, props); + const started = performance.now(); + for (const key of keys) { + // A first character in the field, then the paste replacing it: two updates before one render. + change(keyField(tree), key.slice(0, 1)); + change(keyField(tree), key); + assert.doesNotThrow(__flush, `a ${key.length}-character key must not break the render`); + tree = __render(ProviderSecretsSettings, props); + assert.equal(keyField(tree).props.value, key); + const saveButton = findAll(tree, (node) => node.type === "button" && node.props.children === "Save") + .find((button) => button.props.disabled === false); + assert.ok(saveButton, "a pasted key enables its Save button"); + } + assert.ok(performance.now() - started < 1_000, "typing and rendering long keys stays fast"); +}); + +test("renderer state updaters never read a React event after its dispatch", async () => { + // Static guard for the same bug class anywhere in the renderer, one line at a time: + // `setX((prev) => ... event.currentTarget ...)`. Read the value first, then hand it to the updater. + const directory = join(root, "src", "renderer", "src"); + const files = (await readdir(directory, { recursive: true })).filter((file) => /\.tsx?$/u.test(file)); + const offenders = []; + for (const file of files) { + const source = await readFile(join(directory, file), "utf8"); + const pattern = /\bset[A-Z]\w*\(\s*\(?\s*(\w+)?\s*\)?\s*=>[^;\n]*?\b(event|e|ev)\.(currentTarget|target)\b/gu; + for (const match of source.matchAll(pattern)) offenders.push(`${file}: ${match[0].slice(0, 120)}`); + } + assert.deepEqual(offenders, []); +}); From 9610cd12bf3bf72228ef7eb318afdbd88971ef63 Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:49:34 +0000 Subject: [PATCH 3/5] fix(renderer): bring the window back after an uncaught render error An error thrown while React renders unmounts the whole root, but the renderer process lives on, so render-process-gone never fires and the main process's crash reload never runs: the window stays black until the app is restarted. That is what the broken provider key field did. The React root now handles onUncaughtError. The first such error logs it with its component stack and reloads the application surface in place; sessions live in the main process and survive the reload, as after a renderer crash. A second one within 30 s (or with no session storage to remember the reload) shows a static page with a Reload button instead of reloading in a loop. Tests: tests/uncaught-error-recovery.test.mjs (reload, cooldown, clock change, no storage, root wiring). Checked in a hidden app: an injected render error reloads to a usable Settings screen, a second one within the cooldown shows the recovery page; forcefullyCrashRenderer still reloads through render-process-gone. --- src/renderer/src/lib/i18n.ts | 6 ++ src/renderer/src/lib/uncaughtErrorRecovery.ts | 93 +++++++++++++++++++ src/renderer/src/main.tsx | 8 +- src/renderer/src/styles/app.css | 4 + tests/uncaught-error-recovery.test.mjs | 64 +++++++++++++ 5 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/lib/uncaughtErrorRecovery.ts create mode 100644 tests/uncaught-error-recovery.test.mjs diff --git a/src/renderer/src/lib/i18n.ts b/src/renderer/src/lib/i18n.ts index a735ec3e..4789a25f 100644 --- a/src/renderer/src/lib/i18n.ts +++ b/src/renderer/src/lib/i18n.ts @@ -269,6 +269,9 @@ const ru = { providerSecretNotConfigured: "Не задан", providerSecretSave: "Сохранить", providerSecretClear: "Удалить", + rendererRecoveryTitle: "Окно CanvasTTY столкнулось с ошибкой", + rendererRecoveryText: "Сессии продолжают работать. Перезагрузите окно, чтобы вернуться к ним.", + rendererRecoveryReload: "Перезагрузить окно", apiProfiles: "API-профили", apiProfilesDescription: "Бэкенды для CLI с поддержкой BYOK. Профиль — не агент: он лишь задаёт endpoint и ключ для совместимых рантаймов.", apiProfileSave: "Сохранить", @@ -937,6 +940,9 @@ const en: Record = { providerSecretNotConfigured: "Not set", providerSecretSave: "Save", providerSecretClear: "Remove", + rendererRecoveryTitle: "The CanvasTTY window hit an error", + rendererRecoveryText: "Your sessions are still running. Reload the window to get back to them.", + rendererRecoveryReload: "Reload window", apiProfiles: "API profiles", apiProfilesDescription: "Backends for BYOK-capable CLIs. A profile is not an agent: it only supplies an endpoint and key reference to compatible runtimes.", apiProfileSave: "Save", diff --git a/src/renderer/src/lib/uncaughtErrorRecovery.ts b/src/renderer/src/lib/uncaughtErrorRecovery.ts new file mode 100644 index 00000000..a04befe3 --- /dev/null +++ b/src/renderer/src/lib/uncaughtErrorRecovery.ts @@ -0,0 +1,93 @@ +import type { LocaleId } from "../../../shared/contracts"; +import { t } from "./i18n"; + +// An error thrown while React renders, and not caught by a component, unmounts the whole root: the renderer +// process stays alive, so the main process's render-process-gone recovery never runs, and the window stays +// black until the app is restarted. The root reports such errors here. The first one reloads the +// application surface in place (sessions live in the main process and survive it, as after a renderer +// crash). A second one within the cooldown would only loop, so it gets a static page with a Reload button. + +export const UNCAUGHT_ERROR_RELOAD_KEY = "canvastty.uncaughtErrorReloadAt"; +export const UNCAUGHT_ERROR_RELOAD_COOLDOWN_MS = 30_000; + +export interface UncaughtErrorRecoveryHost { + now(): number; + readLastReloadAt(): number | null; + /** False when the reload time cannot be remembered: reloading then could loop forever. */ + writeLastReloadAt(at: number): boolean; + reload(): void; + showRecoveryPage(): void; +} + +export type UncaughtErrorRecovery = "reload" | "recovery-page"; + +export function recoverFromUncaughtError(host: UncaughtErrorRecoveryHost): UncaughtErrorRecovery { + const now = host.now(); + const last = host.readLastReloadAt(); + if (last !== null && now >= last && now - last < UNCAUGHT_ERROR_RELOAD_COOLDOWN_MS) { + host.showRecoveryPage(); + return "recovery-page"; + } + if (!host.writeLastReloadAt(now)) { + host.showRecoveryPage(); + return "recovery-page"; + } + host.reload(); + return "reload"; +} + +function recoveryLocale(): LocaleId { + return navigator.language.toLowerCase().startsWith("ru") ? "ru" : "en"; +} + +/** Plain DOM, no React: the React root is what just failed. */ +function showRecoveryPage(container: HTMLElement): void { + const locale = recoveryLocale(); + const page = document.createElement("div"); + page.className = "renderer-recovery"; + page.setAttribute("role", "alert"); + const title = document.createElement("strong"); + title.textContent = t(locale, "rendererRecoveryTitle"); + const text = document.createElement("p"); + text.textContent = t(locale, "rendererRecoveryText"); + const button = document.createElement("button"); + button.type = "button"; + button.textContent = t(locale, "rendererRecoveryReload"); + button.addEventListener("click", () => window.location.reload()); + page.append(title, text, button); + container.replaceChildren(page); +} + +function sessionStore(): Storage | null { + try { + return window.sessionStorage; + } catch { + return null; + } +} + +export function handleUncaughtRenderError(container: HTMLElement, error: unknown, componentStack?: string): void { + console.error("CanvasTTY hit an uncaught render error; recovering the application surface.", error, componentStack ?? ""); + const storage = sessionStore(); + recoverFromUncaughtError({ + now: () => Date.now(), + readLastReloadAt: () => { + try { + const value = Number(storage?.getItem(UNCAUGHT_ERROR_RELOAD_KEY) ?? Number.NaN); + return Number.isFinite(value) ? value : null; + } catch { + return null; + } + }, + writeLastReloadAt: (at) => { + try { + storage?.setItem(UNCAUGHT_ERROR_RELOAD_KEY, String(at)); + return storage?.getItem(UNCAUGHT_ERROR_RELOAD_KEY) === String(at); + } catch { + return false; + } + }, + reload: () => window.location.reload(), + showRecoveryPage: () => showRecoveryPage(container) + }); +} diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 12fa92d9..99b49521 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -2,11 +2,17 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import "@xterm/xterm/css/xterm.css"; import { App } from "./App"; +import { handleUncaughtRenderError } from "./lib/uncaughtErrorRecovery"; import "./styles/tokens.css"; import "./styles/app.css"; import "./styles/patterns.css"; -createRoot(document.getElementById("root")!).render( +const container = document.getElementById("root")!; +createRoot(container, { + // React unmounts the whole tree on an uncaught render error while the renderer process lives on: + // without this the window stays black and the main process has no crash to recover from. + onUncaughtError: (error, errorInfo) => handleUncaughtRenderError(container, error, errorInfo.componentStack) +}).render( diff --git a/src/renderer/src/styles/app.css b/src/renderer/src/styles/app.css index ba8e980d..01c4f7da 100644 --- a/src/renderer/src/styles/app.css +++ b/src/renderer/src/styles/app.css @@ -1188,3 +1188,7 @@ button { border: 0; } .terminal-card__confirm span { font-weight: 500; line-height: 1.35; } .terminal-card__confirm-actions { display: flex; gap: 6px; } .terminal-card__confirm-actions button { padding: 4px 10px; border-radius: 7px; color: white; background: rgba(255,255,255,.12); font: inherit; cursor: pointer; } +.renderer-recovery { position: fixed; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; padding: 24px; background: #353442; color: #f8f7f1; text-align: center; } +.renderer-recovery strong { font-size: 16px; } +.renderer-recovery p { margin: 0; max-width: 420px; color: rgba(255, 255, 255, 0.7); font-size: 13px; line-height: 1.5; } +.renderer-recovery button { padding: 8px 16px; border: 0; border-radius: 10px; background: #b8cf99; color: #30313d; font: inherit; font-weight: 700; cursor: pointer; } diff --git a/tests/uncaught-error-recovery.test.mjs b/tests/uncaught-error-recovery.test.mjs new file mode 100644 index 00000000..950289ce --- /dev/null +++ b/tests/uncaught-error-recovery.test.mjs @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { build } from "esbuild"; + +// An uncaught render error unmounts the React root but leaves the renderer process alive, so the main +// process's render-process-gone reload never fires and the window stays black. The root recovers itself. +const { outputFiles } = await build({ + entryPoints: [fileURLToPath(new URL("../src/renderer/src/lib/uncaughtErrorRecovery.ts", import.meta.url))], + bundle: true, + platform: "node", + format: "esm", + write: false +}); +const { + recoverFromUncaughtError, + UNCAUGHT_ERROR_RELOAD_COOLDOWN_MS +} = await import(`data:text/javascript;base64,${Buffer.from(outputFiles[0].contents).toString("base64")}`); + +function host({ now, last = null, remembers = true }) { + const calls = []; + return { + calls, + now: () => now, + readLastReloadAt: () => last, + writeLastReloadAt: (at) => { calls.push(`write ${at}`); return remembers; }, + reload: () => calls.push("reload"), + showRecoveryPage: () => calls.push("page") + }; +} + +test("the first uncaught render error reloads the application surface", () => { + const h = host({ now: 1_000_000 }); + assert.equal(recoverFromUncaughtError(h), "reload"); + assert.deepEqual(h.calls, ["write 1000000", "reload"]); +}); + +test("a second uncaught render error inside the cooldown shows the recovery page instead of looping", () => { + const h = host({ now: 1_000_000, last: 1_000_000 - UNCAUGHT_ERROR_RELOAD_COOLDOWN_MS + 1 }); + assert.equal(recoverFromUncaughtError(h), "recovery-page"); + assert.deepEqual(h.calls, ["page"]); +}); + +test("an error after the cooldown reloads again", () => { + const h = host({ now: 1_000_000, last: 1_000_000 - UNCAUGHT_ERROR_RELOAD_COOLDOWN_MS }); + assert.equal(recoverFromUncaughtError(h), "reload"); +}); + +test("a reload time in the future (clock change) does not block the reload", () => { + const h = host({ now: 1_000_000, last: 2_000_000 }); + assert.equal(recoverFromUncaughtError(h), "reload"); +}); + +test("without storage for the reload time the recovery page is shown, never an unbounded reload loop", () => { + const h = host({ now: 1_000_000, remembers: false }); + assert.equal(recoverFromUncaughtError(h), "recovery-page"); + assert.deepEqual(h.calls, ["write 1000000", "page"]); +}); + +test("the React root routes uncaught render errors to the recovery", async () => { + const source = await readFile(new URL("../src/renderer/src/main.tsx", import.meta.url), "utf8"); + assert.match(source, /createRoot\(container, \{\s*[^}]*onUncaughtError: \(error, errorInfo\) => handleUncaughtRenderError\(container, error/su); +}); From 27a6f516d0837d5461c4b6fb378ad67ab3218015 Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:12:50 +0000 Subject: [PATCH 4/5] fix(safety): refuse agent access to CanvasTTY's own tokens, secret stores and sockets Base protection now treats CanvasTTY's private data as credentials. A shell or file tool call that names the agent-control token or descriptor, the gateways' connection records, the provider and plugin secret stores, account homes, the GitHub sign-in or prepared launch runs (paths taken from the app's own userData folder, passed in by the app), or the control and runtime socket folders under the temporary folder, is refused whatever the program: readers, copies, encoders, sqlite3, recursive walks of the app folder, interpreter one-liners and heredocs, curl --unix-socket, nc -U, socat and Python sockets. The model is told calmly that agents cannot control CanvasTTY this way and to ask for an Orchestrator launch. The project, the app's settings, other sockets, an agent's own account home and the bundled control CLI are unaffected. --- docs/plugins.md | 2 +- docs/plugins.zh-CN.md | 2 +- src/main/index.ts | 4 +- src/main/services/DecisionHooks.ts | 6 +- src/main/services/safety/baseProtection.ts | 27 ++- src/main/services/safety/commandFacts.ts | 190 +++++++++++++++++++-- tests/base-protection-app-private.test.mjs | 143 ++++++++++++++++ 7 files changed, 357 insertions(+), 17 deletions(-) create mode 100644 tests/base-protection-app-private.test.mjs diff --git a/docs/plugins.md b/docs/plugins.md index bec8181c..a7f69147 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -386,7 +386,7 @@ The full example is [`examples/plugins/collect-demo`](../examples/plugins/collec Two safety parts are built in and need no plugin: -- **Base protection** (Settings → Agents, on by default; the person can turn it off) denies, through the same hook, sudo and other elevation, piping downloaded or generated text into a shell, download-and-run, disk and format commands, fork bombs, and writing or deleting outside the working folder: the home folder, other projects and `/tmp` included, and deleting the working folder itself. An agent's own plan and memory folders (`~/.claude/plans`, `~/.claude/projects//memory`, and the same inside the run's `CLAUDE_CONFIG_DIR`) are not "outside". It only ever denies; each reason tells the model what to do instead (a write to `/tmp` suggests a scratch folder inside the project). +- **Base protection** (Settings → Agents, on by default; the person can turn it off) denies, through the same hook, sudo and other elevation, piping downloaded or generated text into a shell, download-and-run, disk and format commands, fork bombs, and writing or deleting outside the working folder: the home folder, other projects and `/tmp` included, and deleting the working folder itself. An agent's own plan and memory folders (`~/.claude/plans`, `~/.claude/projects//memory`, and the same inside the run's `CLAUDE_CONFIG_DIR`) are not "outside". It also denies any use of CanvasTTY's own private data (from the app's userData folder: the agent-control token and descriptor, the gateways' connection records and sockets, the secret stores, account homes; and the control/runtime socket folders under the temporary folder), by any program, interpreter one-liners and socket clients included; the reason points the model at an **Orchestrator** launch and the `canvastty_agents` tools. The bundled control CLI may name its descriptor. It only ever denies; each reason tells the model what to do instead (a write to `/tmp` suggests a scratch folder inside the project). - **Secret redaction**: every text CanvasTTY hands from one agent to another (`observe_agent`, `get_agent_result`, the control CLI's `screen`, `result` and failure details) is masked: provider keys CanvasTTY holds, launch `secretEnv` values, values a service registered with `redaction.register`, also when the terminal wrapped them over lines, plus common key shapes (`sk-…`, GitHub, Slack, AWS, Google, JWT, `Bearer …`, `"apiKey": "…"`, PEM private keys, long random runs). Plugin tool answers, `screen` in session events, card badges and card action messages are masked the same way. host.onStorageChange(listener) notifies every live contribution of the same plugin — canvases, HOME widgets, and separate windows — of writes made through host.storage.set, avoiding polling when a plugin coordinates several surfaces. diff --git a/docs/plugins.zh-CN.md b/docs/plugins.zh-CN.md index dafd0a97..7abb4b1d 100644 --- a/docs/plugins.zh-CN.md +++ b/docs/plugins.zh-CN.md @@ -365,7 +365,7 @@ interface PluginSessionEvent { 两项安全功能内置,无需插件: -- **基础保护**(设置 → Agents → Base protection,默认开启;用户可以关闭)通过同一个 hook 拒绝:sudo 及其他提权、把下载或生成的文本管道给 shell、下载后直接运行、磁盘和格式化命令、fork 炸弹,以及在工作文件夹之外写入或删除(包括主目录、其他项目和 `/tmp`),以及删除工作文件夹本身。agent 自己的计划和记忆文件夹(`~/.claude/plans`、`~/.claude/projects//memory`,以及本次运行 `CLAUDE_CONFIG_DIR` 中的相同位置)不算"外部"。它只会拒绝;每条原因都告诉模型应当改做什么(写入 `/tmp` 时建议在项目内建立临时文件夹)。 +- **基础保护**(设置 → Agents → Base protection,默认开启;用户可以关闭)通过同一个 hook 拒绝:sudo 及其他提权、把下载或生成的文本管道给 shell、下载后直接运行、磁盘和格式化命令、fork 炸弹,以及在工作文件夹之外写入或删除(包括主目录、其他项目和 `/tmp`),以及删除工作文件夹本身。agent 自己的计划和记忆文件夹(`~/.claude/plans`、`~/.claude/projects//memory`,以及本次运行 `CLAUDE_CONFIG_DIR` 中的相同位置)不算"外部"。它还会拒绝任何程序(包括解释器单行命令和套接字客户端)使用 CanvasTTY 自己的私有数据(来自应用的 userData 文件夹:agent-control 令牌与描述文件、各网关的连接记录与套接字、密钥存储、账户主目录;以及临时文件夹下的控制/运行时套接字文件夹);拒绝原因会引导模型改用 **Orchestrator** 启动和 `canvastty_agents` 工具。内置控制 CLI 可以引用它的描述文件。它只会拒绝;每条原因都告诉模型应当改做什么(写入 `/tmp` 时建议在项目内建立临时文件夹)。 - **密钥遮蔽**:CanvasTTY 从一个 agent 交给另一个 agent 的所有文本(`observe_agent`、`get_agent_result`,以及 control CLI 的 `screen`、`result` 和失败详情)都会被遮蔽:CanvasTTY 保存的服务商密钥、启动时的 `secretEnv` 值、服务通过 `redaction.register` 注册的值(包括被终端折行拆开的情况),以及常见密钥形式(`sk-…`、GitHub、Slack、AWS、Google、JWT、`Bearer …`、`"apiKey": "…"`、PEM 私钥、长随机串)。插件工具的回答、会话事件中的 `screen`、卡片标记和卡片动作消息也以同样方式遮蔽。 host.onStorageChange(listener) 会把 host.storage.set 的写入通知给同一插件的所有活动界面——画布卡片、HOME 小组件和独立窗口——从而避免轮询。 diff --git a/src/main/index.ts b/src/main/index.ts index b2ae2c74..3ad8f2bf 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -31,6 +31,7 @@ import { LaunchPipeline } from "./services/LaunchPipeline"; import { EnvironmentRegistry } from "./services/EnvironmentRegistry"; import { DecisionHooks } from "./services/DecisionHooks"; import { SecretRedactionRegistry } from "./services/safety/SecretRedaction"; +import { canvasTtyPrivateData } from "./services/safety/baseProtection"; import { PluginAgentTools } from "./services/PluginAgentTools"; import { PluginSessions } from "./services/PluginSessions"; import { PluginCards } from "./services/PluginCards"; @@ -330,7 +331,8 @@ async function initializeServices(): Promise { baseProtection: () => settings.get().baseProtectionEnabled, services: () => pluginManager!.decisionServices(), call: (pluginId, serviceId, method, params, timeoutMs) => pluginServices!.hostCall(pluginId, serviceId, method, params, timeoutMs), - session: (sessionId) => terminalManager?.decisionContext(sessionId) ?? null + session: (sessionId) => terminalManager?.decisionContext(sessionId) ?? null, + privateData: canvasTtyPrivateData(userDataPath) }); pluginManager.setServiceObserver(async (specs) => { await pluginServices!.sync(specs); diff --git a/src/main/services/DecisionHooks.ts b/src/main/services/DecisionHooks.ts index 32b3e7e9..1ebdedb1 100644 --- a/src/main/services/DecisionHooks.ts +++ b/src/main/services/DecisionHooks.ts @@ -3,6 +3,7 @@ import { homedir } from "node:os"; import type { AgentProviderId, SessionRole } from "../../shared/contracts.ts"; import type { RuntimePermissionDecision, RuntimePermissionRequest } from "./agent-runtime/RuntimeGateway.ts"; import { actionFromHook, checkBaseProtection } from "./safety/baseProtection.ts"; +import type { PrivateData } from "./safety/commandFacts.ts"; import { DEFAULT_DECIDE_TIMEOUT_MS } from "../../agent-runtime/runtime-protocol.mjs"; /** A trusted plugin service that declared `decide` (PluginManager.decisionServices). */ @@ -34,6 +35,8 @@ export interface DecisionHooksDependencies { call(pluginId: string, serviceId: string, method: "canvastty.decide", params: unknown, timeoutMs: number): Promise; session(sessionId: string): DecisionSession | null; home?: string; + /** CanvasTTY's own tokens, secret stores and sockets (canvasTtyPrivateData of its userData folder). */ + privateData?: PrivateData; timeoutMs?: number; } @@ -101,7 +104,8 @@ export class DecisionHooks { root: session.cwd, commandCwd: request.cwd, home, - agentRoots: [join(home, ".claude"), ...session.configDirs] + agentRoots: [join(home, ".claude"), ...session.configDirs], + ...(this.deps.privateData ? { privateData: this.deps.privateData } : {}) }); if (base) return { behavior: "deny", message: base.message }; } diff --git a/src/main/services/safety/baseProtection.ts b/src/main/services/safety/baseProtection.ts index a6f81ed6..6e6381c5 100644 --- a/src/main/services/safety/baseProtection.ts +++ b/src/main/services/safety/baseProtection.ts @@ -1,6 +1,7 @@ import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { isPathInside } from '../../../agent-runtime/path-inside.mjs'; -import { analyzeAction, commandFromArgv, realish, type HardFacts, type ToolAction } from './commandFacts.ts'; +import { analyzeAction, commandFromArgv, realish, type HardFacts, type PrivateData, type ToolAction } from './commandFacts.ts'; /** * Base protection: a small set of deny-only rules the core applies to every local agent tool call it sees through @@ -9,11 +10,12 @@ import { analyzeAction, commandFromArgv, realish, type HardFacts, type ToolActio * model: local rules only, no git, no network. */ -const BASE_DENY_RULES = ['elevation', 'pipe-to-shell', 'download-exec', 'disk', 'fork-bomb', 'delete-outside', 'write-outside'] as const; +const BASE_DENY_RULES = ['app-private', 'elevation', 'pipe-to-shell', 'download-exec', 'disk', 'fork-bomb', 'delete-outside', 'write-outside'] as const; export type BaseDenyRule = typeof BASE_DENY_RULES[number]; /** What the model reads: why the call was refused and what to do instead. */ const DENY_MESSAGES: Readonly> = { + 'app-private': 'CanvasTTY blocked this: it reads CanvasTTY\'s own access tokens or secret stores, or talks to its control socket. Agents can\'t control CanvasTTY this way, and guessing its protocol will not work. If you need other agents, ask the person to start you from CanvasTTY\'s launcher with the Orchestrator role: you will then get the canvastty_agents tools (spawn_agent, list_routes, wait_for_agent and the rest). Otherwise continue your task without controlling CanvasTTY.', elevation: 'CanvasTTY blocked this command: it asks for administrator rights (sudo, doas, runas). Do the work without elevation; if the task truly needs it, stop and ask the person to run that step.', 'pipe-to-shell': 'CanvasTTY blocked this command: it pipes downloaded or generated text straight into a shell or interpreter. Download the file first, show what it contains, and ask the person before running it.', 'download-exec': 'CanvasTTY blocked this command: it downloads code and runs it in one step. Download the file first, show what it contains, and ask the person before running it.', @@ -77,8 +79,24 @@ export function actionFromHook(toolName: string, toolInput: unknown, preview: st return { kind: 'edit', command: null, commandCwd: null, paths: path ? [path] : [] }; } +/** + * CanvasTTY's own private data under its userData folder (the app passes `app.getPath('userData')`; tests pass a + * temporary folder): the control token and descriptor, the gateways' connection records and sockets, the secret + * stores, the per-account homes and the prepared launch runs. Its settings and layouts are not listed. + */ +export function canvasTtyPrivateData(userDataPath: string): PrivateData { + const names = ['agent-control', join('browser', 'runtime'), join('lifecycle', 'runtime'), join('orchestration', 'runtime'), + 'provider-secrets.bin', 'plugin-secrets', 'account-homes', 'github-oauth.json', 'launch-runs']; + return { + appRoots: [userDataPath], + paths: names.map(name => join(userDataPath, name)), + markers: ['agent-control', 'provider-secrets', 'plugin-secrets', 'account-homes', 'github-oauth'] + }; +} + /** The first deny rule a set of facts breaks, in a fixed order. */ export function denyRule(facts: HardFacts): BaseDenyRule | null { + if (facts.appPrivate) return 'app-private'; if (facts.elevation) return 'elevation'; if (facts.pipeToShell) return 'pipe-to-shell'; if (facts.downloadExec) return 'download-exec'; @@ -97,6 +115,8 @@ export function denyRule(facts: HardFacts): BaseDenyRule | null { export function checkBaseProtection(input: { toolName: string; toolInput: unknown; preview?: string | null; root: string; commandCwd?: string | null; home?: string; agentRoots?: readonly string[]; + /** CanvasTTY's own private data (canvasTtyPrivateData); its socket folders are known without it. */ + privateData?: PrivateData; }): BaseVerdict | null { try { const action = actionFromHook(input.toolName, input.toolInput, input.preview ?? null); @@ -105,7 +125,8 @@ export function checkBaseProtection(input: { if (!action.commandCwd && input.commandCwd) action.commandCwd = input.commandCwd; const facts = analyzeAction(action, input.root, { ...(input.home ? { home: input.home } : {}), - ...(input.agentRoots ? { agentRoots: input.agentRoots } : {}) + ...(input.agentRoots ? { agentRoots: input.agentRoots } : {}), + ...(input.privateData ? { privateData: input.privateData } : {}) }); const rule = denyRule(facts); if (!rule) return null; diff --git a/src/main/services/safety/commandFacts.ts b/src/main/services/safety/commandFacts.ts index 58bc9104..aefd9477 100644 --- a/src/main/services/safety/commandFacts.ts +++ b/src/main/services/safety/commandFacts.ts @@ -7,8 +7,9 @@ import { lexShell, shellQuote, type Segment, type Word } from './shellParse.ts'; /** * The hard facts base protection decides on, computed by code from one tool call and the working folder. * Nothing is executed and nothing is read except `realpath` of the paths involved. Only the facts a deny rule - * needs are computed: elevation, a pipe into a shell, download-and-run, disk commands, a fork bomb, and writes or - * deletes outside the working folder (deleting the folder itself included). + * needs are computed: elevation, a pipe into a shell, download-and-run, disk commands, a fork bomb, writes or + * deletes outside the working folder (deleting the folder itself included), and any use of CanvasTTY's own private + * data (its access tokens, secret stores and control sockets). */ /** One tool call, source-neutral: a shell command or the files a file tool writes. */ @@ -43,8 +44,18 @@ export interface HardFacts { deletesOutside: boolean; /** Absolute targets written outside the working folder (for the temporary-folder advice). */ outsideWrites: string[]; + /** Names, reads or connects to CanvasTTY's own private data: tokens, secret stores, control/runtime sockets. */ + appPrivate: boolean; } +/** + * CanvasTTY's own private data, as the running app knows it (its userData folder differs per platform and per + * profile, so it is passed in, never guessed). `appRoots`: the app's data folders; `paths`: the private files and + * folders in them (tokens, connection records, secret stores, account homes); `markers`: names that, together with + * an app root's own name, identify those paths inside interpreter code that builds a path piece by piece. + */ +export interface PrivateData { appRoots: readonly string[]; paths: readonly string[]; markers: readonly string[] } + // --------------------------------------------------------------------------- // Paths // --------------------------------------------------------------------------- @@ -70,14 +81,26 @@ const DEVICE = /^(?:\/dev\/(?:r?disk\d|sd[a-z]|hd[a-z]|nvme\d|mmcblk\d|xvd[a-z]| const HARMLESS_DEVICE = /^\/dev\/(?:null|zero|u?random|stdin|stdout|stderr|tty|fd\/\d+)$|^(?:nul|con)$/iu; const WINDOWS_ABSOLUTE = /^(?:[A-Za-z]:[\\/]|[A-Za-z]:$|\\\\)/u; -export interface PathContext { root: string; rootReal: string; home: string; temp: string; agentRoots: string[] } +export interface PathContext { + root: string; rootReal: string; home: string; temp: string; agentRoots: string[]; + /** CanvasTTY's private paths and data folders (as given and resolved), and the temporary folders its sockets live in. */ + privatePaths: string[]; appRoots: string[]; appNames: string[]; markers: string[]; tempRoots: string[]; +} /** * `agentRoots`: the agent's own config folders (Claude's ~/.claude or the run's CLAUDE_CONFIG_DIR). Their plan and * memory folders belong to the agent, so writing there is not a write outside the project. */ -function pathContext(root: string, home = homedir(), agentRoots?: readonly string[]): PathContext { - return { root, rootReal: realish(resolve(root)), home, temp: tmpdir(), agentRoots: (agentRoots ?? [join(home, '.claude')]).map(dir => realish(resolve(dir))) }; +function pathContext(root: string, home = homedir(), agentRoots?: readonly string[], privateData?: PrivateData): PathContext { + const both = (paths: readonly string[]): string[] => [...new Set(paths.filter(path => path && isAbsolute(path)).flatMap(path => [resolve(path), realish(resolve(path))]))]; + const temp = tmpdir(); + return { + root, rootReal: realish(resolve(root)), home, temp, agentRoots: (agentRoots ?? [join(home, '.claude')]).map(dir => realish(resolve(dir))), + privatePaths: both(privateData?.paths ?? []), appRoots: both(privateData?.appRoots ?? []), + appNames: [...new Set((privateData?.appRoots ?? []).map(path => basename(path).toLowerCase()).filter(name => name.length >= 4))], + markers: (privateData?.markers ?? []).map(marker => marker.toLowerCase()).filter(marker => marker.length >= 6), + tempRoots: both([temp, '/tmp', '/private/tmp', '/var/tmp']) + }; } const AGENT_SERVICE_DIR = /^(?:plans|projects[\\/][^\\/]+[\\/]memory)(?:[\\/]|$)/u; @@ -193,9 +216,11 @@ interface Acc { ctx: PathContext; writes: Target[]; deletes: Target[]; - flags: { elevation: boolean; pipeToShell: boolean; downloadExec: boolean; disk: boolean; forkBomb: boolean }; + flags: { elevation: boolean; pipeToShell: boolean; downloadExec: boolean; disk: boolean; forkBomb: boolean; appPrivate: boolean }; depth: number; budget: number; + /** Paths still to be checked against CanvasTTY's private data (each costs a realpath). */ + privateBudget: number; } interface Stdin { pipeIn: boolean; heredoc: string | null } @@ -227,6 +252,7 @@ function analyzeSegment(segment: Segment, cwd: string | null, acc: Acc, download if (/^[A-Za-z_][A-Za-z0-9_]*=/u.test(first.text) || LEADING_RESERVED.has(first.text)) words.shift(); else break; } + checkPrivate(segment, words, cwd, acc); for (const word of segment.words) inspectWord(word, acc); for (const redirect of segment.redirects) { if (redirect.fdDup || !redirect.target) continue; @@ -437,6 +463,7 @@ function runInterpreter(program: string, argWords: Word[], cwd: string | null, a if (python && text === '-m') return cwd; if (/^(?:-c|-e|--eval|-p|--print|-r|-E)$/u.test(text) || program === 'deno' && text === 'eval' || program === 'osascript' && text === '-e') { if (innerFetch) acc.flags.downloadExec = true; + if (args[i + 1] !== undefined && codeNamesPrivate(args[i + 1]!, acc.ctx)) acc.flags.appPrivate = true; if (argWords[i + 1] && fetchesIn(args[i + 1]!) && /\b(?:exec|eval|system|spawn|child_process|subprocess|os\.system)\b/u.test(args[i + 1]!)) acc.flags.downloadExec = true; return cwd; } @@ -449,9 +476,148 @@ function runInterpreter(program: string, argWords: Word[], cwd: string | null, a return cwd; } if (stdin.pipeIn) acc.flags.pipeToShell = true; + // A program read from a heredoc (`python3 - < privateHit(text, cwd, acc, false)) || codeNamesPrivate(stdin.heredoc, acc.ctx))) acc.flags.appPrivate = true; return cwd; } +// --------------------------------------------------------------------------- +// CanvasTTY's own private data +// --------------------------------------------------------------------------- + +/** The folders CanvasTTY's gateways put their sockets in, under a temporary folder (`ctty-control-XXXX`, …). */ +const SOCKET_DIR = /^ctty-(?:control|runtime|orch|user|\d+)-/u; +/** Variables that carry a control descriptor, a gateway address or a capability. */ +const PRIVATE_ENV = /^(?:ENV:)?CANVASTTY_(?:CONTROL_CONNECTION|[A-Z_]*_(?:CAPABILITY|ADDRESS))$/u; +/** CanvasTTY's own control CLI reads its descriptor itself: an orchestrator may name it. */ +const CONTROL_CLI = /^canvastty-control(?:\.mjs)?$/u; +/** Programs that walk folders by themselves: naming a folder that holds private data reads it. */ +const RECURSIVE = new Set(['rg', 'ag', 'ack', 'find', 'fd', 'tar', 'bsdtar', 'zip', '7z', 'rsync', 'ditto', 'scp', 'rclone']); +const GLOB_CHAR = /[*?[]/u; +const MAX_PRIVATE_CHECKS = 256; +const MAX_SCANNED_TEXT = 64 * 1024; + +const parts = (path: string): string[] => path.split(/[\\/]+/u).filter(Boolean); + +/** A glob component (`token-*`, `ctty-control-????`) against one real name. */ +function componentMatches(pattern: string, name: string): boolean { + if (!GLOB_CHAR.test(pattern)) return pattern === name; + let source = ''; + for (let i = 0; i < pattern.length; i++) { + const char = pattern[i]!; + if (char === '*') source += '.*'; + else if (char === '?') source += '.'; + else if (char === '[') { + const close = pattern.indexOf(']', i + 2); + if (close < 0) { source += '\\['; continue; } + source += `[${pattern.slice(i + 1, close).replace(/^!/u, '^').replace(/[\\\]]/gu, '\\$&')}]`; + i = close; + } else source += char.replace(/[.+^${}()|\\\]]/gu, '\\$&'); + } + try { return new RegExp(`^${source}$`, 'u').test(name); } catch { return true; } +} + +/** + * Whether one path (absolute, maybe a glob) is CanvasTTY's private data: inside a private path, a folder of the app + * that holds one when the command walks folders, or a gateway's socket folder under a temporary folder. + */ +function privatePath(abs: string, glob: boolean, recursive: boolean, ctx: PathContext): boolean { + if (!glob) { + // The project, and the agent's own config folder (an account home it was launched with), are its own. + if (isPathInside(ctx.rootReal, abs) || ctx.agentRoots.some(dir => isPathInside(dir, abs))) return false; + if (ctx.privatePaths.some(path => isPathInside(path, abs))) return true; + if (recursive && ctx.privatePaths.some(path => isPathInside(abs, path)) && ctx.appRoots.some(root => isPathInside(root, abs))) return true; + return ctx.tempRoots.some(temp => isPathInside(temp, abs, { allowRoot: false }) && SOCKET_DIR.test(parts(relative(temp, abs))[0] ?? '')); + } + // A glob: the folders before its first wildcard resolved, the rest matched name by name. + const all = parts(abs); + const first = all.findIndex(part => GLOB_CHAR.test(part)); + const prefix = realish((abs.startsWith('/') ? '/' : '') + all.slice(0, first).join('/')); + const pattern = [...parts(prefix), ...all.slice(first)]; + const matchesFrom = (target: string[]): number => { + let i = 0; + while (i < pattern.length && i < target.length && componentMatches(pattern[i]!, target[i]!)) i++; + return i; + }; + for (const path of ctx.privatePaths) { + const target = parts(path); + const matched = matchesFrom(target); + if (matched >= target.length) return true; + if (matched === pattern.length && recursive && ctx.appRoots.some(root => parts(root).length <= pattern.length)) return true; + } + return ctx.tempRoots.some(temp => { + const target = parts(temp); + if (matchesFrom(target) < target.length || pattern.length <= target.length) return false; + const next = pattern[target.length]!; + return SOCKET_DIR.test(next) || next.startsWith('ctty') && GLOB_CHAR.test(next); + }); +} + +/** Expands `~`, $HOME and $TMPDIR in a path found inside a word or in code; null when it is not a path. */ +function codePath(text: string, ctx: PathContext): string | null { + let value = text.trim().replace(/^file:\/\//iu, '/'); + value = value.replace(/^(?:\$HOME|\$\{HOME\})(?=[\\/]|$)/u, ctx.home).replace(/^(?:\$TMPDIR|\$\{TMPDIR\})(?=[\\/]|$)/u, ctx.temp); + if (value === '~' || value.startsWith('~/')) value = ctx.home + value.slice(1); + return value.startsWith('/') && value.length > 1 ? value : null; +} + +/** Paths inside a word or a program text: after `=` or `:` (`--unix-socket=P`, `UNIX-CONNECT:P`), quoted strings, bare tokens. */ +function privateCandidates(text: string, ctx: PathContext): string[] { + if (text.length > MAX_SCANNED_TEXT) text = text.slice(0, MAX_SCANNED_TEXT); + const found = new Set(); + const add = (value: string | undefined): void => { const path = value ? codePath(value, ctx) : null; if (path && found.size < 64) found.add(path); }; + for (const match of text.matchAll(/[=:]((?:~|\$\{?(?:HOME|TMPDIR)\}?|\/)[^\s,;'"`()<>|&]*)/gu)) add(match[1]); + for (const match of text.matchAll(/(['"`])([^'"`\n]{1,4096}?)\1/gu)) add(match[2]); + for (const token of text.split(/[\s,;()[\]{}<>|&'"`=]+/u)) if (/^(?:~|\$\{?(?:HOME|TMPDIR)\}?|\/)/u.test(token)) add(token); + return [...found]; +} + +/** Interpreter code that builds a private path from pieces: an app folder's name together with a private name. */ +function codeNamesPrivate(code: string, ctx: PathContext): boolean { + const lower = code.slice(0, MAX_SCANNED_TEXT).toLowerCase(); + if (/ctty-(?:control|runtime|orch)-/u.test(lower)) return true; + return ctx.appNames.some(name => lower.includes(name)) && ctx.markers.some(marker => lower.includes(marker)); +} + +function privateHit(text: string, cwd: string | null, acc: Acc, recursive: boolean, glob = GLOB_CHAR.test(text)): boolean { + if (--acc.privateBudget < 0) return false; + if (!isAbsolute(text) && cwd === null) return false; + return privatePath(glob ? resolve(cwd ?? acc.ctx.rootReal, text) : realish(resolve(cwd ?? acc.ctx.rootReal, text)), glob, recursive, acc.ctx); +} + +/** The command is CanvasTTY's control CLI (`canvastty-control.mjs …`, `node "$CANVASTTY_CONTROL_CLI" …`). */ +function runsControlCli(words: readonly Word[]): boolean { + const cli = (word: Word | undefined): boolean => Boolean(word && (word.vars.length === 1 && word.vars[0] === 'CANVASTTY_CONTROL_CLI' && /^\$\{?CANVASTTY_CONTROL_CLI\}?$/u.test(word.text) || !word.vars.length && !word.substitution && CONTROL_CLI.test(programName(word.text)))); + if (cli(words[0])) return true; + if (!words[0] || !INTERPRETERS.has(programName(words[0].text))) return false; + return cli(words.slice(1).find(word => !word.text.startsWith('-'))); +} + +/** + * One segment against CanvasTTY's private data: every word (and each path inside it), every redirection, and the + * variables that carry a descriptor or a capability. Whatever program reads, copies, encodes or connects to them, + * the command uses them. CanvasTTY's own control CLI is the one program that may name its descriptor. + */ +function checkPrivate(segment: Segment, words: Word[], cwd: string | null, acc: Acc): void { + if (acc.flags.appPrivate || acc.privateBudget <= 0) return; + if (runsControlCli(words)) return; + const recursive = words.some(word => RECURSIVE.has(programName(word.text)) || /^(?:-[a-zA-Z]*[rR][a-zA-Z]*|--recursive|--archive)$/u.test(word.text)) + || programName(words[0]?.text ?? '') === 'cp' && words.some(word => /^-[a-zA-Z]*a/u.test(word.text)); + const targets = [...segment.words, ...segment.redirects.filter(redirect => !redirect.fdDup && redirect.target).map(redirect => redirect.target!)]; + for (const word of targets) { + if (word.vars.some(name => PRIVATE_ENV.test(name))) { acc.flags.appPrivate = true; return; } + if (word.substitution) continue; + const text = expand(word, cwd, acc.ctx); + if (text === null || text === '') continue; + const candidates = new Set([text, ...(text.length > 1 && /[=:'"\s]/u.test(text) ? privateCandidates(text, acc.ctx) : [])]); + for (const candidate of candidates) { + if (!privateHit(candidate, cwd, acc, recursive, candidate === text ? word.glob : GLOB_CHAR.test(candidate))) continue; + acc.flags.appPrivate = true; + return; + } + } +} + function classifyProgram(program: string, argWords: Word[], cwd: string | null, acc: Acc, stdin: Stdin, downloadedHere: Target[]): void { const args = argWords.map(word => word.text); const windows = WINDOWS_BUILTINS.has(program) || args.some(arg => /^\/[sq]$/iu.test(arg)); @@ -843,13 +1009,17 @@ function gitEffect(sub: string, rest: readonly string[]): 'read' | 'write' | 'de /** Converts an argv array (Codex style `["bash","-lc","…"]`) into one command string. */ export function commandFromArgv(argv: readonly string[]): string { return argv.map(shellQuote).join(' '); } -export function analyzeAction(action: ToolAction, root: string, options: { home?: string; agentRoots?: readonly string[] } = {}): HardFacts { - const ctx = pathContext(root, options.home, options.agentRoots); - const acc: Acc = { ctx, writes: [], deletes: [], depth: 0, budget: 64, flags: { elevation: false, pipeToShell: false, downloadExec: false, disk: false, forkBomb: false } }; +export function analyzeAction(action: ToolAction, root: string, options: { home?: string; agentRoots?: readonly string[]; privateData?: PrivateData } = {}): HardFacts { + const ctx = pathContext(root, options.home, options.agentRoots, options.privateData); + const acc: Acc = { ctx, writes: [], deletes: [], depth: 0, budget: 64, privateBudget: MAX_PRIVATE_CHECKS, + flags: { elevation: false, pipeToShell: false, downloadExec: false, disk: false, forkBomb: false, appPrivate: false } }; const commandCwd = action.commandCwd ? resolveTarget(action.commandCwd, ctx.rootReal, ctx) : null; const cwd = commandCwd ? commandCwd.abs : ctx.rootReal; if (action.kind === 'shell' && action.command) analyzeText(action.command, cwd, acc); - else if (action.kind === 'edit') acc.writes.push(...action.paths.map(path => resolveTarget(path, cwd, ctx))); + else if (action.kind === 'edit') { + acc.writes.push(...action.paths.map(path => resolveTarget(path, cwd, ctx))); + if (action.paths.some(path => { const text = codePath(path, ctx) ?? path; return privateHit(text, cwd, acc, false, false); })) acc.flags.appPrivate = true; + } const outside = acc.writes.filter(t => t.where === 'outside' && !t.device && !(t.abs && isAgentServicePath(t.abs, ctx))); return { ...acc.flags, diff --git a/tests/base-protection-app-private.test.mjs b/tests/base-protection-app-private.test.mjs new file mode 100644 index 00000000..e79b330d --- /dev/null +++ b/tests/base-protection-app-private.test.mjs @@ -0,0 +1,143 @@ +/** + * Base protection of CanvasTTY's own private data: its control token and descriptor, the gateways' connection + * records and sockets, the secret stores and account homes. Everything lives in temporary folders: HOME and the + * userData folder are fakes, nothing real is read, and no socket is opened. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { analyzeAction } from "../src/main/services/safety/commandFacts.ts"; +import { canvasTtyPrivateData, checkBaseProtection, denyRule } from "../src/main/services/safety/baseProtection.ts"; +import { DecisionHooks } from "../src/main/services/DecisionHooks.ts"; + +const base = realpathSync(mkdtempSync(join(tmpdir(), "canvastty-app-private-"))); +const home = join(base, "home"); +const project = join(base, "project"); +const userData = join(home, "Library", "Application Support", "canvastty"); +const control = join(userData, "agent-control"); +for (const dir of [project, join(project, "src"), join(project, "agent-control"), control, join(userData, "plugin-secrets"), join(userData, "account-homes", "acct-1", ".claude"), join(userData, "lifecycle", "runtime")]) { + mkdirSync(dir, { recursive: true }); +} +writeFileSync(join(control, "token-0123abcd"), "fake-token\n", { mode: 0o600 }); +writeFileSync(join(control, "connection.json"), "{}\n", { mode: 0o600 }); +writeFileSync(join(userData, "settings.json"), "{}\n"); +writeFileSync(join(project, "agent-control", "token-1"), "fixture\n"); +process.on("exit", () => rmSync(base, { recursive: true, force: true })); + +const privateData = canvasTtyPrivateData(userData); +const token = join(control, "token-0123abcd"); +const shell = (command) => ({ kind: "shell", command, commandCwd: null, paths: [] }); +const rule = (command, extra = {}) => denyRule(analyzeAction(shell(command), project, { home, privateData, ...extra })); +const q = (path) => `'${path}'`; +const sock = "$TMPDIR/ctty-control-Ab12Cd/c.sock"; + +const DENY = [ + // Reading the token and the descriptor, whatever the program. + `cat ${q(token)}`, + `cat "$HOME/Library/Application Support/canvastty/agent-control/token-0123abcd"`, + "cat ~/Library/Application\\ Support/canvastty/agent-control/token-*", + `head -c 64 ${q(join(control, "connection.json"))}`, + `grep -a . ${q(token)}`, + `cp ${q(token)} ./copy.txt`, + `base64 < ${q(token)}`, + `xxd ${q(join(userData, "provider-secrets.bin"))}`, + `strings ${q(join(userData, "provider-secrets.bin"))}`, + `sqlite3 ${q(join(userData, "account-homes", "acct-1", "state.db"))} .dump`, + `cat ${q(join(userData, "github-oauth.json"))}`, + `cat ${q(join(userData, "lifecycle", "runtime", "connection.json"))}`, + `ls ${q(control)}`, + `cd ${q(control)} && cat token-0123abcd`, + `cd ${q(userData)} && cat agent-control/token-0123abcd`, + `echo "$(cat ${q(token)})"`, + `bash -c "cat ${q(token)}"`, + `find ${q(userData)} -name 'token-*'`, + `grep -r token ${q(userData)}`, + `rg secret ${q(userData)}`, + `tar -czf out.tgz ${q(userData)}`, + `cat ${q(userData)}/*/token-*`, + "cat \"$CANVASTTY_CONTROL_CONNECTION\"", + "echo $CANVASTTY_RUNTIME_CAPABILITY", + // Interpreter one-liners and heredocs. + `python3 -c "print(open('${token}').read())"`, + "python3 -c \"import os;p=os.path.join(os.path.expanduser('~'),'Library','Application Support','canvastty','agent-control');print(os.listdir(p))\"", + "node -e \"console.log(require('fs').readFileSync(process.env.HOME + '/Library/Application Support/canvastty/plugin-secrets/x', 'utf8'))\"", + `python3 - <<'EOF'\nprint(open("${token}").read())\nEOF`, + // The control and runtime sockets. + `curl --unix-socket ${sock} http://localhost/`, + `curl -s --unix-socket=${sock} http://x/`, + `nc -U ${sock}`, + "echo '{\"v\":1}' | nc -U /tmp/ctty-orch-501-abcd1234/o.sock", + `socat - UNIX-CONNECT:${sock}`, + "ls $TMPDIR/ctty-control-*", + "cat $TMPDIR/ctty-*/c.sock", + "python3 -c \"import socket,os;s=socket.socket(socket.AF_UNIX);s.connect(os.environ['TMPDIR']+'/ctty-control-x/c.sock')\"", + `nc -U ${q(join(userData, "lifecycle", "runtime", "r-ab12.sock"))}`, + // A controlled-looking word that is not the CLI does not excuse the rest. + `cat ${q(token)} canvastty-control.mjs` +]; + +const ALLOW = [ + "cat src/agent-control.ts", + "cat agent-control/token-1", + "grep -rn \"plugin-secrets\" src", + "git commit -m \"fix agent-control token file mode\"", + `cat ${q(join(userData, "settings.json"))}`, + `ls ${q(userData)}`, + "node \"$CANVASTTY_CONTROL_CLI\" list", + `node /Applications/CanvasTTY.app/Contents/Resources/agent-control/canvastty-control.mjs --connection ${q(join(control, "connection.json"))} list`, + "curl --unix-socket /var/run/docker.sock http://localhost/version", + "nc -U /tmp/other.sock", + "ls $TMPDIR", + "ls /tmp/ctty-notes", + "python3 -c \"print('canvastty')\"", + "cat ~/.config/other/token", + "node -e \"console.log(1)\"", + "curl https://example.com/agent-control/token-1", + "echo hi > out.txt" +]; + +test("CanvasTTY's own tokens, secret stores and sockets are refused for every reader and client", () => { + for (const command of DENY) assert.equal(rule(command), "app-private", command); +}); + +test("look-alikes in the project, the app's other files and other sockets stay allowed", () => { + for (const command of ALLOW) assert.equal(rule(command), null, command); +}); + +test("file tools that name private data are refused; the agent's own account home is its own", () => { + const check = (toolName, toolInput, extra = {}) => checkBaseProtection({ toolName, toolInput, root: project, home, privateData, ...extra }); + assert.equal(check("Write", { file_path: join(control, "token-x"), content: "x" })?.rule, "app-private"); + assert.equal(check("edit", { file_path: "~/Library/Application Support/canvastty/plugin-secrets/p.json" })?.rule, "app-private"); + const accountHome = join(userData, "account-homes", "acct-1", ".claude"); + assert.equal(check("Write", { file_path: join(accountHome, "plans", "p.md") }, { agentRoots: [accountHome] }), null); + assert.equal(check("Bash", { command: `cat ${q(join(accountHome, "projects", "p", "memory", "MEMORY.md"))}` }, { agentRoots: [accountHome] }), null); + assert.equal(check("Bash", { command: `cat ${q(token)}` }, { agentRoots: [accountHome] })?.rule, "app-private"); +}); + +test("without the app's folder the socket folders are still known; the userData paths are not guessed", () => { + assert.equal(denyRule(analyzeAction(shell(`nc -U ${sock}`), project, { home })), "app-private"); + assert.equal(denyRule(analyzeAction(shell(`cat ${q(token)}`), project, { home })), null); +}); + +test("the message tells the model calmly why and what to do instead, without paths or protocol details", () => { + const verdict = checkBaseProtection({ toolName: "Bash", toolInput: { command: `cat ${q(token)}` }, root: project, home, privateData }); + assert.equal(verdict.rule, "app-private"); + assert.match(verdict.message, /^CanvasTTY blocked this: it reads CanvasTTY's own access tokens/u); + assert.match(verdict.message, /Orchestrator role/u); + assert.match(verdict.message, /canvastty_agents tools \(spawn_agent, list_routes, wait_for_agent/u); + assert.doesNotMatch(verdict.message, /token-|\.sock|agent-control|Application Support|ctty-/u); +}); + +test("decision hooks pass the app's private data to base protection", async () => { + const hooks = new DecisionHooks({ + baseProtection: () => true, services: () => [], call: async () => null, home, privateData, + session: () => ({ provider: "opencode", role: "agent", cwd: project, configDirs: [] }) + }); + const decision = await hooks.decide("s1", { toolName: "bash", toolInput: { command: `cat ${q(token)}` }, toolInputPreview: null, cwd: null, truncated: false }, new AbortController().signal); + assert.equal(decision.behavior, "deny"); + assert.match(decision.message, /Orchestrator role/u); + const ordinary = await hooks.decide("s1", { toolName: "bash", toolInput: { command: "cat src/a.ts" }, toolInputPreview: null, cwd: null, truncated: false }, new AbortController().signal); + assert.equal(ordinary.behavior, "none"); +}); From 6cd13ee49da54687eca5724b052c13288188646c Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:12:51 +0000 Subject: [PATCH 5/5] fix(agent-control): answer refused and HTTP requests with guidance, then close An unauthenticated or malformed request to the control endpoint now gets a stable INVALID_REQUEST whose message says only sessions CanvasTTY launched as orchestrators may use it and how to get one, with no protocol details, token names or paths. An HTTP request line (curl, a browser) gets a minimal 403 with the same text. Either way the connection is closed right after, instead of waiting for the idle timeout; the connection cap and the one-request-per-connection rule are unchanged. Tests cover a guessed NDJSON request, garbage and an HTTP request, and that the token file is 0600 and its folder 0700 even under umask 0 and a pre-existing loose folder. --- CHANGELOG.md | 1 + CHANGELOG.ru.md | 1 + CHANGELOG.zh-CN.md | 1 + .../agent-control/AgentControlGateway.ts | 27 ++++++++- tests/agent-control.test.mjs | 58 ++++++++++++++++++- 5 files changed, 85 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf3c1d5e..eceb3353 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ## Unreleased +- Base protection now also refuses any use of CanvasTTY's own private data by an agent's shell or file tool: reading, copying or encoding the agent-control token and descriptor, the gateways' connection records, the provider and plugin secret stores, account homes, the GitHub sign-in and prepared launch runs (by any program, interpreter one-liners and heredocs included), and connecting to CanvasTTY's control or runtime sockets (`curl --unix-socket`, `nc -U`, `socat`, a Python socket). The model is told calmly that agents cannot control CanvasTTY this way and to ask the person for an **Orchestrator** launch, which brings the `canvastty_agents` tools. The paths come from the app's own userData folder; the project, the app's settings, other sockets and the bundled control CLI are unaffected. The control endpoint now answers an unauthenticated or malformed request, and an HTTP request (a minimal 403), with the same guidance instead of a bare error, and closes the connection. - Added an **Auto** launch profile for agents whose CLI has a native auto mode, next to Normal (still the default) and YOLO: Codex `--approve-for-me` (its own reviewer in its `workspace-write` sandbox), Claude Code `--permission-mode auto` with its sandbox (`sandbox.enabled`, `autoAllowBashIfSandboxed: false`, merged into the one `--settings`), Grok `--permission-mode auto`; also the control CLI's `create --profile auto` and plugin `sessions.create`. A launch contributor may answer `thirdPartyModel: true` (an API or Ollama account): Auto then runs as the CLI's accept-edits mode in the same sandbox, and the card shows **auto · edits**. Codex no longer stops at "Hooks need review" for the hooks CanvasTTY adds itself (per-run `-c hooks.state`, nothing written to `~/.codex`; plugins cannot pass `-c hooks…`), and a Codex subagent in (or below) the folder the person chose for its orchestrator is not asked to trust it again (per-run `-c projects`); plugins get that folder as `trustedFolder`. Claude Code's «✳» title now reads as idle: a hooked Claude card leaves `needs_approval` only through its hooks, or, when the person declined its prompt, a moment after the answer. Example: `examples/plugins/launch-env` (Local model profile). - Added two launch points for account plugins. A launcher `select` may declare `"optionsFrom": "service"`: the launcher asks the service `canvastty.launch.options` (3 s) and lists up to 64 more choices after the declared ones, such as the plugin's own accounts; the saved value is then checked by the service when it prepares. Orchestrators may pass plugin launch options to `spawn_agent` as `launchOptions`, checked exactly like the launcher's. A plugin's inline Claude `--settings` is merged into CanvasTTY's own (Claude Code keeps only the last one, which dropped the lifecycle and decision hooks); approval and hook keys in it are refused. Example: `examples/plugins/launch-env` (Profile). - Added plugin services (manifest apiVersion 2, `services`): bundled single-file JavaScript that runs as a supervised child process only after the separate per-plugin **Extension native code** confirmation in Settings → Agents (off by default, never granted by install, revoked by update, module change, disable, or a changed entry file). Services get a minimal environment without keys or CanvasTTY internals, speak JSON-RPC over stdio with 1 MB messages and 15 s timeouts, restart with backoff, stop on disable, uninstall, update and quit, and log to a bounded per-plugin log. Plugin surfaces call their own plugin's services through `host.service.request` and receive `host.service.onEvent`; services may call back `log`, own-plugin `storage` and `event`, and read their own plugin's secrets with `secrets.get` (needs `secrets`). Example: `examples/plugins/service-echo` (its service also reads a token the page saved). diff --git a/CHANGELOG.ru.md b/CHANGELOG.ru.md index 514e150b..59c5e807 100644 --- a/CHANGELOG.ru.md +++ b/CHANGELOG.ru.md @@ -4,6 +4,7 @@ ## Unreleased +- Базовая защита теперь также запрещает shell- и файловым инструментам агента любые обращения к собственным закрытым данным CanvasTTY: чтение, копирование или кодирование токена и дескриптора agent-control, файлов подключения шлюзов, хранилищ секретов провайдеров и плагинов, домашних папок аккаунтов, входа GitHub и подготовленных запусков (любой программой, включая однострочники интерпретаторов и heredoc), а также подключение к управляющим и runtime-сокетам CanvasTTY (`curl --unix-socket`, `nc -U`, `socat`, сокет Python). Модель спокойно получает объяснение, что так управлять CanvasTTY нельзя, и совет попросить человека запустить её с ролью **Orchestrator**, которая даёт инструменты `canvastty_agents`. Пути берутся из собственной папки userData приложения; проект, настройки приложения, другие сокеты и встроенный CLI управления не затронуты. Управляющий endpoint теперь отвечает на неаутентифицированный или некорректный запрос, а также на HTTP-запрос (минимальный 403) тем же объяснением вместо голой ошибки и закрывает соединение. - Добавлен профиль запуска **Авто** для агентов, у чьего CLI есть собственный авторежим, рядом с «Обычным» (он остаётся по умолчанию) и YOLO: Codex `--approve-for-me` (его собственная проверка в песочнице `workspace-write`), Claude Code `--permission-mode auto` с его песочницей (`sandbox.enabled`, `autoAllowBashIfSandboxed: false`, в единственном `--settings`), Grok `--permission-mode auto`; также `create --profile auto` в CLI управления и `sessions.create` плагинов. Вклад запуска может ответить `thirdPartyModel: true` (аккаунт API или Ollama): тогда «Авто» работает как режим «только правки» того же CLI в той же песочнице, а окно показывает **авто · правки**. Codex больше не останавливается на «Hooks need review» для хуков, которые добавляет сам CanvasTTY (`-c hooks.state` на этот запуск, в `~/.codex` ничего не пишется; плагины не могут передать `-c hooks…`), а субагента Codex в папке, выбранной человеком для его оркестратора (или внутри неё), не спрашивают о доверии к ней снова (`-c projects` на этот запуск); плагины получают эту папку как `trustedFolder`. Заголовок Claude Code «✳» теперь читается как «ожидает»: окно Claude с хуками выходит из `needs_approval` только по хукам, а если человек отклонил запрос — вскоре после ответа. Пример: `examples/plugins/launch-env` (профиль «Local model»). - Добавлены две точки запуска для плагинов учётных записей. Список (`select`) в параметрах запуска может объявить `"optionsFrom": "service"`: окно запуска спрашивает сервис `canvastty.launch.options` (3 с) и показывает до 64 дополнительных вариантов после объявленных, например учётные записи самого плагина; сохранённое значение проверяет сервис при подготовке запуска. Оркестраторы могут передать параметры запуска плагинов в `spawn_agent` как `launchOptions`; они проверяются так же, как в окне запуска. Встроенный `--settings` плагина для Claude сливается с собственным JSON CanvasTTY (Claude Code применяет только последний, из-за чего пропадали хуки состояния и решений); ключи подтверждений и хуков в нём отклоняются. Пример: `examples/plugins/launch-env` (Profile). - Добавлены сервисы плагинов (манифест apiVersion 2, `services`): собранный одним файлом JavaScript, который запускается отдельным дочерним процессом под надзором хоста только после отдельного подтверждения **Нативный код расширений** для плагина в Настройки → Агенты (по умолчанию выключено, установка его не даёт, обновление, смена модулей, выключение или изменённый файл entry его снимают). Сервис получает минимальное окружение без ключей и внутренних переменных CanvasTTY, общается по JSON-RPC через stdio (сообщения до 1 МБ, таймаут 15 с), перезапускается с паузами, останавливается при выключении, удалении, обновлении и выходе и пишет в ограниченный журнал плагина. Поверхности плагина обращаются к сервисам своего плагина через `host.service.request` и получают `host.service.onEvent`; сервис может вызывать `log`, `storage` своего плагина и `event` и читать секреты своего плагина через `secrets.get` (нужно `secrets`). Пример: `examples/plugins/service-echo` (его сервис ещё и читает токен, сохранённый страницей). diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index 1b609785..f9250e02 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -4,6 +4,7 @@ ## Unreleased +- 基础保护现在还会拒绝智能体的 shell 或文件工具使用 CanvasTTY 自己的私有数据:读取、复制或编码 agent-control 令牌与描述文件、各网关的连接记录、提供商与插件的密钥存储、账户主目录、GitHub 登录信息和已准备的启动运行(任何程序,包括解释器单行命令和 heredoc),以及连接 CanvasTTY 的控制或运行时套接字(`curl --unix-socket`、`nc -U`、`socat`、Python 套接字)。模型会平静地得知智能体不能以这种方式控制 CanvasTTY,并被建议请用户以 **Orchestrator** 角色启动它,从而获得 `canvastty_agents` 工具。路径来自应用自己的 userData 文件夹;项目、应用设置、其他套接字和内置控制 CLI 不受影响。控制端点现在对未认证或格式错误的请求,以及 HTTP 请求(最小的 403),都以相同的指引代替简单错误作答,并关闭连接。 - 为 CLI 自带自动模式的智能体新增 **Auto** 启动配置档,与 Normal(仍为默认)和 YOLO 并列:Codex `--approve-for-me`(其自身审查,位于 `workspace-write` 沙箱),Claude Code `--permission-mode auto` 及其沙箱(`sandbox.enabled`、`autoAllowBashIfSandboxed: false`,合并进唯一的 `--settings`),Grok `--permission-mode auto`;控制 CLI 的 `create --profile auto` 和插件的 `sessions.create` 也支持。启动贡献者可回答 `thirdPartyModel: true`(API 或 Ollama 账户):此时 Auto 以同一沙箱中 CLI 的“仅接受编辑”模式运行,卡片显示 **auto · edits**。Codex 不再因 CanvasTTY 自己添加的 hook 停在 “Hooks need review”(本次运行的 `-c hooks.state`,不写入 `~/.codex`;插件不能传递 `-c hooks…`),位于用户为其编排者所选文件夹(或其子目录)中的 Codex 子智能体不再被再次询问是否信任(本次运行的 `-c projects`);插件以 `trustedFolder` 获得该文件夹。Claude Code 的 «✳» 标题现在表示空闲:带 hook 的 Claude 卡片仅通过 hook 离开 `needs_approval`,或在用户拒绝其提示后稍候离开。示例:`examples/plugins/launch-env`(Local model 配置档)。 - 新增两个供账户插件使用的启动扩展点。启动选项中的 `select` 可以声明 `"optionsFrom": "service"`:启动器向服务发送 `canvastty.launch.options`(3 秒),并在声明的选项之后列出最多 64 个额外选项,例如插件自己的账户;保存的值由服务在准备启动时检查。编排器可以把插件启动选项作为 `launchOptions` 传给 `spawn_agent`,校验方式与启动器相同。插件为 Claude 提供的内联 `--settings` 会合并进 CanvasTTY 自己的 JSON(Claude Code 只保留最后一个,此前会丢失生命周期和决策 hook);其中的审批和 hook 键会被拒绝。示例:`examples/plugins/launch-env`(Profile)。 - 新增插件服务(manifest apiVersion 2,`services`):打包为单文件的 JavaScript,仅在 设置 → Agents 中为该插件单独确认 **Extension native code** 后才作为受监管的子进程运行(默认关闭,安装不会授予;更新、更换模块、禁用或 entry 文件被修改都会撤销)。服务获得不含密钥和 CanvasTTY 内部变量的最小环境,通过 stdio 使用 JSON-RPC(消息上限 1 MB,超时 15 秒),退避重启,在禁用、卸载、更新和退出时停止,并写入有界的插件日志。插件界面通过 `host.service.request` 调用自身插件的服务,并通过 `host.service.onEvent` 接收事件;服务可回调 `log`、自身插件的 `storage` 和 `event`,并可用 `secrets.get` 读取自身插件的机密(需要 `secrets`)。示例:`examples/plugins/service-echo`(其服务还会读取页面保存的令牌)。 diff --git a/src/main/services/agent-control/AgentControlGateway.ts b/src/main/services/agent-control/AgentControlGateway.ts index ef124d86..56f0f5ec 100644 --- a/src/main/services/agent-control/AgentControlGateway.ts +++ b/src/main/services/agent-control/AgentControlGateway.ts @@ -27,6 +27,13 @@ const MAX_TRANSPORT_RESTART_ATTEMPTS = 3; const TRANSPORT_RESTART_BASE_DELAY_MS = 500; const MAX_TEXT = 16_000; const ID = /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$/; +/** + * What a caller that is not CanvasTTY's control CLI reads (an unauthenticated, malformed or HTTP request): why it + * was refused and what to do instead. Stable, and free of protocol details, file names and paths. + */ +export const CONTROL_REFUSAL_MESSAGE = "CanvasTTY refused this request: this endpoint only accepts requests from sessions CanvasTTY itself launched as orchestrators, and guessing its protocol will not work. If you are an agent and need other agents, ask the person to start you from CanvasTTY's launcher with the Orchestrator role: you will then get the canvastty_agents tools (spawn_agent, list_routes, wait_for_agent and the rest)."; +/** An HTTP request line (curl, a browser, an HTTP/2 preface): answered with a minimal 403 instead of NDJSON. */ +const HTTP_REQUEST_LINE = /^[A-Z]{3,10} \S{1,4096} HTTP\/\d(?:\.\d)?\r?$/; const SECRET = /^[a-f0-9]{64}$/; interface TerminalPort { @@ -283,6 +290,24 @@ export class AgentControlGateway { socket.write(data); } catch { socket.destroy(); } }; + // A refusal is answered once and the connection closed, so a caller is not left waiting for the timeout. + const close = (): void => { + // A Unix socket half-closes after the reply is flushed; the Windows relay has no end(), so it is dropped shortly. + const end = (socket as { end?: () => void }).end; + if (typeof end === "function") end.call(socket); + else setTimeout(() => socket.destroy(), 250).unref(); + }; + const refuse = (line: Buffer): void => { + if (socket.destroyed) return; + if (HTTP_REQUEST_LINE.test(line.subarray(0, 4200).toString("latin1"))) { + const body = Buffer.from(`${CONTROL_REFUSAL_MESSAGE}\n`); + socket.write(Buffer.concat([Buffer.from("HTTP/1.1 403 Forbidden\r\nContent-Type: text/plain; charset=utf-8\r\n" + + `Content-Length: ${body.length}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n`), body])); + } else { + reply({ v: 1, ok: false, error: { code: "INVALID_REQUEST", message: CONTROL_REFUSAL_MESSAGE } }); + } + close(); + }; socket.on("data", (chunk) => { if (handled) return; let line: Buffer | undefined; @@ -291,7 +316,7 @@ export class AgentControlGateway { handled = true; let request: ControlRequest; try { request = this.parse(JSON.parse(line.toString("utf8"))); } - catch { reply({ v: 1, ok: false, error: { code: "INVALID_REQUEST", message: "Invalid or unauthenticated control request." } }); return; } + catch { refuse(line); return; } void this.dispatch(request).then( (result) => reply({ v: 1, id: request.id, ok: true, result }), (error: unknown) => reply({ v: 1, id: request.id, ok: false, error: { diff --git a/tests/agent-control.test.mjs b/tests/agent-control.test.mjs index 2aee7ea4..1f53011c 100644 --- a/tests/agent-control.test.mjs +++ b/tests/agent-control.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; import { spawn } from "node:child_process"; -import { mkdtemp, readFile, realpath, rm, stat, writeFile } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, readFile, realpath, rm, stat, writeFile } from "node:fs/promises"; import { EventEmitter } from "node:events"; import { createConnection } from "node:net"; import { tmpdir } from "node:os"; @@ -10,7 +10,7 @@ import { setTimeout as delay } from "node:timers/promises"; import test from "node:test"; import { controlRequest, parseArguments, runCli } from "../scripts/canvastty-control.mjs"; import xterm from "@xterm/headless"; -import { AgentControlGateway, codexComposerReady } from "../src/main/services/agent-control/AgentControlGateway.ts"; +import { AgentControlGateway, CONTROL_REFUSAL_MESSAGE, codexComposerReady } from "../src/main/services/agent-control/AgentControlGateway.ts"; import { TerminalManager, terminalEnvironment } from "../src/main/services/TerminalManager.ts"; import { TerminalSessionStore } from "../src/main/services/TerminalSessionStore.ts"; import { AgentControlService } from "../src/main/services/AgentControlService.ts"; @@ -284,9 +284,63 @@ test("invalid socket credentials cannot launch a native session", localSocket, a }); assert.equal(reply.ok, false); assert.equal(reply.error.code, "INVALID_REQUEST"); + assert.equal(reply.error.message, CONTROL_REFUSAL_MESSAGE); assert.equal(f.calls.length, 0); }); +/** Sends raw bytes to the control socket and collects everything until the gateway closes the connection. */ +function rawExchange(endpoint, bytes) { + return new Promise((resolveReply, reject) => { + const socket = createConnection(endpoint); + const chunks = []; + socket.on("error", reject); + socket.setTimeout(3000, () => { socket.destroy(); reject(new Error("the gateway did not close the refused connection")); }); + socket.on("data", (chunk) => chunks.push(chunk)); + socket.on("end", () => { socket.destroy(); resolveReply(Buffer.concat(chunks).toString("utf8")); }); + socket.on("connect", () => socket.write(bytes)); + }); +} + +test("unauthenticated, garbage and HTTP requests get the same guidance and a closed connection", localSocket, async (t) => { + const f = await fixture(t); + const descriptor = JSON.parse(await readFile(f.connectionPath, "utf8")); + const guessed = await rawExchange(descriptor.endpoint, JSON.stringify({ method: "list", params: {} }) + "\n"); + const garbage = await rawExchange(descriptor.endpoint, "hello?\n"); + for (const text of [guessed, garbage]) { + const reply = JSON.parse(text.trim()); + assert.deepEqual(reply, { v: 1, ok: false, error: { code: "INVALID_REQUEST", message: CONTROL_REFUSAL_MESSAGE } }); + } + const http = await rawExchange(descriptor.endpoint, "GET / HTTP/1.1\r\nHost: localhost\r\nUser-Agent: curl/8\r\nAccept: */*\r\n\r\n"); + const [head, body] = http.split("\r\n\r\n"); + assert.match(head, /^HTTP\/1\.1 403 Forbidden\r\n/u); + assert.match(head, /\r\nConnection: close/u); + assert.equal(Number(/Content-Length: (\d+)/u.exec(head)[1]), Buffer.byteLength(body)); + assert.equal(body, `${CONTROL_REFUSAL_MESSAGE}\n`); + // The guidance names the way in and nothing about the protocol, the token or where anything lives. + assert.match(CONTROL_REFUSAL_MESSAGE, /Orchestrator role/u); + assert.match(CONTROL_REFUSAL_MESSAGE, /canvastty_agents tools/u); + assert.doesNotMatch(CONTROL_REFUSAL_MESSAGE, /token|instanceId|controller|\.sock|connection\.json|agent-control|ndjson|json/iu); + assert.equal(f.calls.length, 0); +}); + +test("the token file is private (0600) in a private folder (0700), even under a loose umask or a loose folder", localSocket, async (t) => { + const root = await realpath(await mkdtemp(join(tmpdir(), "canvastty-control-hygiene-"))); + await mkdir(join(root, "agent-control"), { mode: 0o777 }); + await chmod(join(root, "agent-control"), 0o777); + const previous = process.umask(0); + const terminals = { create() { throw new Error("unused"); }, listMetadata: () => [], readBuffer() { throw new Error("unused"); }, + inputChecked: () => false, geometry: () => ({ cols: 80, rows: 24 }) }; + const gateway = new AgentControlGateway({ userDataPath: root, terminals, lifecycleEnabled: () => false }); + t.after(async () => { process.umask(previous); await gateway.close(); await rm(root, { recursive: true, force: true }); }); + let connectionPath; + try { connectionPath = await gateway.start(); } finally { process.umask(previous); } + const descriptor = JSON.parse(await readFile(connectionPath, "utf8")); + assert.equal((await stat(join(root, "agent-control"))).mode & 0o777, 0o700); + assert.equal((await stat(descriptor.tokenFile)).mode & 0o777, 0o600); + assert.equal((await stat(connectionPath)).mode & 0o777, 0o600); + assert.equal((await stat(join(descriptor.endpoint, ".."))).mode & 0o777, 0o700); +}); + test("a control write waiting on terminal replay cannot reach a restarted session", localSocket, async (t) => { const held = []; const original = xterm.Terminal.prototype.write;