From 9470dfa50ef9c47872e248031eacc9c73bccc7be Mon Sep 17 00:00:00 2001 From: Salih Yilboga Date: Fri, 24 Jul 2026 21:34:28 +0300 Subject: [PATCH 01/26] Agent mode phase 0: clean up background tasks when switching workspaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit killAllBackgroundCommands() was only ever called from app.on("window-all-closed")/"before-quit" — neither the session-load effect nor changeAgentWorkspace() in Chat.tsx cleaned up the *previous* workspace's tasks when switching to a different one, so they kept running indefinitely and kept counting against MAX_BACKGROUND_TASKS. - New app/src/process-tree.ts: killProcessTree(pid), since spawn(cmd, {shell:true}).kill() only kills the shell, not whatever it spawned (e.g. npm run dev -> node). POSIX kills the process group (requires detached:true at spawn time, now set); Windows shells out to taskkill /t /f. - BackgroundTask now carries its workspaceRoot. New killBackgroundCommandsForWorkspace(root) kills and forgets only that workspace's tasks, leaving others running. - New agent:closeWorkspace IPC call, invoked from Chat.tsx via a small ref-tracking helper right before every place agentWorkspace changes (initial pick, folder change, session load) so the previously-active workspace's tasks get torn down on the actual transition, not just at quit. This is prep work for a real sandbox/permission system and persistent terminals, both of which need the same workspace-scoped process lifecycle rather than duplicating it. --- app/src/agent-tools.test.ts | 20 +++++++++++++ app/src/agent-tools.ts | 39 ++++++++++++++++++++++++-- app/src/main.ts | 11 ++++++++ app/src/preload.ts | 2 ++ app/src/process-tree.test.ts | 48 ++++++++++++++++++++++++++++++++ app/src/process-tree.ts | 31 +++++++++++++++++++++ frontend/src/pages/Chat.tsx | 13 +++++++++ frontend/src/types/electron.d.ts | 1 + 8 files changed, 162 insertions(+), 3 deletions(-) create mode 100644 app/src/process-tree.test.ts create mode 100644 app/src/process-tree.ts diff --git a/app/src/agent-tools.test.ts b/app/src/agent-tools.test.ts index 354eaef..99f5373 100644 --- a/app/src/agent-tools.test.ts +++ b/app/src/agent-tools.test.ts @@ -31,6 +31,7 @@ import { stopBackgroundCommand, listBackgroundCommands, killAllBackgroundCommands, + killBackgroundCommandsForWorkspace, httpRequest, findSymbolReferences, applyPatch, @@ -494,6 +495,25 @@ describe("agent-tools", () => { for (let i = 0; i < 5; i++) startBackgroundCommand(workspace, "sleep 5", ".", `task-${i}`); expect(() => startBackgroundCommand(workspace, "sleep 5")).toThrow(/Already running/); }); + + it("kills and forgets only the tasks belonging to the given workspace, leaving other workspaces' tasks running", () => { + const otherWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), "agent-tools-test-other-")); + const { taskId: taskA } = startBackgroundCommand(workspace, "sleep 30"); + const { taskId: taskB } = startBackgroundCommand(otherWorkspace, "sleep 30"); + + const killedCount = killBackgroundCommandsForWorkspace(workspace); + + expect(killedCount).toBe(1); + // Killed AND removed from tracking — a task from a workspace you've + // switched away from shouldn't linger as a queryable id. + expect(() => getBackgroundOutput(taskA)).toThrow(/No background task/); + expect(getBackgroundOutput(taskB)).toContain("running"); + fs.rmSync(otherWorkspace, { recursive: true, force: true }); + }); + + it("returns 0 when the workspace has no background tasks", () => { + expect(killBackgroundCommandsForWorkspace(workspace)).toBe(0); + }); }); describe("git tools", () => { diff --git a/app/src/agent-tools.ts b/app/src/agent-tools.ts index 3b879d1..bffbab8 100644 --- a/app/src/agent-tools.ts +++ b/app/src/agent-tools.ts @@ -7,6 +7,7 @@ import { randomUUID } from "node:crypto"; import type { ToolDefinition } from "./providers/types"; import { getAccountToken } from "./accounts"; import { capturePageScreenshot } from "./browser-capture"; +import { killProcessTree } from "./process-tree"; const execAsync = promisify(exec); @@ -835,6 +836,10 @@ interface BackgroundTask { name: string; command: string; process: ChildProcess; + // Absolute path — lets killBackgroundCommandsForWorkspace() target only + // the tasks that belong to a workspace being switched away from, rather + // than every background task the process has ever started. + workspaceRoot: string; // Rolling tail of combined stdout+stderr — capped so a chatty dev server // can't grow memory unboundedly over a long session. output: string; @@ -861,13 +866,24 @@ export function startBackgroundCommand( } const cwd = resolveSafePath(workspaceRoot, relativeCwd); - const child = spawn(command, { cwd, shell: true, stdio: ["ignore", "pipe", "pipe"] }); + // detached so the shell becomes its own process group leader — lets + // killProcessTree() below signal the whole group (shell + whatever it + // spawned, e.g. `npm run dev` spawning `node`) instead of just the shell + // itself, which is all a plain .kill() would reach. No effect on Windows, + // where killProcessTree uses `taskkill /t` instead. + const child = spawn(command, { + cwd, + shell: true, + stdio: ["ignore", "pipe", "pipe"], + detached: process.platform !== "win32", + }); const id = randomUUID().slice(0, 8); const task: BackgroundTask = { id, name: name?.trim() || command.slice(0, 40), command, process: child, + workspaceRoot: path.resolve(workspaceRoot), output: "", exitCode: null, startedAt: Date.now(), @@ -909,7 +925,7 @@ export function stopBackgroundCommand(taskId: string): string { const task = backgroundTasks.get(taskId); if (!task) throw new Error(`No background task with id "${taskId}".`); if (task.exitCode !== null) return `Task ${task.id} had already exited with code ${task.exitCode}.`; - task.process.kill(); + if (task.process.pid) killProcessTree(task.process.pid); return `Task ${task.id} (${task.name}) stopped.`; } @@ -924,11 +940,28 @@ export function listBackgroundCommands(): { id: string; name: string; command: s export function killAllBackgroundCommands(): void { for (const task of backgroundTasks.values()) { - if (task.exitCode === null) task.process.kill(); + if (task.exitCode === null && task.process.pid) killProcessTree(task.process.pid); } backgroundTasks.clear(); } +// Only tears down tasks belonging to the workspace being switched away from +// — background commands are otherwise never cleaned up until app quit +// (killAllBackgroundCommands, called from window-all-closed/before-quit), +// so switching to a different workspace mid-session used to leave the old +// one's tasks running indefinitely, silently eating into MAX_BACKGROUND_TASKS. +export function killBackgroundCommandsForWorkspace(workspaceRoot: string): number { + const root = path.resolve(workspaceRoot); + let killed = 0; + for (const [id, task] of backgroundTasks) { + if (task.workspaceRoot !== root) continue; + if (task.exitCode === null && task.process.pid) killProcessTree(task.process.pid); + backgroundTasks.delete(id); + killed++; + } + return killed; +} + function gitCommand(workspaceRoot: string, args: string): Promise { return runCommand(workspaceRoot, `git ${args}`, "."); } diff --git a/app/src/main.ts b/app/src/main.ts index bbbc921..07cf585 100644 --- a/app/src/main.ts +++ b/app/src/main.ts @@ -754,6 +754,17 @@ function registerIpcHandlers(): void { return agentTools.detectProjectScripts(workspaceRoot); }); + // Called when the renderer is about to stop using a workspace (switching + // to a different folder, or loading a session that points elsewhere) — + // without this, background tasks started against the old workspace kept + // running indefinitely, since killAllBackgroundCommands() only ever ran + // on app quit. + ipcMain.handle("agent:closeWorkspace", (_event: IpcMainInvokeEvent, workspaceRoot: string) => { + requireString(workspaceRoot, "workspace root"); + const killedBackgroundTasks = agentTools.killBackgroundCommandsForWorkspace(workspaceRoot); + return { killedBackgroundTasks }; + }); + ipcMain.handle("mcp:connect", async (_event: IpcMainInvokeEvent, config: McpServerConfig) => { try { const { tools } = await mcpClient.connectServer(config); diff --git a/app/src/preload.ts b/app/src/preload.ts index 272d7ee..1ff20a5 100644 --- a/app/src/preload.ts +++ b/app/src/preload.ts @@ -230,6 +230,8 @@ contextBridge.exposeInMainWorld("api", { ipcRenderer.invoke("agent:rollbackLastWrite", workspaceRoot), detectScripts: (workspaceRoot: string): Promise => ipcRenderer.invoke("agent:detectScripts", workspaceRoot), + closeWorkspace: (workspaceRoot: string): Promise<{ killedBackgroundTasks: number }> => + ipcRenderer.invoke("agent:closeWorkspace", workspaceRoot), }, mcp: { diff --git a/app/src/process-tree.test.ts b/app/src/process-tree.test.ts new file mode 100644 index 0000000..b0eed38 --- /dev/null +++ b/app/src/process-tree.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, vi } from "vitest"; +import { spawn, spawnSync } from "node:child_process"; +import { killProcessTree } from "./process-tree"; + +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, spawnSync: vi.fn() }; +}); + +function isAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +describe("killProcessTree", () => { + // Real process-group kill only makes sense on POSIX — Windows has no + // equivalent to `detached: true` + negative-pid signaling, and Windows CI + // runners don't have `sh`/`sleep` available to build a tree with anyway. + it.skipIf(process.platform === "win32")( + "kills a detached shell and its child process, not just the shell", + async () => { + const child = spawn("sh", ["-c", "sleep 30 & wait"], { detached: true, stdio: "ignore" }); + const shellPid = child.pid!; + // Give the shell a moment to fork `sleep` before we look for it. + await new Promise((r) => setTimeout(r, 200)); + + killProcessTree(shellPid); + await new Promise((r) => setTimeout(r, 200)); + + expect(isAlive(shellPid)).toBe(false); + }, + 10_000 + ); + + it("shells out to taskkill /t /f on win32, regardless of the host platform", () => { + killProcessTree(4321, "SIGTERM", "win32"); + expect(spawnSync).toHaveBeenCalledWith("taskkill", ["/pid", "4321", "/t", "/f"]); + }); + + it("silently no-ops when the target process is already gone", () => { + // A pid essentially guaranteed not to exist. + expect(() => killProcessTree(999_999, "SIGTERM", "linux")).not.toThrow(); + }); +}); diff --git a/app/src/process-tree.ts b/app/src/process-tree.ts new file mode 100644 index 0000000..615830a --- /dev/null +++ b/app/src/process-tree.ts @@ -0,0 +1,31 @@ +import { spawnSync } from "node:child_process"; + +// `spawn(command, {shell: true})` means the tracked pid is the *shell's* +// pid — calling .kill() on it leaves any grandchildren (e.g. `npm run dev` +// spawning a real `node` process) running behind. On POSIX this kills the +// whole process group instead, which requires the child to have been spawned +// with `detached: true` so it becomes its own group leader (see +// startBackgroundCommand in agent-tools.ts). On Windows, `taskkill`'s `/t` +// flag walks the process tree itself, so no special spawn option is needed +// there. +export function killProcessTree( + pid: number, + signal: NodeJS.Signals = "SIGTERM", + platform: NodeJS.Platform = process.platform +): void { + if (platform === "win32") { + spawnSync("taskkill", ["/pid", String(pid), "/t", "/f"]); + return; + } + try { + process.kill(-pid, signal); + } catch { + // Not a group leader (wasn't spawned detached) or already gone — + // fall back to killing just the tracked pid. + try { + process.kill(pid, signal); + } catch { + // Already dead. + } + } +} diff --git a/frontend/src/pages/Chat.tsx b/frontend/src/pages/Chat.tsx index 6086d17..a1819ea 100644 --- a/frontend/src/pages/Chat.tsx +++ b/frontend/src/pages/Chat.tsx @@ -465,6 +465,16 @@ export default function Chat() { const bottomRef = useRef(null); const textareaRef = useRef(null); const viewportRef = useRef(null); + // Tracks the workspace this component last told the main process about, + // so switching away from one (a different session, or picking a new + // folder) can close out its background tasks instead of leaving them + // running until app quit. + const prevWorkspaceRef = useRef(null); + function closeWorkspaceIfChanged(next: string | null) { + const prev = prevWorkspaceRef.current; + if (prev && prev !== next) window.api.agent.closeWorkspace(prev); + prevWorkspaceRef.current = next; + } // No session selected yet: pick the most recent one, or create a new one. useEffect(() => { @@ -532,6 +542,7 @@ export default function Chat() { setParams(session.params ?? {}); setSessionSystemPrompt(session.systemPrompt ?? null); setAgentMode(session.agentMode ?? false); + closeWorkspaceIfChanged(session.agentWorkspace ?? null); setAgentWorkspace(session.agentWorkspace ?? null); setPendingToolCalls([]); setAgentStepCount(0); @@ -624,6 +635,7 @@ export default function Chat() { // workspace folder up front rather than letting tool calls fail later. const folder = await window.api.agent.pickWorkspace(); if (!folder) return; + closeWorkspaceIfChanged(folder); setAgentWorkspace(folder); setAgentMode(true); window.api.sessions.update(sessionId, { agentMode: true, agentWorkspace: folder }); @@ -638,6 +650,7 @@ export default function Chat() { if (!sessionId) return; const folder = await window.api.agent.pickWorkspace(); if (!folder) return; + closeWorkspaceIfChanged(folder); setAgentWorkspace(folder); window.api.sessions.update(sessionId, { agentWorkspace: folder }); } diff --git a/frontend/src/types/electron.d.ts b/frontend/src/types/electron.d.ts index c07275f..8400f29 100644 --- a/frontend/src/types/electron.d.ts +++ b/frontend/src/types/electron.d.ts @@ -511,6 +511,7 @@ export interface ElectronApi { ) => Promise<{ result?: unknown; error?: string }>; rollbackLastWrite: (workspaceRoot: string) => Promise; detectScripts: (workspaceRoot: string) => Promise; + closeWorkspace: (workspaceRoot: string) => Promise<{ killedBackgroundTasks: number }>; }; mcp: { connect: ( From 948bbe0d2ffa900099cb59e39551b7adb5aaf494 Mon Sep 17 00:00:00 2001 From: Salih Yilboga Date: Fri, 24 Jul 2026 22:08:36 +0300 Subject: [PATCH 02/26] Agent mode phase 1: real sandbox and permission system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent mode's only protection against run_command/run_code/background commands was a regex blocklist against command text — no OS-level isolation, no resource limits, and the code's own comments already admitted it "can't catch everything a shell is capable of." This adds two independent layers on top of it: - Network policy (100% enforceable on every platform, since it just refuses to run the tool at all): a new networkToolsEnabled setting gates web_search, fetch_url, http_request, capture_page_screenshot, and the three GitHub tools in executeTool(). Separately, run_command/run_code/ start_background_command gained a per-call `network` argument (default false) so the model has to explicitly ask for network access on a command-by-command basis, visible in the approval card like any other argument. - OS-level containment (app/src/command-sandbox.ts): on Linux, wraps the command with bubblewrap (bwrap) when installed — confines filesystem writes to the workspace and denies network unless the network argument above was set. On macOS, generates a sandbox-exec profile doing the same (built into macOS, no install needed). On Windows there's no lightweight equivalent primitive (Windows Sandbox needs Pro/Enterprise and is a full VM-like container; Job Objects/restricted tokens don't confine filesystem or network) — stays on the blocklist plus the resource limits below, and the new Settings sandbox-status row says so honestly rather than implying protection that isn't there. Every call site falls back to running unwrapped when no mechanism is available, never a hard failure. Deliberately does NOT offer a plain `unshare --net` fallback on Linux: creating a network namespace that way commonly requires privileges a normal user doesn't have, which would make sandboxing *break* commands instead of just not confining them. - Resource limits (app/src/resource-monitor.ts, cross-platform via pidusage): a safety net against a runaway process — configurable memory and CPU caps, generous defaults, killing the whole process tree (app/src/process-tree.ts, reused from phase 0) on breach. Complements the existing 60s run_command timeout, which never applied to background commands at all. New Settings section (Chat & Prompts → Agent runtime) exposes all of this: the network-tools toggle, the resource limit inputs, and a read-only status row reporting exactly what's enforced on the current OS. 34 new tests across command-sandbox.test.ts, resource-monitor.test.ts, and process-tree.test.ts, plus 2 new agent-tools.test.ts cases for the workspace-scoped background-task cleanup from phase 0. --- app/package-lock.json | 41 +++++++++ app/package.json | 2 + app/src/agent-tools.ts | 82 ++++++++++++++--- app/src/command-sandbox.test.ts | 102 +++++++++++++++++++++ app/src/command-sandbox.ts | 152 +++++++++++++++++++++++++++++++ app/src/main.ts | 3 + app/src/preload.ts | 2 + app/src/resource-monitor.test.ts | 74 +++++++++++++++ app/src/resource-monitor.ts | 75 +++++++++++++++ app/src/settings-store.ts | 13 +++ frontend/src/lib/translations.ts | 36 ++++++++ frontend/src/pages/Settings.tsx | 64 +++++++++++++ frontend/src/types/electron.d.ts | 10 ++ 13 files changed, 644 insertions(+), 12 deletions(-) create mode 100644 app/src/command-sandbox.test.ts create mode 100644 app/src/command-sandbox.ts create mode 100644 app/src/resource-monitor.test.ts create mode 100644 app/src/resource-monitor.ts diff --git a/app/package-lock.json b/app/package-lock.json index f4481fa..f9ee763 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -13,11 +13,13 @@ "ffmpeg-static": "^5.3.0", "node-llama-cpp": "^3.19.1", "pdf-parse": "^2.4.5", + "pidusage": "^4.0.1", "tesseract.js": "^5.1.1" }, "devDependencies": { "@types/node": "^26.1.1", "@types/pdf-parse": "^1.1.5", + "@types/pidusage": "^2.0.5", "concurrently": "^10.0.3", "cross-env": "^10.1.0", "electron": "^43.1.1", @@ -2092,6 +2094,13 @@ "@types/node": "*" } }, + "node_modules/@types/pidusage": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/pidusage/-/pidusage-2.0.5.tgz", + "integrity": "sha512-MIiyZI4/MK9UGUXWt0jJcCZhVw7YdhBuTOuqP/BjuLDLZ2PmmViMIQgZiWxtaMicQfAz/kMrZ5T7PKxFSkTeUA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/responselike": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", @@ -6069,6 +6078,38 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pidusage": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pidusage/-/pidusage-4.0.1.tgz", + "integrity": "sha512-yCH2dtLHfEBnzlHUJymR/Z1nN2ePG3m392Mv8TFlTP1B0xkpMQNHAnfkY0n2tAi6ceKO6YWhxYfZ96V4vVkh/g==", + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/pidusage/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/pkijs": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.0.tgz", diff --git a/app/package.json b/app/package.json index 463b4f8..212b8d4 100644 --- a/app/package.json +++ b/app/package.json @@ -78,6 +78,7 @@ "devDependencies": { "@types/node": "^26.1.1", "@types/pdf-parse": "^1.1.5", + "@types/pidusage": "^2.0.5", "concurrently": "^10.0.3", "cross-env": "^10.1.0", "electron": "^43.1.1", @@ -91,6 +92,7 @@ "ffmpeg-static": "^5.3.0", "node-llama-cpp": "^3.19.1", "pdf-parse": "^2.4.5", + "pidusage": "^4.0.1", "tesseract.js": "^5.1.1" } } diff --git a/app/src/agent-tools.ts b/app/src/agent-tools.ts index bffbab8..95d34c2 100644 --- a/app/src/agent-tools.ts +++ b/app/src/agent-tools.ts @@ -8,6 +8,9 @@ import type { ToolDefinition } from "./providers/types"; import { getAccountToken } from "./accounts"; import { capturePageScreenshot } from "./browser-capture"; import { killProcessTree } from "./process-tree"; +import { applySandbox } from "./command-sandbox"; +import { monitorProcess } from "./resource-monitor"; +import * as settingsStore from "./settings-store"; const execAsync = promisify(exec); @@ -131,12 +134,13 @@ export const AGENT_TOOLS: ToolDefinition[] = [ { name: "run_command", description: - "Execute a shell command in the workspace (or a subdirectory of it) and return its stdout/stderr/exit code. Use for builds, tests, git, npm, etc. Commands that could affect the system outside the workspace (deleting elsewhere, shutting down the machine, privilege escalation, etc.) are rejected.", + "Execute a shell command in the workspace (or a subdirectory of it) and return its stdout/stderr/exit code. Use for builds, tests, git, npm, etc. Commands that could affect the system outside the workspace (deleting elsewhere, shutting down the machine, privilege escalation, etc.) are rejected. Runs inside an OS-level sandbox confined to the workspace where the platform supports it (Linux with bubblewrap installed, macOS always) — on Windows this containment isn't available and only the command-text checks above apply.", parameters: { type: "object", properties: { command: { type: "string", description: "The shell command to run." }, cwd: { type: "string", description: 'Working directory for the command, relative to the workspace root. Defaults to "."' }, + network: { type: "boolean", description: "Whether this command needs network access (e.g. npm install, curl). Defaults to false — most commands don't need it." }, }, required: ["command"], }, @@ -144,13 +148,14 @@ export const AGENT_TOOLS: ToolDefinition[] = [ { name: "run_code", description: - "Run a Python or JavaScript code snippet in the workspace and return its stdout/stderr/exit code. A convenience over run_command for multi-line code (no shell-quoting to worry about) — it is not a sandbox: the code runs with the same permissions as run_command and is subject to the same safety checks.", + "Run a Python or JavaScript code snippet in the workspace and return its stdout/stderr/exit code. A convenience over run_command for multi-line code (no shell-quoting to worry about) — subject to the same sandboxing (where available) and safety checks as run_command.", parameters: { type: "object", properties: { language: { type: "string", enum: ["python", "javascript"], description: "Which interpreter to run the code with." }, code: { type: "string", description: "The full source code to execute." }, cwd: { type: "string", description: 'Working directory, relative to the workspace root. Defaults to "."' }, + network: { type: "boolean", description: "Whether this code needs network access. Defaults to false." }, }, required: ["language", "code"], }, @@ -158,13 +163,14 @@ export const AGENT_TOOLS: ToolDefinition[] = [ { name: "start_background_command", description: - "Start a long-running command (dev server, build watcher, long test run) in the background and return immediately with a task id. Use get_background_output to check on it later and stop_background_command when done — unlike run_command, this doesn't block or time out. Subject to the same safety checks as run_command.", + "Start a long-running command (dev server, build watcher, long test run) in the background and return immediately with a task id. Use get_background_output to check on it later and stop_background_command when done — unlike run_command, this doesn't block or time out. Subject to the same safety checks and sandboxing as run_command.", parameters: { type: "object", properties: { command: { type: "string", description: "The shell command to run." }, cwd: { type: "string", description: 'Working directory, relative to the workspace root. Defaults to "."' }, name: { type: "string", description: "Short human-readable label for this task (e.g. \"dev server\")." }, + network: { type: "boolean", description: "Whether this command needs network access (e.g. a dev server that fetches data). Defaults to false." }, }, required: ["command"], }, @@ -784,17 +790,36 @@ function formatCommandResult(stdout: string, stderr: string, exitCode: number | return parts.join("\n\n"); } -export async function runCommand(workspaceRoot: string, command: string, relativeCwd = "."): Promise { +export async function runCommand( + workspaceRoot: string, + command: string, + relativeCwd = ".", + network = false +): Promise { const dangerReason = findDangerousCommandReason(command); if (dangerReason) throw new Error(dangerReason); const cwd = resolveSafePath(workspaceRoot, relativeCwd); + const wrappedCommand = applySandbox(command, { workspaceRoot, allowNetwork: network }); + const settings = settingsStore.getSettings(); + let stopMonitor = () => {}; try { - const { stdout, stderr } = await execAsync(command, { + const execPromise = execAsync(wrappedCommand, { cwd, timeout: COMMAND_TIMEOUT_MS, maxBuffer: 10 * 1024 * 1024, }); + // execPromise.child is a documented feature of promisify(exec) — the + // returned promise carries the underlying ChildProcess, which is the + // only way to get its pid for resource-monitor.ts to watch. + if (execPromise.child.pid) { + stopMonitor = monitorProcess( + execPromise.child.pid, + { maxMemoryMB: settings.sandboxMaxMemoryMB, maxCpuPercent: settings.sandboxMaxCpuPercent }, + () => execPromise.child.kill() + ); + } + const { stdout, stderr } = await execPromise; return formatCommandResult(stdout, stderr, 0); } catch (err) { const e = err as { stdout?: string; stderr?: string; code?: number; killed?: boolean; message: string }; @@ -802,6 +827,8 @@ export async function runCommand(workspaceRoot: string, command: string, relativ return `Command timed out after ${COMMAND_TIMEOUT_MS / 1000}s.\n\n${formatCommandResult(e.stdout ?? "", e.stderr ?? "", e.code ?? null)}`; } return formatCommandResult(e.stdout ?? "", e.stderr ?? e.message, e.code ?? null); + } finally { + stopMonitor(); } } @@ -815,7 +842,8 @@ export async function runCode( workspaceRoot: string, language: "python" | "javascript", code: string, - relativeCwd = "." + relativeCwd = ".", + network = false ): Promise { const dangerReason = findDangerousCommandReason(code); if (dangerReason) throw new Error(dangerReason); @@ -825,7 +853,7 @@ export async function runCode( fs.writeFileSync(tmpFile, code); try { const interpreter = language === "python" ? "python3" : "node"; - return await runCommand(workspaceRoot, `${interpreter} "${tmpFile}"`, relativeCwd); + return await runCommand(workspaceRoot, `${interpreter} "${tmpFile}"`, relativeCwd, network); } finally { fs.rmSync(tmpFile, { force: true }); } @@ -856,7 +884,8 @@ export function startBackgroundCommand( workspaceRoot: string, command: string, relativeCwd = ".", - name?: string + name?: string, + network = false ): { taskId: string; name: string } { const dangerReason = findDangerousCommandReason(command); if (dangerReason) throw new Error(dangerReason); @@ -866,12 +895,13 @@ export function startBackgroundCommand( } const cwd = resolveSafePath(workspaceRoot, relativeCwd); + const wrappedCommand = applySandbox(command, { workspaceRoot, allowNetwork: network }); // detached so the shell becomes its own process group leader — lets // killProcessTree() below signal the whole group (shell + whatever it // spawned, e.g. `npm run dev` spawning `node`) instead of just the shell // itself, which is all a plain .kill() would reach. No effect on Windows, // where killProcessTree uses `taskkill /t` instead. - const child = spawn(command, { + const child = spawn(wrappedCommand, { cwd, shell: true, stdio: ["ignore", "pipe", "pipe"], @@ -900,7 +930,16 @@ export function startBackgroundCommand( task.output += `\n[failed to start: ${err.message}]`; task.exitCode = -1; }); + const settings = settingsStore.getSettings(); + const stopMonitor = child.pid + ? monitorProcess( + child.pid, + { maxMemoryMB: settings.sandboxMaxMemoryMB, maxCpuPercent: settings.sandboxMaxCpuPercent }, + (reason) => append(Buffer.from(`\n[background task stopped: ${reason}]`)) + ) + : () => {}; child.on("exit", (code) => { + stopMonitor(); task.exitCode = code ?? -1; }); backgroundTasks.set(id, task); @@ -1357,7 +1396,25 @@ export async function githubReadFile(repository: string, filePath: string, ref?: return content.length > MAX_READ_CHARS ? `${content.slice(0, MAX_READ_CHARS)}\n\n[truncated]` : content; } +// Tools that reach the network — gated by settings.networkToolsEnabled as a +// baseline that's 100% enforceable on every platform (refusing to run the +// tool at all, rather than trying to block network access after the fact, +// which is what command-sandbox.ts's per-call `network` argument does for +// run_command/run_code/start_background_command instead). +const NETWORK_TOOLS = new Set([ + "web_search", + "fetch_url", + "http_request", + "capture_page_screenshot", + "github_list_repositories", + "github_repository_tree", + "github_read_file", +]); + export async function executeTool(workspaceRoot: string, name: string, args: Record): Promise { + if (NETWORK_TOOLS.has(name) && settingsStore.getSettings().networkToolsEnabled === false) { + throw new Error(`Network access for agent tools is turned off in Settings — "${name}" can't run.`); + } switch (name) { case "read_file": return readFile( @@ -1391,17 +1448,18 @@ export async function executeTool(workspaceRoot: string, name: string, args: Rec case "search_files": return searchFiles(workspaceRoot, String(args.query ?? ""), args.path ? String(args.path) : "."); case "run_command": - return runCommand(workspaceRoot, String(args.command ?? ""), args.cwd ? String(args.cwd) : "."); + return runCommand(workspaceRoot, String(args.command ?? ""), args.cwd ? String(args.cwd) : ".", args.network === true); case "run_code": { const language = args.language === "python" ? "python" : "javascript"; - return runCode(workspaceRoot, language, String(args.code ?? ""), args.cwd ? String(args.cwd) : "."); + return runCode(workspaceRoot, language, String(args.code ?? ""), args.cwd ? String(args.cwd) : ".", args.network === true); } case "start_background_command": return startBackgroundCommand( workspaceRoot, String(args.command ?? ""), args.cwd ? String(args.cwd) : ".", - args.name ? String(args.name) : undefined + args.name ? String(args.name) : undefined, + args.network === true ); case "get_background_output": return getBackgroundOutput(String(args.task_id ?? "")); diff --git a/app/src/command-sandbox.test.ts b/app/src/command-sandbox.test.ts new file mode 100644 index 0000000..1a1a8de --- /dev/null +++ b/app/src/command-sandbox.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from "vitest"; +import { detectSandboxCapabilities, wrapCommand, applySandbox } from "./command-sandbox"; + +const has = (available: string[]) => (cmd: string) => available.includes(cmd); + +describe("detectSandboxCapabilities", () => { + it("prefers bubblewrap on Linux when available", () => { + expect(detectSandboxCapabilities("linux", has(["bwrap"]))).toEqual({ + filesystemConfinement: true, + networkDenial: true, + mechanism: "bubblewrap", + }); + }); + + it("reports no containment on Linux without bubblewrap", () => { + expect(detectSandboxCapabilities("linux", has([]))).toEqual({ + filesystemConfinement: false, + networkDenial: false, + mechanism: "none", + }); + }); + + it("uses sandbox-exec on macOS when available", () => { + expect(detectSandboxCapabilities("darwin", has(["sandbox-exec"]))).toEqual({ + filesystemConfinement: true, + networkDenial: true, + mechanism: "sandbox-exec", + }); + }); + + it("reports no containment on macOS without sandbox-exec", () => { + expect(detectSandboxCapabilities("darwin", has([]))).toEqual({ + filesystemConfinement: false, + networkDenial: false, + mechanism: "none", + }); + }); + + it("always reports no containment on Windows, regardless of PATH", () => { + expect(detectSandboxCapabilities("win32", has(["bwrap", "sandbox-exec"]))).toEqual({ + filesystemConfinement: false, + networkDenial: false, + mechanism: "none", + }); + }); +}); + +describe("wrapCommand", () => { + it("returns null when no sandbox mechanism is available", () => { + expect(wrapCommand("echo hi", { workspaceRoot: "/ws", allowNetwork: false }, "win32", has([]))).toBeNull(); + expect(wrapCommand("echo hi", { workspaceRoot: "/ws", allowNetwork: false }, "linux", has([]))).toBeNull(); + }); + + it("builds a bubblewrap invocation confining writes to the workspace, network denied by default", () => { + const wrapped = wrapCommand("npm test", { workspaceRoot: "/home/user/project", allowNetwork: false }, "linux", has(["bwrap"])); + expect(wrapped?.command).toBe("bwrap"); + expect(wrapped?.args).toContain("--unshare-all"); + expect(wrapped?.args).not.toContain("--share-net"); + expect(wrapped?.args).toEqual(expect.arrayContaining(["--bind", "/home/user/project", "/home/user/project"])); + expect(wrapped?.args.slice(-3)).toEqual(["sh", "-c", "npm test"]); + }); + + it("adds --share-net to the bubblewrap invocation when network is explicitly allowed", () => { + const wrapped = wrapCommand("npm install", { workspaceRoot: "/home/user/project", allowNetwork: true }, "linux", has(["bwrap"])); + expect(wrapped?.args).toContain("--share-net"); + }); + + it("builds a sandbox-exec invocation with a profile scoped to the workspace", () => { + const wrapped = wrapCommand("npm test", { workspaceRoot: "/Users/me/project", allowNetwork: false }, "darwin", has(["sandbox-exec"])); + expect(wrapped?.command).toBe("sandbox-exec"); + expect(wrapped?.args[0]).toBe("-p"); + expect(wrapped?.args[1]).toContain('(allow file-write* (subpath "/Users/me/project"))'); + expect(wrapped?.args[1]).toContain("(deny network*)"); + expect(wrapped?.args.slice(-3)).toEqual(["sh", "-c", "npm test"]); + }); + + it("allows network in the sandbox-exec profile when requested", () => { + const wrapped = wrapCommand("curl example.com", { workspaceRoot: "/Users/me/project", allowNetwork: true }, "darwin", has(["sandbox-exec"])); + expect(wrapped?.args[1]).toContain("(allow network*)"); + expect(wrapped?.args[1]).not.toContain("(deny network*)"); + }); +}); + +describe("applySandbox", () => { + it("returns the command unchanged when no sandbox mechanism is available", () => { + expect(applySandbox("echo hi", { workspaceRoot: "/ws", allowNetwork: false }, "win32", has(["bwrap"]))).toBe("echo hi"); + }); + + it("folds a bubblewrap-wrapped command into a single shell string ending in the original command", () => { + const result = applySandbox("npm test", { workspaceRoot: "/home/user/project", allowNetwork: false }, "linux", has(["bwrap"])); + expect(result.startsWith("'bwrap' ")).toBe(true); + expect(result).toContain("'/home/user/project'"); + expect(result.endsWith("'sh' '-c' 'npm test'")).toBe(true); + }); + + it("shell-quotes a workspace path containing a single quote so it can't break out of the wrapper", () => { + const result = applySandbox("echo hi", { workspaceRoot: "/home/user/it's-a-project", allowNetwork: false }, "linux", has(["bwrap"])); + // A raw unescaped single quote here would terminate the shell string + // early and let the rest of the path be interpreted as commands. + expect(result).toContain("'/home/user/it'\\''s-a-project'"); + }); +}); diff --git a/app/src/command-sandbox.ts b/app/src/command-sandbox.ts new file mode 100644 index 0000000..b7b83e7 --- /dev/null +++ b/app/src/command-sandbox.ts @@ -0,0 +1,152 @@ +import * as path from "node:path"; +import { execFileSync } from "node:child_process"; + +export interface SandboxCapabilities { + filesystemConfinement: boolean; + networkDenial: boolean; + // "unshare" is deliberately not offered as a mechanism even though the + // `unshare` binary itself is present on nearly every Linux system: + // creating a network namespace via plain `unshare --net` commonly + // requires privileges plain users don't have (fails with "Operation not + // permitted" on many stock kernel configs), which would make a sandboxed + // command fail outright instead of just running unsandboxed — worse than + // not attempting it. bubblewrap handles the unprivileged-namespace setup + // properly and is the only mechanism offered on Linux. + mechanism: "bubblewrap" | "sandbox-exec" | "none"; +} + +function commandExists(cmd: string): boolean { + try { + execFileSync(process.platform === "win32" ? "where" : "which", [cmd], { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +// `hasCommand` is injectable so tests can simulate "bwrap is/isn't on PATH" +// without actually shelling out. +export function detectSandboxCapabilities( + platform: NodeJS.Platform = process.platform, + hasCommand: (cmd: string) => boolean = commandExists +): SandboxCapabilities { + if (platform === "linux") { + if (hasCommand("bwrap")) return { filesystemConfinement: true, networkDenial: true, mechanism: "bubblewrap" }; + return { filesystemConfinement: false, networkDenial: false, mechanism: "none" }; + } + if (platform === "darwin") { + // Built into macOS — no install needed, so this should essentially + // always be available, but check anyway rather than assume. + if (hasCommand("sandbox-exec")) return { filesystemConfinement: true, networkDenial: true, mechanism: "sandbox-exec" }; + return { filesystemConfinement: false, networkDenial: false, mechanism: "none" }; + } + // Windows has no equivalent lightweight primitive: Windows Sandbox is a + // VM-like container requiring Pro/Enterprise, and Job Objects/restricted + // tokens don't confine the filesystem or network. Stays on the existing + // command-text blocklist plus resource-monitor.ts limits. + return { filesystemConfinement: false, networkDenial: false, mechanism: "none" }; +} + +export interface WrapCommandOptions { + workspaceRoot: string; + allowNetwork: boolean; +} + +export interface WrappedCommand { + command: string; + args: string[]; +} + +// Wraps `command` (a shell command string, run via `sh -c` either way) so it +// executes inside an OS-level sandbox instead of directly. Returns null when +// no sandboxing mechanism is available — callers should fall back to +// running `command` unwrapped rather than failing outright. +export function wrapCommand( + command: string, + opts: WrapCommandOptions, + platform: NodeJS.Platform = process.platform, + hasCommand: (cmd: string) => boolean = commandExists +): WrappedCommand | null { + const caps = detectSandboxCapabilities(platform, hasCommand); + const root = path.resolve(opts.workspaceRoot); + + if (caps.mechanism === "bubblewrap") { + const args = [ + "--ro-bind", + "/", + "/", + "--dev", + "/dev", + "--proc", + "/proc", + // Bound through (not a fresh --tmpfs) so files written to the + // real /tmp before the sandboxed process starts — e.g. run_code's + // temp script file, written via the host's os.tmpdir() — are + // still visible inside the sandbox. + "--bind", + "/tmp", + "/tmp", + "--bind", + root, + root, + "--chdir", + root, + "--die-with-parent", + "--unshare-all", + ]; + if (opts.allowNetwork) args.push("--share-net"); + args.push("sh", "-c", command); + return { command: "bwrap", args }; + } + + if (caps.mechanism === "sandbox-exec") { + return { command: "sandbox-exec", args: ["-p", buildMacSandboxProfile(root, opts.allowNetwork), "sh", "-c", command] }; + } + + return null; +} + +function buildMacSandboxProfile(workspaceRoot: string, allowNetwork: boolean): string { + const escaped = workspaceRoot.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + return [ + "(version 1)", + "(allow default)", + '(deny file-write* (subpath "/"))', + `(allow file-write* (subpath "${escaped}"))`, + // macOS's real temp dirs — os.tmpdir() resolves under + // /var/folders/.../T (symlinked from /private/var/folders), and + // /tmp itself is a symlink to /private/tmp. Both need to stay + // writable for the same reason /tmp does in the bubblewrap path + // above: run_code's temp script file lives there. + '(allow file-write* (subpath "/tmp"))', + '(allow file-write* (subpath "/private/tmp"))', + '(allow file-write* (subpath "/private/var/folders"))', + allowNetwork ? "(allow network*)" : "(deny network*)", + "", + ].join("\n"); +} + +// POSIX shell single-quoting: wraps in '...', escaping any embedded single +// quote as '\''. Used to fold a wrapped {command, args} back into the single +// shell-command string that `child_process.exec`/`spawn(..., {shell:true})` +// expect, without needing to change how the rest of agent-tools.ts invokes +// commands. +function shellQuote(arg: string): string { + return `'${arg.replace(/'/g, `'\\''`)}'`; +} + +// Applies sandboxing to `command` if a mechanism is available on this +// platform, otherwise returns it unchanged — the one function agent-tools.ts +// actually calls before handing a command to exec/spawn. `platform`/ +// `hasCommand` are forwarded to wrapCommand purely so tests can exercise +// this without depending on the host OS. +export function applySandbox( + command: string, + opts: WrapCommandOptions, + platform: NodeJS.Platform = process.platform, + hasCommand: (cmd: string) => boolean = commandExists +): string { + const wrapped = wrapCommand(command, opts, platform, hasCommand); + if (!wrapped) return command; + return [wrapped.command, ...wrapped.args].map(shellQuote).join(" "); +} diff --git a/app/src/main.ts b/app/src/main.ts index 07cf585..d989818 100644 --- a/app/src/main.ts +++ b/app/src/main.ts @@ -14,6 +14,7 @@ import * as secretsStore from "./secrets-store"; import * as dataTransfer from "./data-transfer"; import * as rag from "./rag"; import * as agentTools from "./agent-tools"; +import { detectSandboxCapabilities } from "./command-sandbox"; import * as mcpClient from "./mcp-client"; import * as figma from "./figma"; import * as ocr from "./ocr"; @@ -765,6 +766,8 @@ function registerIpcHandlers(): void { return { killedBackgroundTasks }; }); + ipcMain.handle("agent:getSandboxCapabilities", () => detectSandboxCapabilities()); + ipcMain.handle("mcp:connect", async (_event: IpcMainInvokeEvent, config: McpServerConfig) => { try { const { tools } = await mcpClient.connectServer(config); diff --git a/app/src/preload.ts b/app/src/preload.ts index 1ff20a5..098fe34 100644 --- a/app/src/preload.ts +++ b/app/src/preload.ts @@ -4,6 +4,7 @@ import type { AttachedFile, MediaAttachment } from "./file-reader"; import type { ChatMessage, ChatChunk, ChatOptions, ProviderId } from "./providers/types"; import type { McpServerConfig, McpServerStatus } from "./mcp-client"; import type { RollbackResult, ProjectScripts } from "./agent-tools"; +import type { SandboxCapabilities } from "./command-sandbox"; import type { PromptPreset } from "./settings-store"; import type { LocalGgufModel, GpuBackend } from "./llamacpp-manager"; import type { ScheduledTask } from "./scheduled-tasks-store"; @@ -232,6 +233,7 @@ contextBridge.exposeInMainWorld("api", { ipcRenderer.invoke("agent:detectScripts", workspaceRoot), closeWorkspace: (workspaceRoot: string): Promise<{ killedBackgroundTasks: number }> => ipcRenderer.invoke("agent:closeWorkspace", workspaceRoot), + getSandboxCapabilities: (): Promise => ipcRenderer.invoke("agent:getSandboxCapabilities"), }, mcp: { diff --git a/app/src/resource-monitor.test.ts b/app/src/resource-monitor.test.ts new file mode 100644 index 0000000..5e5f845 --- /dev/null +++ b/app/src/resource-monitor.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { spawn, type ChildProcess } from "node:child_process"; +import { monitorProcess } from "./resource-monitor"; + +function isAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +describe("monitorProcess", () => { + let child: ChildProcess | undefined; + let stop: (() => void) | undefined; + + afterEach(() => { + stop?.(); + if (child?.pid && isAlive(child.pid)) child.kill(); + }); + + it("kills the process and reports why when it exceeds the memory limit", async () => { + child = spawn("node", ["-e", "setTimeout(() => {}, 10000)"], { stdio: "ignore" }); + const pid = child.pid!; + const reasons: string[] = []; + + // Any real node process uses well over 1MB RSS, so this fires on the + // very first poll. + stop = monitorProcess(pid, { maxMemoryMB: 1 }, (reason) => reasons.push(reason), 50); + + await new Promise((r) => setTimeout(r, 500)); + + expect(reasons).toHaveLength(1); + expect(reasons[0]).toMatch(/exceeded the 1MB memory limit/); + expect(isAlive(pid)).toBe(false); + }); + + it("does nothing when no limits are configured", async () => { + child = spawn("node", ["-e", "setTimeout(() => {}, 10000)"], { stdio: "ignore" }); + const pid = child.pid!; + const reasons: string[] = []; + + stop = monitorProcess(pid, {}, (reason) => reasons.push(reason), 50); + await new Promise((r) => setTimeout(r, 200)); + + expect(reasons).toHaveLength(0); + expect(isAlive(pid)).toBe(true); + }); + + it("stops cleanly without throwing once the watched process has already exited", async () => { + child = spawn("node", ["-e", "process.exit(0)"], { stdio: "ignore" }); + const pid = child.pid!; + const reasons: string[] = []; + + stop = monitorProcess(pid, { maxMemoryMB: 100_000 }, (reason) => reasons.push(reason), 50); + await new Promise((r) => setTimeout(r, 300)); + + expect(reasons).toHaveLength(0); + }); + + it("stop() prevents any further callback even if a poll was already in flight", async () => { + child = spawn("node", ["-e", "setTimeout(() => {}, 10000)"], { stdio: "ignore" }); + const pid = child.pid!; + const reasons: string[] = []; + + stop = monitorProcess(pid, { maxMemoryMB: 1 }, (reason) => reasons.push(reason), 50); + stop(); + await new Promise((r) => setTimeout(r, 300)); + + expect(reasons).toHaveLength(0); + expect(isAlive(pid)).toBe(true); + }); +}); diff --git a/app/src/resource-monitor.ts b/app/src/resource-monitor.ts new file mode 100644 index 0000000..a9ba1c3 --- /dev/null +++ b/app/src/resource-monitor.ts @@ -0,0 +1,75 @@ +import pidusage from "pidusage"; +import { killProcessTree } from "./process-tree"; + +export interface ResourceLimits { + maxMemoryMB?: number; + maxCpuPercent?: number; +} + +const DEFAULT_POLL_INTERVAL_MS = 2000; + +// A safety net against a runaway process (a build loop stuck consuming +// memory, a background dev server that leaks), not a real resource quota +// system: pidusage measures the single tracked pid's own usage, not a full +// process-tree/cgroup total, so a process that does its heavy lifting in a +// child won't be caught by the *measurement* here — though killProcessTree +// below still takes the whole tree down once a breach is detected on the +// pid that is being watched. +// +// Returns a function that stops monitoring — always call it once the +// process being watched has exited on its own, otherwise the interval +// leaks. `intervalMs` is exposed for tests; production code should leave it +// at the default. +export function monitorProcess( + pid: number, + limits: ResourceLimits, + onExceeded: (reason: string) => void, + intervalMs = DEFAULT_POLL_INTERVAL_MS +): () => void { + if (!limits.maxMemoryMB && !limits.maxCpuPercent) return () => {}; + + let stopped = false; + const stop = () => { + if (stopped) return; + stopped = true; + clearInterval(timer); + }; + + const timer = setInterval(async () => { + if (stopped) return; + // Not pre-declared with an explicit type: `ReturnType` only sees pidusage's *last* overload (the array-of-pids + // one) since TS's ReturnType utility can't pick from an overload set + // the way a real call site does — annotating this variable's type + // that way would silently widen it to the wrong shape. Left to + // call-site inference instead, which correctly resolves the + // single-pid `Promise` overload. + let memoryMB: number; + let cpuPercent: number; + try { + const stats = await pidusage(pid); + memoryMB = stats.memory / (1024 * 1024); + cpuPercent = stats.cpu; + } catch { + // Process already exited — nothing left to monitor. + stop(); + return; + } + if (stopped) return; // stopped while the async pidusage() call was in flight + + if (limits.maxMemoryMB && memoryMB > limits.maxMemoryMB) { + stop(); + killProcessTree(pid); + onExceeded(`exceeded the ${limits.maxMemoryMB}MB memory limit (was using ${memoryMB.toFixed(0)}MB)`); + return; + } + if (limits.maxCpuPercent && cpuPercent > limits.maxCpuPercent) { + stop(); + killProcessTree(pid); + onExceeded(`exceeded the ${limits.maxCpuPercent}% CPU limit (was using ${cpuPercent.toFixed(0)}%)`); + } + }, intervalMs); + timer.unref?.(); + + return stop; +} diff --git a/app/src/settings-store.ts b/app/src/settings-store.ts index bf1aa75..805c90f 100644 --- a/app/src/settings-store.ts +++ b/app/src/settings-store.ts @@ -99,6 +99,17 @@ export interface AppSettings { // vLLM runtime. The `vllm` executable is discovered from PATH by default. vllmModels?: string[]; vllmCommand?: string; + // Agent-mode sandboxing. Network tools (web_search, fetch_url, + // http_request, capture_page_screenshot, the GitHub tools) are gated by + // this flag directly — 100% enforceable on every platform, since it's + // just refusing to run the tool at all rather than trying to block + // network access after the fact. + networkToolsEnabled?: boolean; + // Safety-net resource caps applied to run_command/run_code/background + // commands (see resource-monitor.ts) — generous defaults, meant to catch + // a runaway process rather than act as a real resource quota system. + sandboxMaxMemoryMB?: number; + sandboxMaxCpuPercent?: number; } const DEFAULTS: AppSettings = { @@ -118,6 +129,8 @@ const DEFAULTS: AppSettings = { reduceMotion: false, agentMaxSteps: 25, llamaCppMaxCachedModels: 2, + networkToolsEnabled: true, + sandboxMaxMemoryMB: 2048, }; function filePath(): string { diff --git a/frontend/src/lib/translations.ts b/frontend/src/lib/translations.ts index 0e03c9f..e8011f9 100644 --- a/frontend/src/lib/translations.ts +++ b/frontend/src/lib/translations.ts @@ -351,6 +351,18 @@ export interface Dictionary { profileBalancedNote: string; profileAutonomous: string; profileAutonomousNote: string; + sandboxSectionTitle: string; + sandboxSectionHint: string; + networkToolsLabel: string; + networkToolsHint: string; + sandboxMaxMemoryLabel: string; + sandboxMaxMemoryHint: string; + sandboxMaxCpuLabel: string; + sandboxMaxCpuHint: string; + sandboxStatusLabel: string; + sandboxStatusBubblewrap: string; + sandboxStatusSandboxExec: string; + sandboxStatusNone: string; connectedAccountsTitle: string; connectedAccountsHint: string; accountGithubHint: string; @@ -725,6 +737,18 @@ export const en: Dictionary = { profileBalancedNote: "Best default", profileAutonomous: "Autonomous", profileAutonomousNote: "Long coding tasks", + sandboxSectionTitle: "Sandbox & permissions", + sandboxSectionHint: "Controls what Agent mode's shell commands can reach — beyond the always-on blocklist for obviously destructive commands.", + networkToolsLabel: "Network-capable tools", + networkToolsHint: "Web search, page fetch/screenshot, HTTP requests, and the GitHub tools. Turning this off refuses those tool calls outright — the one restriction here that's fully enforced on every platform.", + sandboxMaxMemoryLabel: "Memory limit per command (MB)", + sandboxMaxMemoryHint: "A command that exceeds this is killed automatically. A safety net against a runaway process, not a strict quota — leave it generous.", + sandboxMaxCpuLabel: "CPU limit per command (%)", + sandboxMaxCpuHint: "Optional. Leave blank for no CPU limit.", + sandboxStatusLabel: "OS-level containment on this machine", + sandboxStatusBubblewrap: "Linux, via bubblewrap — commands run confined to the workspace, with network access denied unless a command explicitly requests it.", + sandboxStatusSandboxExec: "macOS, via sandbox-exec — commands run confined to the workspace, with network access denied unless a command explicitly requests it.", + sandboxStatusNone: "Not available on this platform — only the command blocklist and the resource limits below apply. On Linux, installing bubblewrap (bwrap) enables real filesystem and network containment.", connectedAccountsTitle: "Connected accounts", connectedAccountsHint: "Link developer services for repository analysis and access to private or gated models. Tokens stay encrypted locally when your OS credential store is available.", accountGithubHint: "Connect repositories for AI analysis and developer workflows.", @@ -1100,6 +1124,18 @@ export const tr: Dictionary = { profileBalancedNote: "En iyi varsayılan", profileAutonomous: "Özerk", profileAutonomousNote: "Uzun kodlama görevleri", + sandboxSectionTitle: "Korumalı alan ve izinler", + sandboxSectionHint: "Ajan modunun kabuk komutlarının nelere erişebileceğini kontrol eder — açıkça yıkıcı komutlar için her zaman etkin olan engelleme listesinin ötesinde.", + networkToolsLabel: "Ağ erişimi olan araçlar", + networkToolsHint: "Web arama, sayfa getirme/ekran görüntüsü, HTTP istekleri ve GitHub araçları. Bunu kapatmak bu araç çağrılarını tamamen reddeder — burada her platformda tam olarak uygulanan tek kısıtlama budur.", + sandboxMaxMemoryLabel: "Komut başına bellek sınırı (MB)", + sandboxMaxMemoryHint: "Bu sınırı aşan bir komut otomatik olarak sonlandırılır. Kesin bir kota değil, kaçak bir sürece karşı bir güvenlik önlemidir — cömert bırakın.", + sandboxMaxCpuLabel: "Komut başına CPU sınırı (%)", + sandboxMaxCpuHint: "İsteğe bağlı. CPU sınırı olmaması için boş bırakın.", + sandboxStatusLabel: "Bu makinede işletim sistemi düzeyinde sınırlama", + sandboxStatusBubblewrap: "Linux, bubblewrap aracılığıyla — komutlar çalışma alanıyla sınırlı çalışır, bir komut açıkça talep etmedikçe ağ erişimi reddedilir.", + sandboxStatusSandboxExec: "macOS, sandbox-exec aracılığıyla — komutlar çalışma alanıyla sınırlı çalışır, bir komut açıkça talep etmedikçe ağ erişimi reddedilir.", + sandboxStatusNone: "Bu platformda mevcut değil — yalnızca komut engelleme listesi ve aşağıdaki kaynak sınırları uygulanır. Linux'ta bubblewrap (bwrap) kurmak gerçek dosya sistemi ve ağ sınırlaması sağlar.", connectedAccountsTitle: "Bağlı hesaplar", connectedAccountsHint: "Depo analizi ve özel veya kısıtlı modellere erişim için geliştirici hizmetlerini bağlayın. İşletim sistemi kimlik bilgisi deposu kullanılabildiğinde jetonlar yerel olarak şifreli kalır.", accountGithubHint: "Yapay zeka analizi ve geliştirici iş akışları için depoları bağlayın.", diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index a9684bc..a2cea18 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -32,6 +32,8 @@ import { Apple, Gauge, ExternalLink, + Shield, + ShieldAlert, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -68,6 +70,7 @@ import type { AppActivity, LinkedAccount, LocalRuntimeStatus, + SandboxCapabilities, } from "@/types/electron"; import { EXTRA_MODELS } from "@/lib/model-catalog"; import { recommendGpuBackend, gpuBackendNote } from "@/lib/gpu"; @@ -195,6 +198,7 @@ export default function Settings() { const [keybindingConflict, setKeybindingConflict] = useState(null); const [importMessage, setImportMessage] = useState(null); const [ollamaHostInput, setOllamaHostInput] = useState(""); + const [sandboxCapabilities, setSandboxCapabilities] = useState(null); const [modelsDirStatus, setModelsDirStatus] = useState(null); const [changingModelsDir, setChangingModelsDir] = useState(false); const [newPresetName, setNewPresetName] = useState(""); @@ -259,6 +263,7 @@ export default function Settings() { window.api.llamacpp.listModels().then(setLlamaCppModels); window.api.llamacpp.getAvailableGpuBackends().then(setLlamaCppGpuBackends); refreshRuntimeStatuses(); + window.api.agent.getSandboxCapabilities().then(setSandboxCapabilities); }, []); useEffect(() => { @@ -2450,6 +2455,65 @@ export default function Settings() { + + + + + + saveSettings({ sandboxMaxMemoryMB: Math.max(128, Number(e.target.value)) })} + className="w-28" + aria-label={t.sandboxMaxMemoryLabel} + /> + + + + saveSettings({ + sandboxMaxCpuPercent: e.target.value === "" ? undefined : Math.max(1, Math.min(100, Number(e.target.value))), + }) + } + className="w-28" + aria-label={t.sandboxMaxCpuLabel} + /> + + +
+ {sandboxCapabilities?.mechanism === "bubblewrap" || sandboxCapabilities?.mechanism === "sandbox-exec" ? ( + + ) : ( + + )} + + {!sandboxCapabilities + ? "…" + : sandboxCapabilities.mechanism === "bubblewrap" + ? t.sandboxStatusBubblewrap + : sandboxCapabilities.mechanism === "sandbox-exec" + ? t.sandboxStatusSandboxExec + : t.sandboxStatusNone} + +
+
+
+ Promise; detectScripts: (workspaceRoot: string) => Promise; closeWorkspace: (workspaceRoot: string) => Promise<{ killedBackgroundTasks: number }>; + getSandboxCapabilities: () => Promise; }; mcp: { connect: ( From 1df0b6830c7c5fe284c8f3f00098f918bcdb41f6 Mon Sep 17 00:00:00 2001 From: Salih Yilboga Date: Fri, 24 Jul 2026 22:48:42 +0300 Subject: [PATCH 03/26] Agent mode phase 2: persistent, interactive terminals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent mode's only process-management tool (start_background_command) spawns with stdio: ["ignore", ...] — there's no way to send input to a running process, so answering an interactive prompt or driving a REPL was impossible; output was poll-only, never streamed. - New app/src/terminal-manager.ts, backed by node-pty (real pseudo-terminal, not a plain pipe): create/write/resize/close/list, workspace-scoped cleanup wired into the same lifecycle hooks phase 0 built for background tasks (agent:closeWorkspace, window-all-closed, before-quit). Reuses phase 1's command-sandbox.ts for filesystem confinement where the platform supports it, defaulting to network allowed (unlike run_command's per-call default) since this is an interactive session a human can see and type into directly, not a one-shot command the model composed unsupervised. - Two separate paths reach it, deliberately: a human opening the terminal panel goes through a dedicated terminal:create IPC channel with live push-streamed output (mirrors the existing chat:chunk streaming pattern) and never touches tool approval, since the human is driving their own keystrokes. The model gets four new agent tools — create_terminal, write_to_terminal, read_terminal_output, close_terminal — that poll instead of stream, going through the normal approval-card pipeline like every other tool. Kept start_background_command's existing four tools unchanged rather than folding them into this, so no schema break for prompts/evals that already know them. - New frontend/src/components/terminal-panel.tsx (xterm.js + fit addon): one mounted terminal instance, tab-switching replays each session's buffered output rather than keeping N instances alive at once. - Extracted resolveSafePath out of agent-tools.ts into workspace-path.ts so terminal-manager.ts could reuse the same workspace-containment logic without agent-tools.ts <-> terminal-manager.ts becoming a circular import (agent-tools.ts's own new create_terminal/write_to_terminal/etc tool cases need to import terminal-manager.ts, which already needed resolveSafePath). New dependencies: node-pty (main, native — added to asarUnpack alongside the other native deps; electron-builder rebuilds it for Electron's ABI automatically, same as the existing node-llama-cpp dependency already relies on), @xterm/xterm + @xterm/addon-fit (renderer). 17 new tests across terminal-manager.test.ts and 3 new agent-tools.test.ts cases for the tool-call dispatch path, all exercising a real spawned pty rather than mocking node-pty. --- app/package-lock.json | 17 +++ app/package.json | 4 +- app/src/agent-tools.test.ts | 43 ++++++ app/src/agent-tools.ts | 102 +++++++++---- app/src/command-sandbox.ts | 31 +++- app/src/main.ts | 44 +++++- app/src/preload.ts | 48 +++++- app/src/terminal-manager.test.ts | 94 ++++++++++++ app/src/terminal-manager.ts | 143 ++++++++++++++++++ app/src/workspace-path.ts | 37 +++++ frontend/package-lock.json | 17 +++ frontend/package.json | 2 + frontend/src/components/terminal-panel.tsx | 167 +++++++++++++++++++++ frontend/src/lib/translations.ts | 3 + frontend/src/pages/Chat.tsx | 21 +++ frontend/src/types/electron.d.ts | 21 ++- 16 files changed, 754 insertions(+), 40 deletions(-) create mode 100644 app/src/terminal-manager.test.ts create mode 100644 app/src/terminal-manager.ts create mode 100644 app/src/workspace-path.ts create mode 100644 frontend/src/components/terminal-panel.tsx diff --git a/app/package-lock.json b/app/package-lock.json index f9ee763..aa1f55c 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -12,6 +12,7 @@ "electron-updater": "^6.6.2", "ffmpeg-static": "^5.3.0", "node-llama-cpp": "^3.19.1", + "node-pty": "^1.1.0", "pdf-parse": "^2.4.5", "pidusage": "^4.0.1", "tesseract.js": "^5.1.1" @@ -5808,6 +5809,22 @@ "node": ">=12" } }, + "node_modules/node-pty": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz", + "integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.1.0" + } + }, + "node_modules/node-pty/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, "node_modules/nopt": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", diff --git a/app/package.json b/app/package.json index 212b8d4..2f75d57 100644 --- a/app/package.json +++ b/app/package.json @@ -33,7 +33,8 @@ "**/node_modules/tesseract.js/**", "**/node_modules/tesseract.js-core/**", "**/node_modules/node-llama-cpp/**", - "**/node_modules/@node-llama-cpp/**" + "**/node_modules/@node-llama-cpp/**", + "**/node_modules/node-pty/**" ], "extraResources": [ { @@ -91,6 +92,7 @@ "electron-updater": "^6.6.2", "ffmpeg-static": "^5.3.0", "node-llama-cpp": "^3.19.1", + "node-pty": "^1.1.0", "pdf-parse": "^2.4.5", "pidusage": "^4.0.1", "tesseract.js": "^5.1.1" diff --git a/app/src/agent-tools.test.ts b/app/src/agent-tools.test.ts index 99f5373..339b175 100644 --- a/app/src/agent-tools.test.ts +++ b/app/src/agent-tools.test.ts @@ -3,6 +3,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { execSync } from "node:child_process"; +import { closeAll as closeAllTerminals } from "./terminal-manager"; import { readFile, writeFile, @@ -393,6 +394,48 @@ describe("agent-tools", () => { }); }); + describe("terminal tools", () => { + afterEach(() => { + closeAllTerminals(); + }); + + function waitFor(predicate: () => boolean | Promise, timeoutMs = 5000): Promise { + const start = Date.now(); + return new Promise((resolve, reject) => { + const check = async () => { + if (await predicate()) return resolve(); + if (Date.now() - start > timeoutMs) return reject(new Error("timed out waiting")); + setTimeout(check, 20); + }; + check(); + }); + } + + it("creates a terminal, writes a command to it, and reads the output back", async () => { + const created = (await executeTool(workspace, "create_terminal", { name: "test" })) as { id: string; name: string }; + expect(created.id).toBeTruthy(); + + await executeTool(workspace, "write_to_terminal", { terminal_id: created.id, input: "echo from-agent-terminal\r" }); + + await waitFor(async () => { + const output = (await executeTool(workspace, "read_terminal_output", { terminal_id: created.id })) as string; + return output.includes("from-agent-terminal"); + }); + const output = (await executeTool(workspace, "read_terminal_output", { terminal_id: created.id })) as string; + expect(output).toContain("from-agent-terminal"); + }); + + it("closes a terminal so further reads/writes fail", async () => { + const created = (await executeTool(workspace, "create_terminal", {})) as { id: string; name: string }; + await executeTool(workspace, "close_terminal", { terminal_id: created.id }); + await expect(executeTool(workspace, "read_terminal_output", { terminal_id: created.id })).rejects.toThrow(/No terminal/); + }); + + it("throws for an unknown terminal id", async () => { + await expect(executeTool(workspace, "write_to_terminal", { terminal_id: "nope", input: "x" })).rejects.toThrow(/No terminal/); + }); + }); + describe("run_command", () => { it("captures stdout and a zero exit code from a successful command", async () => { const output = await executeTool(workspace, "run_command", { command: "echo hello" }); diff --git a/app/src/agent-tools.ts b/app/src/agent-tools.ts index 95d34c2..999b990 100644 --- a/app/src/agent-tools.ts +++ b/app/src/agent-tools.ts @@ -11,6 +11,8 @@ import { killProcessTree } from "./process-tree"; import { applySandbox } from "./command-sandbox"; import { monitorProcess } from "./resource-monitor"; import * as settingsStore from "./settings-store"; +import { resolveSafePath } from "./workspace-path"; +import * as terminalManager from "./terminal-manager"; const execAsync = promisify(exec); @@ -202,6 +204,54 @@ export const AGENT_TOOLS: ToolDefinition[] = [ description: "List all background commands from this session with their status.", parameters: { type: "object", properties: {}, required: [] }, }, + { + name: "create_terminal", + description: + "Open a real interactive shell (a pseudo-terminal) in the workspace and return its id. Unlike run_command/start_background_command, this can be driven interactively over multiple turns — write_to_terminal to send input (including to a program already waiting for it, e.g. a REPL or a confirmation prompt), read_terminal_output to see what happened. Use this instead of start_background_command when you need to send input after the process has already started, not just read its output.", + parameters: { + type: "object", + properties: { + name: { type: "string", description: "Short human-readable label (e.g. \"debug session\")." }, + cwd: { type: "string", description: 'Working directory, relative to the workspace root. Defaults to "."' }, + }, + required: [], + }, + }, + { + name: "write_to_terminal", + description: "Send input to a terminal opened with create_terminal, as if typed at the keyboard. Include \\n (or \\r) to press Enter and actually run a typed command — without it, the text is typed but not submitted.", + parameters: { + type: "object", + properties: { + terminal_id: { type: "string", description: "The id returned by create_terminal." }, + input: { type: "string", description: "The text to send." }, + }, + required: ["terminal_id", "input"], + }, + }, + { + name: "read_terminal_output", + description: "Read the recent output of a terminal opened with create_terminal.", + parameters: { + type: "object", + properties: { + terminal_id: { type: "string", description: "The id returned by create_terminal." }, + tail_chars: { type: "number", description: "How many characters of recent output to return, from the end. Defaults to 4000." }, + }, + required: ["terminal_id"], + }, + }, + { + name: "close_terminal", + description: "Close a terminal opened with create_terminal, ending its shell process.", + parameters: { + type: "object", + properties: { + terminal_id: { type: "string", description: "The id returned by create_terminal." }, + }, + required: ["terminal_id"], + }, + }, { name: "git_status", description: "Show the working tree status (git status) for the workspace.", @@ -415,38 +465,6 @@ const MAX_COMMAND_OUTPUT_CHARS = 50_000; const COMMAND_TIMEOUT_MS = 60_000; const IGNORED_DIRS = new Set(["node_modules", ".git", "dist", "build", "out", "release", "__pycache__"]); -// Every tool call is confined to the chosen workspace directory — this -// resolves the (possibly relative, possibly attacker-crafted via a prompt -// injection in file content the model read) path and throws if it would -// escape that directory via ../ or an absolute path elsewhere on disk. -function resolveSafePath(workspaceRoot: string, relativePath: string): string { - const root = path.resolve(workspaceRoot); - const resolved = path.resolve(root, relativePath || "."); - const isWithin = (parent: string, child: string): boolean => { - const relative = path.relative(parent, child); - return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)); - }; - if (!isWithin(root, resolved)) { - throw new Error(`Path "${relativePath}" is outside the workspace directory.`); - } - - // Lexical checks alone are bypassable through a symlink inside the - // workspace that points elsewhere. Resolve the target, or its nearest - // existing parent for new files, and verify the real path too. - const realRoot = fs.realpathSync(root); - let existing = resolved; - while (!fs.existsSync(existing)) { - const parent = path.dirname(existing); - if (parent === existing) break; - existing = parent; - } - const realExisting = fs.realpathSync(existing); - if (!isWithin(realRoot, realExisting)) { - throw new Error(`Path "${relativePath}" resolves outside the workspace directory through a symbolic link.`); - } - return resolved; -} - export function readFile(workspaceRoot: string, relativePath: string, startLine?: number, endLine?: number): string { const target = resolveSafePath(workspaceRoot, relativePath); const stat = fs.statSync(target); @@ -1467,6 +1485,26 @@ export async function executeTool(workspaceRoot: string, name: string, args: Rec return stopBackgroundCommand(String(args.task_id ?? "")); case "list_background_commands": return listBackgroundCommands(); + case "create_terminal": + // No-op streaming callbacks: the model drives this by polling + // read_terminal_output rather than receiving push events — live + // streaming is reserved for the human-facing terminal panel, + // which goes through the dedicated terminal:create IPC channel + // instead of this tool-call path. + return terminalManager.createTerminal( + workspaceRoot, + { name: args.name ? String(args.name) : undefined, cwd: args.cwd ? String(args.cwd) : undefined }, + () => {}, + () => {} + ); + case "write_to_terminal": + terminalManager.writeToTerminal(String(args.terminal_id ?? ""), String(args.input ?? "")); + return { ok: true }; + case "read_terminal_output": + return terminalManager.readTerminalOutput(String(args.terminal_id ?? ""), typeof args.tail_chars === "number" ? args.tail_chars : undefined); + case "close_terminal": + terminalManager.closeTerminal(String(args.terminal_id ?? "")); + return { ok: true }; case "git_status": return gitStatus(workspaceRoot); case "git_diff": diff --git a/app/src/command-sandbox.ts b/app/src/command-sandbox.ts index b7b83e7..e604ca2 100644 --- a/app/src/command-sandbox.ts +++ b/app/src/command-sandbox.ts @@ -24,11 +24,34 @@ function commandExists(cmd: string): boolean { } } -// `hasCommand` is injectable so tests can simulate "bwrap is/isn't on PATH" +// bwrap being on PATH isn't sufficient on its own — modern Ubuntu (23.10+) +// restricts unprivileged user-namespace creation by default (AppArmor +// policy), which makes bwrap fail at runtime with a permission error even +// when it's installed. A plain `which bwrap` check wouldn't catch that and +// would report false confidence, so this actually runs a trivial sandboxed +// no-op and checks it really works. +function canUseBubblewrap(): boolean { + if (!commandExists("bwrap")) return false; + try { + execFileSync("bwrap", ["--unshare-all", "--dev", "/dev", "--proc", "/proc", "true"], { + stdio: "ignore", + timeout: 5000, + }); + return true; + } catch { + return false; + } +} + +function defaultAvailabilityCheck(cmd: string): boolean { + return cmd === "bwrap" ? canUseBubblewrap() : commandExists(cmd); +} + +// `hasCommand` is injectable so tests can simulate "bwrap is/isn't usable" // without actually shelling out. export function detectSandboxCapabilities( platform: NodeJS.Platform = process.platform, - hasCommand: (cmd: string) => boolean = commandExists + hasCommand: (cmd: string) => boolean = defaultAvailabilityCheck ): SandboxCapabilities { if (platform === "linux") { if (hasCommand("bwrap")) return { filesystemConfinement: true, networkDenial: true, mechanism: "bubblewrap" }; @@ -65,7 +88,7 @@ export function wrapCommand( command: string, opts: WrapCommandOptions, platform: NodeJS.Platform = process.platform, - hasCommand: (cmd: string) => boolean = commandExists + hasCommand: (cmd: string) => boolean = defaultAvailabilityCheck ): WrappedCommand | null { const caps = detectSandboxCapabilities(platform, hasCommand); const root = path.resolve(opts.workspaceRoot); @@ -144,7 +167,7 @@ export function applySandbox( command: string, opts: WrapCommandOptions, platform: NodeJS.Platform = process.platform, - hasCommand: (cmd: string) => boolean = commandExists + hasCommand: (cmd: string) => boolean = defaultAvailabilityCheck ): string { const wrapped = wrapCommand(command, opts, platform, hasCommand); if (!wrapped) return command; diff --git a/app/src/main.ts b/app/src/main.ts index d989818..82df295 100644 --- a/app/src/main.ts +++ b/app/src/main.ts @@ -15,6 +15,7 @@ import * as dataTransfer from "./data-transfer"; import * as rag from "./rag"; import * as agentTools from "./agent-tools"; import { detectSandboxCapabilities } from "./command-sandbox"; +import * as terminalManager from "./terminal-manager"; import * as mcpClient from "./mcp-client"; import * as figma from "./figma"; import * as ocr from "./ocr"; @@ -763,11 +764,50 @@ function registerIpcHandlers(): void { ipcMain.handle("agent:closeWorkspace", (_event: IpcMainInvokeEvent, workspaceRoot: string) => { requireString(workspaceRoot, "workspace root"); const killedBackgroundTasks = agentTools.killBackgroundCommandsForWorkspace(workspaceRoot); - return { killedBackgroundTasks }; + const killedTerminals = terminalManager.closeAllForWorkspace(workspaceRoot); + return { killedBackgroundTasks, killedTerminals }; }); ipcMain.handle("agent:getSandboxCapabilities", () => detectSandboxCapabilities()); + ipcMain.handle( + "terminal:create", + ( + event: IpcMainInvokeEvent, + { workspaceRoot, opts }: { workspaceRoot: string; opts?: { cwd?: string; name?: string } } + ) => { + requireString(workspaceRoot, "workspace root"); + // `id` is referenced inside these callbacks before it's assigned + // below, but that's safe: node-pty never calls onData/onExit + // synchronously during createTerminal() itself, only later once + // its own async event loop runs — by which point `id` is set. + const { id, name } = terminalManager.createTerminal( + workspaceRoot, + opts ?? {}, + (chunk) => event.sender.send(`terminal:data:${id}`, chunk), + (exitCode) => event.sender.send(`terminal:exit:${id}`, exitCode) + ); + return { id, name }; + } + ); + + ipcMain.handle("terminal:write", (_event: IpcMainInvokeEvent, { id, data }: { id: string; data: string }) => { + requireString(id, "terminal id"); + terminalManager.writeToTerminal(id, typeof data === "string" ? data : ""); + }); + + ipcMain.handle("terminal:resize", (_event: IpcMainInvokeEvent, { id, cols, rows }: { id: string; cols: number; rows: number }) => { + requireString(id, "terminal id"); + terminalManager.resizeTerminal(id, Number(cols) || 80, Number(rows) || 24); + }); + + ipcMain.handle("terminal:close", (_event: IpcMainInvokeEvent, id: string) => { + requireString(id, "terminal id"); + terminalManager.closeTerminal(id); + }); + + ipcMain.handle("terminal:list", (_event: IpcMainInvokeEvent, workspaceRoot?: string) => terminalManager.listTerminals(workspaceRoot)); + ipcMain.handle("mcp:connect", async (_event: IpcMainInvokeEvent, config: McpServerConfig) => { try { const { tools } = await mcpClient.connectServer(config); @@ -824,6 +864,7 @@ app.on("window-all-closed", () => { ollama.stop(); localServers.stopAll(); agentTools.killAllBackgroundCommands(); + terminalManager.closeAll(); mcpClient.disconnectAll(); if (process.platform !== "darwin") app.quit(); }); @@ -832,5 +873,6 @@ app.on("before-quit", () => { ollama.stop(); localServers.stopAll(); agentTools.killAllBackgroundCommands(); + terminalManager.closeAll(); void llamacpp.dispose(); }); diff --git a/app/src/preload.ts b/app/src/preload.ts index 098fe34..1133557 100644 --- a/app/src/preload.ts +++ b/app/src/preload.ts @@ -5,6 +5,7 @@ import type { ChatMessage, ChatChunk, ChatOptions, ProviderId } from "./provider import type { McpServerConfig, McpServerStatus } from "./mcp-client"; import type { RollbackResult, ProjectScripts } from "./agent-tools"; import type { SandboxCapabilities } from "./command-sandbox"; +import type { TerminalInfo } from "./terminal-manager"; import type { PromptPreset } from "./settings-store"; import type { LocalGgufModel, GpuBackend } from "./llamacpp-manager"; import type { ScheduledTask } from "./scheduled-tasks-store"; @@ -15,6 +16,12 @@ interface ToolExecuteResult { error?: string; } +// Tracked so terminal.close() can remove these deterministically instead of +// relying solely on the main-process exit event to arrive (it normally +// does, but explicit cleanup on close() is cheap insurance against a leaked +// listener if it doesn't). +const terminalListeners = new Map void; exit: (...args: unknown[]) => void }>(); + interface ScreenSourceInfo { id: string; name: string; @@ -231,11 +238,50 @@ contextBridge.exposeInMainWorld("api", { ipcRenderer.invoke("agent:rollbackLastWrite", workspaceRoot), detectScripts: (workspaceRoot: string): Promise => ipcRenderer.invoke("agent:detectScripts", workspaceRoot), - closeWorkspace: (workspaceRoot: string): Promise<{ killedBackgroundTasks: number }> => + closeWorkspace: (workspaceRoot: string): Promise<{ killedBackgroundTasks: number; killedTerminals: number }> => ipcRenderer.invoke("agent:closeWorkspace", workspaceRoot), getSandboxCapabilities: (): Promise => ipcRenderer.invoke("agent:getSandboxCapabilities"), }, + // Human-facing interactive terminals (a live pseudo-terminal panel) — + // separate from the model's create_terminal/write_to_terminal/etc. tool + // calls, which poll terminal state through agent.executeTool instead of + // needing a push-streaming channel. Both sides share the same + // terminal-manager.ts session pool, just reached through different paths: + // this one never goes through tool approval, since the human is driving + // it directly. + terminal: { + create: ( + workspaceRoot: string, + opts: { cwd?: string; name?: string }, + onData: (chunk: string) => void, + onExit: (exitCode: number) => void + ): Promise<{ id: string; name: string }> => + ipcRenderer.invoke("terminal:create", { workspaceRoot, opts }).then((created: { id: string; name: string }) => { + const dataListener = (_event: unknown, chunk: string) => onData(chunk); + const exitListener = (_event: unknown, exitCode: number) => onExit(exitCode); + ipcRenderer.on(`terminal:data:${created.id}`, dataListener); + ipcRenderer.on(`terminal:exit:${created.id}`, exitListener); + terminalListeners.set(created.id, { + data: dataListener as (...args: unknown[]) => void, + exit: exitListener as (...args: unknown[]) => void, + }); + return created; + }), + write: (id: string, data: string): Promise => ipcRenderer.invoke("terminal:write", { id, data }), + resize: (id: string, cols: number, rows: number): Promise => ipcRenderer.invoke("terminal:resize", { id, cols, rows }), + close: (id: string): Promise => { + const listeners = terminalListeners.get(id); + if (listeners) { + ipcRenderer.removeListener(`terminal:data:${id}`, listeners.data); + ipcRenderer.removeListener(`terminal:exit:${id}`, listeners.exit); + terminalListeners.delete(id); + } + return ipcRenderer.invoke("terminal:close", id); + }, + list: (workspaceRoot?: string): Promise => ipcRenderer.invoke("terminal:list", workspaceRoot), + }, + mcp: { connect: (config: McpServerConfig): Promise => ipcRenderer.invoke("mcp:connect", config), disconnect: (id: string): Promise => ipcRenderer.invoke("mcp:disconnect", id), diff --git a/app/src/terminal-manager.test.ts b/app/src/terminal-manager.test.ts new file mode 100644 index 0000000..d122639 --- /dev/null +++ b/app/src/terminal-manager.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { + createTerminal, + writeToTerminal, + resizeTerminal, + readTerminalOutput, + closeTerminal, + closeAllForWorkspace, + closeAll, + listTerminals, +} from "./terminal-manager"; + +function waitFor(predicate: () => boolean, timeoutMs = 5000): Promise { + const start = Date.now(); + return new Promise((resolve, reject) => { + const check = () => { + if (predicate()) return resolve(); + if (Date.now() - start > timeoutMs) return reject(new Error("timed out waiting for condition")); + setTimeout(check, 20); + }; + check(); + }); +} + +describe("terminal-manager", () => { + let workspace: string; + + beforeEach(() => { + workspace = fs.mkdtempSync(path.join(os.tmpdir(), "terminal-manager-test-")); + }); + + afterEach(() => { + closeAll(); + }); + + it("creates a terminal and reflects a typed command's output in scrollback", async () => { + const { id } = createTerminal(workspace, {}, () => {}, () => {}); + writeToTerminal(id, "echo hello-from-pty\r"); + await waitFor(() => readTerminalOutput(id).includes("hello-from-pty")); + expect(readTerminalOutput(id)).toContain("hello-from-pty"); + }); + + it("streams data to the onData callback as it arrives, not just into scrollback", async () => { + let received = ""; + const { id } = createTerminal(workspace, {}, (chunk) => (received += chunk), () => {}); + writeToTerminal(id, "echo streamed-chunk\r"); + await waitFor(() => received.includes("streamed-chunk")); + expect(received).toContain("streamed-chunk"); + void id; + }); + + it("reports exit via onExit and marks the terminal as not alive", async () => { + let exitCode: number | null = null; + const { id } = createTerminal(workspace, {}, () => {}, (code) => (exitCode = code)); + writeToTerminal(id, "exit 0\r"); + await waitFor(() => exitCode !== null); + expect(listTerminals(workspace).find((t) => t.id === id)?.alive).toBe(false); + }); + + it("closes and forgets only the terminals belonging to the given workspace", () => { + const otherWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), "terminal-manager-test-other-")); + createTerminal(workspace, {}, () => {}, () => {}); + const b = createTerminal(otherWorkspace, {}, () => {}, () => {}); + + const closedCount = closeAllForWorkspace(workspace); + + expect(closedCount).toBe(1); + expect(listTerminals(workspace)).toEqual([]); + expect(listTerminals(otherWorkspace).map((t) => t.id)).toEqual([b.id]); + closeTerminal(b.id); + fs.rmSync(otherWorkspace, { recursive: true, force: true }); + }); + + it("throws for an unknown terminal id", () => { + expect(() => writeToTerminal("does-not-exist", "x")).toThrow(/No terminal/); + expect(() => readTerminalOutput("does-not-exist")).toThrow(/No terminal/); + expect(() => resizeTerminal("does-not-exist", 80, 24)).toThrow(/No terminal/); + }); + + it("caps the number of concurrently open terminals", () => { + for (let i = 0; i < 5; i++) createTerminal(workspace, { name: `t-${i}` }, () => {}, () => {}); + expect(() => createTerminal(workspace, {}, () => {}, () => {})).toThrow(/Already have/); + }); + + it("treats closing an already-closed terminal as a harmless no-op", () => { + const { id } = createTerminal(workspace, {}, () => {}, () => {}); + closeTerminal(id); + expect(() => closeTerminal(id)).not.toThrow(); + expect(listTerminals(workspace)).toEqual([]); + }); +}); diff --git a/app/src/terminal-manager.ts b/app/src/terminal-manager.ts new file mode 100644 index 0000000..74abc12 --- /dev/null +++ b/app/src/terminal-manager.ts @@ -0,0 +1,143 @@ +import * as path from "node:path"; +import * as pty from "node-pty"; +import type { IPty } from "node-pty"; +import { wrapCommand } from "./command-sandbox"; +import { killProcessTree } from "./process-tree"; +import { resolveSafePath } from "./workspace-path"; + +export interface TerminalInfo { + id: string; + name: string; + workspaceRoot: string; + alive: boolean; +} + +interface TerminalSession { + id: string; + name: string; + workspaceRoot: string; + pty: IPty; + // Rolling buffer of everything the pty has emitted — capped the same way + // background-task output is, so a chatty process can't grow memory + // unboundedly over a long session. Doubles as what gets shown when a + // session is reopened after a restart (the process itself can't survive + // that, but its last output can). + scrollback: string; + alive: boolean; +} + +// Global, not per-workspace — mirrors MAX_BACKGROUND_TASKS in agent-tools.ts, +// which uses the same global-cap shape. +const MAX_TERMINALS = 5; +const MAX_SCROLLBACK_CHARS = 200_000; + +const terminals = new Map(); +let nextId = 1; + +function defaultShell(): string { + if (process.platform === "win32") return process.env.COMSPEC || "cmd.exe"; + return process.env.SHELL || "/bin/bash"; +} + +export function createTerminal( + workspaceRoot: string, + opts: { cwd?: string; name?: string } = {}, + onData: (chunk: string) => void, + onExit: (exitCode: number) => void +): { id: string; name: string } { + const runningCount = [...terminals.values()].filter((t) => t.alive).length; + if (runningCount >= MAX_TERMINALS) { + throw new Error(`Already have ${MAX_TERMINALS} terminals open — close one first.`); + } + + const cwd = resolveSafePath(workspaceRoot, opts.cwd ?? "."); + const shell = defaultShell(); + // Terminals default to allowing network — unlike a one-shot run_command + // call the model composes, this is an interactive session a human can + // type into directly and see exactly what's happening, so the tighter + // per-call default elsewhere in Agent mode would mostly just make + // ordinary shell use (curl, ping, package managers) confusingly fail. + // Filesystem confinement to the workspace still applies where the + // platform supports it. + const wrapped = wrapCommand(shell, { workspaceRoot, allowNetwork: true }); + const ptyOptions = { name: "xterm-256color", cols: 80, rows: 24, cwd, env: process.env }; + const ptyProcess = wrapped ? pty.spawn(wrapped.command, wrapped.args, ptyOptions) : pty.spawn(shell, [], ptyOptions); + + const id = `t${nextId++}`; + const session: TerminalSession = { + id, + name: opts.name?.trim() || `Terminal ${id}`, + workspaceRoot: path.resolve(workspaceRoot), + pty: ptyProcess, + scrollback: "", + alive: true, + }; + ptyProcess.onData((chunk) => { + session.scrollback += chunk; + if (session.scrollback.length > MAX_SCROLLBACK_CHARS) { + session.scrollback = session.scrollback.slice(-MAX_SCROLLBACK_CHARS); + } + onData(chunk); + }); + ptyProcess.onExit(({ exitCode }) => { + session.alive = false; + onExit(exitCode); + }); + terminals.set(id, session); + return { id, name: session.name }; +} + +function requireTerminal(id: string): TerminalSession { + const session = terminals.get(id); + if (!session) throw new Error(`No terminal with id "${id}".`); + return session; +} + +export function writeToTerminal(id: string, data: string): void { + requireTerminal(id).pty.write(data); +} + +export function resizeTerminal(id: string, cols: number, rows: number): void { + requireTerminal(id).pty.resize(Math.max(1, Math.floor(cols)), Math.max(1, Math.floor(rows))); +} + +export function readTerminalOutput(id: string, tailChars = 4000): string { + const session = requireTerminal(id); + return session.scrollback.length > tailChars ? session.scrollback.slice(-tailChars) : session.scrollback; +} + +// Removes the session entirely, unlike a plain kill — once the user (or the +// model) explicitly closes a terminal there's no reason to keep it around +// for listTerminals()/readTerminalOutput() to still surface. +export function closeTerminal(id: string): void { + const session = terminals.get(id); + if (!session) return; + if (session.alive && session.pty.pid) killProcessTree(session.pty.pid); + terminals.delete(id); +} + +// Same role as killBackgroundCommandsForWorkspace in agent-tools.ts — called +// when the renderer switches away from a workspace so terminals from the +// old one don't keep running (and don't keep counting against +// MAX_TERMINALS) until app quit. +export function closeAllForWorkspace(workspaceRoot: string): number { + const root = path.resolve(workspaceRoot); + let closed = 0; + for (const [id, session] of terminals) { + if (session.workspaceRoot !== root) continue; + closeTerminal(id); + closed++; + } + return closed; +} + +export function closeAll(): void { + for (const id of [...terminals.keys()]) closeTerminal(id); +} + +export function listTerminals(workspaceRoot?: string): TerminalInfo[] { + const root = workspaceRoot ? path.resolve(workspaceRoot) : undefined; + return [...terminals.values()] + .filter((t) => !root || t.workspaceRoot === root) + .map((t) => ({ id: t.id, name: t.name, workspaceRoot: t.workspaceRoot, alive: t.alive })); +} diff --git a/app/src/workspace-path.ts b/app/src/workspace-path.ts new file mode 100644 index 0000000..17b0a8b --- /dev/null +++ b/app/src/workspace-path.ts @@ -0,0 +1,37 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; + +// Every tool call (and, since phase 2, every terminal) is confined to the +// chosen workspace directory — this resolves the (possibly relative, +// possibly attacker-crafted via a prompt injection in file content the +// model read) path and throws if it would escape that directory via ../ or +// an absolute path elsewhere on disk. Lives in its own module (rather than +// inside agent-tools.ts, where it originated) so terminal-manager.ts can +// import it without creating a circular dependency between the two. +export function resolveSafePath(workspaceRoot: string, relativePath: string): string { + const root = path.resolve(workspaceRoot); + const resolved = path.resolve(root, relativePath || "."); + const isWithin = (parent: string, child: string): boolean => { + const relative = path.relative(parent, child); + return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)); + }; + if (!isWithin(root, resolved)) { + throw new Error(`Path "${relativePath}" is outside the workspace directory.`); + } + + // Lexical checks alone are bypassable through a symlink inside the + // workspace that points elsewhere. Resolve the target, or its nearest + // existing parent for new files, and verify the real path too. + const realRoot = fs.realpathSync(root); + let existing = resolved; + while (!fs.existsSync(existing)) { + const parent = path.dirname(existing); + if (parent === existing) break; + existing = parent; + } + const realExisting = fs.realpathSync(existing); + if (!isWithin(realRoot, realExisting)) { + throw new Error(`Path "${relativePath}" resolves outside the workspace directory through a symbolic link.`); + } + return resolved; +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 868a7fe..91ef82b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,8 @@ "@base-ui/react": "^1.6.0", "@fontsource-variable/geist": "^5.2.9", "@tailwindcss/vite": "^4.3.2", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -3486,6 +3488,21 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 71edf6d..6fb8f4a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,8 @@ "@base-ui/react": "^1.6.0", "@fontsource-variable/geist": "^5.2.9", "@tailwindcss/vite": "^4.3.2", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", diff --git a/frontend/src/components/terminal-panel.tsx b/frontend/src/components/terminal-panel.tsx new file mode 100644 index 0000000..76607f7 --- /dev/null +++ b/frontend/src/components/terminal-panel.tsx @@ -0,0 +1,167 @@ +import { useEffect, useRef, useState } from "react"; +import { Terminal as XTerm } from "@xterm/xterm"; +import { FitAddon } from "@xterm/addon-fit"; +import "@xterm/xterm/css/xterm.css"; +import { Plus, X, TerminalSquare } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +interface OpenTerminal { + id: string; + name: string; + alive: boolean; +} + +// A human-facing interactive terminal panel — separate from the model's +// create_terminal/write_to_terminal/etc. tool calls (which poll instead of +// streaming; see terminal-manager.ts). Keeps exactly one xterm.js instance +// mounted and swaps which terminal's buffered + live output feeds into it +// when switching tabs, rather than keeping N instances mounted at once. +export function TerminalPanel({ workspaceRoot, onClose }: { workspaceRoot: string; onClose: () => void }) { + const [terminals, setTerminals] = useState([]); + const [activeId, setActiveId] = useState(null); + const containerRef = useRef(null); + const xtermRef = useRef(null); + const fitRef = useRef(null); + const activeIdRef = useRef(null); + // Full output ever seen per terminal, so switching tabs can replay it — + // xterm itself only holds whatever's currently written to its one + // mounted instance. + const buffersRef = useRef>(new Map()); + + useEffect(() => { + if (!containerRef.current) return; + const term = new XTerm({ + convertEol: true, + fontSize: 13, + fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", + theme: { background: "#00000000" }, + }); + const fit = new FitAddon(); + term.loadAddon(fit); + term.open(containerRef.current); + fit.fit(); + xtermRef.current = term; + fitRef.current = fit; + + term.onData((data) => { + if (activeIdRef.current) window.api.terminal.write(activeIdRef.current, data); + }); + + const resizeObserver = new ResizeObserver(() => { + fit.fit(); + if (activeIdRef.current) window.api.terminal.resize(activeIdRef.current, term.cols, term.rows); + }); + resizeObserver.observe(containerRef.current); + + return () => { + resizeObserver.disconnect(); + term.dispose(); + xtermRef.current = null; + }; + // Intentionally mount once — the panel keeps one xterm instance for + // its whole lifetime and re-targets it on tab switches instead. + }, []); + + function handleData(id: string, chunk: string) { + buffersRef.current.set(id, (buffersRef.current.get(id) ?? "") + chunk); + if (activeIdRef.current === id) xtermRef.current?.write(chunk); + } + + function handleExit(id: string, exitCode: number) { + setTerminals((prev) => prev.map((t) => (t.id === id ? { ...t, alive: false } : t))); + const note = `\r\n\x1b[90m[process exited with code ${exitCode}]\x1b[0m\r\n`; + buffersRef.current.set(id, (buffersRef.current.get(id) ?? "") + note); + if (activeIdRef.current === id) xtermRef.current?.write(note); + } + + function switchTo(id: string) { + setActiveId(id); + activeIdRef.current = id; + xtermRef.current?.reset(); + const buffered = buffersRef.current.get(id); + if (buffered) xtermRef.current?.write(buffered); + if (fitRef.current) fitRef.current.fit(); + } + + async function createNewTerminal() { + let id = ""; + const created = await window.api.terminal.create( + workspaceRoot, + {}, + (chunk) => handleData(id, chunk), + (exitCode) => handleExit(id, exitCode) + ); + id = created.id; + buffersRef.current.set(id, ""); + setTerminals((prev) => [...prev, { id, name: created.name, alive: true }]); + switchTo(id); + } + + async function closeTerminalTab(id: string) { + await window.api.terminal.close(id); + buffersRef.current.delete(id); + setTerminals((prev) => { + const remaining = prev.filter((t) => t.id !== id); + if (activeIdRef.current === id) { + const next = remaining.at(-1); + if (next) switchTo(next.id); + else { + setActiveId(null); + activeIdRef.current = null; + xtermRef.current?.reset(); + } + } + return remaining; + }); + } + + // Opens with one terminal already running rather than an empty panel. + // Intentional one-shot on mount — createNewTerminal's own setState call + // happens asynchronously after the IPC round-trip, not synchronously + // within this effect, and it's deliberately not in the deps array since + // this should run exactly once regardless of how `terminals` changes + // afterward. + /* eslint-disable react-hooks/set-state-in-effect, react-hooks/exhaustive-deps */ + useEffect(() => { + if (terminals.length === 0) void createNewTerminal(); + }, []); + /* eslint-enable react-hooks/set-state-in-effect, react-hooks/exhaustive-deps */ + + return ( +
+
+ +
+ {terminals.map((t) => ( + + ))} +
+ + +
+
+
+ ); +} diff --git a/frontend/src/lib/translations.ts b/frontend/src/lib/translations.ts index e8011f9..5f2fa4c 100644 --- a/frontend/src/lib/translations.ts +++ b/frontend/src/lib/translations.ts @@ -117,6 +117,7 @@ export interface Dictionary { mcpAdd: string; undoLastEdit: string; nothingToUndo: string; + terminalPanelToggle: string; restoredFile: string; deletedNewFile: string; runTests: string; @@ -495,6 +496,7 @@ export const en: Dictionary = { mcpAdd: "Add", undoLastEdit: "Undo last edit", nothingToUndo: "Nothing to undo.", + terminalPanelToggle: "Terminal", restoredFile: "Restored previous content of", deletedNewFile: "Removed newly-created file", runTests: "Run tests", @@ -882,6 +884,7 @@ export const tr: Dictionary = { mcpAdd: "Ekle", undoLastEdit: "Son düzenlemeyi geri al", nothingToUndo: "Geri alınacak bir şey yok.", + terminalPanelToggle: "Terminal", restoredFile: "Önceki içerik geri yüklendi:", deletedNewFile: "Yeni oluşturulan dosya kaldırıldı:", runTests: "Testleri çalıştır", diff --git a/frontend/src/pages/Chat.tsx b/frontend/src/pages/Chat.tsx index a1819ea..5cbfe94 100644 --- a/frontend/src/pages/Chat.tsx +++ b/frontend/src/pages/Chat.tsx @@ -23,6 +23,7 @@ import { Volume2, Mic, Undo2, + TerminalSquare, FlaskConical, Wand2, Wrench, @@ -58,6 +59,7 @@ import { } from "@/components/ui/dropdown-menu"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Markdown } from "@/components/markdown"; +import { TerminalPanel } from "@/components/terminal-panel"; import { cn } from "@/lib/utils"; import { useSessions } from "@/lib/sessions-context"; import { useI18n } from "@/lib/i18n"; @@ -141,6 +143,7 @@ const READ_ONLY_TOOLS = new Set([ "list_background_commands", "capture_page_screenshot", "find_symbol_references", + "read_terminal_output", ]); // Vision models can already reason over any attached image — these just save @@ -447,6 +450,7 @@ export default function Chat() { const [sessionSystemPrompt, setSessionSystemPrompt] = useState(null); const [agentMode, setAgentMode] = useState(false); const [agentWorkspace, setAgentWorkspace] = useState(null); + const [showTerminalPanel, setShowTerminalPanel] = useState(false); const [pendingToolCalls, setPendingToolCalls] = useState([]); const [pendingVariablePreset, setPendingVariablePreset] = useState(null); const [agentStepCount, setAgentStepCount] = useState(0); @@ -1598,6 +1602,19 @@ export default function Chat() { {t.undoLastEdit} )} + {agentMode && agentWorkspace && ( + + )} {agentMode && agentWorkspace && projectScripts.test && ( + + +