/** * PTY and SSH channel command execution. * * Provides a unified `execViaPty` that works for both MCP server bridge * (tracking in activePtyExecs for cancellation) and Catty Agent * (stripping MCP markers from output). * * Also provides `execViaChannel` for SSH exec channel fallback. */ "use strict"; const crypto = require("crypto"); const { StringDecoder } = require("node:string_decoder"); const { invalidateSshTransport } = require("../sshTransportInvalidation.cjs"); const { createStatefulDecoder, detectShellKind, subscribeToPtyData, hasExpectedPromptSuffix, resolveEffectiveShellKind, buildPendingInputClearPrefix, buildWrappedCommand, findEndMarker, normalizePtyOutput, appendBoundedOutput, consumeVisibleText, stripAnsi, } = require("./ptyExecHelpers.cjs"); const { extractTrailingIdlePrompt } = require("./shellUtils.cjs"); const { buildLiveShellProbe, parseLiveShellProbe } = require("./liveShellProbe.cjs"); const DEFAULT_FOREGROUND_PTY_CAPTURE_CHARS = 1024 * 1024; const END_MARKER_PROMPT_WAIT_MS = 30000; const promptRecoveryPendingPtys = new WeakSet(); function stripJobMarkerLines(text, marker) { return text.replace( new RegExp(`^([^\r\n]*?)${marker}[^\r\n]*[\r\n]*`, "gm"), "$1", ); } function trailingPrefixLength(text, prefix) { const maxLength = Math.min(text.length, prefix.length); for (let length = maxLength; length > 0; length -= 1) { if (text.endsWith(prefix.slice(0, length))) return length; } return 0; } function startPtyJob(ptyStream, command, options) { const { stripMarkers = false, trackForCancellation = null, timeoutMs = 60000, shellKind, loginShellHint, probeLiveShell = false, onProbeAborted, chatSessionId, abortSignal, expectedPrompt, typedInput = false, echoCommand, maxBufferedChars = 0, normalizeFinalOutput = true, enforceWallTimeout = false, } = options || {}; const marker = `__NCMCP_${Date.now().toString(36)}_${crypto.randomBytes(16).toString('hex')}__`; let resolvedShellKind = resolveEffectiveShellKind(shellKind, expectedPrompt, { loginShellHint, }); const waitForReturnedPrompt = loginShellHint === "cmd" && resolvedShellKind === "powershell" && Boolean(expectedPrompt); if (promptRecoveryPendingPtys.has(ptyStream)) { if (extractTrailingIdlePrompt(expectedPrompt || "")) { promptRecoveryPendingPtys.delete(ptyStream); } else { const error = new Error( "Terminal is still waiting for the shell prompt after the previous command", ); error.code = "SHELL_PROMPT_PENDING"; throw error; } } const captureLimitChars = maxBufferedChars > 0 ? maxBufferedChars : DEFAULT_FOREGROUND_PTY_CAPTURE_CHARS; const CANCEL_RETRY_MS = 5000; const CANCEL_WALL_TIMEOUT_MS = 30000; const usesLiveShellProbe = probeLiveShell && ["posix", "fish"].includes(resolvedShellKind); let probingShell = usesLiveShellProbe; let deliveringInput = false; let probeOutput = ""; let output = ""; let foundStart = false; let preStartOutput = ""; let visibleOutput = ""; let visibleOutputOffset = 0; // Monotonic high-water mark for the visible byte stream. Increases on every // append; never decreases when CR redraws collapse visibleOutput. Used as // the polling nextOffset so callers' offsets stay monotonic. let visibleHighWatermark = 0; let visibleCarry = ""; let timeoutId = null; let wallTimeoutId = null; let startupTimeoutId = null; let promptFallbackTimer = null; let endMarkerWaitTimer = null; let cancelRetryTimerId = null; // Track one-shot timers scheduled inside requestCancel so finish() can // clear them when the job exits early; otherwise they keep the Node // event loop alive after the resultPromise has already resolved. const cancelOneShotTimers = []; let cancelRequested = false; let finished = false; let unsubscribe = null; const cleanupFns = []; let pendingStart = ""; let pendingEnd = null; let resolveResult; const outputDecoder = new StringDecoder("utf8"); const resultPromise = new Promise((resolve) => { resolveResult = resolve; }); function clearPromptFallback() { if (promptFallbackTimer) { clearTimeout(promptFallbackTimer); promptFallbackTimer = null; } } function clearEndMarkerWait() { if (endMarkerWaitTimer) { clearTimeout(endMarkerWaitTimer); endMarkerWaitTimer = null; } } function clearCancelRetryTimer() { if (cancelRetryTimerId) { clearTimeout(cancelRetryTimerId); cancelRetryTimerId = null; } } function clearCancelOneShotTimers() { while (cancelOneShotTimers.length) { clearTimeout(cancelOneShotTimers.pop()); } } function finishWithoutReturnedPrompt() { if (!pendingEnd || finished) return; promptRecoveryPendingPtys.add(ptyStream); finish(pendingEnd.stdout, pendingEnd.exitCode); } function armOutputTimeout() { clearTimeout(timeoutId); timeoutId = setTimeout(() => { sendInterrupt(); if (cancelRequested) { armOutputTimeout(); return; } const timeoutSec = Math.round(timeoutMs / 1000); finish(foundStart ? output : preStartOutput, -1, `Command timed out after ${timeoutSec}s without output`); }, timeoutMs); } // Hard wall-clock deadline: opt-in via enforceWallTimeout. Used by callers // that have a strict tool-call budget (e.g. MCP terminal_execute, where the // model can fall back to terminal_start). Default is off so existing // foreground execution paths (Catty Agent) keep their inactivity-based // timeout for long-running streaming commands. function armWallTimeout() { if (!enforceWallTimeout || maxBufferedChars > 0) return; wallTimeoutId = setTimeout(() => { if (finished) return; if (pendingEnd) { finishWithoutReturnedPrompt(); return; } sendInterrupt(); const timeoutSec = Math.round(timeoutMs / 1000); finish(foundStart ? output : preStartOutput, -1, `Command timed out (${timeoutSec}s)`); }, timeoutMs); } // Bounded startup deadline: we always need a hard limit on how long we // wait for the wrapped command's start marker. Otherwise an already-chatty // PTY (e.g. a tab running tail -f) would let onData re-arm the inactivity // timer forever before _S arrives, hanging the call and the session lock. // Foreground execs use the configured timeoutMs as the deadline (matching // the pre-PR behavior); background jobs use a fixed 30s since their main // timeout is much longer (1 hour) and meant for the actual command. // The timer is armed once input delivery completes (see writeInput) so the // paced-typing time for oversized probes/wrappers is excluded from the // startup budget instead of counting against it. const BG_STARTUP_TIMEOUT_MS = 30000; function armStartupTimeout() { const startupMs = maxBufferedChars > 0 ? BG_STARTUP_TIMEOUT_MS : timeoutMs; startupTimeoutId = setTimeout(() => { if (finished || foundStart) return; sendInterrupt(); const label = maxBufferedChars > 0 ? "Background job startup" : "Command startup"; finish(preStartOutput, -1, `${label} timed out — start marker never arrived`); }, startupMs); } function clearStartupTimeout() { if (startupTimeoutId) { clearTimeout(startupTimeoutId); startupTimeoutId = null; } } function sendInterrupt() { try { if (typeof ptyStream.signal === "function") { ptyStream.signal("INT"); } } catch { // Ignore signal failures and fall back to ETX. } try { if (typeof ptyStream.write === "function") { ptyStream.write("\x03"); } } catch { // Ignore PTY write failures during cancellation. } } function requestCancel() { if (finished || cancelRequested) return; if (pendingEnd) { // The command already completed. Do not send Ctrl+C into the restoring // prompt or release the lock before the next shell can be identified. return; } cancelRequested = true; clearPromptFallback(); clearCancelRetryTimer(); // Cancel the startup timer too — otherwise a pre-start cancel resolves // as "Background job startup timed out" instead of "Cancelled". clearStartupTimeout(); // For pre-start cancellation on sessions without a known idle prompt, // schedule a short fallback to finish the job after Ctrl+C has had time // to take effect. Without this, the cancel waits the full forced-cancel // window even though the shell may have returned to idle quickly. if (!foundStart && !expectedPrompt) { const t = setTimeout(() => { if (finished || foundStart) return; finish(preStartOutput, 130, "Cancelled"); }, 2000); cancelOneShotTimers.push(t); } sendInterrupt(); cancelRetryTimerId = setTimeout(function retryCancel() { if (finished || !cancelRequested) return; sendInterrupt(); cancelRetryTimerId = setTimeout(retryCancel, CANCEL_RETRY_MS); }, CANCEL_RETRY_MS); armOutputTimeout(); const t150 = setTimeout(() => { if (!finished) sendInterrupt(); }, 150); cancelOneShotTimers.push(t150); // Hard wall-clock deadline for cancellation: if the process ignores // Ctrl+C and never redraws the prompt, force-finish after a bounded // period so the session is not stuck in "stopping" forever. // Mark as "forced" so callers can tell the shell may still be busy. const tWall = setTimeout(() => { if (!finished) { finish(foundStart ? output : preStartOutput, 130, "Cancelled (forced — process may still be running)"); } }, CANCEL_WALL_TIMEOUT_MS); cancelOneShotTimers.push(tWall); } function schedulePromptFallback() { clearPromptFallback(); if (!hasExpectedPromptSuffix(output, expectedPrompt)) return; // Background jobs use a much longer delay (30s) so commands that open // child shells / REPLs with the same prompt have time to print past // their initial prompt and avoid being misdetected as completed. // Foreground execs use 250ms to match the pre-PR behavior. const delayMs = maxBufferedChars > 0 ? 30000 : 250; promptFallbackTimer = setTimeout(() => { if (!hasExpectedPromptSuffix(output, expectedPrompt)) return; finish(output, null, null); }, delayMs); } function checkEnd() { if (pendingEnd) { if (extractTrailingIdlePrompt(output)) { finish(pendingEnd.stdout, pendingEnd.exitCode); } return; } const found = findEndMarker(output, marker, { allowInline: true }); if (!found) return; const stdout = output.slice(0, found.endIdx); if (maxBufferedChars > 0) { // visibleOutput is assembled independently from the raw marker buffer. // If a chunk split happens inside the constant "__NCMCP_" prefix, the // partial prefix may already have entered visibleOutput before the next // chunk makes the full marker recognizable. Roll back at the complete // marker now that checkEnd has reconstructed it from raw output. const visibleEnd = findEndMarker(visibleOutput, marker, { allowInline: true }); if (visibleEnd) { visibleOutput = visibleOutput.slice(0, visibleEnd.endIdx); visibleMarkerCarry = ""; visibleCarry = ""; } } pendingEnd = { stdout, exitCode: found.exitCode }; clearTimeout(timeoutId); timeoutId = null; clearStartupTimeout(); clearCancelRetryTimer(); clearCancelOneShotTimers(); if (!waitForReturnedPrompt || extractTrailingIdlePrompt(output)) { finish(stdout, found.exitCode); return; } // In the Windows OpenSSH cmd-to-PowerShell startup path, the end marker and // restored prompt can arrive separately. Keep the lock until the prompt // returns so a consecutive command cannot fall back to cmd.exe. if (!endMarkerWaitTimer) { endMarkerWaitTimer = setTimeout( finishWithoutReturnedPrompt, END_MARKER_PROMPT_WAIT_MS, ); } } // Carry buffer for incomplete marker lines split across chunks. let visibleMarkerCarry = ""; // Note: we intentionally do NOT collapse CR redraws in visibleOutput. // Doing so makes polling offsets non-monotonic and can drop finalized // lines after a CR rewrite. Instead, the buffer stores raw bytes // (including \r) and the bounded-buffer cap (256KB) keeps progress-bar // accumulation under control. Consumers that want a "collapsed" view // can apply CR processing themselves. function appendToVisible(text) { if (!text) return; let boundedInput = text; if (boundedInput.length > captureLimitChars) { const skipped = boundedInput.length - captureLimitChars; boundedInput = boundedInput.slice(-captureLimitChars); // A skipped prefix may end inside a control sequence; drop any carry // from that prefix and treat offsets as a conservative raw-char count. visibleCarry = ""; visibleOutputOffset += skipped; visibleHighWatermark += skipped; } const normalized = consumeVisibleText(visibleCarry, boundedInput); visibleCarry = normalized.carry; if (!normalized.visibleText) return; let cleanVisible = normalized.visibleText; if (maxBufferedChars > 0) { // Rejoin with any incomplete line from the previous chunk so marker // lines split across PTY data boundaries are matched as a whole. cleanVisible = visibleMarkerCarry + cleanVisible; visibleMarkerCarry = ""; // Once the end marker is visible, freeze the background result at that // exact boundary. A changed PowerShell prompt may not match // expectedPrompt, but it is session state rather than command output. const completedMarker = findEndMarker(cleanVisible, marker, { allowInline: true }); if (completedMarker) { cleanVisible = cleanVisible.slice(0, completedMarker.endIdx); } else { // Hold back the longest suffix that could still become this job's end // marker. This covers chunk splits anywhere in the random marker, // including before the constant "__NCMCP_" prefix is complete, while // allowing preceding command output to remain visible to pollers. const partialMarkerLength = trailingPrefixLength( cleanVisible, `${marker}_E:`, ); if (partialMarkerLength > 0) { visibleMarkerCarry = cleanVisible.slice(-partialMarkerLength); cleanVisible = cleanVisible.slice(0, -partialMarkerLength); } } // Strip only this job's specific marker lines so user output that // happens to contain "__NCMCP_" (e.g. printf '__NCMCP_demo\n') is // preserved. cleanVisible = stripJobMarkerLines(cleanVisible, marker); if (!cleanVisible) return; } visibleHighWatermark += cleanVisible.length; const next = appendBoundedOutput(visibleOutput, cleanVisible, captureLimitChars); visibleOutput = next.text; visibleOutputOffset += next.dropped; } function appendToOutput(text) { if (!text) return; const next = appendBoundedOutput(output, text, captureLimitChars); output = next.text; if (!pendingEnd) appendToVisible(text); } function finish(stdout, exitCode, error) { if (finished) return; finished = true; if (usesLiveShellProbe && !foundStart && typeof onProbeAborted === "function") { try { onProbeAborted(marker); } catch { // Display cleanup must never prevent command cancellation or completion. } } clearTimeout(timeoutId); clearTimeout(wallTimeoutId); clearStartupTimeout(); clearPromptFallback(); clearEndMarkerWait(); clearCancelRetryTimer(); // Clear any pending one-shot cancel timers so they do not keep the // Node event loop alive after the job has resolved. clearCancelOneShotTimers(); unsubscribe?.(); for (const fn of cleanupFns) { try { fn(); } catch { // Ignore cleanup failures } } if (trackForCancellation) { trackForCancellation.delete(marker); } // Flush any incomplete marker carry — if it wasn't this job's marker, append it. if (visibleMarkerCarry) { const leftover = stripJobMarkerLines(visibleMarkerCarry, marker); visibleMarkerCarry = ""; if (leftover) { const next = appendBoundedOutput(visibleOutput, leftover, captureLimitChars); visibleOutput = next.text; visibleOutputOffset += next.dropped; } } // For background jobs (maxBufferedChars > 0), use the already-stripped // visibleOutput so completion offsets are consistent with polling offsets. // Re-normalizing from the raw buffer would produce a shorter result because // ANSI codes inflate the raw buffer, causing it to truncate earlier. let cleaned; let outputBaseOffset; let totalOutputChars; if (maxBufferedChars > 0 && foundStart) { // Always strip this job's markers from the visible buffer — it accumulates // raw PTY data including the end-marker line that must not leak to callers. const strippedVisible = normalizePtyOutput(visibleOutput, { stripMarkers: true, markerToStrip: marker, expectedPrompt, trimOutput: normalizeFinalOutput, stripPrompt: true, }); cleaned = strippedVisible; outputBaseOffset = visibleOutputOffset; totalOutputChars = outputBaseOffset + visibleOutput.length; } else { const visibleStdout = normalizePtyOutput(stdout, { stripMarkers, markerToStrip: marker, expectedPrompt, trimOutput: false, stripPrompt: true, }); cleaned = normalizeFinalOutput ? normalizePtyOutput(stdout, { stripMarkers, markerToStrip: marker, expectedPrompt, trimOutput: true, stripPrompt: true, }) : visibleStdout; outputBaseOffset = foundStart ? visibleOutputOffset : 0; totalOutputChars = outputBaseOffset + visibleStdout.length; } const finalError = (!error && cancelRequested) ? "Cancelled" : error; const finalExitCode = finalError === "Cancelled" ? (exitCode ?? 130) : exitCode; if (finalError) { resolveResult({ ok: false, stdout: cleaned, stderr: "", exitCode: finalExitCode ?? -1, error: finalError, outputBaseOffset, totalOutputChars, outputTruncated: outputBaseOffset > 0, }); } else { resolveResult({ ok: exitCode === 0 || exitCode === null, stdout: cleaned, stderr: "", exitCode: finalExitCode ?? 0, outputBaseOffset, totalOutputChars, outputTruncated: outputBaseOffset > 0, }); } } function onData(data) { const bytes = Buffer.isBuffer(data) ? data : data instanceof Uint8Array ? Buffer.from(data) : Buffer.from(String(data ?? "")); const text = outputDecoder.write(bytes); if (!text) return; if (!pendingEnd && !deliveringInput) armOutputTimeout(); if (probingShell) { probeOutput = (probeOutput + text).slice(-16384); if (cancelRequested && hasExpectedPromptSuffix(probeOutput, expectedPrompt)) { finish("", -1, "Cancelled"); return; } const probe = parseLiveShellProbe(stripAnsi(probeOutput), marker); if (!probe) return; probingShell = false; probeOutput = ""; if (probe.kind) resolvedShellKind = probe.kind; if (finished || cancelRequested) return; writeWrappedCommand(); return; } if (!foundStart) { preStartOutput += text; const combined = pendingStart + text; pendingStart = ""; const startMarker = marker + "_S"; let matched = false; const lines = combined.split(/\r?\n/); const trailingPartial = /[\r\n]$/.test(combined) ? "" : lines.pop() || ""; for (const line of lines) { if (stripAnsi(line).trim() === startMarker) { foundStart = true; matched = true; break; } } pendingStart = trailingPartial; if (foundStart) { clearStartupTimeout(); // Use the *last* occurrence of the start marker to skip the echoed // wrapper command and capture only output after the real printf line. const markerPattern = new RegExp(`${marker}_S[^\n\r]*(?:\r?\n|$)`, "g"); let boundary = -1; let m; while ((m = markerPattern.exec(preStartOutput)) !== null) { boundary = m.index; } if (boundary !== -1) { const afterBoundary = preStartOutput.slice(boundary); const firstNl = afterBoundary.search(/\r?\n/); const initialOutput = firstNl === -1 ? "" : afterBoundary.slice(firstNl).replace(/^\r?\n/, ""); output = ""; visibleOutput = ""; visibleOutputOffset = 0; visibleCarry = ""; appendToOutput(initialOutput); } preStartOutput = ""; schedulePromptFallback(); checkEnd(); return; } if (!matched) { const fallbackEnd = findEndMarker(preStartOutput, marker); if (fallbackEnd) { let stdout = preStartOutput.slice(0, fallbackEnd.endIdx); const lastStartIdx = stdout.lastIndexOf(startMarker); if (lastStartIdx !== -1) { const nlAfterStart = stdout.indexOf("\n", lastStartIdx); if (nlAfterStart !== -1) { stdout = stdout.slice(nlAfterStart + 1); } } finish(stdout, fallbackEnd.exitCode); return; } } // A noisy PTY before the start marker must not grow without bound. Keep // enough tail for marker/prompt detection; the current chunk was already // scanned above so dropping its prefix cannot hide a completed marker. if (preStartOutput.length > captureLimitChars) { preStartOutput = preStartOutput.slice(-captureLimitChars); } const markerCarryLimit = marker.length + 256; if (pendingStart.length > markerCarryLimit) { pendingStart = pendingStart.slice(-markerCarryLimit); } // If we're cancelling a still-queued command and the shell has returned // to its idle prompt, finish immediately as Cancelled instead of waiting // for the cancel wall-clock timer. if (cancelRequested && hasExpectedPromptSuffix(preStartOutput, expectedPrompt)) { finish(preStartOutput, 130, "Cancelled"); return; } return; } appendToOutput(text); // Process a completed marker before cancellation/prompt handling so the // internal marker cannot leak when both arrive in the same PTY chunk. checkEnd(); if (finished) return; if (!cancelRequested) { schedulePromptFallback(); } else if (hasExpectedPromptSuffix(output, expectedPrompt)) { finish( pendingEnd?.stdout ?? output, pendingEnd?.exitCode ?? 130, "Cancelled", ); return; } } if (abortSignal?.aborted) { finish("", -1, "Cancelled"); return { marker, cancel: () => {}, getSnapshot: () => ({ stdout: "", status: "cancelled", foundStart: false }), resultPromise, }; } armOutputTimeout(); armWallTimeout(); unsubscribe = subscribeToPtyData(ptyStream, onData); const cancel = () => { requestCancel(); }; if (trackForCancellation) { trackForCancellation.set(marker, { ptyStream, chatSessionId: chatSessionId || null, cancel, cleanup: () => { clearTimeout(timeoutId); unsubscribe?.(); }, }); } if (typeof ptyStream.on === "function") { const onClose = () => { if (pendingEnd) { finish(pendingEnd.stdout, pendingEnd.exitCode, cancelRequested ? "Cancelled" : null); return; } finish(foundStart ? output : preStartOutput, null, cancelRequested ? "Cancelled" : "Stream closed unexpectedly"); }; const onError = (err) => { if (pendingEnd) { finish(pendingEnd.stdout, pendingEnd.exitCode, cancelRequested ? "Cancelled" : null); return; } finish(foundStart ? output : preStartOutput, -1, cancelRequested ? "Cancelled" : `Stream error: ${err?.message || err}`); }; ptyStream.on("close", onClose); ptyStream.on("end", onClose); ptyStream.on("error", onError); cleanupFns.push(() => { try { ptyStream.removeListener("close", onClose); } catch {} try { ptyStream.removeListener("end", onClose); } catch {} try { ptyStream.removeListener("error", onError); } catch {} }); } if (typeof ptyStream.onExit === "function") { const disposable = ptyStream.onExit(() => { if (pendingEnd) { finish(pendingEnd.stdout, pendingEnd.exitCode, cancelRequested ? "Cancelled" : null); return; } finish(foundStart ? output : preStartOutput, null, cancelRequested ? "Cancelled" : "Process exited"); }); cleanupFns.push(() => { try { disposable?.dispose?.(); } catch { // Ignore cleanup failures } }); } if (abortSignal) { const onAbort = () => { requestCancel(); }; abortSignal.addEventListener("abort", onAbort, { once: true }); cleanupFns.push(() => abortSignal.removeEventListener("abort", onAbort)); } if (typedInput && typeof echoCommand === "function") { try { echoCommand(command); } catch { // Ignore synthetic echo failures. } } let inputWriteTimer = null; let inputWriteGeneration = 0; function stopInputWrite() { inputWriteGeneration += 1; clearTimeout(inputWriteTimer); inputWriteTimer = null; } cleanupFns.push(stopInputWrite); function writeInput(text) { stopInputWrite(); // Each delivery gets a fresh wait budget, including the transition from // probe to wrapper. Echo may be disabled while we are still typing. clearStartupTimeout(); clearTimeout(timeoutId); deliveringInput = true; const generation = inputWriteGeneration; let offset = 0; // Readline may overrun its input queue when long probes arrive in one // write. Yield between bounded chunks; cancelled/replaced jobs must never // finish typing an old command into a subsequent prompt. const chunkSize = usesLiveShellProbe && text.length > 1024 ? 128 : text.length; const writeNext = () => { if (finished || cancelRequested || generation !== inputWriteGeneration) return; let end = Math.min(offset + chunkSize, text.length); // Do not split a UTF-16 surrogate pair across independently encoded writes. if (end < text.length && /[\uD800-\uDBFF]/.test(text[end - 1])) end -= 1; ptyStream.write(text.slice(offset, end)); offset = end; if (offset < text.length && generation === inputWriteGeneration && !finished && !cancelRequested) { inputWriteTimer = setTimeout(() => { try { writeNext(); } catch (error) { finish(preStartOutput, -1, `Terminal input failed: ${error.message}`); } }, 30); } else { // Input delivery is complete: only now does the startup deadline // begin, so paced typing time never consumes the startup budget. if (!finished && !cancelRequested && generation === inputWriteGeneration) { deliveringInput = false; armOutputTimeout(); if (!foundStart) armStartupTimeout(); } } }; writeNext(); } function writeWrappedCommand() { const wrapped = buildWrappedCommand(command, resolvedShellKind, marker, probeLiveShell); writeInput(`${buildPendingInputClearPrefix(resolvedShellKind)}${wrapped}`); } if (probingShell) { writeInput(`${buildPendingInputClearPrefix(resolvedShellKind)}${buildLiveShellProbe(marker)}`); } else { writeWrappedCommand(); } return { marker, cancel, // Until the start marker arrives, return empty stdout/zero offsets so // an early poll cannot advance nextOffset past pre-start PTY noise that // gets discarded once the real command begins. getSnapshot: () => ({ stdout: foundStart ? visibleOutput : "", outputBaseOffset: foundStart ? visibleOutputOffset : 0, totalOutputChars: foundStart ? visibleOutputOffset + visibleOutput.length : 0, outputTruncated: foundStart ? visibleOutputOffset > 0 : false, status: finished ? "finished" : (cancelRequested ? "stopping" : "running"), foundStart, }), resultPromise, }; } /** * Execute command through a terminal PTY stream. * The user sees the command typed and output in their terminal. * Uses a unique marker to detect when the command finishes and capture the exit code. * * @param {object} ptyStream - The PTY stream to write to * @param {string} command - The command to execute * @param {object} [options] * @param {boolean} [options.stripMarkers=false] - Strip leaked MCP markers from output * @param {Map} [options.trackForCancellation] - Map to register this execution in for cancellation * @param {number} [options.timeoutMs=60000] - Command timeout in milliseconds * @param {string} [options.chatSessionId] - Chat session ID for scoped cancellation * @param {AbortSignal} [options.abortSignal] - AbortSignal to cancel execution * @param {string} [options.expectedPrompt] - Last observed idle prompt for exact fallback matching * @param {boolean} [options.typedInput=false] - Emit synthetic command echo before execution * @param {(command: string) => void} [options.echoCommand] - Callback used to display synthetic command echo */ function execViaPty(ptyStream, command, options) { return startPtyJob(ptyStream, command, options).resultPromise; } /** * Fallback: execute via a separate SSH exec channel (invisible to terminal). * * @param {object} sshClient - SSH client with .exec() method * @param {string} command - The command to execute * @param {object} [options] * @param {number} [options.timeoutMs=60000] - Command timeout in milliseconds * @param {number} [options.maxOutputBytes=1048576] - Combined stdout/stderr hard limit */ function execViaChannel(sshClient, command, options) { const { timeoutMs = 60000, maxOutputBytes = 1024 * 1024, trackForCancellation = null, chatSessionId, } = options || {}; const outputLimitBytes = Number.isSafeInteger(maxOutputBytes) && maxOutputBytes > 0 ? maxOutputBytes : 1024 * 1024; return new Promise((resolve) => { // Register a *pending* cancellation marker synchronously, before // `sshClient.exec` opens the channel. Without this, a cancel that // arrives while we're still waiting on `sshClient.exec`'s callback // finds nothing in `activePtyExecs` to act on — the channel then // opens, the real marker registers, and the command runs to // completion despite the user already cancelling. The pending // marker latches `cancelled = true`; when the callback fires we // check the latch and short-circuit instead of starting work. const pendingMarker = `__NCMCP_CH_PENDING_${Date.now().toString(36)}_${crypto.randomBytes(8).toString('hex')}__`; let cancelled = false; let settled = false; let openingTimer = null; let activeMarker = null; const settle = (result) => { if (settled) return false; settled = true; clearTimeout(openingTimer); if (trackForCancellation) { trackForCancellation.delete(pendingMarker); if (activeMarker) trackForCancellation.delete(activeMarker); } resolve(result); return true; }; const cancelPending = () => { cancelled = true; settle({ ok: false, stdout: "", stderr: "", exitCode: -1, error: "Cancelled" }); invalidateSshTransport(sshClient); }; if (trackForCancellation) { trackForCancellation.set(pendingMarker, { chatSessionId: chatSessionId || null, cancel: cancelPending, cleanup: cancelPending, }); } openingTimer = setTimeout(() => { const timeoutSec = Math.round(timeoutMs / 1000); settle({ ok: false, stdout: "", stderr: "", exitCode: -1, error: `Command timed out (${timeoutSec}s) while opening SSH exec channel`, }); invalidateSshTransport(sshClient); }, timeoutMs); openingTimer.unref?.(); try { sshClient.exec(command, (err, execStream) => { if (trackForCancellation) { trackForCancellation.delete(pendingMarker); } clearTimeout(openingTimer); if (settled || cancelled) { if (execStream) { try { execStream.close(); } catch { /* ignore */ } } if (!settled) cancelPending(); return; } if (err) { settle({ ok: false, error: err.message }); return; } if (!execStream) { settle({ ok: false, error: 'Failed to create exec stream', exitCode: 1 }); return; } activeMarker = `__NCMCP_CH_${Date.now().toString(36)}_${crypto.randomBytes(16).toString('hex')}__`; let stdout = ""; let stderr = ""; let outputBytes = 0; const stdoutDecoder = new StringDecoder("utf8"); const stderrDecoder = new StringDecoder("utf8"); let finished = false; let timeoutId = null; let cleanupStreamListeners = () => {}; const finish = (result) => { if (finished) return; finished = true; clearTimeout(timeoutId); cleanupStreamListeners(); settle(result); }; const terminateExecStream = () => { try { execStream.close?.(); } catch { /* ignore */ } try { execStream.destroy?.(); } catch { /* ignore */ } }; const appendOutput = (target, data) => { if (finished) return; const chunk = Buffer.isBuffer(data) ? data : Buffer.from(String(data)); const remaining = Math.max(0, outputLimitBytes - outputBytes); if (chunk.length <= remaining) { if (target === "stdout") stdout += stdoutDecoder.write(chunk); else stderr += stderrDecoder.write(chunk); outputBytes += chunk.length; return; } if (remaining > 0) { const accepted = chunk.subarray(0, remaining); if (target === "stdout") stdout += stdoutDecoder.write(accepted); else stderr += stderrDecoder.write(accepted); outputBytes += remaining; } finish({ ok: false, stdout, stderr, exitCode: -1, error: `Command output exceeded the ${outputLimitBytes} byte limit`, }); terminateExecStream(); }; timeoutId = setTimeout(() => { const timeoutSec = Math.round(timeoutMs / 1000); finish({ ok: false, stdout, stderr, exitCode: -1, error: `Command timed out (${timeoutSec}s)` }); terminateExecStream(); }, timeoutMs); if (trackForCancellation) { trackForCancellation.set(activeMarker, { chatSessionId: chatSessionId || null, cancel: () => { finish({ ok: false, stdout, stderr, exitCode: -1, error: "Cancelled" }); terminateExecStream(); }, cleanup: () => { clearTimeout(timeoutId); terminateExecStream(); }, }); } const onStdoutData = (data) => appendOutput("stdout", data); const onStderrData = (data) => appendOutput("stderr", data); const onClose = (code) => { // code is null when SSH disconnects or process is signal-terminated if (code == null) { finish({ ok: false, stdout, stderr, exitCode: -1, error: "Command terminated unexpectedly (connection lost or signal)" }); } else { finish({ ok: code === 0, stdout, stderr, exitCode: code }); } }; const onError = (error) => { finish({ ok: false, stdout, stderr, exitCode: -1, error: error?.message || String(error || "SSH exec channel failed"), }); terminateExecStream(); }; cleanupStreamListeners = () => { execStream.removeListener?.("data", onStdoutData); execStream.stderr?.removeListener?.("data", onStderrData); execStream.stderr?.removeListener?.("error", onError); execStream.removeListener?.("close", onClose); execStream.removeListener?.("error", onError); }; execStream.on("data", onStdoutData); execStream.stderr?.on?.("data", onStderrData); execStream.stderr?.on?.("error", onError); execStream.on("close", onClose); execStream.on("error", onError); }); } catch (err) { // Rare path: `sshClient.exec` itself synchronously throws (e.g. // because the underlying ssh2 client was destroyed between the // session lookup and now). Drop the pending marker so it doesn't // leak in `activePtyExecs`, and resolve as a normal failure // result instead of letting the Promise reject — the tool layer // expects `{ ok, error }` shape, not a thrown error. settle({ ok: false, error: err?.message || String(err) }); } }); } /** * Execute command on a raw serial port (no shell wrapping). * * Used for network devices (Cisco IOS, Huawei VRP, etc.) and embedded systems * that do not run a standard POSIX/PowerShell/CMD shell. * * The command is sent as-is followed by CR. Completion is detected via idle * timeout (no new data for `idleMs` milliseconds). The idle timer does NOT * start until the first data chunk arrives, so slow devices won't time out * before producing any output. * * Exit code is always `null` because vendor CLIs do not expose exit codes. * * @param {object} serialPort - The SerialPort instance with .write() and .on("data") * @param {string} command - The raw command to send * @param {object} [options] * @param {number} [options.timeoutMs=60000] - Overall timeout * @param {number} [options.idleMs=3000] - Idle timeout to detect command completion * @param {Map} [options.trackForCancellation] - Map for cancellation tracking * @param {string} [options.chatSessionId] - Chat session ID for scoped cancellation * @param {AbortSignal} [options.abortSignal] - AbortSignal to cancel execution */ function execViaRawPty(serialPort, command, options) { const { timeoutMs = 60000, idleMs = 3000, trackForCancellation = null, chatSessionId, abortSignal, encoding = "utf8", // Callers should pass the session's resolved encoding } = options || {}; // Simple incrementing key for the cancellation map (no markers sent to device) const cancelKey = `__NCRAW_${Date.now().toString(36)}_${(++execViaRawPty._seq).toString(36)}`; if (abortSignal?.aborted) { return Promise.resolve({ ok: false, stdout: "", stderr: "", exitCode: null, error: "Cancelled" }); } return new Promise((resolve) => { let output = ""; let finished = false; let overallTimer = null; let idleTimer = null; const cleanupFns = []; const decoder = createStatefulDecoder(encoding); function safeWrite(data) { try { if (typeof serialPort.write === "function") serialPort.write(data); } catch { /* serial port may already be closed */ } } // finish signature differs from execViaPty intentionally: no exitCode param // because vendor CLIs have no exit code concept (always null). function finish(stdout, error) { if (finished) return; finished = true; clearTimeout(overallTimer); clearTimeout(idleTimer); for (const fn of cleanupFns) { try { fn(); } catch { /* ignore */ } } if (trackForCancellation) { trackForCancellation.delete(cancelKey); } // Flush any bytes the decoder is still holding (e.g. the leading // half of a multi-byte char that arrived right before finish). let tail = ""; try { tail = decoder.end() || ""; } catch { /* ignore */ } const complete = (stdout || "") + tail; let cleaned = stripAnsi(complete).replace(/\r/g, ""); // Strip echoed command from the beginning of output. // Network devices typically echo back the typed command on the first line, // often prefixed by the device prompt (e.g. "Router#show version"). // Only strip when the first line is a close match to avoid removing // legitimate output on devices that don't echo. const lines = cleaned.split("\n"); if (lines.length > 1) { const firstLine = lines[0].trim(); const cmdTrimmed = command.trim(); if (cmdTrimmed && (firstLine === cmdTrimmed || firstLine.endsWith(cmdTrimmed))) { lines.shift(); } } cleaned = lines.join("\n").trim(); if (error) { resolve({ ok: false, stdout: cleaned, stderr: "", exitCode: null, error }); } else { resolve({ ok: true, stdout: cleaned, stderr: "", exitCode: null }); } } // Track data chunks to distinguish echo phase from real output. // The first 1-2 chunks are typically the echoed command + prompt. // Use a longer idle timeout during this phase so that commands like // ping/traceroute/copy that stay quiet after the echo aren't truncated. let chunkCount = 0; const ECHO_PHASE_CHUNKS = 2; function resetIdleTimer() { clearTimeout(idleTimer); // During echo phase (first few chunks), use 2× idleMs to avoid // truncating commands that produce output after a delay. const effectiveIdle = chunkCount <= ECHO_PHASE_CHUNKS ? idleMs * 2 : idleMs; idleTimer = setTimeout(() => { finish(output, null); }, effectiveIdle); } let noResponseTimer = null; // Cap output to prevent unbounded accumulation on noisy serial consoles // (e.g. devices that continuously emit syslog/debug messages). Once the cap // is reached, stop resetting the idle timer so the function can resolve. const MAX_OUTPUT_BYTES = 512 * 1024; // 512 KB const onData = (data) => { // Encoding follows the session: utf8 for SSH PTY streams, whatever the // user picked for serial (utf-8/gb18030/...). The decoder is stateful // so multi-byte characters split across chunks get stitched back // together instead of emitting replacement bytes. const chunk = typeof data === "string" ? data : decoder.write(data); chunkCount++; // Cancel the no-response fallback on first data if (noResponseTimer) { clearTimeout(noResponseTimer); noResponseTimer = null; } if (output.length < MAX_OUTPUT_BYTES) { output += chunk; // Only reset idle timer while accumulating — once capped, let it fire // so noisy sessions don't hang until the overall timeout. resetIdleTimer(); } }; // Subscribe to serial port data if (typeof serialPort.on === "function") { serialPort.on("data", onData); cleanupFns.push(() => { try { serialPort.removeListener("data", onData); } catch { /* ignore */ } }); // Error / close detection const onError = (err) => finish(output, `Serial port error: ${err?.message || err}`); const onClose = () => finish(output, "Serial port closed unexpectedly"); serialPort.on("error", onError); serialPort.on("close", onClose); cleanupFns.push(() => { try { serialPort.removeListener("error", onError); } catch { /* */ } try { serialPort.removeListener("close", onClose); } catch { /* */ } }); } // Overall timeout overallTimer = setTimeout(() => { safeWrite("\x03"); const timeoutSec = Math.round(timeoutMs / 1000); finish(output, `Command timed out (${timeoutSec}s)`); }, timeoutMs); // Cancellation tracking if (trackForCancellation) { trackForCancellation.set(cancelKey, { chatSessionId: chatSessionId || null, cancel: () => { safeWrite("\x03"); finish(output, "Cancelled"); }, cleanup: () => { clearTimeout(overallTimer); clearTimeout(idleTimer); }, }); } // AbortSignal handling if (abortSignal) { const onAbort = () => { safeWrite("\x03"); finish(output, "Cancelled"); }; abortSignal.addEventListener("abort", onAbort, { once: true }); cleanupFns.push(() => abortSignal.removeEventListener("abort", onAbort)); } // Send the raw command followed by CR (network devices expect \r). safeWrite(command + "\r"); // Start a "no-response" fallback timer. If the device produces no output at // all (e.g. silent mode-changing commands like "enable", "configure terminal", // or devices with echo disabled), the idle timer never starts because onData // never fires. This fallback resolves successfully to avoid waiting for the // full overall timeout. Uses min(idleMs * 4, timeoutMs / 4) to balance between // not waiting too long for silent commands and not truncating slow operations. // Cleared on first data in onData. const noResponseMs = Math.min(idleMs * 4, Math.floor(timeoutMs / 4)); noResponseTimer = setTimeout(() => { // Resolve with ok:true but include a hint that no output was received, // so the AI knows the command may still be running or produced no output. finish(output || "(no output received — command may have completed silently or may still be running)", null); }, noResponseMs); cleanupFns.push(() => clearTimeout(noResponseTimer)); }); } execViaRawPty._seq = 0; module.exports = { DEFAULT_FOREGROUND_PTY_CAPTURE_CHARS, execViaPty, startPtyJob, execViaChannel, execViaRawPty, detectShellKind, resolveEffectiveShellKind, stripAnsi, };