/* eslint-disable no-undef */ // Module-level require: code inside createExecHandlerApi runs under `with (ctx)` // where bare `require` resolves to ctx.require (based in electron/bridges/). const { ensureSessionShellKind, ensureSessionShellKindForExec, } = require("../ai/sessionShellKind.cjs"); function createExecHandlerApi(ctx) { with (ctx) { function resolveExecContext(params) { const { sessionId, command } = params; debugLog("handleExec:start", { sessionId, command, chatSessionId: params?.chatSessionId }); if (!sessionId || !command) throw new Error("sessionId and command are required"); if (typeof command !== 'string' || !command.trim()) { return { ok: false, error: 'Invalid command', exitCode: 1 }; } const session = sessions?.get(sessionId); debugLog("handleExec:sessionLookup", { sessionId, found: Boolean(session), protocol: session?.protocol || session?.type || null, shellKind: session?.shellKind || null, }); if (!session) return { ok: false, error: "Session not found" }; // Look up device type from metadata (set by renderer from Host.deviceType). const chatSessionId = params?.chatSessionId || null; const meta = getSessionMeta(sessionId, chatSessionId) || {}; // Mosh sessions use a shell-backed PTY and cannot connect to vendor CLIs, // so network device mode only applies to SSH and serial sessions. // Prefer session.protocol (runtime truth) over meta.protocol (renderer hint) // because Mosh tabs report as protocol:"ssh" in metadata but "mosh" in session. const sessionProtocol = session.protocol || session.type || meta.protocol || ""; const isSshOrSerial = sessionProtocol === "ssh" || sessionProtocol === "serial"; const isNetworkDevice = (meta.deviceType === "network" && isSshOrSerial) || sessionProtocol === "serial"; // The blocklist targets shell-specific patterns (rm -rf, eval, $(), etc.) that // are meaningless on network device CLIs. Serial sessions skip the check because // commands like "shutdown" (disable an interface) are routine on Cisco/Huawei. // // Design note: the serial protocol is explicitly chosen by the user in the UI // for network devices / embedded systems. While startSerialSession technically // supports PTY devices, users connecting to a Linux/BusyBox shell should use // the "local" protocol (which goes through the normal shell path with blocklist). // Additionally, execViaRawPty sends commands without shell wrapping, so shell // metacharacters in blocklist patterns (eval, $(), backticks, pipes) cannot // actually be interpreted even if sent to a serial-connected shell. if ((session.protocol === "local" || session.type === "local") && session.shellKind === "unknown") { return { ok: false, error: "AI execution is not supported for this local shell executable. Configure the local terminal to use bash/zsh/sh, fish, PowerShell/pwsh, or cmd.exe.", }; } const sshClient = session.conn || session.sshClient; const ptyStream = session.stream || session.pty || session.proc; return { ok: true, context: { sessionId, command, session, chatSessionId, sessionProtocol, isNetworkDevice, sshClient, ptyStream, }, }; } function handleExec(params) { const resolved = resolveExecContext(params); if (!resolved.ok) return resolved; const { sessionId, command, session, chatSessionId, sessionProtocol, isNetworkDevice, sshClient, ptyStream, } = resolved.context; const reservation = reserveSessionExecution(sessionId, "exec"); if (!reservation.ok) return reservation; const sessionToken = reservation.token; const executionLock = beginChatExecution(chatSessionId, sessionId, command); if (!executionLock.ok) { releaseSessionExecution(sessionId, sessionToken); return { ok: false, code: "COMMAND_ALREADY_RUNNING", error: `Another Netcatty command is already running for chat session "${chatSessionId}". Wait for it to finish before starting a new exec.`, activeCommand: executionLock.active.command, activeSessionId: executionLock.active.sessionId, }; } const runExecution = (factory) => { try { return Promise.resolve(factory()).finally(() => { releaseSessionExecution(sessionId, sessionToken); executionLock.release(); }); } catch (err) { releaseSessionExecution(sessionId, sessionToken); executionLock.release(); return { ok: false, error: err?.message || String(err) }; } }; // Network devices (switches/routers) connected via SSH: use raw execution. // Their vendor CLIs (Huawei VRP, Cisco IOS, etc.) don't run a POSIX shell, // so shell-wrapped commands with markers would fail. Raw mode sends commands // as-is with idle-timeout completion detection — same as serial sessions. if (isNetworkDevice && ptyStream && typeof ptyStream.write === "function") { return runExecution(() => execViaRawPty(ptyStream, command, { timeoutMs: commandTimeoutMs, trackForCancellation: activePtyExecs, chatSessionId: params?.chatSessionId, encoding: "utf8", // SSH PTY streams use UTF-8, not latin1 })); } // Prefer the interactive PTY so the user sees command/output in-session. if (ptyStream && typeof ptyStream.write === "function") { // Probe remote login shell once when shellKind is unset so fish // sessions get the fish wrapper instead of the posix default (#1854). // Cancellable: Stop during the probe must not still type the command. return runExecution(async () => { const probed = await ensureSessionShellKindForExec(session, { trackForCancellation: activePtyExecs, chatSessionId, }); if (!probed.ok) return probed; const safety = checkCommandSafetyForShell(command, resolveSessionBlocklistShellKind(session)); if (safety.blocked) { debugLog("handleExec:blocklisted", { sessionId, matchedPattern: safety.matchedPattern }); return { ok: false, error: `Command blocked by safety policy. Pattern: ${safety.matchedPattern}` }; } return execViaPty(ptyStream, command, { trackForCancellation: activePtyExecs, timeoutMs: commandTimeoutMs, shellKind: session.shellKind, loginShellHint: session._loginShellKind, probeLiveShell: true, onProbeAborted: (marker) => echoCommandToSession(session, sessionId, `${marker}_R`, { syntheticEcho: false }), expectedPrompt: getFreshIdlePrompt(session), typedInput: true, echoCommand: (rawCommand) => echoCommandToSession(session, sessionId, rawCommand), chatSessionId, // MCP callers have terminal_start as a fallback for long commands, // so enforce a hard wall-clock timeout here to match the MCP budget. enforceWallTimeout: true, }); }); } // Network devices require an interactive PTY for raw command execution. // If we got here, ptyStream wasn't writable — there's no usable channel. if (isNetworkDevice) { releaseSessionExecution(sessionId, sessionToken); executionLock.release(); return { ok: false, error: "Network device session has no writable PTY stream for command execution" }; } // Fallback: SSH exec channel (invisible to terminal). // At this point ptyStream is not writable (already returned above if it was). if (sshClient && typeof sshClient.exec === "function") { return runExecution(async () => { const probed = await ensureSessionShellKindForExec(session, { trackForCancellation: activePtyExecs, chatSessionId, }); if (!probed.ok) return probed; const safety = checkCommandSafetyForShell(command, resolveSessionBlocklistShellKind(session)); if (safety.blocked) { debugLog("handleExec:blocklisted", { sessionId, matchedPattern: safety.matchedPattern }); return { ok: false, error: `Command blocked by safety policy. Pattern: ${safety.matchedPattern}` }; } return execViaChannel(sshClient, command, { timeoutMs: commandTimeoutMs, trackForCancellation: activePtyExecs, // Pass chatSessionId so cancelPtyExecsForSession can interrupt this // exec channel when the originating SDK agent run is stopped. chatSessionId: params?.chatSessionId, }); }); } // Serial port: raw command execution (no shell wrapping) if (session.protocol === "serial" && session.serialPort && typeof session.serialPort.write === "function") { if (session.ymodemActive || session.zmodemSentry?.isActive?.()) { releaseSessionExecution(sessionId, sessionToken); executionLock.release(); return { ok: false, error: "Serial file transfer is already in progress" }; } return runExecution(() => execViaRawPty(session.serialPort, command, { timeoutMs: commandTimeoutMs, trackForCancellation: activePtyExecs, chatSessionId: params?.chatSessionId, encoding: session.serialEncoding || "utf8", })); } releaseSessionExecution(sessionId, sessionToken); executionLock.release(); return { ok: false, error: "Session does not support command execution" }; } function handleJobStart(params) { const resolved = resolveExecContext(params); if (!resolved.ok) return resolved; const { sessionId, command, session, chatSessionId, isNetworkDevice, sessionProtocol, ptyStream, } = resolved.context; if (isNetworkDevice || sessionProtocol === "serial") { return { ok: false, error: "Background execution currently supports shell-backed PTY sessions only.", }; } if (!ptyStream || typeof ptyStream.write !== "function") { return { ok: false, error: "Background execution requires a writable PTY-backed terminal session.", }; } const reservation = reserveSessionExecution(sessionId, "job"); if (!reservation.ok) return reservation; const sessionToken = reservation.token; const jobId = createBackgroundJobId(); const timeoutMs = Math.max(commandTimeoutMs, DEFAULT_BACKGROUND_JOB_TIMEOUT_MS); const startedAt = Date.now(); // Register the job *before* the shell-kind probe so chat-delete / // cancelBackgroundJobsForSession can see it while we await. Without // this, the first terminal_start on an unprobed remote session has no // backgroundJobs entry during the probe and still starts the PTY job // after cancel (Codex P2 on #2061). Not registered in activePtyExecs — // terminal_start is designed to survive SDK Stop; only chat cancel // (which walks backgroundJobs) should abort a pending start. let probeCancelRequested = false; const job = { id: jobId, sessionId, chatSessionId: chatSessionId || null, command, status: "running", startedAt, updatedAt: startedAt, exitCode: null, error: null, stdout: "", outputBaseOffset: 0, totalOutputChars: 0, outputTruncated: false, pendingShellProbe: true, handle: { cancel: () => { probeCancelRequested = true; }, }, }; backgroundJobs.set(jobId, job); // Probe so fish login shells are not mis-wrapped as posix (#1854). return Promise.resolve(ensureSessionShellKind(session)).then(() => { if (probeCancelRequested || job.status === "stopping") { job.status = "cancelled"; job.error = "Cancelled"; job.updatedAt = Date.now(); job.pendingShellProbe = false; releaseSessionExecution(sessionId, sessionToken); return { ok: false, error: "Cancelled", jobId, sessionId, status: "cancelled", }; } const safety = checkCommandSafetyForShell(command, resolveSessionBlocklistShellKind(session)); if (safety.blocked) { job.status = "failed"; job.error = `Command blocked by safety policy. Pattern: ${safety.matchedPattern}`; job.updatedAt = Date.now(); job.pendingShellProbe = false; backgroundJobs.delete(jobId); releaseSessionExecution(sessionId, sessionToken); return { ok: false, error: job.error }; } let handle; try { handle = startPtyJob(ptyStream, command, { // Intentionally do NOT register in activePtyExecs: terminal_start jobs // are designed to survive SDK agent "Stop" so the model can stop polling // without aborting a long-running build/scan/log stream. The job is // managed via terminal_stop and the per-session execution lock. timeoutMs, shellKind: session.shellKind, loginShellHint: session._loginShellKind, probeLiveShell: true, onProbeAborted: (marker) => echoCommandToSession(session, sessionId, `${marker}_R`, { syntheticEcho: false }), chatSessionId, expectedPrompt: getFreshIdlePrompt(session), typedInput: true, echoCommand: (rawCommand) => echoCommandToSession(session, sessionId, rawCommand), maxBufferedChars: MAX_BACKGROUND_JOB_OUTPUT_CHARS, normalizeFinalOutput: false, }); } catch (err) { job.status = "failed"; job.error = err?.message || String(err); job.updatedAt = Date.now(); job.pendingShellProbe = false; releaseSessionExecution(sessionId, sessionToken); return { ok: false, error: err?.message || String(err) }; } job.handle = handle; job.pendingShellProbe = false; handle.resultPromise.then((result) => { job.updatedAt = Date.now(); job.exitCode = result.exitCode ?? null; storeCompletedJobOutput(job, result.stdout || "", result); const isForcedCancel = typeof result.error === "string" && result.error.includes("forced"); if (result.error === "Cancelled" || isForcedCancel) { // Forced cancel means the process ignored SIGINT for the cancel // wall-clock window. We mark the job as cancelled and release the // lock so the session is reusable; the error message tells the // caller the process may still be running so subsequent commands // should be considered carefully. This is consistent: callers see // completed=true exactly when the lock is no longer held. job.status = "cancelled"; job.error = result.error; releaseSessionExecution(sessionId, sessionToken); return; } if (result.error) { job.status = "failed"; job.error = result.error; releaseSessionExecution(sessionId, sessionToken); return; } // A non-zero exit code without an error message still represents a // failed command (e.g. a build/test that returned 1). Mark it as failed // so callers don't have to special-case exitCode against status. if (typeof result.exitCode === "number" && result.exitCode !== 0) { job.status = "failed"; job.error = `Command exited with code ${result.exitCode}`; releaseSessionExecution(sessionId, sessionToken); return; } job.status = "completed"; releaseSessionExecution(sessionId, sessionToken); }).catch((err) => { job.updatedAt = Date.now(); job.status = "failed"; job.error = err?.message || String(err); storeCompletedJobOutput(job, job.stdout || ""); releaseSessionExecution(sessionId, sessionToken); }); return { ok: true, jobId, sessionId, command, status: "running", startedAt, outputMode: "foreground-mirrored", recommendedPollIntervalMs: DEFAULT_BACKGROUND_JOB_POLL_INTERVAL_MS, }; }).catch((err) => { // Probe (or unexpected rejection) must not leave the session lock held. job.status = "failed"; job.error = err?.message || String(err); job.updatedAt = Date.now(); job.pendingShellProbe = false; releaseSessionExecution(sessionId, sessionToken); return { ok: false, error: err?.message || String(err) }; }); } function getScopedJob(jobId, chatSessionId) { const job = backgroundJobs.get(jobId); if (!job) return null; // Per-chat isolation: a job started under a chat session can only be // accessed by callers presenting the same chatSessionId. Unscoped or // statically-scoped callers cannot reach into another chat's jobs. if (job.chatSessionId) { if (!chatSessionId || job.chatSessionId !== chatSessionId) { return null; } } return job; } function handleJobPoll(params) { const { jobId, offset = 0, chatSessionId, scopedSessionIds } = params || {}; if (!jobId) throw new Error("jobId is required"); const job = getScopedJob(jobId, chatSessionId || null); if (!job) return { ok: false, error: "Background job not found" }; // Re-check session scope so a caller that lost access to the host // cannot continue reading output from jobs on that session. // Covers dynamic (chatSessionId) and static (scopedSessionIds) modes. if (job.sessionId) { const scopeErr = validateSessionScope(job.sessionId, chatSessionId || null, scopedSessionIds); if (scopeErr) return { ok: false, error: scopeErr }; } return serializeBackgroundJob(job, offset); } function handleJobStop(params) { const { jobId, chatSessionId, scopedSessionIds } = params || {}; if (!jobId) throw new Error("jobId is required"); const job = getScopedJob(jobId, chatSessionId || null); if (!job) return { ok: false, error: "Background job not found" }; // For statically scoped MCP clients, validate that the job's session is // within the caller's static scope so a foreign jobId cannot cancel jobs // outside the caller's allowed sessions. Dynamic chat scope is already // enforced by getScopedJob (caller's chatSessionId must match the job's), // and we intentionally do NOT re-check dynamic scope here so jobs can // still be stopped after workspace membership changes — otherwise the // session lock would stay held forever. if (Array.isArray(scopedSessionIds) && job.sessionId) { if (!scopedSessionIds.includes(job.sessionId)) { return { ok: false, error: `Session "${job.sessionId}" is not in the current scope.` }; } } if (job.status === "running") { try { job.handle?.cancel?.(); } catch (err) { return { ok: false, error: err?.message || String(err) }; } job.status = "stopping"; job.error = "Cancellation requested"; job.updatedAt = Date.now(); } return serializeBackgroundJob(job, 0); } return { resolveExecContext, handleExec, handleJobStart, getScopedJob, handleJobPoll, handleJobStop, }; } } module.exports = { createExecHandlerApi };