/** * ACP Terminal Manager — Client-side implementation of the ACP Terminal API. * * Manages terminal lifecycle for commands delegated by ACP agents via * `terminal/create`. On Windows, commands are routed through * `qiming-sandbox-helper.exe run` for per-command sandboxing. * On macOS/Linux, commands are executed directly (process-level sandboxing * is handled separately via seatbelt/bwrap). * * Implements the ACP Terminal protocol methods: * - terminal/create → createTerminal() * - terminal/output → terminalOutput() * - terminal/wait_for_exit → waitForExit() * - terminal/kill → kill() * - terminal/release → release() * * @see https://agentclientprotocol.com/protocol/terminals */ import { spawn, ChildProcess } from "child_process"; import { randomUUID } from "crypto"; import path from "path"; import log from "electron-log"; import { SandboxInvoker } from "@main/services/sandbox/SandboxInvoker"; import { killProcessTree } from "@main/services/utils/processTree"; import { createPlatformAdapter } from "@main/services/system/platformAdapter"; import type { SandboxMode, WindowsSandboxMode } from "@shared/types/sandbox"; // ============================================================================ // Types // ============================================================================ interface TerminalExitStatus { exitCode: number | null; signal: string | null; } interface TerminalSession { id: string; sessionId: string; /** Running child process (null after exit) */ process: ChildProcess | null; /** Accumulated stdout + stderr output */ output: string; /** Max output bytes to retain (null = unlimited) */ byteLimit: number | null; /** Whether output was truncated due to byteLimit */ truncated: boolean; /** Exit status (null while running) */ exitStatus: TerminalExitStatus | null; /** Whether waitForExit promise has resolved */ resolved: boolean; /** Promise that resolves when the process exits */ exitPromise: Promise; /** Resolver for exitPromise */ resolveExit: (status: TerminalExitStatus) => void; } export const TERMINAL_SANDBOX_SAFE_ENV_KEYS = [ "PATH", "Path", "SYSTEMROOT", "SystemRoot", "WINDIR", "windir", "SYSTEMDRIVE", "SystemDrive", "COMSPEC", "ComSpec", "PATHEXT", "PATHExt", "TEMP", "TMP", "USERPROFILE", "HOME", "LOCALAPPDATA", "APPDATA", "COMPUTERNAME", "USERNAME", "OS", "PROCESSOR_ARCHITECTURE", "LANG", "TZ", ] as const; function isPathWithinRoot(candidate: string, root: string): boolean { const relative = path.relative(root, candidate); return ( relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)) ); } export function resolveSandboxCwdWithinRoots( requestedCwd: string, writableRoots: string[], ): string { const resolvedCwd = path.resolve(requestedCwd); const resolvedRoots = writableRoots.map((root) => path.resolve(root)); if (resolvedRoots.length === 0) { return resolvedCwd; } if (resolvedRoots.some((root) => isPathWithinRoot(resolvedCwd, root))) { return resolvedCwd; } throw new Error( `Sandbox terminal cwd is outside writable roots: ${resolvedCwd}`, ); } export function resolveSandboxCwdWithFallback( requestedCwd: string | null | undefined, writableRoots: string[], fallbackCwd: string, ): { cwd: string; usedFallback: boolean } { const resolvedRoots = writableRoots.map((root) => path.resolve(root)); if (resolvedRoots.length === 0) { return { cwd: path.resolve(requestedCwd ?? fallbackCwd), usedFallback: false, }; } const fallbackRoot = resolvedRoots[0]; if (!requestedCwd) { return { cwd: fallbackRoot, usedFallback: true }; } try { return { cwd: resolveSandboxCwdWithinRoots(requestedCwd, resolvedRoots), usedFallback: false, }; } catch { return { cwd: fallbackRoot, usedFallback: true }; } } export function buildTerminalSandboxEnv( hostEnv: NodeJS.ProcessEnv, overrides?: Array<{ name: string; value: string }>, ): Record { const env: Record = {}; for (const key of TERMINAL_SANDBOX_SAFE_ENV_KEYS) { const val = hostEnv[key]; if (val !== undefined && val !== null) { env[key] = String(val); } } if (overrides) { for (const { name, value } of overrides) { env[name] = value; } } return env; } export interface AcpTerminalManagerOptions { /** Path to qiming-sandbox-helper.exe (Windows only) */ windowsSandboxHelperPath?: string; /** Windows sandbox mode */ windowsSandboxMode?: WindowsSandboxMode; /** Whether network access is allowed for sandboxed commands */ networkEnabled?: boolean; /** Paths that sandboxed commands can write to */ writablePaths?: string[]; /** Sandbox strictness mode (strict / compat / permissive) */ mode?: SandboxMode; } // ============================================================================ // AcpTerminalManager // ============================================================================ export class AcpTerminalManager { private static readonly MAX_CONCURRENT = 50; private terminals = new Map(); private sandboxInvoker: SandboxInvoker | null; private readonly useSandbox: boolean; private readonly networkEnabled: boolean; private readonly writablePaths: string[]; constructor(options?: AcpTerminalManagerOptions) { const platformAdapter = createPlatformAdapter(); this.useSandbox = !!( options?.windowsSandboxHelperPath && platformAdapter.isWindows ); this.networkEnabled = options?.networkEnabled ?? true; this.writablePaths = options?.writablePaths ?? []; if (this.useSandbox) { this.sandboxInvoker = new SandboxInvoker("windows-sandbox", { windowsSandboxHelperPath: options!.windowsSandboxHelperPath, windowsSandboxMode: options!.windowsSandboxMode, networkEnabled: options!.networkEnabled ?? true, mode: options!.mode, }); log.info( "[AcpTerminalManager] Initialized with Windows sandbox helper:", options!.windowsSandboxHelperPath, ); } else { this.sandboxInvoker = null; log.info( "[AcpTerminalManager] Initialized (direct execution, no sandbox)", ); } } // --- terminal/create --- async createTerminal(params: { sessionId: string; command: string; args?: string[]; env?: Array<{ name: string; value: string }>; cwd?: string | null; outputByteLimit?: number | null; }): Promise { if (this.terminals.size >= AcpTerminalManager.MAX_CONCURRENT) { throw new Error( `Terminal limit reached (${AcpTerminalManager.MAX_CONCURRENT}). Release existing terminals first.`, ); } const terminalId = `term_${randomUUID()}`; let resolveExit: ((status: TerminalExitStatus) => void) | null = null; const exitPromise = new Promise((r) => { resolveExit = r; }); const session: TerminalSession = { id: terminalId, sessionId: params.sessionId, process: null, output: "", byteLimit: params.outputByteLimit ?? null, truncated: false, exitStatus: null, resolved: false, exitPromise, resolveExit: resolveExit!, }; // Build a sanitized environment for sandboxed commands. // Avoid leaking sensitive host env vars (API keys, tokens, etc.) // into sandboxed child processes. const env: Record = this.useSandbox ? buildTerminalSandboxEnv(process.env, params.env) : { ...(process.env as Record), ...(params.env ? Object.fromEntries( params.env.map(({ name, value }) => [name, value]), ) : {}), }; const requestedCwd = params.cwd ?? process.cwd(); let cwd = requestedCwd; if (this.useSandbox && this.writablePaths.length > 0) { const resolved = resolveSandboxCwdWithFallback( params.cwd, this.writablePaths, process.cwd(), ); cwd = resolved.cwd; if (resolved.usedFallback) { log.warn( "[AcpTerminalManager] terminal/create cwd adjusted to writable root", { terminalId, sessionId: params.sessionId, requestedCwd: params.cwd ?? null, resolvedCwd: cwd, }, ); } } // Register BEFORE spawning so fast-exiting processes don't cause "not found" errors // in terminalOutput()/waitForExit() calls from the agent. this.terminals.set(terminalId, session); if (this.useSandbox && this.sandboxInvoker) { // Windows: route through qiming-sandbox-helper.exe run let invocation; try { invocation = await this.sandboxInvoker.buildInvocation({ command: params.command, args: params.args || [], cwd, env, writablePaths: this.writablePaths, networkEnabled: this.networkEnabled, subcommand: "run", }); } catch (invErr) { this.terminals.delete(terminalId); throw invErr; } log.info("[AcpTerminalManager] ✅ SANDBOXED terminal created:", { terminalId, sessionId: params.sessionId, originalCommand: params.command, originalArgs: params.args, wrappedCommand: invocation.command, wrappedArgs: invocation.args, sandboxed: true, sandboxMode: "windows-sandbox-helper", networkEnabled: this.networkEnabled, writablePaths: this.writablePaths, cwd, parseJson: invocation.parseJson, }); try { this.spawnProcess( session, invocation.command, invocation.args, invocation.env, cwd, true, // parseJson: helper run returns JSON ); } catch (spawnErr) { this.cleanupFailedSpawn(session, terminalId); throw spawnErr; } } else { // macOS/Linux or no sandbox: execute directly log.info( "[AcpTerminalManager] ⚡ DIRECT terminal created (no sandbox):", { terminalId, sessionId: params.sessionId, command: params.command, args: params.args, sandboxed: false, platform: createPlatformAdapter().platform, cwd, }, ); try { this.spawnProcess( session, params.command, params.args || [], env, cwd, false, ); } catch (spawnErr) { this.cleanupFailedSpawn(session, terminalId); throw spawnErr; } } return terminalId; } // --- terminal/output --- async terminalOutput( terminalId: string, sessionId?: string, ): Promise<{ output: string; truncated: boolean; exitStatus: TerminalExitStatus | null; }> { const t = this.terminals.get(terminalId); if (!t) throw new Error(`Terminal not found: ${terminalId}`); if (sessionId && t.sessionId !== sessionId) { throw new Error( `Terminal ${terminalId} does not belong to session ${sessionId}`, ); } return { output: t.output, truncated: t.truncated, exitStatus: t.exitStatus, }; } // --- terminal/wait_for_exit --- async waitForExit( terminalId: string, sessionId?: string, ): Promise { const t = this.terminals.get(terminalId); if (!t) throw new Error(`Terminal not found: ${terminalId}`); if (sessionId && t.sessionId !== sessionId) { throw new Error( `Terminal ${terminalId} does not belong to session ${sessionId}`, ); } return t.exitPromise; } // --- terminal/kill --- async kill(terminalId: string, sessionId?: string): Promise { const t = this.terminals.get(terminalId); if (!t || !t.process) return; if (sessionId && t.sessionId !== sessionId) { throw new Error( `Terminal ${terminalId} does not belong to session ${sessionId}`, ); } log.info("[AcpTerminalManager] Killing terminal:", terminalId); try { const pid = t.process.pid; if (pid) { await killProcessTree(pid, "SIGKILL"); } else { t.process.kill("SIGKILL"); } } catch { // Process may have already exited } // Safety-net: resolve exitPromise in case the 'close' event is lost // after killProcessTree (possible on Windows where process tree kill // can race with the Node.js event loop). if (!t.resolved) { t.resolved = true; t.resolveExit({ exitCode: null, signal: "SIGKILL" }); } } // --- terminal/release --- async release(terminalId: string, sessionId?: string): Promise { const t = this.terminals.get(terminalId); if (!t) return; if (sessionId && t.sessionId !== sessionId) { throw new Error( `Terminal ${terminalId} does not belong to session ${sessionId}`, ); } log.info("[AcpTerminalManager] Releasing terminal:", terminalId); if (t.process) { try { const pid = t.process.pid; if (pid) { await killProcessTree(pid, "SIGKILL"); } else { t.process.kill("SIGKILL"); } } catch { // ignore } } // Resolve exitPromise if still pending so waitForExit() doesn't hang if (!t.resolved) { t.resolved = true; t.resolveExit({ exitCode: null, signal: null }); } this.terminals.delete(terminalId); } /** Release all terminals belonging to a specific session */ async releaseForSession(sessionId: string): Promise { const ids: string[] = []; for (const [id, t] of this.terminals) { if (t.sessionId === sessionId) { ids.push(id); } } if (ids.length > 0) { log.info( `[AcpTerminalManager] Releasing ${ids.length} terminals for session ${sessionId}`, ); } for (const id of ids) { await this.release(id); } } /** Release all terminals (called during engine destroy) */ async releaseAll(): Promise { const ids = Array.from(this.terminals.keys()); if (ids.length > 0) { log.info(`[AcpTerminalManager] Releasing ${ids.length} terminals`); } for (const id of ids) { await this.release(id); } } /** * Returns the ACP Client handler methods for terminal/* operations. * * Spread into `buildClientHandler()` return value: * ```ts * return { ...existingHandlers, ...this.terminalManager.getClientHandlers() }; * ``` */ getClientHandlers(): Pick< import("./acpClient").AcpClientHandler, | "createTerminal" | "terminalOutput" | "waitForTerminalExit" | "killTerminal" | "releaseTerminal" > { return { createTerminal: async (params) => { const terminalId = await this.createTerminal(params); return { terminalId }; }, terminalOutput: async (params) => { return this.terminalOutput(params.terminalId, params.sessionId); }, waitForTerminalExit: async (params) => { return this.waitForExit(params.terminalId, params.sessionId); }, killTerminal: async (params) => { await this.kill(params.terminalId, params.sessionId); return {}; }, releaseTerminal: async (params) => { await this.release(params.terminalId, params.sessionId); return {}; }, }; } // ============================================================================ // Private helpers // ============================================================================ private spawnProcess( session: TerminalSession, command: string, args: string[], env: Record | undefined, cwd: string, parseJson: boolean, ): void { const sandboxed = parseJson; const platformAdapter = createPlatformAdapter(); const useWindowsShell = !parseJson && platformAdapter.isWindows; log.info("[AcpTerminalManager] 🚀 Spawning process:", { terminalId: session.id, command, args: args.length > 0 ? args : undefined, cwd, sandboxed, shell: useWindowsShell, }); const proc = spawn(command, args, { cwd, env, // sandbox helper is an .exe, no shell needed; direct commands may need shell shell: useWindowsShell, windowsHide: true, stdio: ["ignore", "pipe", "pipe"], // stdin not wired — non-interactive commands only }); session.process = proc; log.info("[AcpTerminalManager] Process spawned:", { terminalId: session.id, pid: proc.pid, sandboxed, }); if (parseJson) { // Windows sandbox helper run mode: stdout is JSON blob, stderr is log let jsonBuffer = ""; proc.stdout?.on("data", (data: Buffer) => { jsonBuffer += data.toString(); }); proc.stderr?.on("data", (data: Buffer) => { // stderr from helper itself is debug output — log but don't mix into // the session output buffer. The real command stderr comes from the // JSON envelope after the helper exits. log.debug( "[AcpTerminalManager] Sandbox helper stderr:", data.toString().trim(), ); }); proc.on("close", (code, signal) => { // Parse the JSON result from helper log.info( "[AcpTerminalManager] 📦 Sandbox helper exited, parsing JSON result:", { terminalId: session.id, exitCode: code, signal, jsonBufferLength: jsonBuffer.length, }, ); try { const result = JSON.parse(jsonBuffer) as { exit_code: number; stdout: string; stderr: string; timed_out: boolean; }; // The real command output is inside the JSON envelope log.info("[AcpTerminalManager] ✅ Sandbox result parsed:", { terminalId: session.id, exit_code: result.exit_code, stdoutLength: result.stdout.length, stderrLength: result.stderr.length, timed_out: result.timed_out, }); if (result.stdout) { this.appendOutput(session, Buffer.from(result.stdout)); } if (result.stderr) { this.appendOutput(session, Buffer.from(result.stderr)); } const status: TerminalExitStatus = { exitCode: result.exit_code, signal: null, }; session.exitStatus = status; session.process = null; if (!session.resolved) { session.resolved = true; session.resolveExit(status); } } catch (parseErr) { // JSON parse failed, fall back to raw exit code log.warn( "[AcpTerminalManager] ⚠️ JSON parse failed, using raw exit code:", { terminalId: session.id, exitCode: code, jsonBufferPreview: jsonBuffer.slice(0, 200), error: parseErr instanceof Error ? parseErr.message : String(parseErr), }, ); const status: TerminalExitStatus = { exitCode: code ?? 1, signal: signal ?? null, }; session.exitStatus = status; session.process = null; if (!session.resolved) { session.resolved = true; session.resolveExit(status); } } }); } else { // Direct execution: stdout/stderr are command output proc.stdout?.on("data", (data: Buffer) => this.appendOutput(session, data), ); proc.stderr?.on("data", (data: Buffer) => this.appendOutput(session, data), ); proc.on("close", (code, signal) => { log.info("[AcpTerminalManager] 🔚 Direct process exited:", { terminalId: session.id, exitCode: code, signal, outputLength: session.output.length, }); const status: TerminalExitStatus = { exitCode: code, signal: signal ?? null, }; session.exitStatus = status; session.process = null; if (!session.resolved) { session.resolved = true; session.resolveExit(status); } }); } proc.on("error", (err) => { log.error("[AcpTerminalManager] Process spawn error:", err); const status: TerminalExitStatus = { exitCode: 1, signal: null }; session.exitStatus = status; session.process = null; if (!session.resolved) { session.resolved = true; session.resolveExit(status); } }); } /** * Clean up a terminal session when spawn() throws synchronously. * Resolves the exitPromise and removes the session from the map * to prevent resource leaks. */ private cleanupFailedSpawn( session: TerminalSession, terminalId: string, ): void { log.warn( "[AcpTerminalManager] Spawn failed, cleaning up terminal:", terminalId, ); const status: TerminalExitStatus = { exitCode: 1, signal: null }; session.exitStatus = status; session.process = null; if (!session.resolved) { session.resolved = true; session.resolveExit(status); } this.terminals.delete(terminalId); } private appendOutput(session: TerminalSession, data: Buffer): void { const text = data.toString(); session.output += text; if (session.byteLimit && session.output.length > session.byteLimit) { // Truncate from the beginning, preserving the most recent output session.output = session.output.slice( session.output.length - session.byteLimit, ); session.truncated = true; } } }