Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
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,
|
||
};
|