Files
qiming/qimingclaw/crates/agent-electron-client/src/main/services/engines/acp/acpTerminalManager.ts

741 lines
22 KiB
TypeScript

/**
* 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<TerminalExitStatus>;
/** 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<string, string> {
const env: Record<string, string> = {};
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<string, TerminalSession>();
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<string> {
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<TerminalExitStatus>((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<string, string> = this.useSandbox
? buildTerminalSandboxEnv(process.env, params.env)
: {
...(process.env as Record<string, string>),
...(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<TerminalExitStatus> {
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<void> {
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<void> {
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<void> {
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<void> {
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<string, string> | 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;
}
}
}