1252 lines
45 KiB
JavaScript
1252 lines
45 KiB
JavaScript
|
|
/**
|
|||
|
|
* 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,
|
|||
|
|
};
|