Files
NetMesh/electron/bridges/ai/ptyExec.cjs
zhaolei 3c72efcb7f
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
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

1252 lines
45 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,
};