diff --git a/docs/performance-benchmark.md b/docs/performance-benchmark.md new file mode 100644 index 00000000..f5aae9d8 --- /dev/null +++ b/docs/performance-benchmark.md @@ -0,0 +1,50 @@ +# Runtime benchmark + +`scripts/bench-runtime.mjs` measures what CanvasTTY costs while it runs: memory and CPU of the built app, +and the main-process hot paths that grow with the number of cards. Use it before and after a change that +touches terminal output, the canvas, orchestration, redaction or the Even G2 companion. + +```sh +npx electron-vite build # the app scenarios measure out/ +node scripts/bench-runtime.mjs # 3 runs, medians +node scripts/bench-runtime.mjs --runs 1 --micro-only +node scripts/bench-runtime.mjs --runs 1 --pan-only # cards and the pan only, about 30 s +node scripts/bench-runtime.mjs --terminals 8 --kbps 1024 --flood-seconds 20 --json bench.json +``` + +Every run gets its own temporary `HOME` (with `GROK_HOME`, `CODEX_HOME`, `CLAUDE_CONFIG_DIR` and the XDG +folders inside it), userData and working folder, removed afterwards. The app runs from `out/` through a +small harness in `scripts/bench-runtime/app/`: its windows are created hidden, off-screen and unfocusable, +native dialogs are answered locally, `safeStorage` is off and `/usr/bin/security` is refused, so a run never +shows a window, takes focus or touches the keychain. Temporary folders go under `/tmp` (or `BENCH_TMPDIR`) +because the app's Unix sockets live in userData and macOS caps a socket path at 104 bytes. + +## App scenarios + +| Scenario | What happens | Reported | +| --- | --- | --- | +| idle | 8 s after the workspace is ready, 10 s window | RSS per process kind, CPU % per kind | +| terminals | N plain terminal cards (`--terminals`, default 8), 8 s settle, 10 s window | same | +| flood | every card runs `scripts/bench-runtime/flood.mjs` at `--kbps` KB/s (default 1024) | same, peak RSS, MB of `terminal:data` sent to the renderer | +| after | 10 s after the flood ended, 5 s window | same | +| pan | a middle-button drag of 300 moves, 16 ms apart, over the N cards | React commits, components rendered with new props per move, TerminalCard renders per move, renderer main-thread ms per move (script, style, layout, all tasks, from the DevTools Performance domain), the most rendered components, renderer CPU % | + +CPU % is per process kind, of one core, from the kernel's per-process CPU time (`ps`) over the window. +"pty" is the shells and whatever runs in them (the flood generators included); "electron" is the app itself. +Component renders are counted by a minimal React DevTools hook the harness installs in the page; it needs no +component names, so a minified build counts the same way. + +## Micro-benchmarks + +`scripts/bench-runtime/micro.mjs` runs in plain Node against `src/` with fake PTYs: + +| Key | Meaning | +| --- | --- | +| `scrollbackRetainedChars` | characters the scrollback chunk array still references after 2 MB of output in 1/4/16 KB chunks (the ring keeps 240 000) | +| `visibleResendBytes8Cards` | bytes sent to the renderer when 8 hidden cards with full scrollback become visible after 1 KB more output each | +| `orchestratorToolCallMs`, `orchestratorToolCallAllocatedBytes` | one `observe_agent` call (ownership check, status, observation) on a canvas of 20 agent cards with full scrollback | +| `observeAgentMs`, `listAgentsMs` | `observe_agent` and `list_agents` alone | +| `evenG2FeedMs`, `evenG2HeadlessTerminals` | Even G2 companion on, 8 cards stream 512 KB each while the glasses show one | +| `kimiFirstLaunchBlockMs` | how long the first Kimi launch blocks the main thread when the CLI takes 1 s to answer `--help` | + +The report prints medians; `--json` also keeps every run. diff --git a/electron.vite.config.ts b/electron.vite.config.ts index aaa9cec6..eefc3536 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -36,6 +36,11 @@ export default defineConfig({ }, renderer: { root: resolve("src/renderer"), - plugins: [react()] + plugins: [react()], + // electron-vite leaves every bundle unminified; the renderer's (React, xterm and the app, about 1.8 MB) + // is parsed on every window load, so it is minified. Source maps stay off, as before. + build: { + minify: "esbuild" + } } }); diff --git a/scripts/bench-runtime.mjs b/scripts/bench-runtime.mjs new file mode 100644 index 00000000..3e5efbd4 --- /dev/null +++ b/scripts/bench-runtime.mjs @@ -0,0 +1,191 @@ +#!/usr/bin/env node +// Runtime cost benchmark: memory and CPU of the built app in hidden windows (idle, N terminal cards, a +// fixed-rate output flood in every card, 10 s after it, a canvas pan) plus micro-benchmarks of the +// main-process hot paths. Every run gets its own throw-away HOME, userData and working folder, and the app +// never reads the keychain or shows a window. See docs/performance-benchmark.md. +// +// npx electron-vite build # the app scenarios measure out/ +// node scripts/bench-runtime.mjs [--runs 3] [--terminals 8] [--kbps 1024] [--flood-seconds 20] +// [--micro-only | --app-only | --pan-only] [--json report.json] +import { spawn } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { tmpdir, cpus, totalmem, release } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const HERE = join(ROOT, "scripts", "bench-runtime"); +// Short temporary paths: the app puts Unix sockets under userData, and macOS caps a socket path at 104 bytes. +const TEMP = process.env.BENCH_TMPDIR || (process.platform === "win32" ? tmpdir() : "/tmp"); + +function options(argv) { + const result = { runs: 3, terminals: 8, kbps: 1024, floodSeconds: 20, micro: true, app: true, panOnly: false, json: null }; + for (let i = 0; i < argv.length; i++) { + const flag = argv[i]; + const value = () => argv[++i]; + if (flag === "--runs") result.runs = Number(value()); + else if (flag === "--terminals") result.terminals = Number(value()); + else if (flag === "--kbps") result.kbps = Number(value()); + else if (flag === "--flood-seconds") result.floodSeconds = Math.max(15, Number(value())); + else if (flag === "--micro-only") result.app = false; + else if (flag === "--app-only") result.micro = false; + else if (flag === "--pan-only") { result.micro = false; result.panOnly = true; } + else if (flag === "--json") result.json = resolve(value()); + else throw new Error(`Unknown option ${flag}`); + } + return result; +} + +/** A fresh HOME and every tool home inside it, so nothing reads or writes the person's own configuration. */ +function isolatedEnvironment(extra = {}) { + const home = mkdtempSync(join(TEMP, "ctb-home-")); + writeFileSync(join(home, ".zshrc"), ""); + return { + home, + env: { + HOME: home, USER: process.env.USER ?? "bench", LOGNAME: process.env.USER ?? "bench", TMPDIR: TEMP, LANG: "en_US.UTF-8", + PATH: `${dirname(process.execPath)}:/usr/bin:/bin:/usr/sbin:/sbin`, SHELL: "/bin/zsh", + GROK_HOME: join(home, ".grok"), CODEX_HOME: join(home, ".codex"), CLAUDE_CONFIG_DIR: join(home, ".claude"), + XDG_CONFIG_HOME: join(home, ".config"), XDG_DATA_HOME: join(home, ".local/share"), XDG_STATE_HOME: join(home, ".local/state"), + ...extra + } + }; +} + +function run(command, args, env, timeoutMs) { + return new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { env, stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { stdout += chunk; }); + child.stderr.on("data", (chunk) => { stderr += chunk; }); + const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs); + child.on("error", reject); + child.on("close", (code) => { + clearTimeout(timer); + if (code === 0) resolvePromise(stdout); + else reject(new Error(`${command} exited ${code}: ${stderr.slice(-2000)}`)); + }); + }); +} + +async function microRun() { + const { home, env } = isolatedEnvironment(); + try { + const stdout = await run(process.execPath, ["--experimental-strip-types", "--no-warnings", join(HERE, "micro.mjs"), ROOT], env, 300_000); + return JSON.parse(stdout.trim().split("\n").at(-1)); + } finally { + rmSync(home, { recursive: true, force: true }); + } +} + +async function appRun(settings) { + const electron = createRequire(import.meta.url)("electron"); + const userData = mkdtempSync(join(TEMP, "ctb-u-")); + const work = mkdtempSync(join(TEMP, "ctb-w-")); + const out = join(userData, "..", `${userData.split("/").at(-1)}-report.json`); + writeFileSync(join(userData, "settings.json"), JSON.stringify({ settingsVersion: 21, locale: "en", sessionRestoreMode: "off" })); + const { home, env } = isolatedEnvironment({ + BENCH_ROOT: ROOT, BENCH_OUT: out, BENCH_USERDATA: userData, BENCH_WORK: work, + BENCH_NODE: process.execPath, BENCH_FLOOD: join(HERE, "flood.mjs"), BENCH_KBPS: String(settings.kbps), + BENCH_TERMINALS: String(settings.terminals), BENCH_FLOOD_SECONDS: String(settings.floodSeconds), + BENCH_PAN_ONLY: settings.panOnly ? "1" : "0" + }); + try { + let exit = null; + await run(electron, [join(HERE, "app")], env, 240_000 + settings.floodSeconds * 1000).catch((error) => { exit = error; }); + const report = existsSync(out) ? JSON.parse(readFileSync(out, "utf8")) : null; + // A crash after the report was complete (while quitting) is recorded, not fatal. + if (!report?.done) throw exit ?? new Error("the benchmark app wrote no report"); + if (exit) report.exitAfterReport = exit.message.slice(0, 300); + if (report.window?.visible || report.window?.focused) throw new Error("the app window was visible or focused"); + return report; + } finally { + for (const path of [userData, work, home, out]) rmSync(path, { recursive: true, force: true }); + } +} + +function median(values) { + const numbers = values.filter((value) => typeof value === "number" && Number.isFinite(value)).sort((a, b) => a - b); + if (numbers.length === 0) return null; + const middle = Math.floor(numbers.length / 2); + return numbers.length % 2 ? numbers[middle] : (numbers[middle - 1] + numbers[middle]) / 2; +} + +/** The median of every numeric leaf across runs, keeping the report's shape. */ +function medianOf(reports) { + const first = reports[0]; + if (typeof first === "number") return median(reports); + if (!first || typeof first !== "object" || Array.isArray(first)) return first; + return Object.fromEntries(Object.keys(first).map((key) => [key, medianOf(reports.map((report) => report?.[key]))])); +} + +function bundle() { + const assets = join(ROOT, "out", "renderer", "assets"); + if (!existsSync(assets)) return null; + const files = readdirSync(assets).map((name) => ({ name, bytes: statSync(join(assets, name)).size })); + const sum = (filter) => files.filter(filter).reduce((total, file) => total + file.bytes, 0); + return { + rendererJsKb: Math.round(sum((file) => file.name.endsWith(".js")) / 1024), + rendererCssKb: Math.round(sum((file) => file.name.endsWith(".css")) / 1024), + rendererImagesKb: Math.round(sum((file) => /\.(png|ico|svg|webp)$/u.test(file.name)) / 1024), + largestImage: files.filter((file) => /\.(png|ico|webp)$/u.test(file.name)).sort((a, b) => b.bytes - a.bytes)[0] ?? null + }; +} + +function machine() { + let os = `${process.platform} ${release()}`; + try { + if (process.platform === "darwin") { + const version = readFileSync("/System/Library/CoreServices/SystemVersion.plist", "utf8").match(/ProductVersion<\/key>\s*([^<]+)/u)?.[1]; + if (version) os = `macOS ${version}`; + } + } catch {} + return { os, cpu: cpus()[0]?.model ?? "unknown", cores: cpus().length, ramGb: Math.round(totalmem() / 1024 ** 3), node: process.version }; +} + +function table(result) { + const lines = []; + const scenarios = result.app?.scenarios ?? {}; + for (const name of ["idle", "terminals", "flood", "after"]) { + const s = scenarios[name]; + if (!s) continue; + lines.push(`${name.padEnd(10)} RSS total ${s.rssMb.total} MB (electron ${s.rssMb.electron}, main ${s.rssMb.main}, renderer ${s.rssMb.renderer}, gpu ${s.rssMb.gpu}, pty ${s.rssMb.pty}) peak ${s.peakRssMb} MB | CPU main ${s.cpu.main}% renderer ${s.cpu.renderer}% gpu ${s.cpu.gpu}% utility ${s.cpu.utility}%`); + } + if (scenarios.flood?.terminalDataToRendererMb !== undefined) lines.push(`flood terminal:data to renderer ${scenarios.flood.terminalDataToRendererMb} MB`); + if (scenarios.pan) lines.push(`pan ${JSON.stringify(scenarios.pan)}`); + if (result.micro) lines.push(`micro ${JSON.stringify(result.micro)}`); + if (result.bundle) lines.push(`bundle ${JSON.stringify(result.bundle)}`); + return lines.join("\n"); +} + +async function main() { + const settings = options(process.argv.slice(2)); + if (settings.app && !existsSync(join(ROOT, "out", "main", "index.js"))) { + throw new Error("Build the app first: npx electron-vite build"); + } + const result = { machine: machine(), settings, runs: { micro: [], app: [] } }; + for (let i = 0; i < settings.runs; i++) { + if (settings.micro) { + process.stderr.write(`micro run ${i + 1}/${settings.runs}\n`); + result.runs.micro.push(await microRun()); + } + if (settings.app) { + process.stderr.write(`app run ${i + 1}/${settings.runs}\n`); + const report = await appRun(settings); + if (report.errors.length || report.rendererErrors.length) process.stderr.write(` errors: ${JSON.stringify([...report.errors, ...report.rendererErrors])}\n`); + result.runs.app.push(report); + } + } + if (settings.micro) result.micro = medianOf(result.runs.micro); + if (settings.app) result.app = { scenarios: medianOf(result.runs.app.map((report) => report.scenarios)) }; + result.bundle = bundle(); + if (settings.json) writeFileSync(settings.json, `${JSON.stringify(result, null, 1)}\n`); + process.stdout.write(`${JSON.stringify(result.machine)}\n${table(result)}\n`); +} + +main().catch((error) => { + process.stderr.write(`${error.stack ?? error}\n`); + process.exit(1); +}); diff --git a/scripts/bench-runtime/app/boot.cjs b/scripts/bench-runtime/app/boot.cjs new file mode 100644 index 00000000..881ba38b --- /dev/null +++ b/scripts/bench-runtime/app/boot.cjs @@ -0,0 +1,209 @@ +// Benchmark harness entry (Electron main): loads the built app from BENCH_ROOT/out/main with hidden windows +// (hidden-electron.mjs), keychain access refused (no-keychain.mjs) and a render counter in the page, then runs +// the scenarios and writes one JSON report to BENCH_OUT. Started by scripts/bench-runtime.mjs. +const { app, session } = require("electron"); +const Module = require("node:module"); +const { execFileSync } = require("node:child_process"); +const { writeFileSync } = require("node:fs"); +const { join } = require("node:path"); +const { pathToFileURL } = require("node:url"); + +const env = process.env; +const MAIN = join(env.BENCH_ROOT, "out", "main") + "/"; +const report = { errors: [], rendererErrors: [], scenarios: {} }; +const save = () => writeFileSync(env.BENCH_OUT, JSON.stringify(report, null, 1)); +const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +const mainUrl = pathToFileURL(MAIN).href; +const electronShim = pathToFileURL(join(__dirname, "hidden-electron.mjs")).href; +const childProcessShim = pathToFileURL(join(__dirname, "no-keychain.mjs")).href; +Module.registerHooks({ + resolve(specifier, context, next) { + const fromApp = context.parentURL && context.parentURL.startsWith(mainUrl); + if (fromApp && specifier === "electron") return { url: electronShim, format: "module", shortCircuit: true }; + if (fromApp && (specifier === "node:child_process" || specifier === "child_process")) { + return { url: childProcessShim, format: "module", shortCircuit: true }; + } + return next(specifier, context); + } +}); +app.commandLine.appendSwitch("use-mock-keychain"); +app.dock?.hide(); +app.setPath("userData", env.BENCH_USERDATA); +process.on("uncaughtException", (error) => report.errors.push(`uncaught: ${error.message}`)); +process.on("unhandledRejection", (error) => report.errors.push(`rejection: ${error?.message ?? String(error)}`)); +app.on("browser-window-created", (_event, window) => { + if (!globalThis.__benchHiddenShim) { + try { window.destroy(); } catch {} + report.errors.push("ABORT: hidden window shim inactive"); + save(); + app.exit(7); + return; + } + window.webContents.on("console-message", (event) => { + if (event.level === "error") report.rendererErrors.push(String(event.message).slice(0, 300)); + }); +}); + +/** RSS (KB) and cumulative CPU seconds of this process and every descendant, from ps. */ +function processTree() { + const rows = execFileSync("/bin/ps", ["-A", "-o", "pid=,ppid=,rss=,time="], { encoding: "utf8" }) + .trim().split("\n").map((row) => row.trim().split(/\s+/u)); + const byParent = new Map(); + const info = new Map(); + for (const [pid, ppid, rss, time] of rows) { + info.set(Number(pid), { rss: Number(rss), cpu: cpuSeconds(time) }); + if (!byParent.has(Number(ppid))) byParent.set(Number(ppid), []); + byParent.get(Number(ppid)).push(Number(pid)); + } + const tree = new Map(); + const stack = [process.pid]; + while (stack.length) { + const pid = stack.pop(); + if (tree.has(pid) || !info.has(pid)) continue; + tree.set(pid, info.get(pid)); + stack.push(...(byParent.get(pid) ?? [])); + } + return tree; +} +function cpuSeconds(value) { + const parts = value.split(":").map(Number); + return parts.reduce((total, part) => total * 60 + part, 0); +} + +const TYPES = { Browser: "main", Tab: "renderer", GPU: "gpu", Utility: "utility" }; +/** Per process kind: RSS in MB now, CPU % (of one core) averaged over the window. */ +async function sample(seconds, during = async () => undefined) { + const before = processTree(); + const started = Date.now(); + let peakRssMb = 0; + const poll = setInterval(() => { + let total = 0; + for (const { rss } of processTree().values()) total += rss; + peakRssMb = Math.max(peakRssMb, total / 1024); + }, 1000); + await Promise.all([wait(seconds * 1000), during()]); + clearInterval(poll); + const elapsed = (Date.now() - started) / 1000; + const metrics = app.getAppMetrics(); + const after = processTree(); + const electronPids = new Map(metrics.map((metric) => [metric.pid, TYPES[metric.type] ?? "utility"])); + const cpu = { main: 0, renderer: 0, gpu: 0, utility: 0, pty: 0 }; + const rss = { main: 0, renderer: 0, gpu: 0, utility: 0, pty: 0 }; + // CPU from the kernel's per-process time (ps), not getAppMetrics: the same clock for every process kind. + for (const [pid, { rss: kb, cpu: seconds }] of after) { + const kind = electronPids.get(pid) ?? "pty"; + rss[kind] += kb / 1024; + cpu[kind] += ((seconds - (before.get(pid)?.cpu ?? 0)) / elapsed) * 100; + } + const round = (object) => Object.fromEntries(Object.entries(object).map(([key, value]) => [key, Math.round(value * 10) / 10])); + const total = Object.values(rss).reduce((sum, value) => sum + value, 0); + const electronTotal = total - rss.pty; + return { cpu: round(cpu), rssMb: round({ ...rss, electron: electronTotal, total }), peakRssMb: Math.round(peakRssMb) }; +} + +/** Renderer main-thread time (ms) by kind, from the DevTools Performance domain. */ +async function rendererTimes(win) { + const { metrics } = await win.webContents.debugger.sendCommand("Performance.getMetrics"); + const value = (name) => (metrics.find((metric) => metric.name === name)?.value ?? 0) * 1000; + return { script: value("ScriptDuration"), layout: value("LayoutDuration"), style: value("RecalcStyleDuration"), task: value("TaskDuration") }; +} +const PAN_MOVES = 300; +const perMove = (after, before, moves) => Object.fromEntries(Object.keys(after).map((key) => [key, Math.round((after[key] - before[key]) / moves * 100) / 100])); + +async function scenarios(win) { + const js = (code) => win.webContents.executeJavaScript(code); + win.webContents.debugger.attach("1.3"); + await win.webContents.debugger.sendCommand("Performance.enable", { timeDomain: "threadTicks" }); + const terminals = Number(env.BENCH_TERMINALS); + const floodSeconds = Number(env.BENCH_FLOOD_SECONDS); + // The window shows a startup page until the services are up; the app is ready once its IPC answers. + const started = Date.now(); + for (;;) { + const ready = await js("window.canvasTTY?.terminal?.list?.().then(() => true, () => false) ?? false").catch(() => false); + if (ready && await js("document.querySelector('.workspace') !== null").catch(() => false)) break; + if (Date.now() - started > 90_000) { + report.page = await js("location.href.slice(0, 80) + ' | ' + document.body.innerText.slice(0, 400)").catch((error) => error.message); + throw new Error("the app did not become ready"); + } + await wait(250); + } + report.readyAfterMs = Date.now() - started; + // --pan-only: cards and the pan, without the timed load scenarios. + const panOnly = env.BENCH_PAN_ONLY === "1"; + await wait(panOnly ? 2000 : 8000); + if (!panOnly) report.scenarios.idle = await sample(10); + + const ids = []; + for (let i = 0; i < terminals; i++) { + const position = { x: 80 + (i % 4) * 760, y: 1400 + Math.floor(i / 4) * 520 }; + const created = await js(`window.canvasTTY.terminal.create({ provider: "terminal", profile: "normal", cwd: ${JSON.stringify(env.BENCH_WORK)}, position: ${JSON.stringify(position)} }).then((s) => s.id)`); + ids.push(created); + } + await wait(panOnly ? 3000 : 8000); + if (!panOnly) { + report.scenarios.terminals = await sample(10); + + const command = `"${env.BENCH_NODE}" "${env.BENCH_FLOOD}" ${env.BENCH_KBPS} ${floodSeconds}\r`; + const ipcStart = globalThis.__benchIpc.terminalDataBytes; + for (const id of ids) await js(`window.canvasTTY.terminal.input(${JSON.stringify(id)}, ${JSON.stringify(command)})`); + await wait(3000); + report.scenarios.flood = await sample(10); + await wait(Math.max(0, floodSeconds * 1000 - 13_000) + 1500); + report.scenarios.flood.terminalDataToRendererMb = Math.round((globalThis.__benchIpc.terminalDataBytes - ipcStart) / 1048576 * 10) / 10; + await wait(10_000); + report.scenarios.after = await sample(5); + } + + // Pan: a middle-button drag across the canvas, PAN_MOVES moves ~16 ms apart. + const countsBefore = await js("JSON.stringify(window.__benchRenderCounts ?? null)").then(JSON.parse); + const timesBefore = await rendererTimes(win); + const [width, height] = win.getContentSize(); + const x0 = Math.round(width / 2), y0 = Math.round(height / 2); + const pan = await sample(0, async () => { + win.webContents.sendInputEvent({ type: "mouseDown", x: x0, y: y0, button: "middle", clickCount: 1 }); + for (let i = 1; i <= PAN_MOVES; i++) { + win.webContents.sendInputEvent({ type: "mouseMove", x: x0 + (i % 60) * 4, y: y0 + (i % 30) * 2, button: "middle", modifiers: ["middleButtonDown"] }); + await wait(16); + } + win.webContents.sendInputEvent({ type: "mouseUp", x: x0, y: y0, button: "middle", clickCount: 1 }); + await wait(300); + }); + const countsAfter = await js("JSON.stringify(window.__benchRenderCounts ?? null)").then(JSON.parse); + const timesAfter = await rendererTimes(win); + report.scenarios.pan = countsBefore && countsAfter ? { + moves: PAN_MOVES, + commits: countsAfter.commits - countsBefore.commits, + componentRendersPerMove: Math.round((countsAfter.rendered - countsBefore.rendered) / PAN_MOVES * 10) / 10, + terminalCardRendersPerMove: Math.round((countsAfter.terminalCards - countsBefore.terminalCards) / PAN_MOVES * 10) / 10, + rendererCpu: pan.cpu.renderer, + // Renderer main-thread milliseconds per move: JavaScript, style, layout, all tasks. + rendererMsPerMove: perMove(timesAfter, timesBefore, PAN_MOVES), + // Most rendered components during the pan (names are minified in a minified build). + top: Object.entries(countsAfter.byName).map(([name, count]) => [name, count - (countsBefore.byName[name] ?? 0)]) + .filter(([, count]) => count > 0).sort((a, b) => b[1] - a[1]).slice(0, 8).map(([name, count]) => `${name}:${count}`).join(" ") + } : { error: "render counter missing" }; +} + +app.whenReady().then(() => { + session.defaultSession.registerPreloadScript({ type: "frame", id: "bench-render-counter", filePath: join(__dirname, "render-counter.cjs") }); + setTimeout(async () => { + const { BrowserWindow } = require("electron"); + const win = BrowserWindow.getAllWindows()[0]; + if (!win) { report.errors.push("no window"); save(); app.exit(8); return; } + report.window = { visible: win.isVisible(), focused: win.isFocused() }; + if (win.isVisible() || win.isFocused()) { report.errors.push("ABORT: window visible or focused"); save(); app.exit(7); return; } + win.setContentSize(1440, 1000); + try { await scenarios(win); } catch (error) { report.errors.push(`scenario: ${error.message}`); } + report.windowEnd = { visible: win.isVisible(), focused: win.isFocused() }; + report.done = true; + save(); + setTimeout(() => app.exit(0), 5000); + app.quit(); + }, 3000); +}); +import(pathToFileURL(join(MAIN, "index.js")).href).catch((error) => { + report.errors.push(`main import: ${error.message}`); + save(); + app.exit(9); +}); diff --git a/scripts/bench-runtime/app/hidden-electron.mjs b/scripts/bench-runtime/app/hidden-electron.mjs new file mode 100644 index 00000000..dbaebf71 --- /dev/null +++ b/scripts/bench-runtime/app/hidden-electron.mjs @@ -0,0 +1,45 @@ +// Benchmark harness: the app's own `electron` import, with every window created hidden, off-screen and +// unfocusable, native dialogs and shell calls answered locally, and safeStorage off (a real call would ask +// the keychain). terminal:data bytes sent to the renderer are counted for the report. +export * from "electron"; +import { BrowserWindow as Base, dialog as realDialog, shell as realShell } from "electron"; + +globalThis.__benchIpc = { terminalDataBytes: 0 }; +export class BrowserWindow extends Base { + constructor(options = {}) { + super({ ...options, show: false, x: -32000, y: -32000, focusable: false, paintWhenInitiallyHidden: true }); + const contents = this.webContents; + const send = contents.send.bind(contents); + contents.send = (channel, ...args) => { + if (channel === "terminal:data" && typeof args[0]?.data === "string") globalThis.__benchIpc.terminalDataBytes += args[0].data.length; + return send(channel, ...args); + }; + } + show() {} + showInactive() {} + focus() {} + moveTop() {} + maximize() {} + setFullScreen() {} +} +export const dialog = { + ...realDialog, + showOpenDialog: async () => ({ canceled: true, filePaths: [] }), + showSaveDialog: async () => ({ canceled: true }), + showMessageBox: async () => ({ response: 0, checkboxChecked: false }), + showMessageBoxSync: () => 0, + showErrorBox: () => undefined +}; +export const shell = { + ...realShell, + openExternal: async () => undefined, + openPath: async () => "", + showItemInFolder: () => undefined +}; +export const safeStorage = { + isEncryptionAvailable: () => false, + getSelectedStorageBackend: () => "basic_text", + encryptString: () => { throw new Error("safeStorage is off in the benchmark"); }, + decryptString: () => { throw new Error("safeStorage is off in the benchmark"); } +}; +globalThis.__benchHiddenShim = true; diff --git a/scripts/bench-runtime/app/no-keychain.mjs b/scripts/bench-runtime/app/no-keychain.mjs new file mode 100644 index 00000000..83f76d22 --- /dev/null +++ b/scripts/bench-runtime/app/no-keychain.mjs @@ -0,0 +1,37 @@ +// Benchmark harness: the app's child_process with /usr/bin/security refused (the app reads provider +// credentials from the macOS keychain; a benchmark must never touch it). Every other call is unchanged. +import * as real from "node:child_process"; +export * from "node:child_process"; + +const refused = (file) => typeof file === "string" && /(^|\/)security$/u.test(file); +const refusedLine = (line) => typeof line === "string" && /(^|[\s/;&|])security(\s|$)/u.test(line); +const error = () => Object.assign(new Error("keychain access is off in the benchmark"), { code: 44 }); +export function execFile(file, ...rest) { + if (refused(file)) { + const callback = rest.find((value) => typeof value === "function"); + if (callback) setImmediate(() => callback(error(), "", "")); + return { on() {}, kill() {} }; + } + return real.execFile(file, ...rest); +} +export function execFileSync(file, ...rest) { + if (refused(file)) throw error(); + return real.execFileSync(file, ...rest); +} +export function spawn(command, ...rest) { + if (refused(command)) throw error(); + return real.spawn(command, ...rest); +} +export function spawnSync(command, ...rest) { + if (refused(command)) throw error(); + return real.spawnSync(command, ...rest); +} +export function exec(line, ...rest) { + if (refusedLine(line)) throw error(); + return real.exec(line, ...rest); +} +export function execSync(line, ...rest) { + if (refusedLine(line)) throw error(); + return real.execSync(line, ...rest); +} +export default { ...real, execFile, execFileSync, spawn, spawnSync, exec, execSync }; diff --git a/scripts/bench-runtime/app/package.json b/scripts/bench-runtime/app/package.json new file mode 100644 index 00000000..1a021e8b --- /dev/null +++ b/scripts/bench-runtime/app/package.json @@ -0,0 +1 @@ +{ "name": "canvastty-bench-runtime", "private": true, "main": "boot.cjs", "type": "commonjs" } diff --git a/scripts/bench-runtime/app/render-counter.cjs b/scripts/bench-runtime/app/render-counter.cjs new file mode 100644 index 00000000..b837a7d6 --- /dev/null +++ b/scripts/bench-runtime/app/render-counter.cjs @@ -0,0 +1,46 @@ +// Benchmark harness preload: a minimal React DevTools hook in the page's main world that counts, per commit, +// the components rendered with new props. TerminalCard renders are told apart by their props (no names are +// needed, so minified builds count the same way). +const { contextBridge } = require("electron"); + +contextBridge.executeInMainWorld({ + func: () => { + const counts = { commits: 0, rendered: 0, terminalCards: 0, byName: {} }; + const seen = new WeakSet(); + const renderers = new Map(); + const walk = (root) => { + const stack = [root]; + while (stack.length) { + const fiber = stack.pop(); + if (!fiber) continue; + // Function, class, forwardRef and memo components. + if (fiber.tag === 0 || fiber.tag === 1 || fiber.tag === 11 || fiber.tag === 15) { + const props = fiber.memoizedProps; + if (props && typeof props === "object" && !seen.has(props)) { + seen.add(props); + counts.rendered++; + const type = fiber.type?.type ?? fiber.type?.render ?? fiber.type; + const name = (type && (type.displayName || type.name)) || "?"; + counts.byName[name] = (counts.byName[name] ?? 0) + 1; + if ("focusChangeSource" in props && "session" in props) counts.terminalCards++; + } + } + if (fiber.sibling) stack.push(fiber.sibling); + if (fiber.child) stack.push(fiber.child); + } + }; + window.__REACT_DEVTOOLS_GLOBAL_HOOK__ = { + supportsFiber: true, + isDisabled: false, + renderers, + inject(renderer) { const id = renderers.size + 1; renderers.set(id, renderer); return id; }, + onCommitFiberRoot(_id, root) { counts.commits++; walk(root.current); }, + onCommitFiberUnmount() {}, + onPostCommitFiberRoot() {}, + onScheduleFiberRoot() {}, + setStrictMode() {}, + checkDCE() {} + }; + window.__benchRenderCounts = counts; + } +}); diff --git a/scripts/bench-runtime/flood.mjs b/scripts/bench-runtime/flood.mjs new file mode 100644 index 00000000..a153123d --- /dev/null +++ b/scripts/bench-runtime/flood.mjs @@ -0,0 +1,13 @@ +// Fixed-rate terminal output for scripts/bench-runtime.mjs: `node flood.mjs `. +// Coloured, numbered log lines, written every 50 ms. +const [kbps = "256", seconds = "20"] = process.argv.slice(2); +const perTick = Math.round((Number(kbps) * 1024) / 20); +const end = Date.now() + Number(seconds) * 1000; +let n = 0; +const line = () => `\x1b[36m${String(n++).padStart(8, "0")}\x1b[0m bench \x1b[1mflood\x1b[0m src/main/services/Example.ts:${n % 997} value=${(n * 7919) % 100003}\r\n`; +const timer = setInterval(() => { + let chunk = ""; + while (chunk.length < perTick) chunk += line(); + process.stdout.write(chunk); + if (Date.now() >= end) { clearInterval(timer); process.stdout.write("\r\nflood done\r\n"); } +}, 50); diff --git a/scripts/bench-runtime/micro.mjs b/scripts/bench-runtime/micro.mjs new file mode 100644 index 00000000..314472f1 --- /dev/null +++ b/scripts/bench-runtime/micro.mjs @@ -0,0 +1,192 @@ +// Micro-benchmarks for the main-process hot paths, run by scripts/bench-runtime.mjs in a plain Node process +// (`--experimental-strip-types`, the sources are imported as they are). Every scenario uses only public +// entry points that exist before and after the performance work, so the same file measures both. +// Prints one JSON object on stdout. +import { mkdtemp, rm, writeFile, chmod } from "node:fs/promises"; +import { Session } from "node:inspector/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +const ROOT = resolve(process.argv[2] ?? "."); +const load = (path) => import(pathToFileURL(join(ROOT, path)).href); +const { TerminalManager } = await load("src/main/services/TerminalManager.ts"); +const { AgentControlService } = await load("src/main/services/AgentControlService.ts"); +const { SecretRedactionRegistry } = await load("src/main/services/safety/SecretRedaction.ts"); +const { TerminalPresentation } = await load("src/main/services/companion/TerminalPresentation.ts"); +const { ProviderLaunchAdapters } = await load("src/main/services/agent-browser/ProviderLaunch.ts"); + +const SCROLLBACK = 240_000; +const inspector = new Session(); +inspector.connect(); +await inspector.post("HeapProfiler.enable"); + +/** Bytes allocated while fn runs, garbage included (sampled every 1 KiB). */ +async function allocated(fn) { + await inspector.post("HeapProfiler.startSampling", { + samplingInterval: 1024, includeObjectsCollectedByMajorGC: true, includeObjectsCollectedByMinorGC: true + }); + await fn(); + const { profile } = await inspector.post("HeapProfiler.stopSampling"); + let total = 0; + const walk = (node) => { total += node.selfSize; node.children.forEach(walk); }; + walk(profile.head); + return total; +} + +function timed(fn, iterations) { + fn(); + const start = performance.now(); + for (let i = 0; i < iterations; i++) fn(); + return (performance.now() - start) / iterations; +} + +const clis = { + get: (provider) => ({ state: "available", provider, executable: `/resolved/${provider}`, launcher: "native", environment: {}, checked: [] }), + snapshot: () => ({}) +}; + +/** A TerminalManager on fake PTYs; `print(id, text)` is what the PTY would have written. */ +function manager(emit = () => undefined) { + const writers = new Map(); + let pending = null; + const terminals = new TerminalManager(emit, clis, undefined, undefined, true, () => { + const pty = { pid: 1, write() {}, resize() {}, kill() {}, + onData(listener) { pending = listener; return { dispose() {} }; }, + onExit() { return { dispose() {} }; } }; + return pty; + }); + const create = (request) => { + const session = terminals.create({ profile: "normal", cwd: tmpdir(), position: { x: 0, y: 0 }, ...request }); + writers.set(session.id, pending); + return session; + }; + return { terminals, create, print: (id, text) => writers.get(id)(text) }; +} + +// A line of ordinary agent output (colour, paths, numbers), no secrets. +const line = (i) => `\x1b[32m✓\x1b[0m step ${i} src/main/services/Example.ts:${i} finished in ${i % 97} ms — value = compute(${i}, "ok")\r\n`; +function fill(print, id, chars, chunk = 4096) { + let text = ""; + let i = 0; + while (text.length < chars) text += line(i++); + for (let offset = 0; offset < text.length; offset += chunk) print(id, text.slice(offset, offset + chunk)); +} + +const results = {}; + +// (a) Scrollback retention: characters still referenced by the chunk array after 2 MB of output. +{ + results.scrollbackRetainedChars = {}; + for (const chunk of [1024, 4096, 16384]) { + const f = manager(); + const session = f.create({ provider: "terminal" }); + fill(f.print, session.id, 2_000_000, chunk); + const managed = f.terminals.sessions.get(session.id); + const retained = managed.bufferChunks.reduce((sum, part) => sum + (typeof part === "string" ? part.length : 0), 0); + results.scrollbackRetainedChars[`chunk${chunk}`] = retained; + await f.terminals.shutdown(); + } +} + +// (b) Bytes sent to the renderer when 8 hidden cards (full 240K scrollback each) become visible after 1 KB more output. +{ + let rendererBytes = 0; + let counting = false; + const f = manager((channel, payload) => { + if (counting && channel === "terminal:data" && payload.audience !== "observers") rendererBytes += payload.data.length; + }); + const ids = []; + for (let i = 0; i < 8; i++) { + const session = f.create({ provider: "terminal" }); + ids.push(session.id); + fill(f.print, session.id, SCROLLBACK + 10_000); + } + for (const id of ids) f.terminals.setVisible(id, false); + for (const id of ids) f.print(id, "x".repeat(1024)); + counting = true; + for (const id of ids) f.terminals.setVisible(id, true); + counting = false; + results.visibleResendBytes8Cards = rendererBytes; + await f.terminals.shutdown(); +} + +// (c)+(d) One orchestrator tool call on a canvas of 20 agent cards with full scrollback: +// the ownership check, status lookup and observe_agent (redaction included). +{ + const f = manager(); + const orchestrator = f.create({ provider: "claude", role: "orchestrator" }); + const children = []; + for (let i = 0; i < 19; i++) { + const child = f.create({ provider: "claude", role: "subagent", parentSessionId: orchestrator.id }); + children.push(child.id); + } + for (const id of [orchestrator.id, ...children]) fill(f.print, id, SCROLLBACK + 10_000); + const registry = new SecretRedactionRegistry(); + registry.add("vault", ["purple-otter-marmalade-sings-loudly-7"]); + f.terminals.configureRedaction(registry); + const control = new AgentControlService(f.terminals); + const target = children[7]; + const toolCall = () => { + control.status(orchestrator.id); + control.isInSubtree(orchestrator.id, target); + return control.observe(target).output.length; + }; + results.orchestratorToolCallMs = Number(timed(toolCall, 20).toFixed(2)); + results.orchestratorToolCallAllocatedBytes = await allocated(() => { for (let i = 0; i < 10; i++) toolCall(); }) / 10; + results.observeAgentMs = Number(timed(() => control.observe(target), 20).toFixed(2)); + results.listAgentsMs = Number(timed(() => control.children(orchestrator.id), 20).toFixed(2)); + await f.terminals.shutdown(); +} + +// (j) Even G2 companion on: 8 cards stream 512 KB each, the glasses show one of them. +{ + const sessions = []; + const buffers = new Map(); + const port = { + listMetadata: () => sessions.map((id) => ({ id, provider: "terminal", status: "running", title: id })), + geometry: () => ({ cols: 120, rows: 40 }), + readBuffer: (id) => ({ buffer: buffers.get(id)?.text ?? "", outputOffset: buffers.get(id)?.text.length ?? 0 }) + }; + const presentation = new TerminalPresentation(port); + for (let i = 0; i < 8; i++) { sessions.push(`s${i}`); buffers.set(`s${i}`, { text: "" }); } + await presentation.read("s0"); + let body = ""; + for (let i = 0; i < 1500; i++) body += line(i); + const start = performance.now(); + for (let offset = 0; offset < 512_000; offset += body.length) { + for (const id of sessions) { + const state = buffers.get(id); + state.text += body; + presentation.observe("terminal:data", { id, data: body, outputOffset: state.text.length }); + } + } + await presentation.read("s0"); + results.evenG2FeedMs = Number((performance.now() - start).toFixed(1)); + results.evenG2HeadlessTerminals = [...presentation.screens.values()].filter((screen) => screen.terminal).length; + presentation.close(); +} + +// (e) Main-thread block on the first Kimi launch; the stand-in CLI answers --help after 1 s. +{ + const root = await mkdtemp(join(tmpdir(), "bench-kimi-")); + const kimi = join(root, "kimi"); + await writeFile(kimi, "#!/bin/sh\nsleep 1\necho ' --mcp-config-file PATH'\n"); + await chmod(kimi, 0o755); + const adapters = new ProviderLaunchAdapters({ + helper: { command: "/opt/CanvasTTY/helper", args: ["--stdio"] }, + providerClis: { get: (provider) => ({ state: "available", provider, executable: kimi, launcher: "native", environment: {}, checked: [] }), snapshot: () => ({}) }, + kimiHomeDirectory: join(root, "kimi-home"), + hermesHomeDirectory: join(root, "hermes-home"), + runtimeDirectory: join(root, "runtime") + }); + // The app warms the probe in the background when it can (after the fix); the launch comes later. + if (typeof adapters.warmKimiProbe === "function") await adapters.warmKimiProbe(); + const start = performance.now(); + adapters.prepare("kimi", "bench").releaseConfiguration(); + results.kimiFirstLaunchBlockMs = Math.round(performance.now() - start); + await rm(root, { recursive: true, force: true }); +} + +process.stdout.write(`${JSON.stringify(results)}\n`); +process.exit(0); diff --git a/src/main/index.ts b/src/main/index.ts index 452bb13b..20a2421e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -397,6 +397,9 @@ async function initializeServices(): Promise { hermesHomeDirectory, kimiHomeDirectory }); + // Off the startup path: the first Kimi launch then finds the probe answered instead of blocking on it. + const bridge = agentBrowserBridge; + setTimeout(() => void bridge.warmProviderProbes().catch(() => undefined), 5_000).unref(); const lifecycleRuntimeDirectory = join(userDataPath, "lifecycle", "runtime"); runtimeGateway = new RuntimeGateway({ @@ -651,6 +654,7 @@ async function initializeServices(): Promise { recheckProviderClis: async () => { providerClis!.refresh(); agentBrowserBridge?.providerClisRefreshed(); + void agentBrowserBridge?.warmProviderProbes().catch(() => undefined); await limitsService!.providerClisRefreshed(); const availability = providerCliAvailability(providerClis!); const updatedSettings = await settings.setAvailableProviders(availability); @@ -1033,12 +1037,17 @@ async function shutdownServices(): Promise { browserRequests.clear(); await evenG2?.close(); if (terminalManager) await terminalManager.shutdown(); + // The hung-up PTYs exit while the other services close; quitting waits for them (see waitForProcessExits). + const ptyExits = terminalManager?.waitForProcessExits().then((left) => { + if (left > 0) console.warn(`CanvasTTY quit with ${left} terminal process(es) that did not exit after SIGKILL.`); + }); limitsService?.dispose(); if (agentGateway) await Promise.allSettled([agentGateway.close()]); if (runtimeGateway) await Promise.allSettled([runtimeGateway.close()]); if (browserService) await Promise.allSettled([browserService.dispose()]); if (pluginServices) await Promise.allSettled([pluginServices.dispose()]); if (pluginManager) await Promise.allSettled([pluginManager.dispose()]); + await ptyExits; } async function openPluginWindow(pluginId: string, contributionId: string): Promise { diff --git a/src/main/ipc/registerIpc.ts b/src/main/ipc/registerIpc.ts index fb69ecc4..23a1e485 100644 --- a/src/main/ipc/registerIpc.ts +++ b/src/main/ipc/registerIpc.ts @@ -477,7 +477,7 @@ export function registerIpc({ } if (method === "sessions.list") { plugins.assertPermission(pluginId, "sessions:read"); - return terminals.list().map((session) => ({ + return terminals.listMetadata().map((session) => ({ id: session.id, provider: session.provider, title: session.title, diff --git a/src/main/services/AgentControlService.ts b/src/main/services/AgentControlService.ts index 1b2883f8..5aaf8ef9 100644 --- a/src/main/services/AgentControlService.ts +++ b/src/main/services/AgentControlService.ts @@ -2,6 +2,7 @@ import type { AgentProviderId, CreateSessionRequest, LaunchProfileId, + SessionMetadata, SessionSnapshot } from "../../shared/contracts.ts"; import { PROVIDER_CAPABILITIES } from "../../shared/contracts.ts"; @@ -63,7 +64,7 @@ export class AgentControlService { * (after an asynchronous launch has started) and rejects with PromptNotDeliveredError when that launch did not * start; the card stays, so the caller can inspect or cancel it. */ - spawn(request: SpawnAgentRequest): Promise { + spawn(request: SpawnAgentRequest): Promise { if (!request || typeof request.parentSessionId !== "string") { throw new Error("A parent session id is required."); } @@ -93,7 +94,7 @@ export class AgentControlService { }); if (request.initialPrompt === undefined || request.initialPrompt.length === 0) return Promise.resolve(created); return this.deliver(created.id, `${request.initialPrompt}\r`, "prompt") - .then(() => this.terminals.list().find((session) => session.id === created.id) ?? created); + .then(() => this.terminals.getMetadata(created.id) ?? created); } /** Validates at once (throws); resolves once the text reached the agent, and rejects when it did not. */ @@ -107,13 +108,13 @@ export class AgentControlService { return this.deliver(sessionId, submit ? `${text}\r` : text, "text"); } - status(sessionId: string): SessionSnapshot { + status(sessionId: string): SessionMetadata { return this.requireSession(sessionId); } - children(parentSessionId: string): SessionSnapshot[] { + children(parentSessionId: string): SessionMetadata[] { this.requireSession(parentSessionId); - return this.terminals.list() + return this.terminals.listMetadata() .filter((session) => session.parentSessionId === parentSessionId) .sort((a, b) => a.startedAt - b.startedAt); } @@ -121,7 +122,7 @@ export class AgentControlService { /** True when sessionId is parentSessionId itself or any of its descendants. */ isInSubtree(parentSessionId: string, sessionId: string): boolean { if (typeof parentSessionId !== "string" || typeof sessionId !== "string") return false; - const snapshots = new Map(this.terminals.list().map((session) => [session.id, session])); + const snapshots = new Map(this.terminals.listMetadata().map((session) => [session.id, session])); let current: string | undefined = sessionId; const seen = new Set(); while (current !== undefined) { @@ -141,8 +142,9 @@ export class AgentControlService { return { sessionId: session.id, status: session.status, - // The whole buffer is masked first: a cut inside a secret would leave a tail no pattern recognizes. - output: tail(this.redact(this.terminals.readBuffer(sessionId).buffer), maxChars) + // Masked before the cut (a cut inside a secret would leave a tail no pattern recognizes), over a window + // wider than any match rather than the whole scrollback. + output: this.redactTail(this.terminals.readBuffer(sessionId).buffer, maxChars) }; } @@ -162,7 +164,7 @@ export class AgentControlService { ? "running" : session.exitCode === 0 ? "done" : "failed", exitCode: session.exitCode, - output: tail(this.redact(buffer), MAX_OBSERVE_CHARS) + output: this.redactTail(buffer, MAX_OBSERVE_CHARS) }; } @@ -179,16 +181,18 @@ export class AgentControlService { } } - /** Plugin launch secrets never reach another agent through observed output. */ - private redact(text: string): string { - return typeof this.terminals.redactSecrets === "function" ? this.terminals.redactSecrets(text) : text; + /** Plugin launch secrets never reach another agent through observed output: the tail as masking the whole text leaves it. */ + private redactTail(text: string, maxChars: number): string { + if (typeof this.terminals.redactSecretsTail === "function") return this.terminals.redactSecretsTail(text, maxChars); + return tail(typeof this.terminals.redactSecrets === "function" ? this.terminals.redactSecrets(text) : text, maxChars); } - private requireSession(sessionId: string): SessionSnapshot { + /** A lookup by id: metadata only, so no other session's scrollback is copied. */ + private requireSession(sessionId: string): SessionMetadata { if (typeof sessionId !== "string" || sessionId.length === 0) { throw new Error("A session id is required."); } - const session = this.terminals.list().find((candidate) => candidate.id === sessionId); + const session = this.terminals.getMetadata(sessionId); if (!session) throw new Error("Terminal session does not exist."); return session; } diff --git a/src/main/services/PluginSessions.ts b/src/main/services/PluginSessions.ts index c22a9da2..bcef803d 100644 --- a/src/main/services/PluginSessions.ts +++ b/src/main/services/PluginSessions.ts @@ -60,6 +60,7 @@ interface TerminalPort { deliverInput(id: string, text: string): Promise<{ delivered: boolean }>; dispose(id: string, options?: { keepEnvironmentData?: boolean }): void; redactSecrets(text: string): string; + redactSecretsTail(text: string, maxChars: number): string; } export interface PluginSessionsDependencies { @@ -203,8 +204,8 @@ export class PluginSessions { private screen(sessionId: string): string { try { const { buffer } = this.deps.terminals.readBuffer(sessionId); - // Masked whole before the cut, so a secret the cut splits leaves no readable tail. - return this.deps.terminals.redactSecrets(plainText(buffer)).slice(-MAX_SCREEN_CHARS); + // Masked before the cut (over a window wider than any match), so a secret the cut splits leaves no readable tail. + return this.deps.terminals.redactSecretsTail(plainText(buffer), MAX_SCREEN_CHARS); } catch { return ""; } diff --git a/src/main/services/TerminalManager.ts b/src/main/services/TerminalManager.ts index d36f8256..f6d8f4b2 100644 --- a/src/main/services/TerminalManager.ts +++ b/src/main/services/TerminalManager.ts @@ -143,6 +143,13 @@ interface PlannedSpawn { } /** Quitting with saving off asks environments to stop compute, but never waits longer than this. */ const QUIT_RELEASE_TIMEOUT_MS = 3_000; +/** + * Quitting waits this long for the PTYs it hung up to exit, then kills the rest and waits `PTY_KILL_WAIT_MS` more. + * node-pty reports an exit through a native callback into JavaScript; one that arrives while Electron tears the + * Node environment down cannot run there, and node-pty turns that into a C++ exception that aborts the app. + */ +export const PTY_EXIT_WAIT_MS = 2_000; +export const PTY_KILL_WAIT_MS = 1_000; /** Longer than every plugin step of a launch together (prepare, resume, launch options, wrap). */ export const LAUNCH_INPUT_WAIT_MS = 60_000; @@ -197,6 +204,8 @@ export class TerminalManager { private readonly launchContexts = new Map(); private quitting = false; private readonly quitReleases: Promise[] = []; + // Every PTY started here whose exit has not been reported yet, closed cards included, with that exit. + private readonly liveProcesses = new Map>(); private suppressPersistence = false; // The live agent-control descriptor, handed only to orchestrator-role sessions // spawned while it is set; null while the endpoint is off. @@ -251,6 +260,11 @@ export class TerminalManager { return (text === null ? text : this.redaction.redact(text)) as T; } + /** `redactSecrets(text)` cut to its last `maxChars` characters, masking only a window around that tail. */ + redactSecretsTail(text: string, maxChars: number): string { + return this.redaction.redactTail(text, maxChars); + } + /** What decision hooks need to know about a running agent card; null for terminals and unknown ids. */ decisionContext(id: string): DecisionSession | null { const session = this.sessions.get(id); @@ -357,6 +371,35 @@ export class TerminalManager { if (this.sessionStore) await this.sessionStore.flush().catch(() => undefined); } + /** + * Resolves once every PTY this manager started has exited, so the app never finishes quitting while a native + * exit watcher is still pending. Called after `shutdown()` (which hung every card up): a process still running + * after `exitWaitMs` is killed, and after `killWaitMs` more the wait gives up. Returns how many never exited. + */ + async waitForProcessExits(exitWaitMs = PTY_EXIT_WAIT_MS, killWaitMs = PTY_KILL_WAIT_MS): Promise { + if (this.liveProcesses.size === 0) return 0; + if (!await this.allProcessesExited(exitWaitMs)) { + for (const process of this.liveProcesses.keys()) { + try { + // Windows PTYs take no signal. + if (globalThis.process.platform === "win32") process.kill(); + else process.kill("SIGKILL"); + } catch { + // Already gone. + } + } + await this.allProcessesExited(killWaitMs); + } + return this.liveProcesses.size; + } + + private allProcessesExited(timeoutMs: number): Promise { + let timer: ReturnType | undefined; + const timedOut = new Promise((resolve) => { timer = setTimeout(() => resolve(false), timeoutMs); }); + const exited = Promise.all(this.liveProcesses.values()).then(() => true as const); + return Promise.race([exited, timedOut]).finally(() => clearTimeout(timer)); + } + list(): SessionSnapshot[] { return [...this.sessions.values()].map((session) => snapshot(session)); } @@ -376,6 +419,12 @@ export class TerminalManager { return [...this.sessions.values()].map((session) => structuredClone(session.metadata)); } + /** One session's metadata by id, or null. Unlike list(), a lookup never copies any scrollback. */ + getMetadata(id: string): SessionMetadata | null { + const session = this.sessions.get(id); + return session ? structuredClone(session.metadata) : null; + } + geometry(id: string): { cols: number; rows: number } { const session = this.sessions.get(id); if (!session) throw new Error("Terminal session does not exist."); @@ -801,11 +850,13 @@ export class TerminalManager { // Hidden -> visible: first hand the observers whatever is still batched // (still addressed to them alone, since the card has not seen it and the - // replay below covers it), then replay the retained scrollback ending at - // the current outputOffset to the renderer alone. The card drops everything - // it already wrote (its offset is absolute; - // features/terminal/terminalOutput.ts), so the missed suffix arrives — - // once. The observers get no replay: they already received every chunk. + // replay below covers it), then replay the output produced since + // hiddenSince, ending at the current outputOffset, to the renderer alone. + // The card already wrote everything up to hiddenSince (the batch pending at + // hide time was flushed to it), and it drops anything it already wrote (its + // offset is absolute; features/terminal/terminalOutput.ts), so the missed + // suffix arrives — once — without resending the history before it. The + // observers get no replay: they already received every chunk. // // The window is bounded by MAX_SCROLLBACK_CHARS: when the hidden stretch // was longer than the ring, the buffer no longer reaches back to @@ -818,7 +869,7 @@ export class TerminalManager { this.flushOutput(id, session); this.hiddenSinceOffset.delete(id); if (hiddenSince === undefined || session.outputOffset === hiddenSince) return; - const data = session.bufferChunks.slice(session.bufferStart).join(""); + const data = scrollbackTail(session, session.outputOffset - hiddenSince); if (data.length > 0) { this.emit(IPC.terminalData, { id, data, outputOffset: session.outputOffset, audience: "renderer" }); } @@ -1525,30 +1576,42 @@ export class TerminalManager { this.queueOutput(id, current, data); }); + let exited!: () => void; + this.liveProcesses.set(process, new Promise((resolve) => { exited = resolve; })); process.onExit(({ exitCode }) => { + this.liveProcesses.delete(process); + exited(); const current = this.sessions.get(id); if (!current || current !== session || current.process !== process) return; - - this.flushOutput(id, current); - current.metadata.exitCode = exitCode; - current.metadata.status = exitCode === 0 ? "done" : "failed"; - current.metadata.failureDetails = exitCode === 0 - ? null - : terminalFailureDetails(this.redactSecrets(current.bufferChunks.slice(current.bufferStart).join(""))); - current.agentBrowser?.cleanup(); - current.agentBrowser = null; - current.agentRuntime?.cleanup(); - current.agentRuntime = null; - current.agentOrchestration?.cleanup(); - current.agentOrchestration = null; - void current.launchCleanup?.().catch(() => undefined); - current.launchCleanup = null; - this.emitSession(current.metadata); - // Recorded at the moment of exit, so a finished agent is never relaunched. - this.schedulePersistence(); + // node-pty calls this from a native callback that aborts the whole app when JavaScript throws in it. + try { + this.recordExit(id, current, exitCode); + } catch (error) { + console.warn(`PTY ${id} exit could not be recorded.`, error); + } }); } + private recordExit(id: string, current: ManagedSession, exitCode: number): void { + this.flushOutput(id, current); + current.metadata.exitCode = exitCode; + current.metadata.status = exitCode === 0 ? "done" : "failed"; + current.metadata.failureDetails = exitCode === 0 + ? null + : terminalFailureDetails(this.redactSecrets(current.bufferChunks.slice(current.bufferStart).join(""))); + current.agentBrowser?.cleanup(); + current.agentBrowser = null; + current.agentRuntime?.cleanup(); + current.agentRuntime = null; + current.agentOrchestration?.cleanup(); + current.agentOrchestration = null; + void current.launchCleanup?.().catch(() => undefined); + current.launchCleanup = null; + this.emitSession(current.metadata); + // Recorded at the moment of exit, so a finished agent is never relaunched. + this.schedulePersistence(); + } + private queueOutput(id: string, session: ManagedSession, data: string): void { session.pendingOutput.push(data); if (session.outputTimer !== null) return; @@ -1725,6 +1788,19 @@ function snapshot(session: ManagedSession): SessionSnapshot { }; } +/** The last `chars` characters of the scrollback (all of it when it holds fewer), joined from the end. */ +function scrollbackTail(session: ManagedSession, chars: number): string { + if (chars >= session.bufferLength) return session.bufferChunks.slice(session.bufferStart).join(""); + const parts: string[] = []; + let needed = chars; + for (let index = session.bufferChunks.length - 1; index >= session.bufferStart && needed > 0; index--) { + const chunk = session.bufferChunks[index]!; + parts.push(chunk.length <= needed ? chunk : chunk.slice(chunk.length - needed)); + needed -= chunk.length; + } + return parts.reverse().join(""); +} + function appendScrollback(session: ManagedSession, data: string): void { session.outputOffset += data.length; session.bufferChunks.push(data); @@ -1740,6 +1816,8 @@ function appendScrollback(session: ManagedSession, data: string): void { } const overflow = session.bufferLength - MAX_SCROLLBACK_CHARS; if (first.length <= overflow) { + // Release the dropped chunk now: the slot stays until the array is compacted, the text must not. + session.bufferChunks[session.bufferStart] = ""; session.bufferStart += 1; session.bufferLength -= first.length; continue; diff --git a/src/main/services/agent-browser/AgentBrowserBridge.ts b/src/main/services/agent-browser/AgentBrowserBridge.ts index f16e4079..b9e70a2c 100644 --- a/src/main/services/agent-browser/AgentBrowserBridge.ts +++ b/src/main/services/agent-browser/AgentBrowserBridge.ts @@ -55,6 +55,11 @@ export class AgentBrowserBridge implements AgentBrowserLaunchCoordinator { this.providers.providerClisRefreshed(); } + /** Background provider probes a first launch would otherwise run on the main thread (Kimi's `--help`). */ + warmProviderProbes(): Promise { + return this.providers.warmKimiProbe(); + } + prepareLaunch(input: PrepareAgentBrowserLaunchInput): PreparedAgentBrowserPtyLaunch | null { if (!this.gateway.isEnabled) return null; const capability = this.gateway.registerAgent(input); diff --git a/src/main/services/agent-browser/ProviderLaunch.ts b/src/main/services/agent-browser/ProviderLaunch.ts index caf780c8..24399b09 100644 --- a/src/main/services/agent-browser/ProviderLaunch.ts +++ b/src/main/services/agent-browser/ProviderLaunch.ts @@ -20,7 +20,7 @@ import { } from "node:fs"; import { homedir } from "node:os"; import { dirname, isAbsolute, join } from "node:path"; -import { spawnSync } from "node:child_process"; +import { execFile, spawnSync } from "node:child_process"; import { APPROVED_BROWSER_TOOL_NAMES, MCP_SERVER_NAME, @@ -67,6 +67,8 @@ export interface ProviderLaunchOptions { kimiHomeDirectory?: string; runtimeDirectory: string; probeKimiPerRunConfig?: (cli: AvailableProviderCli) => boolean; + /** The same probe off the main thread, for warmKimiProbe; defaults to the sync probe's answer when only that is given. */ + probeKimiPerRunConfigAsync?: (cli: AvailableProviderCli) => Promise; environment?: Readonly>; } @@ -82,8 +84,13 @@ export class ProviderLaunchAdapters { private readonly kimiHomeDirectory: string; private kimiProbedExecutable: string | null = null; private readonly probe: (cli: AvailableProviderCli) => boolean; + private readonly probeAsync: (cli: AvailableProviderCli) => Promise; private readonly environment: Readonly>; private kimiSupportsPerRunConfig: boolean | null = null; + /** A background probe's answer, used by the next Kimi launch of the same executable instead of a blocking probe. */ + private kimiWarmed: { executable: string; generation: number; result: boolean } | null = null; + private kimiWarming: Promise | null = null; + private kimiGeneration = 0; private kimiConfiguration: KimiTemporaryConfiguration | null = null; private kimiConfigurationUsers = 0; private hermesConfiguration: HermesTemporaryConfiguration | null = null; @@ -98,12 +105,40 @@ export class ProviderLaunchAdapters { options.kimiHomeDirectory ?? join(homedir(), ".kimi-code") ); this.probe = options.probeKimiPerRunConfig ?? probeKimiPerRunMcpConfig; + const syncProbe = options.probeKimiPerRunConfig; + this.probeAsync = options.probeKimiPerRunConfigAsync + ?? (syncProbe ? async (cli) => syncProbe(cli) : probeKimiPerRunMcpConfigAsync); this.environment = options.environment ?? process.env; } providerClisRefreshed(): void { this.kimiProbedExecutable = null; this.kimiSupportsPerRunConfig = null; + this.kimiWarmed = null; + this.kimiGeneration += 1; + } + + /** + * Asks the Kimi CLI whether it takes a per-run MCP config in the background (`kimi --help`, up to 3 s), so the + * first Kimi launch finds the answer instead of blocking the main process on the same probe. A launch that + * comes first still probes synchronously, exactly as before; a recheck of the CLIs discards the answer. + */ + warmKimiProbe(): Promise { + const kimiCli = this.providerClis.get("kimi"); + if (kimiCli.state === "unavailable") return Promise.resolve(); + if (this.kimiSupportsPerRunConfig !== null && this.kimiProbedExecutable === kimiCli.executable) return Promise.resolve(); + const generation = this.kimiGeneration; + if (this.kimiWarmed?.executable === kimiCli.executable && this.kimiWarmed.generation === generation) return Promise.resolve(); + if (this.kimiWarming) return this.kimiWarming; + const warming = this.probeAsync(kimiCli) + .then((result) => { + if (generation === this.kimiGeneration) this.kimiWarmed = { executable: kimiCli.executable, generation, result }; + }, () => undefined) + .finally(() => { + if (this.kimiWarming === warming) this.kimiWarming = null; + }); + this.kimiWarming = warming; + return warming; } /** `orchestrationTools`: the canvastty_agents tools this session may use (default: the core tools). */ @@ -194,7 +229,10 @@ export class ProviderLaunchAdapters { this.kimiProbedExecutable = kimiCli.executable; } if (this.kimiSupportsPerRunConfig === null) { - this.kimiSupportsPerRunConfig = this.probe(kimiCli); + const warmed = this.kimiWarmed; + this.kimiSupportsPerRunConfig = warmed && warmed.executable === kimiCli.executable && warmed.generation === this.kimiGeneration + ? warmed.result + : this.probe(kimiCli); KimiTemporaryConfiguration.recover(this.kimiHomeDirectory); } const supportsPerRun = this.kimiSupportsPerRunConfig; @@ -374,12 +412,12 @@ function orchestrationServerEntry(helper: StdioHelperLaunch): Record { + const launch = providerChildProcessLaunch(cli, ["--help"]); + return new Promise((resolve) => { + execFile(launch.command, launch.args, { + encoding: "utf8", + env: { ...process.env, ...launch.environment }, + timeout: timeoutMs, + maxBuffer: 256 * 1024, + windowsHide: true, + ...(launch.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}) + }, (error, stdout, stderr) => { + resolve(!error && `${stdout ?? ""}\n${stderr ?? ""}`.includes("--mcp-config-file")); + }); + }); +} + export function recoverKimiConfigurationOnStartup( kimiHomeDirectory = join(homedir(), ".kimi-code") ): void { diff --git a/src/main/services/companion/TerminalPresentation.ts b/src/main/services/companion/TerminalPresentation.ts index 8a6be07e..0d76dad2 100644 --- a/src/main/services/companion/TerminalPresentation.ts +++ b/src/main/services/companion/TerminalPresentation.ts @@ -18,7 +18,11 @@ type Port = { readBuffer(id: string): { buffer: string; outputOffset: number }; }; interface Screen { - terminal: InstanceType; + /** + * The session's headless screen, made the first time the glasses read it (from the scrollback, which is + * the same text the live stream carries) and fed from then on. Sessions nobody reads cost nothing to parse. + */ + terminal: InstanceType | null; ready: Promise; offset: number; lastAnswer: string; @@ -38,19 +42,14 @@ export class TerminalPresentation { constructor(port: Port) { this.port = port; } + /** The session's answer state; cheap, made for every session the companion hears about. */ private screen(id: string): Screen { const prior = this.screens.get(id); if (prior) return prior; - const terminal = new xterm.Terminal({ - ...this.port.geometry(id), - allowProposedApi: true, - scrollback: 300, - }); - const buffer = this.port.readBuffer(id); const screen: Screen = { - terminal, - ready: new Promise((resolve) => terminal.write(buffer.buffer, resolve)), - offset: buffer.outputOffset, + terminal: null, + ready: Promise.resolve(), + offset: 0, lastAnswer: "", answerTurn: null, answerExpiresAt: null, @@ -65,10 +64,26 @@ export class TerminalPresentation { this.screens.set(id, screen); return screen; } + /** The session's state with its headless screen, made from the scrollback on first use. */ + private parsed(id: string): Screen & { terminal: InstanceType } { + const screen = this.screen(id); + if (!screen.terminal) { + const terminal = new xterm.Terminal({ + ...this.port.geometry(id), + allowProposedApi: true, + scrollback: 300, + }); + const buffer = this.port.readBuffer(id); + screen.terminal = terminal; + screen.offset = buffer.outputOffset; + screen.ready = new Promise((resolve) => terminal.write(buffer.buffer, resolve)); + } + return screen as Screen & { terminal: InstanceType }; + } observe(channel: string, payload: unknown): void { if (channel === IPC.terminalRemoved) { const id = (payload as { id: string }).id; - this.screens.get(id)?.terminal.dispose(); + this.screens.get(id)?.terminal?.dispose(); this.screens.delete(id); return; } @@ -83,7 +98,10 @@ export class TerminalPresentation { } if (channel !== IPC.terminalData) return; const event = payload as TerminalDataEvent, - screen = this.screen(event.id); + screen = this.screens.get(event.id); + // Not read yet: the scrollback will hold this output when the glasses first ask. + const terminal = screen?.terminal; + if (!screen || !terminal) return; const overlap = Math.max( 0, screen.offset - (event.outputOffset - event.data.length), @@ -92,14 +110,14 @@ export class TerminalPresentation { screen.offset = Math.max(screen.offset, event.outputOffset); const geometry = this.port.geometry(event.id); if ( - geometry.cols !== screen.terminal.cols || - geometry.rows !== screen.terminal.rows + geometry.cols !== terminal.cols || + geometry.rows !== terminal.rows ) - screen.terminal.resize(geometry.cols, geometry.rows); + terminal.resize(geometry.cols, geometry.rows); if (data) screen.ready = screen.ready.then( () => - new Promise((resolve) => screen.terminal.write(data, resolve)), + new Promise((resolve) => terminal.write(data, resolve)), ); } answer(id: string, text: string, turnId: string | null, expiresAt: number): void { @@ -130,7 +148,7 @@ export class TerminalPresentation { screen.busySeen = false; } private async text(id: string, history = false): Promise { - const screen = this.screen(id); + const screen = this.parsed(id); await screen.ready; const buffer = screen.terminal.buffer.active, lines: string[] = []; @@ -243,7 +261,7 @@ export class TerminalPresentation { }; } close(): void { - for (const screen of this.screens.values()) screen.terminal.dispose(); + for (const screen of this.screens.values()) screen.terminal?.dispose(); this.screens.clear(); } } diff --git a/src/main/services/safety/SecretRedaction.ts b/src/main/services/safety/SecretRedaction.ts index 10b29484..2ffdf4b2 100644 --- a/src/main/services/safety/SecretRedaction.ts +++ b/src/main/services/safety/SecretRedaction.ts @@ -6,7 +6,11 @@ * 1. Known values: keys from the provider secret vault as this process reads them, plugin `secretEnv` values * of open cards, and values a trusted plugin service registers. Each is removed where it stands, also when * the terminal's wrapping put a line break, indentation or a box side between its characters, and in its - * JSON-escaped form. + * JSON-escaped form. They are found by a linear search over the text with those gaps taken out, never by a + * pattern built from the value: a pattern for a key of a few thousand characters exceeds what the regular + * expression engine accepts, and the error broke every masking call. A value that holds wrap characters of + * its own is also searched exactly as written, so it is masked even when too few characters remain without + * them for the wrap-tolerant search, or when its own gaps are wider than a wrap gap. * 2. JSON string values under key-like names (`"apiKey"`, `"token"`, `"authorization"`, …), across lines too. * 3. Generic shapes: PEM private keys, `sk-…`, GitHub, Slack, AWS, Google, xAI tokens, JWTs, `Bearer …`, * `Authorization:` values, URL credentials, secret-looking query values and assignments, and long @@ -19,16 +23,45 @@ const SECRET_MARKER = ''; /** Shorter values are not keys, and removing them would only garble ordinary text. */ const MIN_SECRET_CHARS = 8; -const MAX_SECRET_CHARS = 4_096; +/** Long enough for a PEM key or a service-account JSON; the search costs the same for any length. */ +const MAX_SECRET_CHARS = 65_536; const MAX_VALUES_PER_OWNER = 64; const MAX_OWNERS = 512; /** What wrapping may put between two characters of a key: whitespace and line breaks, box-drawing sides. */ -const WRAP_GAP = '[\\s\\u2500-\\u257f]{0,64}'; +const MAX_WRAP_GAP = 64; +/** + * redactTail masks a window that starts about this far before the tail it returns, so that masking, which can + * shorten the text, still leaves at least the tail after the window's start. + */ +const TAIL_MARGIN_CHARS = 16_384; +/** + * How much further back than the margin redactTail looks for a line start no match can cross (see + * safeWindowStart); where there is none, it masks the whole text. + */ +const TAIL_SEARCH_CHARS = 65_536; +/** The longest value JSON_SECRET_VALUE takes, the one rule whose value may run over a line break. */ +const JSON_VALUE_MAX_CHARS = 2_048; +const PRIVATE_KEY_HEADER = /-----BEGIN [A-Z0-9 ]{0,40}PRIVATE KEY-----/gu; +const PRIVATE_KEY_FOOTER = /-----END [A-Z0-9 ]{0,40}PRIVATE KEY-----/gu; +const PRIVATE_KEY_HEADER_TEXT = /-----BEGIN [A-Z0-9 ]{0,40}PRIVATE KEY-----/u; const JSON_SECRET_VALUE = /("(?:[A-Za-z0-9_.-]{0,40}(?:api[_-]?key|token|secret|password|authorization))"\s*:\s*")(?!>(); - private pattern: RegExp | null = null; + private forms: KnownForms = NO_FORMS; + /** The longest text one held value can match: its characters plus a full wrap gap between each two. */ + private knownSpan = 0; private dirty = false; /** Adds values under an owner (`vault`, `session:`, `plugin:`); short or oversized values are ignored. */ @@ -56,32 +89,250 @@ export class SecretRedactionRegistry { redact(text: string): string { if (typeof text !== 'string' || text.length === 0) return typeof text === 'string' ? text : ''; - let result = text; - const known = this.knownPattern(); - if (known) result = result.replace(known, SECRET_MARKER); + let result = maskKnownValues(text, this.knownForms()); result = result.replace(JSON_SECRET_VALUE, (_match, prefix: string, closing: string) => `${prefix}${SECRET_MARKER}${closing}`); return redactCredentials(result); } - /** One pattern for every held value and its JSON-escaped form, longest first; rebuilt only after a change. */ - private knownPattern(): RegExp | null { - if (!this.dirty) return this.pattern; - const forms = new Set(); + /** + * The same text as `redact(text)` cut to its last `maxChars` characters, without masking the whole text: a + * card's 240 000-character scrollback costs as much as its last `maxChars` plus a margin (wider when a held + * value could wrap over it). The window starts at a line start that no match of any rule or held value + * crosses (safeWindowStart), so everything after it masks exactly as in the whole text; where no such line + * start is near, or masking left less than the tail after it, the whole text is masked. + */ + redactTail(text: string, maxChars: number): string { + if (typeof text !== 'string' || text.length === 0 || maxChars <= 0) return ''; + const forms = this.knownForms(); + const margin = Math.max(TAIL_MARGIN_CHARS, 2 * this.knownSpan + 4_096); + if (text.length <= maxChars + margin) return lastChars(this.redact(text), maxChars); + // Masking a held value that holds PEM armour can move where a private-key block ends; only the whole text + // tells where. + if (forms.armour && PRIVATE_KEY_HEADER_TEXT.test(text)) return lastChars(this.redact(text), maxChars); + const start = safeWindowStart(text, text.length - maxChars - margin, forms, this.knownSpan); + if (start === null) return lastChars(this.redact(text), maxChars); + const masked = this.redact(text.slice(start)); + if (masked.length < maxChars) return lastChars(this.redact(text), maxChars); + return lastChars(masked, maxChars); + } + + /** The search forms of every held value; rebuilt only after a change. */ + private knownForms(): KnownForms { + if (!this.dirty) return this.forms; + const bare = new Set(); + const exact = new Set(); + let longest = 0; + let armour = false; for (const values of this.owners.values()) { for (const value of values) { - forms.add(value); - forms.add(JSON.stringify(value).slice(1, -1)); + if (value.includes('-----')) armour = true; + for (const form of [value, JSON.stringify(value).slice(1, -1)]) { + longest = Math.max(longest, form.length); + const stripped = withoutWrapCharacters(form); + // Without its wrap characters, a value that is mostly spaces would leave a fragment that garbles + // ordinary text; such a value is found as written (below). + if (stripped.length >= MIN_SECRET_CHARS) bare.add(stripped); + // Every held form is at least MIN_SECRET_CHARS long as written: the value was trimmed and checked in + // add(), and escaping only lengthens it. + if (stripped !== form) exact.add(form); + } } } - const sources = [...forms].sort((a, b) => b.length - a.length).map(form => [...form].map(escapeCharacter).join(WRAP_GAP)); - this.pattern = sources.length ? new RegExp(sources.join('|'), 'gu') : null; + const longestFirst = (a: string, b: string): number => b.length - a.length; + this.forms = { bare: [...bare].sort(longestFirst), exact: [...exact].sort(longestFirst), armour }; + // A form of n characters matches at most n characters plus a full wrap gap between each two. + this.knownSpan = longest * (MAX_WRAP_GAP + 1); this.dirty = false; - return this.pattern; + return this.forms; + } +} + +/** Whitespace (as `\s` has it) and the box-drawing block: what terminal wrapping may put inside a key. */ +function isWrapCharacter(code: number): boolean { + if (code <= 0x20) return code === 0x20 || (code >= 0x09 && code <= 0x0d); + if (code < 0xa0) return false; + return code === 0xa0 || code === 0x1680 || (code >= 0x2000 && code <= 0x200a) || code === 0x2028 || code === 0x2029 + || code === 0x202f || code === 0x205f || code === 0x3000 || code === 0xfeff || (code >= 0x2500 && code <= 0x257f); +} + +function withoutWrapCharacters(text: string): string { + let result = ""; + let from = 0; + for (let i = 0; i < text.length; i++) { + if (!isWrapCharacter(text.charCodeAt(i))) continue; + result += text.slice(from, i); + from = i + 1; } + return from === 0 ? text : result + text.slice(from); } -function escapeCharacter(character: string): string { - return character.replace(/[\\^$.*+?()[\]{}|/]/gu, '\\$&'); +/** + * Replaces every held value in `text`: the wrap-free forms also where wrapping put up to MAX_WRAP_GAP wrap + * characters between two of their characters, the exact forms as written. Matches are taken leftmost first, the + * longest at a position, and never overlap. + */ +function maskKnownValues(text: string, forms: KnownForms): string { + const endAt = knownMatchEnds(text, forms); + if (!endAt) return text; + let result = ""; + let kept = 0; + for (let at = 0; at < text.length;) { + const end = endAt[at]; + if (end === 0) { at++; continue; } + result += text.slice(kept, at) + SECRET_MARKER; + kept = end; + at = end; + } + return kept === 0 ? text : result + text.slice(kept); +} + +/** + * For each position of `text`, the end of the longest held-value match that starts there (0: none), or null when + * nothing matches. The wrap-free forms are searched in the text with its wrap characters taken out (a map leads + * back to the original positions), the exact forms in the text itself; each with Knuth-Morris-Pratt, linear in + * the text plus the form, whatever either holds. + */ +function knownMatchEnds(text: string, forms: KnownForms): Int32Array | null { + if (forms.bare.length === 0 && forms.exact.length === 0) return null; + const exact = forms.exact.filter(form => text.includes(form)); + const bare = forms.bare.length ? withoutWrapCharacters(text) : ''; + const present = forms.bare.filter(form => bare.includes(form)); + if (present.length === 0 && exact.length === 0) return null; + const endAt = new Int32Array(text.length); + for (const form of exact) { + for (const start of occurrences(text, form)) endAt[start] = Math.max(endAt[start], start + form.length); + } + if (present.length === 0) return endAt; + // Where each character of `bare` stands in `text`, and how many oversized gaps lie before it (a match may + // not cross one). + const positions = new Int32Array(bare.length); + const oversized = new Int32Array(bare.length + 1); + for (let i = 0, count = 0, previous = -1; i < text.length; i++) { + if (isWrapCharacter(text.charCodeAt(i))) continue; + oversized[count + 1] = oversized[count] + (previous >= 0 && i - previous - 1 > MAX_WRAP_GAP ? 1 : 0); + positions[count++] = i; + previous = i; + } + for (const form of present) { + for (const start of occurrences(bare, form)) { + // Only the gaps inside the match count, not the one before its first character. + if (oversized[start + form.length] - oversized[start + 1] !== 0) continue; + const from = positions[start]; + endAt[from] = Math.max(endAt[from], positions[start + form.length - 1] + 1); + } + } + return endAt; +} + +/** Every start of `pattern` in `text`, overlapping ones included (Knuth-Morris-Pratt). */ +function* occurrences(text: string, pattern: string): Generator { + const failure = new Int32Array(pattern.length); + for (let i = 1, k = 0; i < pattern.length; i++) { + while (k > 0 && pattern.charCodeAt(i) !== pattern.charCodeAt(k)) k = failure[k - 1]; + if (pattern.charCodeAt(i) === pattern.charCodeAt(k)) k++; + failure[i] = k; + } + for (let i = 0, k = 0; i < text.length; i++) { + while (k > 0 && text.charCodeAt(i) !== pattern.charCodeAt(k)) k = failure[k - 1]; + if (text.charCodeAt(i) === pattern.charCodeAt(k)) k++; + if (k === pattern.length) { + yield i - k + 1; + k = failure[k - 1]; + } + } +} + +function lastChars(text: string, maxChars: number): string { + return text.length <= maxChars ? text : text.slice(text.length - maxChars); +} + +/** + * Where the window of redactTail may start: the start of a line at or before `desired`, and not more than + * TAIL_SEARCH_CHARS before it, that no match of a held value or of a rule crosses. From such a line start on, + * masking the window yields exactly what masking the whole text yields there: every pass (held values, JSON + * values, each rule) finds the same matches after it, and a lookbehind at it sees a line break in the whole text + * and the start in the window, which every rule treats alike. Null when there is none. + * + * Which matches can run over a line break, and what rules each out at a line start `b`: + * - a private-key block, from its header to its END or the end of the text: `b` lies in no such block; + * - a wrapped token or high-entropy run, which goes on over a line break right after a run character, and a + * separator's whitespace (`Authorization:`, `Bearer`, `name =`, `"key":`): the last character before `b` that is + * not whitespace is none those continue after (a letter, digit, `+ = _ - : " '`, or `>` of `=>`); + * - a JSON value under a key-like name, up to JSON_VALUE_MAX_CHARS characters of anything but `"`: no such + * value is open at `b` (the last `"` before `b` is not preceded by `:`, or lies further back); + * - a held value, which can hold line breaks: none of their matches crosses `b` or covers the characters the + * two checks above read (masking one there could change what they see). + * Masking before `b` only puts `` markers there, whose last character `>` none of the above + * continues after, so the checks hold for every pass, not only on the text as it came. + */ +function safeWindowStart(text: string, desired: number, forms: KnownForms, knownSpan: number): number | null { + const floor = Math.max(0, desired - TAIL_SEARCH_CHARS); + const blocks = privateKeyBlocks(text, desired + 1); + const seen = new Map(); + let b = text.lastIndexOf('\n', desired - 1) + 1; + while (b > floor) { + const block = blocks.find(([from, to]) => from < b && b < to); + if (block) { + b = text.lastIndexOf('\n', block[0] - 1) + 1; + continue; + } + // The last character before `b` that is not whitespace; every line start in the whitespace before it + // shares it, so the search goes on from its line. + let last = b - 1; + while (last >= 0 && WHITESPACE.test(text[last]!)) last--; + if (isLineStartUncrossed(text, b, last, forms, knownSpan, seen)) return b; + b = last < 0 ? 0 : text.lastIndexOf('\n', Math.min(last, b - 2)) + 1; + } + return null; +} + +/** The private-key blocks the PEM rule masks that start before `limit`: from each header to its END or the end. */ +function privateKeyBlocks(text: string, limit: number): Array<[number, number]> { + const blocks: Array<[number, number]> = []; + PRIVATE_KEY_HEADER.lastIndex = 0; + for (;;) { + const header = PRIVATE_KEY_HEADER.exec(text); + if (!header || header.index >= limit) return blocks; + PRIVATE_KEY_FOOTER.lastIndex = header.index + header[0].length; + const footer = PRIVATE_KEY_FOOTER.exec(text); + const end = footer ? footer.index + footer[0].length : text.length; + blocks.push([header.index, end]); + PRIVATE_KEY_HEADER.lastIndex = end; + } +} + +const WHITESPACE = /\s/u; + +/** Whether the `"` at `quote` follows a `:` (whitespace between); answers are kept per search in `seen`. */ +function opensJsonValue(text: string, quote: number, seen: Map): boolean { + let answer = seen.get(quote); + if (answer === undefined) { + let before = quote - 1; + while (before >= 0 && WHITESPACE.test(text[before]!)) before--; + answer = before >= 0 && text[before] === ':'; + seen.set(quote, answer); + } + return answer; +} +/** Characters after which a wrapped run or a separator's whitespace may go on over a line break. */ +const CONTINUED_AFTER = /[A-Za-z0-9+=_\-:"']/u; + +function isLineStartUncrossed(text: string, b: number, last: number, forms: KnownForms, knownSpan: number, seen: Map): boolean { + if (last >= 0 && (CONTINUED_AFTER.test(text[last]!) || (text[last] === '>' && text[last - 1] === '='))) return false; + let quote = text.lastIndexOf('"', b - 1); + if (quote >= 0 && b - quote <= JSON_VALUE_MAX_CHARS + 2) { + if (opensJsonValue(text, quote, seen)) return false; + } else quote = b; + if (forms.bare.length === 0 && forms.exact.length === 0) return true; + // Held-value matches that start before `b` lie within knownSpan of it. + const checkFrom = Math.max(0, Math.min(last, quote)); + const from = Math.max(0, checkFrom - knownSpan); + const endAt = knownMatchEnds(text.slice(from, Math.min(text.length, b + knownSpan)), forms); + if (!endAt) return true; + for (let at = 0; from + at < b; at++) { + if (endAt[at] !== 0 && from + endAt[at] > checkFrom) return false; + } + return true; } type Rule = { kind: string; pattern: RegExp; replace?: (match: string, ...groups: string[]) => string }; diff --git a/src/renderer/src/assets/providers/README.md b/src/renderer/src/assets/providers/README.md index 27c3eeaf..6e4fcab0 100644 --- a/src/renderer/src/assets/providers/README.md +++ b/src/renderer/src/assets/providers/README.md @@ -1,12 +1,14 @@ # Provider brand assets -These files are vendor-supplied marks. Do not redraw, recolor, or modify them. +These files are vendor-supplied marks. Do not redraw, recolor, or modify them. Large raster marks are only +scaled down (macOS `sips -Z`, aspect ratio kept) to 128 px plus a 256 px variant for high-density displays: +the largest place an icon is drawn (the home launcher at the canvas's maximum zoom) is about 125 CSS px. -- `codex.png` — Codex app mark shipped in the official ChatGPT browser extension (`app-D0g8sCle.png`, extension version `1.2.27236.6274`). +- `codex-128.png`, `codex-256.png` — scaled down from the 544 px Codex app mark shipped in the official ChatGPT browser extension (`app-D0g8sCle.png`, extension version `1.2.27236.6274`). - `claude.svg` — `Claude Spark - Clay.svg` from the official [Anthropic press kit](https://www.anthropic.com/press-kit). - `kimi.ico` — official [Kimi favicon](https://www.kimi.com/favicon.ico), containing 48, 32, and 16 px variants. - `opencode.svg` — unmodified OpenCode [`packages/identity/mark.svg`](https://github.com/anomalyco/opencode/blob/1251a870cb384543c150c4a72fb101b55eec971b/packages/identity/mark.svg). -- `hermes.png` — unmodified Hermes Agent [`apps/desktop/assets/icon.png`](https://github.com/NousResearch/hermes-agent/blob/13ce0c5c675e843af70d19c9e5144249cd51c8d1/apps/desktop/assets/icon.png). +- `hermes-128.png`, `hermes-256.png` — scaled down from the 1024 px Hermes Agent [`apps/desktop/assets/icon.png`](https://github.com/NousResearch/hermes-agent/blob/13ce0c5c675e843af70d19c9e5144249cd51c8d1/apps/desktop/assets/icon.png). - `grok.png` — unmodified official SpaceXAI black transparent symbol linked by the [Grok Build repository](https://github.com/xai-org/grok-build). - `qwen.svg` — unmodified Qwen Code [`packages/desktop-shell/bootstrap/qwen-code-logo.svg`](https://github.com/QwenLM/qwen-code/blob/c3d9279932f592c39d8bf24de5da56c53d4ca60f/packages/desktop-shell/bootstrap/qwen-code-logo.svg). - `omp.svg` — unmodified oh-my-pi [`packages/collab-web/public/favicon.svg`](https://github.com/can1357/oh-my-pi/blob/d3606c36ec3e23d7b8dbd02bf1db50bd97a87cb6/packages/collab-web/public/favicon.svg), byte-identical to the mark served by . diff --git a/src/renderer/src/assets/providers/codex-128.png b/src/renderer/src/assets/providers/codex-128.png new file mode 100644 index 00000000..67b0b9a0 Binary files /dev/null and b/src/renderer/src/assets/providers/codex-128.png differ diff --git a/src/renderer/src/assets/providers/codex-256.png b/src/renderer/src/assets/providers/codex-256.png new file mode 100644 index 00000000..dc8e1ac4 Binary files /dev/null and b/src/renderer/src/assets/providers/codex-256.png differ diff --git a/src/renderer/src/assets/providers/codex.png b/src/renderer/src/assets/providers/codex.png deleted file mode 100644 index 727f7f01..00000000 Binary files a/src/renderer/src/assets/providers/codex.png and /dev/null differ diff --git a/src/renderer/src/assets/providers/hermes-128.png b/src/renderer/src/assets/providers/hermes-128.png new file mode 100644 index 00000000..977a7ba4 Binary files /dev/null and b/src/renderer/src/assets/providers/hermes-128.png differ diff --git a/src/renderer/src/assets/providers/hermes-256.png b/src/renderer/src/assets/providers/hermes-256.png new file mode 100644 index 00000000..6ddeb95a Binary files /dev/null and b/src/renderer/src/assets/providers/hermes-256.png differ diff --git a/src/renderer/src/assets/providers/hermes.png b/src/renderer/src/assets/providers/hermes.png deleted file mode 100644 index 539fdf9b..00000000 Binary files a/src/renderer/src/assets/providers/hermes.png and /dev/null differ diff --git a/src/renderer/src/components/ProviderIcon.tsx b/src/renderer/src/components/ProviderIcon.tsx index 772c74c6..713df34d 100644 --- a/src/renderer/src/components/ProviderIcon.tsx +++ b/src/renderer/src/components/ProviderIcon.tsx @@ -1,10 +1,12 @@ import type { ProviderId } from "../../../shared/contracts"; import terminalIcon from "../assets/icons/lucide/square-terminal.svg"; import claudeIcon from "../assets/providers/claude.svg"; -import codexIcon from "../assets/providers/codex.png"; +import codexIcon from "../assets/providers/codex-128.png"; +import codexIcon2x from "../assets/providers/codex-256.png"; import kimiIcon from "../assets/providers/kimi.ico"; import openCodeIcon from "../assets/providers/opencode.svg"; -import hermesIcon from "../assets/providers/hermes.png"; +import hermesIcon from "../assets/providers/hermes-128.png"; +import hermesIcon2x from "../assets/providers/hermes-256.png"; import grokIcon from "../assets/providers/grok.png"; import ompIcon from "../assets/providers/omp.svg"; import piIcon from "../assets/providers/pi.svg"; @@ -35,6 +37,15 @@ const PROVIDER_ASSETS = { antigravity: antigravityIcon } as const; +/** + * Large raster marks ship at 128 px with a 256 px variant for high-density displays: the largest place an + * icon is drawn (the home launcher at the canvas's maximum zoom, default UI scale) is about 125 CSS px. + */ +const PROVIDER_ASSETS_2X: Partial> = { + codex: codexIcon2x, + hermes: hermesIcon2x +}; + export function ProviderIcon({ provider, size = "medium" }: ProviderIconProps): React.JSX.Element { return ( ); } diff --git a/src/renderer/src/features/terminal/TerminalCard.tsx b/src/renderer/src/features/terminal/TerminalCard.tsx index 330dfb37..8023340a 100644 --- a/src/renderer/src/features/terminal/TerminalCard.tsx +++ b/src/renderer/src/features/terminal/TerminalCard.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { memo, useCallback, useEffect, useRef, useState } from "react"; import { FitAddon } from "@xterm/addon-fit"; import { SearchAddon } from "@xterm/addon-search"; import { WebLinksAddon } from "@xterm/addon-web-links"; @@ -44,6 +44,7 @@ import { shouldActivateCanvasFromClick } from "../workspace/focus"; import type { ResizeDirection } from "../workspace/snap"; import { terminalCanvasWidgetId } from "../workspace/canvasWidgetFocus"; import { renameCommit, visibleTerminalTitle } from "./terminalTitle"; +import { canvasCardPropsEqual } from "./terminalCardProps"; interface TerminalCardProps { session: SessionSnapshot; @@ -104,7 +105,13 @@ const SEARCH_DECORATIONS = { activeMatchColorOverviewRuler: "#9a96c2" } as const; -export function TerminalCard({ +/** + * A card renders again only when one of its props changes (snap targets by value): a pan re-renders the + * workspace on every pointer move, and none of that reaches the cards. + */ +export const TerminalCard = memo(TerminalCardView, canvasCardPropsEqual); + +function TerminalCardView({ session, locale, palette, diff --git a/src/renderer/src/features/terminal/terminalCardProps.ts b/src/renderer/src/features/terminal/terminalCardProps.ts new file mode 100644 index 00000000..bc5b2271 --- /dev/null +++ b/src/renderer/src/features/terminal/terminalCardProps.ts @@ -0,0 +1,37 @@ +import type { SessionBounds } from "../../../../shared/contracts.ts"; + +/** Two snap target lists with the same bounds in the same order. */ +export function sameBoundsList(a: readonly SessionBounds[], b: readonly SessionBounds[]): boolean { + if (a === b) return true; + if (a.length !== b.length) return false; + for (let index = 0; index < a.length; index++) { + const left = a[index]!; + const right = b[index]!; + if (left === right) continue; + if ( + left.position.x !== right.position.x + || left.position.y !== right.position.y + || left.size.width !== right.size.width + || left.size.height !== right.size.height + ) return false; + } + return true; +} + +/** + * React.memo equality for a canvas card: every prop by identity, except `snapTargets`, which the workspace + * rebuilds on every render and which compares by value. The workspace hands the card callbacks that stay + * the same functions across renders (they call its latest handlers), so a camera pan, which changes none of + * a card's props, renders no card; any real change (session, zoom, focus, a moved neighbour) still does. + */ +export function canvasCardPropsEqual

(previous: P, next: P): boolean { + const keys = new Set([...Object.keys(previous), ...Object.keys(next)] as Array); + for (const key of keys) { + if (key === "snapTargets") { + if (!sameBoundsList(previous.snapTargets, next.snapTargets)) return false; + } else if (!Object.is(previous[key], next[key])) { + return false; + } + } + return true; +} diff --git a/src/renderer/src/features/workspace/WorkspaceCanvas.tsx b/src/renderer/src/features/workspace/WorkspaceCanvas.tsx index 4ef32387..dffc12ae 100644 --- a/src/renderer/src/features/workspace/WorkspaceCanvas.tsx +++ b/src/renderer/src/features/workspace/WorkspaceCanvas.tsx @@ -101,6 +101,20 @@ const CANVAS_FOCUS_ARROWS: Readonly = new Set(); +const NO_SNAP_TARGETS: readonly SessionBounds[] = []; + +/** What the workspace does for a terminal card; the card gets stable functions that call the latest of these. */ +interface TerminalCardHandlers { + activate(selectedSession: SessionSnapshot, fullscreen: boolean): void; + select(id: string, fullscreen: boolean): void; + toggleFullscreen(id: string): void; + rename(id: string, title: string): Promise; + renameEnd(): void; + boundsChange(id: string, bounds: SessionBounds): void; + restart(id: string, resume?: boolean): Promise; + dispose(id: string, keepEnvironmentData?: boolean): void; + openUrl(url: string): void; +} /** A group drag's commit basis, frozen once when the press activates: the pressed layer's start * bounds plus every member's, so nothing the gesture itself previews can feed back into it. */ @@ -510,6 +524,62 @@ export function WorkspaceCanvas(props: WorkspaceCanvasProps): React.JSX.Element ); const widgetFocus = focusController.state; const routeWidgetWheelToCanvas = wheelNavigation.routeWidgetWheelToCanvas; + // TerminalCard is memoized: its callbacks are the same functions on every render and call the latest + // handlers through this ref, so a pan (a workspace render per pointer move) renders no card. + const terminalCardHandlers = useRef(null); + terminalCardHandlers.current = { + activate(selectedSession, fullscreen) { + if (!fullscreen) raiseLayer(terminalLayerId(selectedSession.id)); + focusController.focus(terminalCanvasWidgetId(selectedSession.id), "explicit"); + onFocusSession(selectedSession); + }, + select(id, fullscreen) { + if (!fullscreen) raiseLayer(terminalLayerId(id)); + focusController.cancelHover(); + focusController.focus(terminalCanvasWidgetId(id), "explicit"); + onSelectSession(id); + }, + toggleFullscreen: onToggleFullscreen, + rename: onRenameSession, + renameEnd: onRenameEnd, + boundsChange: onSessionBoundsChange, + restart: onRestartSession, + dispose: onDisposeSession, + openUrl: onOpenTerminalUrl + }; + const terminalCardCallbacks = useMemo(() => { + const latest = terminalCardHandlers; + const shared = { + onRename: (id: string, title: string) => latest.current!.rename(id, title), + onRenameEnd: () => latest.current!.renameEnd(), + onRestart: (id: string, resume?: boolean) => latest.current!.restart(id, resume), + onDispose: (id: string, keepEnvironmentData?: boolean) => latest.current!.dispose(id, keepEnvironmentData), + onOpenUrl: (url: string) => latest.current!.openUrl(url) + }; + return { + canvas: { + ...shared, + onActivate: (selectedSession: SessionSnapshot) => latest.current!.activate(selectedSession, false), + onSelect: (id: string) => latest.current!.select(id, false), + onBoundsChange: (id: string, bounds: SessionBounds) => latest.current!.boundsChange(id, bounds) + }, + fullscreen: { + ...shared, + onActivate: (selectedSession: SessionSnapshot) => latest.current!.activate(selectedSession, true), + onSelect: (id: string) => latest.current!.select(id, true), + onBoundsChange: () => {} + } + }; + }, []); + const fullscreenToggles = useRef(new Map void>()); + const toggleFullscreenFor = (id: string): (() => void) => { + let toggle = fullscreenToggles.current.get(id); + if (!toggle) { + toggle = () => terminalCardHandlers.current!.toggleFullscreen(id); + fullscreenToggles.current.set(id, toggle); + } + return toggle; + }; const canvasOverrideActive = wheelNavigation.canvasOverrideActive; const homeLayoutValid = homeLayoutFitsGrid(settings.homeLayout, settings.homeGridSize); const editedRegion = regionEditor?.mode === "edit" @@ -843,30 +913,14 @@ export function WorkspaceCanvas(props: WorkspaceCanvasProps): React.JSX.Element groupSelected={marqueeSelection.has(terminalLayerId(session.id))} renaming={renamingSessionId === session.id} fullscreen={fullscreenSessionId === session.id} - onToggleFullscreen={() => onToggleFullscreen(session.id)} + onToggleFullscreen={toggleFullscreenFor(session.id)} snapTargets={[ homeBounds, ...renderedCanvasRegions.map((candidate) => ({ position: candidate.position, size: candidate.size })), ...allWindowBounds.filter((candidate) => candidate !== session) ]} - onActivate={(selectedSession) => { - raiseLayer(terminalLayerId(selectedSession.id)); - focusController.focus(terminalCanvasWidgetId(selectedSession.id), "explicit"); - onFocusSession(selectedSession); - }} - onSelect={(id) => { - raiseLayer(terminalLayerId(id)); - focusController.cancelHover(); - focusController.focus(terminalCanvasWidgetId(id), "explicit"); - onSelectSession(id); - }} - onRename={onRenameSession} - onRenameEnd={onRenameEnd} - onBoundsChange={onSessionBoundsChange} - onRestart={onRestartSession} - onDispose={onDisposeSession} + {...terminalCardCallbacks.canvas} restoreEnabled={settings.sessionRestoreMode !== "off"} - onOpenUrl={onOpenTerminalUrl} /> ))} {renderedPluginCanvas.map((instance) => { @@ -1002,24 +1056,10 @@ export function WorkspaceCanvas(props: WorkspaceCanvasProps): React.JSX.Element groupSelected={false} renaming={renamingSessionId === session.id} fullscreen={true} - onToggleFullscreen={() => onToggleFullscreen(session.id)} - snapTargets={[]} - onActivate={(selectedSession) => { - focusController.focus(terminalCanvasWidgetId(selectedSession.id), "explicit"); - onFocusSession(selectedSession); - }} - onSelect={(id) => { - focusController.cancelHover(); - focusController.focus(terminalCanvasWidgetId(id), "explicit"); - onSelectSession(id); - }} - onRename={onRenameSession} - onRenameEnd={onRenameEnd} - onBoundsChange={() => {}} - onRestart={onRestartSession} - onDispose={onDisposeSession} + onToggleFullscreen={toggleFullscreenFor(session.id)} + snapTargets={NO_SNAP_TARGETS} + {...terminalCardCallbacks.fullscreen} restoreEnabled={settings.sessionRestoreMode !== "off"} - onOpenUrl={onOpenTerminalUrl} /> ))} diff --git a/tests/agent-browser-provider-launch.test.mjs b/tests/agent-browser-provider-launch.test.mjs index 27eb578c..acae9c3d 100644 --- a/tests/agent-browser-provider-launch.test.mjs +++ b/tests/agent-browser-provider-launch.test.mjs @@ -16,6 +16,8 @@ import { KimiTemporaryConfiguration, ProviderLaunchAdapters, claudeMcpArgs, + probeKimiPerRunMcpConfig, + probeKimiPerRunMcpConfigAsync, codexMcpArgs, qwenMcpArgs, recoverKimiConfigurationOnStartup, @@ -799,3 +801,52 @@ test("ProviderLaunchAdapters fallback adds and removes only temporary Kimi state assert.equal(await exists(join(home, "mcp.json")), false); assert.equal(await exists(join(home, "config.toml")), false); }); + +test("a background Kimi probe answers the first launch, so the main thread never runs kimi --help", async (t) => { + const root = await fixture(t, "canvastty-provider-kimi-warm-"); + const blocking = []; + const background = []; + const adapters = new ProviderLaunchAdapters({ + helper, + providerClis, + kimiHomeDirectory: join(root, "kimi-home"), + hermesHomeDirectory: join(root, "hermes-home"), + runtimeDirectory: join(root, "runtime"), + probeKimiPerRunConfig: (cli) => { blocking.push(cli.executable); return false; }, + probeKimiPerRunConfigAsync: async (cli) => { background.push(cli.executable); return true; } + }); + + await Promise.all([adapters.warmKimiProbe(), adapters.warmKimiProbe()]); + const launch = adapters.prepare("kimi", "warmed"); + assert.equal(launch.args[0], "--mcp-config-file", "the background answer (per-run config) is used"); + launch.releaseConfiguration(); + await adapters.warmKimiProbe(); + assert.deepEqual(background, ["/resolved/kimi"], "probed once, in the background"); + assert.deepEqual(blocking, [], "no blocking probe"); + + // A recheck discards the answer: a launch before the next background probe finishes probes as before. + adapters.providerClisRefreshed(); + const fallback = adapters.prepare("kimi", "rechecked"); + assert.deepEqual(fallback.args, [], "the blocking probe's answer applies"); + fallback.releaseConfiguration(); + assert.deepEqual(blocking, ["/resolved/kimi"]); +}); + +test("the background Kimi probe gives the same answer as the blocking one", { skip: process.platform === "win32" }, async (t) => { + const root = await fixture(t, "canvastty-provider-kimi-probe-"); + const cli = (body) => { + const executable = join(root, `kimi-${Math.random().toString(36).slice(2)}`); + writeFileSync(executable, `#!/bin/sh\n${body}\n`, { mode: 0o755 }); + return { state: "available", provider: "kimi", executable, launcher: "native", environment: {}, checked: [] }; + }; + for (const [body, expected] of [ + ["echo ' --mcp-config-file PATH per-run MCP config'", true], + ["echo ' --config PATH' >&2", false], + ["echo '--mcp-config-file'; exit 2", false] + ]) { + const kimi = cli(body); + // A generous limit: the answers are compared, not the 3 s default, which a loaded machine can hit. + assert.equal(probeKimiPerRunMcpConfig(kimi, 30_000), expected, body); + assert.equal(await probeKimiPerRunMcpConfigAsync(kimi, 30_000), expected, body); + } +}); diff --git a/tests/agent-control.test.mjs b/tests/agent-control.test.mjs index cede7487..e9afce2d 100644 --- a/tests/agent-control.test.mjs +++ b/tests/agent-control.test.mjs @@ -562,3 +562,27 @@ test("the control CLI screen masks a custom secret the viewport's top edge cuts" assert.equal(/marmalade|loudly/u.test(text), false); assert.match(text, //u, "masked where it stood, as one value"); }); + +test("an orchestrator tool call looks its sessions up by id: no other card's scrollback is copied", async () => { + const { terminals, control } = serviceFixture(); + const parent = terminals.create({ provider: "claude", cwd: process.cwd(), profile: "normal", position: { x: 0, y: 0 } }); + const child = await control.spawn({ parentSessionId: parent.id, provider: "codex", cwd: process.cwd() }); + const bystanders = Array.from({ length: 5 }, () => terminals.create({ provider: "claude", cwd: process.cwd(), profile: "normal", position: { x: 0, y: 0 } })); + assert.equal(bystanders.length, 5); + const copied = []; + const list = terminals.list.bind(terminals); + const readBuffer = terminals.readBuffer.bind(terminals); + terminals.list = () => { copied.push("list"); return list(); }; + terminals.readBuffer = (id) => { copied.push(id); return readBuffer(id); }; + + // What observe_agent, get_agent_result, list_agents and the ownership check do. + assert.equal(control.status(child.id).id, child.id); + assert.equal(control.isInSubtree(parent.id, child.id), true); + assert.deepEqual(control.children(parent.id).map((session) => session.id), [child.id]); + control.observe(child.id); + control.result(child.id); + assert.throws(() => control.status("missing"), /does not exist/u); + + assert.deepEqual(copied, [child.id, child.id], "only the observed card's own scrollback is read, and no list() snapshot of every card"); + terminals.disposeAll(); +}); diff --git a/tests/canvas-card-render-cost.test.mjs b/tests/canvas-card-render-cost.test.mjs new file mode 100644 index 00000000..22fba7c8 --- /dev/null +++ b/tests/canvas-card-render-cost.test.mjs @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { canvasCardPropsEqual, sameBoundsList } from "../src/renderer/src/features/terminal/terminalCardProps.ts"; + +// A pan or zoom gesture renders the workspace on every pointer move. TerminalCard is memoized so those +// renders do not reach the cards: equal props (snap targets by value) skip the card, any real change does not. + +const bounds = (x, y, width = 700, height = 430) => ({ position: { x, y }, size: { width, height } }); +const session = { id: "s1", position: { x: 0, y: 0 }, size: { width: 700, height: 430 } }; +const callbacks = { onActivate() {}, onSelect() {}, onBoundsChange() {} }; +const props = (overrides = {}) => ({ + session, zoom: 1, focused: false, selected: false, stackIndex: 3, + snapTargets: [bounds(0, 0), bounds(800, 0)], ...callbacks, ...overrides +}); + +test("a card's props compare equal across a pan: same data, rebuilt snap target list, same callbacks", () => { + assert.equal(canvasCardPropsEqual(props(), props()), true); + assert.equal(canvasCardPropsEqual(props(), props({ snapTargets: [bounds(0, 0), bounds(800, 0)] })), true); +}); + +test("any real change renders the card", () => { + for (const change of [ + { zoom: 0.49 }, { focused: true }, { selected: true }, { stackIndex: 4 }, + { session: { ...session, title: "renamed" } }, + { snapTargets: [bounds(0, 0), bounds(801, 0)] }, { snapTargets: [bounds(0, 0)] }, + { onSelect() {} }, { restoreEnabled: true } + ]) { + assert.equal(canvasCardPropsEqual(props(), props(change)), false, JSON.stringify(Object.keys(change))); + } + assert.equal(sameBoundsList([bounds(1, 2, 3, 4)], [bounds(1, 2, 3, 5)]), false); +}); + +test("TerminalCard is memoized and the workspace hands it callbacks that stay the same functions", async () => { + const card = await readFile(new URL("../src/renderer/src/features/terminal/TerminalCard.tsx", import.meta.url), "utf8"); + assert.match(card, /export const TerminalCard = memo\(TerminalCardView, canvasCardPropsEqual\)/u); + const workspace = await readFile(new URL("../src/renderer/src/features/workspace/WorkspaceCanvas.tsx", import.meta.url), "utf8"); + const cards = [...workspace.matchAll(//gu)].map((match) => match[1]); + assert.equal(cards.length, 2, "the canvas card and the fullscreen card"); + for (const body of cards) { + // A new arrow function per render would defeat the memo: every callback comes from the stable set. + assert.doesNotMatch(body, /\bon[A-Z]\w*=\{[^}]*=>/u); + assert.match(body, /\{\.\.\.terminalCardCallbacks\.(canvas|fullscreen)\}/u); + assert.match(body, /onToggleFullscreen=\{toggleFullscreenFor\(session\.id\)\}/u); + } + assert.match(workspace, /const terminalCardCallbacks = useMemo\(\(\) => \{[\s\S]*?\}, \[\]\);/u); +}); diff --git a/tests/companion-presentation.test.mjs b/tests/companion-presentation.test.mjs index 3b4de7a4..b7f4fac2 100644 --- a/tests/companion-presentation.test.mjs +++ b/tests/companion-presentation.test.mjs @@ -122,3 +122,34 @@ test("old status warnings are not prepended to an active Codex menu title", () = assert.match(menu.title, /^Select Model/); assert.doesNotMatch(menu.title, /Heads up/); }); + +test("only sessions the glasses read get a headless screen; a first read later shows what live parsing shows", async () => { + const ids = ["s0", "s1", "s2"]; + const buffers = new Map(ids.map((id) => [id, ""])); + const port = { + listMetadata: () => ids.map((id) => ({ id, provider: "claude", status: "idle", title: id })), + geometry: () => ({ cols: 60, rows: 12 }), + readBuffer: (id) => ({ buffer: buffers.get(id), outputOffset: buffers.get(id).length }) + }; + const lazy = new TerminalPresentation(port); + const live = new TerminalPresentation(port); + await lazy.read("s0"); + await live.read("s1"); + for (let i = 0; i < 400; i++) { + for (const id of ids) { + const data = `\x1b[3${i % 7}m${id} line ${i}\x1b[0m ${"·".repeat(i % 50)}\r\n${i % 40 === 0 ? "\x1b[2J\x1b[H" : ""}`; + buffers.set(id, buffers.get(id) + data); + const event = { id, data, outputOffset: buffers.get(id).length }; + lazy.observe("terminal:data", event); + live.observe("terminal:data", event); + } + } + const parsed = (presentation) => [...presentation.screens].filter(([, screen]) => screen.terminal).map(([id]) => id); + assert.deepEqual(parsed(lazy), ["s0"], "output of sessions nobody reads is not parsed"); + assert.deepEqual(await lazy.read("s1"), await live.read("s1"), "a first read from the scrollback matches the live screen"); + assert.deepEqual(parsed(lazy), ["s0", "s1"]); + lazy.observe("terminal:removed", { id: "s1" }); + assert.deepEqual(parsed(lazy), ["s0"]); + lazy.close(); + live.close(); +}); diff --git a/tests/plugin-launch-choices.test.mjs b/tests/plugin-launch-choices.test.mjs index 1fe865f6..811c4e19 100644 --- a/tests/plugin-launch-choices.test.mjs +++ b/tests/plugin-launch-choices.test.mjs @@ -155,7 +155,8 @@ test("spawn_agent takes plugin launch options and hands them to the launch", () const parent = { id: "orch", provider: "claude", role: "orchestrator", position: { x: 0, y: 0 }, exitCode: null }; const terminals = { get: (id) => (id === "orch" ? parent : undefined), - list: () => [parent], + getMetadata: (id) => (id === "orch" ? parent : null), + listMetadata: () => [parent], create: (request) => { created.push(request); return { id: "child", ...request, status: "starting", title: "c" }; } }; const control = new AgentControlService(terminals); diff --git a/tests/provider-icon-assets.test.mjs b/tests/provider-icon-assets.test.mjs new file mode 100644 index 00000000..ebaff01b --- /dev/null +++ b/tests/provider-icon-assets.test.mjs @@ -0,0 +1,22 @@ +import assert from "node:assert/strict"; +import { readdir, readFile, stat } from "node:fs/promises"; +import test from "node:test"; + +// A 1024 px mark drawn at icon size costs a 574 KB download and decode for nothing. + +test("raster provider marks ship sized for where they are drawn, with a 2x variant", async () => { + const directory = new URL("../src/renderer/src/assets/providers/", import.meta.url); + const pngs = (await readdir(directory)).filter((name) => name.endsWith(".png")); + for (const name of pngs) { + const bytes = await readFile(new URL(name, directory)); + const width = bytes.readUInt32BE(16); + assert.ok(width <= 600, `${name} is ${width} px wide`); + assert.ok((await stat(new URL(name, directory))).size <= 64 * 1024, `${name} stays small`); + } + const icon = await readFile(new URL("../src/renderer/src/components/ProviderIcon.tsx", import.meta.url), "utf8"); + for (const provider of ["hermes", "codex"]) { + assert.match(icon, new RegExp(`import ${provider}Icon from "../assets/providers/${provider}-128.png"`, "u")); + assert.match(icon, new RegExp(`import ${provider}Icon2x from "../assets/providers/${provider}-256.png"`, "u")); + } + assert.match(icon, /srcSet=/u); +}); diff --git a/tests/renderer-build-config.test.mjs b/tests/renderer-build-config.test.mjs new file mode 100644 index 00000000..82a6332c --- /dev/null +++ b/tests/renderer-build-config.test.mjs @@ -0,0 +1,11 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +// electron-vite leaves bundles unminified by default; the renderer is parsed on every window load. +test("the renderer production bundle is minified, and source maps stay off", async () => { + const config = await readFile(new URL("../electron.vite.config.ts", import.meta.url), "utf8"); + const renderer = config.slice(config.indexOf("renderer: {")); + assert.match(renderer, /build: \{\s*minify: "esbuild"\s*\}/u); + assert.doesNotMatch(config, /sourcemap/u); +}); diff --git a/tests/secret-redaction.test.mjs b/tests/secret-redaction.test.mjs index 2682eddf..6bb4bdb0 100644 --- a/tests/secret-redaction.test.mjs +++ b/tests/secret-redaction.test.mjs @@ -143,7 +143,7 @@ test("the vault hands every value it reads or writes to the registry; agent-read const manager = new TerminalManager(() => undefined, { get: () => ({ state: "unavailable" }), snapshot: () => ({}) }); manager.configureRedaction(registry); const control = new AgentControlService({ - list: () => [{ id: "s1", provider: "claude", status: "working", exitCode: null }], + getMetadata: (id) => (id === "s1" ? { id: "s1", provider: "claude", status: "working", exitCode: null } : null), readBuffer: () => ({ buffer: `env OPENAI=${value}\n${secrets.github}\n` }), redactSecrets: (text) => manager.redactSecrets(text) }); @@ -223,3 +223,254 @@ test("plugin screen text and failure details mask the whole buffer before cuttin assert.ok(failed.failureDetails.length <= 8_000); assert.deepEqual(fragments(failed.failureDetails), []); }); + +// Ordinary agent output around the secrets below: colour, paths, numbers, box sides. +function scrollback(chars) { + let text = ""; + for (let i = 0; text.length < chars; i++) { + text += `\x1b[32m✓\x1b[0m step ${i} src/main/Example.ts:${i} took ${i % 97} ms\r\n`; + if (i % 9 === 0) text += `│ ${"─".repeat(40)} │\r\n`; + } + return text.slice(0, chars); +} + +test("redactTail returns exactly what masking the whole text and cutting it returns, wherever the secrets fall", () => { + const registry = new SecretRedactionRegistry(); + registry.add("plugin:p.custom", [PLAIN_SECRET]); + const wrapped = `${PLAIN_SECRET.slice(0, 15)}\r\n${PLAIN_SECRET.slice(15)}`; + const pem = (body) => `-----${"BEGIN"} RSA ${"PRIVATE"} KEY-----\n${body}\n-----${"END"} RSA ${"PRIVATE"} KEY-----`; + const samples = [ + PLAIN_SECRET, wrapped, secrets.openai, secrets.github, secrets.jwt, secrets.google, mixed, + `\r\n${sk(run("q", 18))}\r\n${run("7", 12)}x${run("R", 8)}\r\n`, `"apiKey": "${run("k", 30)}"`, + `export OPENAI_API_KEY=${run("v", 24)}`, `Authorization: Bearer ${run("t", 24)}`, + `https://deploy:${run("p", 12)}@example.test/repo`, pem(run("M", 64)), pem(`${run("N", 64)}\n`.repeat(400)) + ]; + const base = scrollback(240_000); + let compared = 0; + for (const tail of [300, 4_000, 8_192]) { + const tailCut = base.length - tail; + const windowCut = base.length - tail - 16_384; + for (const cut of [tailCut, windowCut]) { + for (const offset of [-3_000, -400, -20, -5, 0, 3, 17, 300]) { + for (const sample of samples) { + const at = cut + offset - Math.floor(sample.length / 2); + const text = `${base.slice(0, at)}${sample}${base.slice(at)}`; + assert.equal(registry.redactTail(text, tail), registry.redact(text).slice(-tail), `${tail} ${cut === tailCut ? "tail" : "window"} ${offset} ${sample.slice(0, 12)}`); + compared++; + } + } + } + } + // A private key opened long before the window and never closed masks everything after it, as before. + const open = `${base.slice(0, 1_000)}-----${"BEGIN"} ${"PRIVATE"} KEY-----\n${base.slice(1_000)}`; + assert.equal(registry.redactTail(open, 4_000), registry.redact(open).slice(-4_000)); + assert.ok(compared > 600); + assert.equal(registry.redactTail("", 10), ""); + assert.equal(registry.redactTail(`x ${PLAIN_SECRET}`, 0), ""); +}); + +test("redactTail masks a window around the tail, not the whole scrollback", () => { + class Measured extends SecretRedactionRegistry { + lengths = []; + redact(text) { this.lengths.push(text.length); return super.redact(text); } + } + const registry = new Measured(); + registry.add("plugin:p.custom", [PLAIN_SECRET]); + const text = `${scrollback(240_000)}${PLAIN_SECRET}${"z".repeat(100)}`; + const masked = registry.redactTail(text, 8_192); + assert.deepEqual(fragments(masked), []); + assert.ok(Math.max(...registry.lengths) <= 8_192 + 16_384 + 4_096, `masked ${registry.lengths} characters`); + // A held value that could wrap over the margin widens the window with it (each gap may hold 64 characters); + // one that could span the whole scrollback means masking the whole text. + registry.lengths.length = 0; + registry.add("plugin:p.long", [run("L", 400)]); + assert.deepEqual(fragments(registry.redactTail(text, 8_192)), []); + const widened = Math.max(...registry.lengths); + assert.ok(widened > 8_192 + 2 * 400 * 65 && widened < text.length, `masked ${widened} characters`); + registry.lengths.length = 0; + registry.add("plugin:p.longer", [run("K", 2_000)]); + registry.redactTail(text, 8_192); + assert.equal(Math.max(...registry.lengths), text.length); +}); + +test("observe_agent, get_agent_result and the plugin screen mask a bounded window of a full scrollback", async (t) => { + const f = cutFixture(t); + const masked = []; + const registry = f.terminals.redaction; + const redact = registry.redact.bind(registry); + registry.redact = (text) => { masked.push(text.length); return redact(text); }; + f.print(scrollback(250_000)); + f.print(`${PLAIN_SECRET}${"g".repeat(200)}`); + const control = new AgentControlService(f.terminals); + assert.deepEqual(fragments(control.observe(f.card.id).output), []); + assert.deepEqual(fragments(control.result(f.card.id).output), []); + const { PluginSessions } = await import("../src/main/services/PluginSessions.ts"); + const screens = []; + const sessions = new PluginSessions({ terminals: f.terminals, notify: (_p, _s, _m, event) => { if (event.screen !== undefined) screens.push(event.screen); return true; } }); + f.attach(sessions); + sessions.handle("p.reader", "svc", "sessions.subscribe", {}, ["sessions:events", "sessions:read-screen"]); + f.terminals.applyProviderSignal(f.card.id, { kind: "lifecycle", state: "working" }); + f.terminals.applyProviderSignal(f.card.id, { kind: "lifecycle", state: "idle" }); + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.ok(screens.length > 0); + assert.deepEqual(fragments(screens.join("\n")), []); + assert.ok(masked.length >= 3 && Math.max(...masked) < 40_000, `masked ${masked} characters per call`); +}); + +/** + * A held value of `length` characters that no generic rule would catch (short lower-case runs between `.`, `/` + * and `:`), so only the registry can mask it; `quoted` adds a quote and a backslash (its JSON form differs). + */ +function longSecret(length, seed = 7, quoted = false) { + let state = seed; + const next = () => { state = (state * 1_103_515_245 + 12_345) % 2_147_483_648; return Math.floor(state / 65_536); }; + const letters = "abcdefghijklmnopqrstuvwxyz0123456789"; + let value = quoted ? 'q"\\' : ""; + while (value.length < length) { + for (let i = 3 + (next() % 5); i > 0; i--) value += letters[next() % letters.length]; + value += ".:/"[next() % 3]; + } + return value.slice(0, length); +} + +/** Slices of the value that must not survive masking: a 24-character piece every 997 characters, and its end. */ +function longFragments(text, value) { + const found = []; + for (let at = 0; at + 24 <= value.length; at += 997) if (text.includes(value.slice(at, at + 24))) found.push(at); + if (text.includes(value.slice(-24))) found.push(value.length - 24); + return found; +} + +/** What a terminal of `columns` columns shows: the text cut into lines, each after a box side. */ +function wrapped(text, columns = 80) { + const lines = []; + for (let at = 0; at < text.length; at += columns) lines.push(text.slice(at, at + columns)); + return lines.join("\r\n│ "); +} + +test("registry: held values of 4k, 16k and 64k characters are masked whole, also wrapped and JSON-escaped", () => { + for (const length of [3_800, 4_096, 16_384, 65_536]) { + const registry = new SecretRedactionRegistry(); + const value = longSecret(length, length, true); + registry.add("vault", [value, PLAIN_SECRET]); + const plain = registry.redact(`before ${value} after ${PLAIN_SECRET}`); + assert.equal(plain, "before after ", `${length} plain`); + const screen = registry.redact(`$ cat key\r\n${wrapped(value)}\r\n$ `); + assert.equal(screen, "$ cat key\r\n\r\n$ ", `${length} wrapped`); + const json = registry.redact(JSON.stringify({ value, note: "kept" })); + assert.deepEqual(longFragments(json, JSON.stringify(value).slice(1, -1)), [], `${length} JSON-escaped`); + assert.match(json, /"note":"kept"/u); + // Two halves further apart than a wrap gap are two unrelated texts, not the value. + const apart = `${value.slice(0, length / 2)}${" ".repeat(65)}${value.slice(length / 2)}`; + assert.equal(registry.redact(apart), apart, `${length} split by more than a wrap gap`); + } +}); + +test("registry: a long held value never breaks masking of anything else", () => { + const registry = new SecretRedactionRegistry(); + registry.add("plugin:p.big", [longSecret(4_000)]); + registry.add("session:a", [PLAIN_SECRET]); + assert.doesNotThrow(() => registry.redact("nothing to see")); + assert.equal(registry.redact(`x ${PLAIN_SECRET} ${secrets.openai}`), "x "); + assert.equal(registry.redactTail(`x ${PLAIN_SECRET}`, 100), "x "); + // Longer than any key the registry holds: ignored, as a value shorter than a key is. + const oversized = longSecret(65_537); + registry.add("plugin:p.huge", [oversized]); + assert.equal(registry.redact(`y ${PLAIN_SECRET}`), "y "); +}); + +test("registry: masking held values stays linear, even for values that overlap themselves", () => { + const registry = new SecretRedactionRegistry(); + // Every position of the text starts a near-match: a naive search would compare ~4 000 characters at each. + registry.add("vault", [`${"a".repeat(4_000)}b`, `${"a".repeat(64_000)}c`, "a".repeat(9), longSecret(16_384)]); + for (const text of ["a".repeat(240_000), `${"a ".repeat(120_000)}`, `${"a\r\n│ ".repeat(60_000)}`]) { + const started = performance.now(); + const masked = registry.redact(text); + assert.ok(performance.now() - started < 1_500, `${JSON.stringify(text.slice(0, 6))} took ${Math.round(performance.now() - started)} ms`); + assert.ok(!/a{9}/u.test(masked.replace(//gu, "")), "the short held value is masked where it stands"); + } +}); + +test("redactTail with long held values equals masking the whole text and cutting it, wherever the value falls", () => { + const base = scrollback(240_000); + for (const length of [4_096, 16_384]) { + const registry = new SecretRedactionRegistry(); + const value = longSecret(length, length + 1); + registry.add("plugin:p.long", [value]); + for (const sample of [value, wrapped(value)]) { + for (const tail of [4_000, 8_192]) { + for (const at of [base.length - tail - Math.floor(sample.length / 2), base.length - tail - 16_384 - Math.floor(sample.length / 2), base.length - 10]) { + const text = `${base.slice(0, at)}${sample}${base.slice(at)}`; + const cut = registry.redactTail(text, tail); + assert.equal(cut, registry.redact(text).slice(-tail), `${length} ${tail} ${at}`); + assert.deepEqual(longFragments(cut, value), [], `${length} ${tail} ${at}: no fragment of the value survives the cut`); + } + } + } + } +}); + +test("registry: an accepted value is masked where it stands even when its wrap characters leave fewer than eight others", () => { + const registry = new SecretRedactionRegistry(); + const spaced = ["abc", "defg"].join(" "); + const tabbed = ["abc", "defg"].join("\t"); + const broken = ["abc", "defg"].join("\n"); + const sparse = ["a", "b"].join(" ".repeat(7)); + // Two halves further apart than a wrap gap, but that is how the value itself is written. + const gapped = ["abcd", "efgh"].join(" ".repeat(70)); + registry.add("test", [spaced, tabbed, broken, sparse, gapped]); + for (const value of [spaced, tabbed, broken, sparse, gapped]) { + assert.equal(registry.redact(`x ${value} y`), "x y", JSON.stringify(value)); + assert.equal(registry.redact(`{"v":"${JSON.stringify(value).slice(1, -1)}"}`), `{"v":""}`, `${JSON.stringify(value)} JSON-escaped`); + } + // Only the value as written: its characters alone, or with other gaps, are ordinary text. + for (const text of ["a b", "ab", "a b", "abcdefg"]) assert.equal(registry.redact(`x ${text} y`), `x ${text} y`, text); + const base = scrollback(60_000); + for (const at of [base.length - 8_192 - 3, base.length - 8_192 - 16_384 - 4]) { + const text = `${base.slice(0, at)}${spaced}${base.slice(at)}`; + assert.equal(registry.redactTail(text, 8_192), registry.redact(text).slice(-8_192), `tail at ${at}`); + } +}); + +test("redactTail equals masking the whole text when a match with no length bound starts before the window", () => { + const registry = new SecretRedactionRegistry(); + const pem = (body) => `-----${"BEGIN"} RSA ${"PRIVATE"} KEY-----\n${body}\n-----${"END"} RSA ${"PRIVATE"} KEY-----`; + const wrappedToken = `${sk(run("w", 22))}${`\n${run("1", 3)}${run("x", 76)}`.repeat(500)}`; + const long = { + "quoted assignment": `password="${"a ".repeat(20_000)}"`, + "single-quoted assignment": `api_key='${"b ".repeat(20_000)}'`, + "unquoted assignment": `export GITHUB_TOKEN=${"c".repeat(40_000)}`, + "assignment over blank lines": `SECRET_KEY =${"\n".repeat(30_000)}${run("d", 12)}`, + "authorization over spaces": `Authorization:${" ".repeat(30_000)}${run("e", 12)}`, + "bearer over lines": `Bearer${"\r\n".repeat(15_000)}${run("f", 12)}`, + "url query": `https://example.test/?token=${"g".repeat(40_000)}`, + "url userinfo": `https://${"h".repeat(40_000)}:pw@example.test/`, + "json key over lines": `"apiKey":${"\n".repeat(30_000)}"${run("i", 30)}"`, + "wrapped token": wrappedToken, + "nested private-key header": pem(`${run("N", 64)}\n`.repeat(200) + pem(run("M", 64)).split("\n-----END")[0]) + }; + const base = scrollback(40_000); + for (const [name, sample] of Object.entries(long)) { + for (const maxChars of [300, 8_192]) { + // The match ends just inside the tail, a little before it, and far before it. + for (const after of [maxChars - 40, maxChars + 200, maxChars + 12_000]) { + const at = base.length - after; + const text = `${base.slice(0, at)}\n${sample}\n${base.slice(at)}`; + assert.equal(registry.redactTail(text, maxChars), registry.redact(text).slice(-maxChars), `${name} ${maxChars} ${after}`); + } + } + } + // A held value that holds a private key: masking it decides where the PEM rule sees a block. + const armoured = new SecretRedactionRegistry(); + const keyValue = `{"private_key": "${pem(run("K", 64)).replace(/\n/gu, "\\n")}", "id": "${run("j", 12)}"}`; + armoured.add("plugin:p.sa", [pem(`${run("P", 64)}\n`.repeat(3)), keyValue]); + for (const after of [8_192 - 40, 8_192 + 200, 8_192 + 16_384 + 100]) { + const at = base.length - after; + const text = `${base.slice(0, at)}\n${pem(`${run("P", 64)}\n`.repeat(3))}\n${pem(run("Q", 64))}\n${base.slice(at)}`; + assert.equal(armoured.redactTail(text, 8_192), armoured.redact(text).slice(-8_192), `held private key ${after}`); + } + // The case from review: an otherwise empty registry and one long quoted value. + const text = `start\n${long["quoted assignment"]}\nend`; + assert.equal(registry.redactTail(text, 8_192), registry.redact(text).slice(-8_192)); + assert.equal(registry.redactTail(text, 8_192).includes("a a a"), false); +}); diff --git a/tests/terminal-hidden-output.test.mjs b/tests/terminal-hidden-output.test.mjs index b960d85a..77c32905 100644 --- a/tests/terminal-hidden-output.test.mjs +++ b/tests/terminal-hidden-output.test.mjs @@ -130,7 +130,7 @@ test("the renderer gets nothing while hidden, then exactly one replay, and write assert.equal(rendered.length, 2, "becoming visible delivers exactly one replay to the renderer"); const replay = rendered[1]; assert.equal(replay.audience, "renderer"); - assert.equal(replay.data, "first\r\nhidden one\r\nhidden two\r\n", "the replay is the current buffer, not stale content"); + assert.equal(replay.data, "hidden one\r\nhidden two\r\n", "the replay is what the card missed, not stale content or the whole history"); assert.equal(replay.outputOffset, manager.readBuffer(id).outputOffset, "the replay carries the current absolute offset"); assert.equal(written.join(""), "first\r\nhidden one\r\nhidden two\r\n", "the real renderer dedup writes the hidden stretch once"); @@ -175,8 +175,8 @@ test("a batch still pending when the card is shown goes to the observers before assert.deepEqual(emitted.slice(1).map((event) => [event.audience, event.data]), [ ["observers", "pending\r\n"], - ["renderer", "first\r\npending\r\n"] - ], "the observers get the batch, the renderer gets the replay, in that order"); + ["renderer", "pending\r\n"] + ], "the observers get the batch, the renderer gets the replay of what it missed, in that order"); assert.equal(observerScreen(observed), "first\r\npending\r\n"); assert.equal(rendered.length, 2); // The timer that would have flushed the same batch must not fire a duplicate. diff --git a/tests/terminal-links.test.mjs b/tests/terminal-links.test.mjs index 95a1aeae..4fb1ab11 100644 --- a/tests/terminal-links.test.mjs +++ b/tests/terminal-links.test.mjs @@ -22,7 +22,9 @@ test("terminal HTTP(S) links open a Canvas or system-browser chooser", async () assert.match(terminal, /new WebLinksAddon/); assert.match(terminal, /onOpenUrlRef\.current\(uri\)/); assert.match(terminal, /linkHandler:\s*\{[\s\S]*?activate:\s*\(event, uri\)[\s\S]*?onOpenUrlRef\.current\(uri\)/); - assert.match(workspace, /onOpenUrl=\{onOpenTerminalUrl\}/); + // The card gets a stable callback that calls the workspace's latest onOpenTerminalUrl. + assert.match(workspace, /openUrl: onOpenTerminalUrl/); + assert.match(workspace, /onOpenUrl: \(url: string\) => latest\.current!\.openUrl\(url\)/); assert.match(app, /onOpenTerminalUrl=\{\(url\) => \{[\s\S]*?normalizeExternalUrl\(url\)[\s\S]*?showToast/); assert.match(dialog, /onOpenCanvas\(url\)/); assert.match(dialog, /onOpenExternal\(url\)/); diff --git a/tests/terminal-output-costs.test.mjs b/tests/terminal-output-costs.test.mjs new file mode 100644 index 00000000..bd4036f3 --- /dev/null +++ b/tests/terminal-output-costs.test.mjs @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { TerminalManager, reachesRenderer } from "../src/main/services/TerminalManager.ts"; +import { IPC } from "../src/shared/contracts.ts"; +import { attachTerminalOutput } from "../src/renderer/src/features/terminal/terminalOutput.ts"; + +// What terminal output costs the main process: the scrollback ring holds only what it keeps, and a card +// that becomes visible again is sent the output it missed, not its whole history. + +const MAX_SCROLLBACK_CHARS = 240_000; +const availableRegistry = { + get: (provider) => ({ state: "available", provider, executable: "/resolved/codex", launcher: "native", environment: {}, checked: [] }) +}; + +function createManager(t) { + const rendered = []; + const listeners = new Set(); + let emitData; + const manager = new TerminalManager((channel, event) => { + if (channel !== IPC.terminalData || !reachesRenderer(event)) return; + rendered.push(event); + for (const listener of listeners) listener(event); + }, availableRegistry, undefined, undefined, true, () => ({ + pid: 10000, process: "codex", kill() {}, write() {}, resize() {}, + onData(listener) { emitData = listener; return { dispose() {} }; }, + onExit() { return { dispose() {} }; } + })); + t.after(() => manager.disposeAll()); + const { id } = manager.create({ provider: "codex", cwd: process.cwd(), profile: "normal", position: { x: 0, y: 0 } }); + const flush = () => manager.flushOutput(id, manager.sessions.get(id)); + const rendererApi = { + onData(listener) { listeners.add(listener); return () => listeners.delete(listener); }, + readBuffer() { return Promise.resolve(manager.readBuffer(id)); } + }; + return { manager, id, rendered, rendererApi, data: (chunk) => emitData(chunk), flush }; +} + +const settle = () => new Promise((resolve) => setImmediate(resolve)); +const chunkOf = (index, size) => `${String(index).padStart(8, "0")}${"x".repeat(size - 10)}\r\n`; + +test("the scrollback ring references only the text it keeps: dropped chunks are released at once", (t) => { + const { manager, id, data, flush } = createManager(t); + for (const size of [1_024, 16_384, 65_536]) { + for (let i = 0; i < 64; i++) data(chunkOf(i, size)); + flush(); + const session = manager.sessions.get(id); + const referenced = session.bufferChunks.reduce((sum, chunk) => sum + chunk.length, 0); + assert.equal(referenced, session.bufferLength, `${size}-char chunks: nothing outside the ring is still referenced`); + assert.ok(session.bufferLength <= MAX_SCROLLBACK_CHARS); + } + // History is unchanged by the release: the ring still reads back as the last 240 000 characters. + let expected = ""; + for (let i = 0; i < 64; i++) expected += chunkOf(i, 65_536); + assert.equal(manager.readBuffer(id).buffer, expected.slice(-MAX_SCROLLBACK_CHARS)); +}); + +test("a card that becomes visible again is sent the output it missed, not its whole scrollback", async (t) => { + const { manager, id, rendered, rendererApi, data, flush } = createManager(t); + const written = []; + const detach = attachTerminalOutput(rendererApi, id, (chunk) => written.push(chunk), assert.fail, () => { + throw new Error("unexpected replay gap in a sub-limit stretch"); + }); + t.after(detach); + const history = "h".repeat(200_000); + data(history); + flush(); + await settle(); + manager.setVisible(id, false); + data("missed one\r\n"); + flush(); + data("missed two\r\n"); + flush(); + const before = rendered.length; + + manager.setVisible(id, true); + await settle(); + + assert.equal(rendered.length, before + 1, "exactly one replay"); + const replay = rendered.at(-1); + assert.equal(replay.data, "missed one\r\nmissed two\r\n", "only the missed stretch crosses IPC"); + assert.equal(replay.outputOffset, manager.readBuffer(id).outputOffset); + assert.equal(written.join(""), `${history}missed one\r\nmissed two\r\n`, "the card shows the same text as before, each byte once"); +}); + +test("zooming every card out and back in costs the missed output only, however long the history", (t) => { + const cards = Array.from({ length: 8 }, () => createManager(t)); + for (const card of cards) { + card.data("y".repeat(MAX_SCROLLBACK_CHARS + 5_000)); + card.flush(); + card.manager.setVisible(card.id, false); + card.data("z".repeat(1_024)); + card.flush(); + } + const sent = cards.map((card) => { + const before = card.rendered.length; + card.manager.setVisible(card.id, true); + return card.rendered.slice(before).reduce((sum, event) => sum + event.data.length, 0); + }); + assert.deepEqual(sent, Array(8).fill(1_024)); +}); diff --git a/tests/terminal-quit-exits.test.mjs b/tests/terminal-quit-exits.test.mjs new file mode 100644 index 00000000..1ba5c501 --- /dev/null +++ b/tests/terminal-quit-exits.test.mjs @@ -0,0 +1,110 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { TerminalManager } from "../src/main/services/TerminalManager.ts"; + +// Quitting must not finish while a PTY it hung up is still running: node-pty reports the exit through a native +// callback into JavaScript, and one that lands while Electron frees the Node environment aborts the app with an +// uncaught Napi::Error. The manager waits for every exit (bounded, killing what ignores the hang-up). + +const availableRegistry = { + get: (provider) => ({ state: "available", provider, executable: "/bin/zsh", launcher: "native", environment: {}, checked: [] }) +}; + +/** A fake PTY; `onKill(signal, exit)` decides whether (and when) a signal ends it. */ +function fakePty(onKill) { + const exits = []; + const pty = { + pid: 0, process: "zsh", signals: [], exited: false, + write() {}, resize() {}, pause() {}, resume() {}, + onData() { return { dispose() {} }; }, + onExit(listener) { exits.push(listener); return { dispose() {} }; }, + kill(signal) { pty.signals.push(signal ?? "SIGHUP"); onKill(signal ?? "SIGHUP", exit); }, + }; + function exit(exitCode = 0) { + if (pty.exited) return; + pty.exited = true; + for (const listener of exits) listener({ exitCode, signal: 0 }); + } + pty.exit = exit; + return pty; +} + +function managerWith(ptys, emit = () => {}) { + let next = 0; + const manager = new TerminalManager(emit, availableRegistry, undefined, undefined, false, () => ptys[next++]); + for (let i = 0; i < ptys.length; i++) manager.create({ provider: "terminal", cwd: process.cwd(), profile: "normal", position: { x: 0, y: 0 } }); + return manager; +} + +test("quitting waits for every hung-up PTY to exit before it resolves", async () => { + const ptys = [0, 1, 2].map((i) => fakePty((_signal, exit) => setTimeout(() => exit(0), 20 + i * 15))); + const manager = managerWith(ptys); + await manager.shutdown(); + assert.deepEqual(ptys.map((pty) => pty.signals), [["SIGHUP"], ["SIGHUP"], ["SIGHUP"]]); + assert.equal(ptys.some((pty) => pty.exited), false, "nothing has exited yet when shutdown returns"); + const started = Date.now(); + assert.equal(await manager.waitForProcessExits(5_000, 5_000), 0); + assert.ok(ptys.every((pty) => pty.exited), "the wait ends only after the last exit"); + assert.ok(Date.now() - started < 1_000, "and does not sit out the deadline"); + assert.deepEqual(ptys.map((pty) => pty.signals), [["SIGHUP"], ["SIGHUP"], ["SIGHUP"]], "no SIGKILL when the hang-up was enough"); +}); + +test("a PTY that ignores the hang-up is killed after the wait, and quitting waits for that exit too", async () => { + const stubborn = fakePty((signal, exit) => { if (signal === "SIGKILL") setTimeout(() => exit(137), 10); }); + const polite = fakePty((_signal, exit) => exit(0)); + const manager = managerWith([stubborn, polite]); + await manager.shutdown(); + assert.equal(await manager.waitForProcessExits(50, 2_000), 0); + assert.ok(stubborn.exited); + assert.deepEqual(stubborn.signals.slice(-1), [process.platform === "win32" ? "SIGHUP" : "SIGKILL"]); + assert.deepEqual(polite.signals, ["SIGHUP"], "a process that already exited is not signalled again"); +}); + +test("the wait is bounded: a PTY that never exits is reported, not waited on forever", async () => { + const stuck = fakePty(() => {}); + const manager = managerWith([stuck]); + await manager.shutdown(); + const started = Date.now(); + assert.equal(await manager.waitForProcessExits(30, 30), 1); + assert.ok(Date.now() - started < 1_000); +}); + +test("a card closed just before quitting is waited for as well", async () => { + let exitLater; + const closed = fakePty((_signal, exit) => { exitLater = exit; }); + const manager = managerWith([closed]); + const [card] = manager.list(); + manager.dispose(card.id); + await manager.shutdown(); + let resolved = false; + const waiting = manager.waitForProcessExits(5_000, 5_000).then((left) => { resolved = true; return left; }); + await new Promise((resolve) => setTimeout(resolve, 30)); + assert.equal(resolved, false); + exitLater(0); + assert.equal(await waiting, 0); +}); + +test("an exit handler that throws never escapes into node-pty's native exit callback", () => { + const pty = fakePty(() => {}); + let failEmits = false; + const manager = managerWith([pty], () => { if (failEmits) throw new Error("Object has been destroyed"); }); + failEmits = true; + const warn = console.warn; + console.warn = () => {}; + try { + assert.doesNotThrow(() => pty.exit(1)); + } finally { + console.warn = warn; + } + failEmits = false; + assert.equal(manager.list()[0].exitCode, 1, "the exit is still recorded"); + manager.disposeAll(); +}); + +test("the quit path awaits the PTY exits before the app may finish quitting", () => { + const main = readFileSync(new URL("../src/main/index.ts", import.meta.url), "utf8"); + const shutdown = main.slice(main.indexOf("async function shutdownServices")); + const body = shutdown.slice(0, shutdown.indexOf("\n}\n")); + assert.match(body, /terminalManager\.shutdown\(\)[\s\S]*waitForProcessExits\(\)[\s\S]*await ptyExits;\s*$/u); +}); diff --git a/tests/terminal-visibility.test.mjs b/tests/terminal-visibility.test.mjs index 52ffcfd8..60f68c19 100644 --- a/tests/terminal-visibility.test.mjs +++ b/tests/terminal-visibility.test.mjs @@ -49,9 +49,9 @@ test("a hidden session keeps history but stops streaming, then replays exactly t assert.equal(snapshot.outputOffset, visibleOffset + "hidden\r\n".length); manager.setVisible(id, true); - assert.equal(emitted.length, 2, "becoming visible replays the current buffer once"); + assert.equal(emitted.length, 2, "becoming visible replays the missed output once"); const replay = emitted[1]; - assert.equal(replay.data, "visible\r\nhidden\r\n"); + assert.equal(replay.data, "hidden\r\n", "only the output produced while hidden, not the history the card already has"); assert.equal(replay.outputOffset, snapshot.outputOffset, "the replay carries the current absolute offset"); // The renderer slices from its own offset, so the event must cover the whole // hidden stretch and start at or before everything the card already wrote.