Files
NetMesh/electron/bridges/ai/ptyExecHelpers.cjs

511 lines
20 KiB
JavaScript
Raw Permalink Normal View History

"use strict";
const { StringDecoder } = require("node:string_decoder");
const iconv = require("iconv-lite");
const {
stripAnsi,
isDefaultPowerShellPromptLine,
isDefaultCmdPromptLine,
isDefaultPosixPromptLine,
} = require("./shellUtils.cjs");
const { classifyLocalShellType } = require("../../../lib/localShell.cjs");
// Build a stateful decoder for a full exec call. Serial data events can
// split multi-byte characters across chunks (very common on GBK/GB18030
// consoles), and a stateless iconv.decode per chunk would emit
// replacement bytes for the leading half. StringDecoder and
// iconv.getDecoder both preserve partial-byte state across write() calls
// and flush any trailing bytes on end(), which is what we need.
function createStatefulDecoder(encoding) {
const enc = encoding || "utf8";
if (Buffer.isEncoding(enc)) {
return new StringDecoder(enc);
}
try {
return iconv.getDecoder(enc);
} catch {
return new StringDecoder("utf8");
}
}
function detectShellKind(shellPath, platform = process.platform) {
return classifyLocalShellType(shellPath, platform);
}
function subscribeToPtyData(ptyStream, onData) {
if (typeof ptyStream?.onData === "function") {
const disposable = ptyStream.onData((data) => onData(data));
return () => {
try {
disposable?.dispose?.();
} catch {
// Ignore cleanup failures
}
};
}
if (typeof ptyStream?.on === "function" && typeof ptyStream?.removeListener === "function") {
ptyStream.on("data", onData);
return () => {
try {
ptyStream.removeListener("data", onData);
} catch {
// Ignore cleanup failures
}
};
}
throw new Error("PTY stream does not support data subscriptions");
}
function hasExpectedPromptSuffix(text, expectedPrompt) {
if (!expectedPrompt) return false;
const normalizedText = stripAnsi(String(text || "")).replace(/\r/g, "");
const normalizedPrompt = stripAnsi(String(expectedPrompt || "")).replace(/\r/g, "");
return !!normalizedPrompt && normalizedText.endsWith(normalizedPrompt);
}
function escapePosixSingleQuoted(text) {
return String(text || "").replace(/'/g, "'\\''");
}
function escapePowerShellSingleQuoted(text) {
return String(text || "").replace(/'/g, "''");
}
function escapeFishSingleQuoted(text) {
return String(text || "").replace(/\\/g, "\\\\").replace(/'/g, "\\'");
}
function escapeCmdForNestedShell(text) {
return String(text || "").replace(/"/g, '""').replace(/%/g, "%%");
}
// Matches PowerShell's default prompt only (e.g. `PS C:\Users\alice>`,
// `PS>`). Custom prompt functions (oh-my-posh, starship, PSReadLine themes
// that emit ``/`λ`/etc.) intentionally fall through — we'd rather miss
// the override than wrap a fish/zsh prompt as PowerShell. Pattern lives
// in shellUtils.cjs so prompt extraction and wrapper selection share one
// source of truth.
function isPowerShellPrompt(prompt) {
// Treat `\r` as a line break too so a PSReadLine/ConPTY redraw like
// `PS C:\old>\rPS C:\new>` is matched against the redrawn last line,
// not the doubled string.
const lastLine = stripAnsi(String(prompt || ""))
.replace(/\r/g, "\n")
.split("\n")
.pop()
.replace(/\s+$/, "");
return isDefaultPowerShellPromptLine(lastLine);
}
function isCmdPrompt(prompt) {
const lastLine = stripAnsi(String(prompt || ""))
.replace(/\r/g, "\n")
.split("\n")
.pop()
.replace(/\s+$/, "");
return isDefaultCmdPromptLine(lastLine);
}
function isPosixPrompt(prompt) {
const lastLine = stripAnsi(String(prompt || ""))
.replace(/\r/g, "\n")
.split("\n")
.pop()
.replace(/\s+$/, "");
return isDefaultPosixPromptLine(lastLine);
}
// Prompt-driven override is intentionally narrow: only flip to PowerShell
// when the session has no confirmed shell type. This keeps the issue #841
// fix working for remote Windows shells that never set shellKind at connect
// time, while preventing a malicious remote process from spoofing a
// `PS ...>` line on a real bash/zsh/fish/cmd session to coerce a single
// mis-wrapped command.
//
// Remote login-shell probing stores a *soft* hint (`loginShellHint` /
// session._loginShellKind) without pinning session.shellKind:
// - hint "fish" → fish wrapper (issue #1854) without permanent pin
// - hint "posix" → native posix wrapper evaluated by interactive bash/zsh
// (NOT sh -c / dash — Codex P2 on #2061)
// - hint "powershell" / "cmd" → Windows DefaultShell (issue #2959) without
// permanent pin, so a live opposing PS/cmd prompt can still win, and a
// live `user@host:...$` POSIX prompt (e.g. WSL nesting) can override too
// - live PS ...> still overrides when base kind is open
// - live C:\...> selects cmd when base kind is open (Windows OpenSSH default)
//
// Universe of shellKind values (see lib/localShell.cjs:23-33 and
// terminalBridge.cjs:368, :932, :1074):
// "posix" | "powershell" | "cmd" | "fish" | "unknown" | "raw" | "" | undefined
// Excluded on purpose from prompt override:
// - "posix" / "fish" / "cmd" / "powershell": confirmed local/spawn kinds —
// never override (anti-spoof for #841).
// - "raw": serial / network device — execViaRawPty bypasses buildWrappedCommand.
const SHELL_KINDS_OPEN_TO_PROMPT_OVERRIDE = new Set([
"",
"unknown",
]);
const LOGIN_SHELL_HINTS = new Set(["posix", "fish", "powershell", "cmd"]);
function resolveEffectiveShellKind(shellKind, expectedPrompt, options = {}) {
const baseKind = shellKind || "";
const hint = options.loginShellHint || "";
if (SHELL_KINDS_OPEN_TO_PROMPT_OVERRIDE.has(baseKind)) {
if (isPowerShellPrompt(expectedPrompt)) {
return "powershell";
}
if (isCmdPrompt(expectedPrompt)) {
return "cmd";
}
// Windows OpenSSH DefaultShell soft hint + nested WSL/bash: live
// `user@host:...$` must win so AI does not type a PS/cmd wrapper into
// a POSIX shell and hang on markers. Fish/posix soft hints stay put —
// those login shells already share this prompt family.
if (
isPosixPrompt(expectedPrompt)
&& (hint === "powershell" || hint === "cmd")
) {
return "posix";
}
}
if (baseKind) return baseKind;
// Soft login-shell hint from remote probe (not a permanent pin).
if (LOGIN_SHELL_HINTS.has(hint)) return hint;
return "posix";
}
// Discard unfinished prompt-line input before the agent wrapper so typed-but-
// not-entered text is not concatenated onto the injected command (#2962).
// Raw/serial devices have no portable line-kill binding; leave them alone.
function buildPendingInputClearPrefix(shellKind) {
switch (shellKind) {
case "raw":
return "";
case "cmd":
return "\x1b";
case "powershell":
// Vi gg plus a counted dd removes the whole multiline buffer, including
// in PSReadLine 2.0 where dG is unavailable. Escape+r is Emacs
// RevertLine. Repeated Escape clears Windows mode, and the final
// i+Backspace leaves every mode on an empty editable line.
return "\x1bggd2147483647d\x1br\x1b\x1bi\x08";
default:
// Kill the suffix before the prefix. Canonical/no-editing terminals do
// not bind Ctrl+K; the trailing Ctrl+U must erase that literal byte too.
return "\x0b\x15";
}
}
function bashHistoryScratchNames(marker) {
const suffix = String(marker || "").toLowerCase().replace(/[^a-z0-9]/g, "").slice(-12) || "dflt";
return { entry: `__nc_h_${suffix}`, dispatcher: `__nc_d_${suffix}` };
}
function buildBashHistoryCleanup(marker, keepDispatcher = false) {
// Expanded dispatcher names bypass aliases. Verify that a dispatcher really
// executes builtins before trusting an empty history read from a no-op function.
// After deletion, verify the entry is gone: a shadowed history function may
// delete only in a subshell. Stop as soon as the real dispatcher succeeds.
// Invocation-specific scratch names avoid readonly user variables. Clear the
// history-bearing scratch through the verified dispatcher, never plain unset.
const { entry, dispatcher } = bashHistoryScratchNames(marker);
const unsetNames = keepDispatcher ? entry : `${entry} ${dispatcher}`;
return `[ "\${BASH_VERSION-}" ]&&{ for ${dispatcher} in command builtin;do ${entry}=$($${dispatcher} printf x);[ "$${entry}" = x ]||continue;${entry}=$($${dispatcher} history 1);case "$${entry}" in *${marker}*) ${entry}=\${${entry}#"\${${entry}%%[^[:space:]]*}"};$${dispatcher} history -d "\${${entry}%%[[:space:]]*}";${entry}=$($${dispatcher} history 1);case "$${entry}" in *${marker}*) continue;;esac;;esac;$${dispatcher} unset ${unsetNames};break;done; } 2>/dev/null`;
}
function buildPosixWrapperBody(command, marker, startFormat) {
const noPager = "PAGER=cat SYSTEMD_PAGER= GIT_PAGER=cat LESS= ";
const commandLines = String(command || "").replace(/\r\n?/g, "\n").split("\n");
let cmdAssign = commandLines.length > 1
? `${marker}_cmd=$(printf '%s\\n' ${commandLines.map((line) => `'${escapePosixSingleQuoted(line)}'`).join(" ")})`
: `${marker}_cmd='${escapePosixSingleQuoted(command)}'`;
if (Buffer.byteLength(cmdAssign, 'utf8') > 650) {
// Canonical PTYs limit bytes per physical input line, regardless of write
// pacing. Emit bounded quoted pieces inside one command substitution; each
// continuation keeps the marker visible to the terminal echo filter.
const writes = [];
for (const [index, line] of commandLines.entries()) {
let chunk = '';
let bytes = 0;
for (const character of line) {
const quoted = escapePosixSingleQuoted(character);
const size = Buffer.byteLength(quoted, 'utf8');
if (bytes + size > 512) {
writes.push(`printf '%s' '${chunk}'`);
chunk = '';
bytes = 0;
}
chunk += quoted;
bytes += size;
}
writes.push(`printf '${index < commandLines.length - 1 ? '%s\\n' : '%s'}' '${chunk}'`);
}
cmdAssign = `${marker}_cmd=$(${writes.join(`; \\\n: '${marker}'; `)})`;
}
const historyCleanup = buildBashHistoryCleanup(marker);
const prefix = `${marker}=0; ${cmdAssign}; { printf '${startFormat}' '${marker}_S'; trap ':' INT; ( ${noPager}eval "$${marker}_cmd" ); __NCMCP_rc=$?; trap - INT; printf '%s\\n' '${marker}_E:'\"$__NCMCP_rc\"`;
const suffix = `${historyCleanup}; (exit $__NCMCP_rc); }`;
const separator = prefix.length + suffix.length + 2 > 1000
? `; \\\n: '${marker}'; ` : "; ";
return `${prefix}${separator}${suffix}`;
}
function buildWrappedCommand(command, shellKind, marker, separateStartMarker = false) {
// A live probe leaves its completion marker unterminated to hide the next
// prompt. With terminal echo disabled, only the wrapper can end that line.
const startFormat = separateStartMarker ? "\\n%s\\n" : "%s\\n";
switch (shellKind) {
case "powershell": {
const psPager = "$env:PAGER='cat'; $env:SYSTEMD_PAGER=''; $env:GIT_PAGER='cat'; $env:LESS=''; ";
const psEscaped = escapePowerShellSingleQuoted(command);
return (
`$${marker}=0; $${marker}_cmd='${psEscaped}'; & { Write-Output '${marker}_S'; ${psPager}$LASTEXITCODE=$null; try { Invoke-Expression $${marker}_cmd; $${marker}_rc = if ($LASTEXITCODE -ne $null) { $LASTEXITCODE } elseif ($?) { 0 } else { 1 } } catch { $${marker}_rc = 1 }; Write-Output "${marker}_E:$${marker}_rc" }\r\n`
);
}
case "cmd": {
const cmdEscaped = escapeCmdForNestedShell(command);
return (
`set "${marker}=0" & set "${marker}_CMD=${cmdEscaped}" & (echo ${marker}_S & set "PAGER=cat" & set "SYSTEMD_PAGER=" & set "GIT_PAGER=cat" & set "LESS=" & call cmd /d /s /c "%${marker}_CMD%" & call echo ${marker}_E:^%errorlevel^%)\r\n`
);
}
case "fish":
// Leading space: see the comment in the POSIX branch below. Fish
// does not skip leading-space commands by default, but users can
// define a `fish_should_add_to_history` function that filters them
// — this prefix is what lets that opt-in actually take effect.
return (
` set ${marker} 0; function __ncmcp_int --on-signal INT; printf '%s\\n' '${marker}_E:130'; functions -e __ncmcp_int; end; ` +
`set -l ${marker}_cmd '${escapeFishSingleQuoted(command)}'; ` +
`begin; set -gx PAGER cat; set -gx SYSTEMD_PAGER ''; set -gx GIT_PAGER cat; set -gx LESS ''; ` +
`printf '${startFormat}' '${marker}_S'; eval \$${marker}_cmd; set __NCMCP_rc $status; ` +
`functions -e __ncmcp_int; printf '%s\\n' '${marker}_E:'\$__NCMCP_rc; end\n`
);
case "posix":
default: {
// Compound command with an early marker on each physical line.
//
// Layout: __NCMCP_xxx=0; { ... MARKER_S; eval command; MARKER_E; }
//
// Key design decisions:
//
// 1) __NCMCP_xxx=0 at the VERY START ensures the PTY echo line
// contains __NCMCP_ in its first few bytes. This is critical:
// preload.cjs filters chunks by buffering incomplete lines that
// contain __NCMCP_. Without this prefix, the first chunk of a
// long echo line might not contain the marker and would leak
// through to the terminal as garbage.
//
// 2) The user command is executed via eval on a quoted string. This
// keeps shell syntax errors inside the eval call so the wrapper
// can still emit the end marker and return a non-zero exit code.
//
// 3) The complete { ... } group is parsed before execution, so SIGINT
// cannot cause bash to flush the end marker from the input buffer.
// trap ':' INT lets child processes receive SIGINT normally while
// preventing the shell from aborting the compound command.
//
// 4) The eval runs inside a subshell ( ... ) so shell-terminating
// constructs in the generated command — set -e / set -o errexit
// followed by a failure, exit, shell option changes, traps,
// function/alias definitions — end or mutate only the subshell,
// never the user's active login shell (issue #1850). set -e still
// behaves normally *inside* the command, and the subshell shares
// the PTY so the user sees all output live. The intentional
// trade-off is that cd/export no longer persist into the user's
// shell or across agent commands; the terminal.execute tool
// description tells the model to combine cd with its command.
// Earlier attempts (PRs #1852/#1882) that instead tried to detect
// dangerous commands grew into shell parsing and were abandoned —
// do not reintroduce detection here.
//
// Leading single space: lets bash/zsh skip recording this command
// in history when the user already has HISTCONTROL=ignorespace
// (bash) or HIST_IGNORE_SPACE (zsh) configured — Debian/Ubuntu and
// most Oh-My-Zsh setups have this on by default; CentOS/RHEL users
// can opt in by adding `HISTCONTROL=ignoreboth` to ~/.bashrc.
// Without that config the prefix is harmless; it just doesn't
// suppress history recording.
return ` ${buildPosixWrapperBody(command, marker, startFormat)}\n`;
}
}
}
function findEndMarker(outputText, marker, { allowInline = false } = {}) {
const endPattern = marker + "_E:";
let searchFrom = 0;
while (searchFrom < outputText.length) {
const endIdx = outputText.indexOf(endPattern, searchFrom);
if (endIdx === -1) return null;
// Before the start marker is confirmed, require a line boundary so the
// echoed wrapper command cannot be mistaken for real completion. Once the
// command has started, the random marker can safely follow output that did
// not end with a newline.
if (allowInline || endIdx === 0 || outputText[endIdx - 1] === "\n" || outputText[endIdx - 1] === "\r") {
const afterEnd = outputText.slice(endIdx + endPattern.length);
const codeMatch = afterEnd.match(/^(\d+)/);
const exitCode = codeMatch ? parseInt(codeMatch[1], 10) : null;
if (exitCode !== null) {
return { endIdx, exitCode };
}
}
searchFrom = endIdx + 1;
}
return null;
}
function normalizePtyOutput(stdout, {
stripMarkers = false,
expectedPrompt = "",
trimOutput = true,
stripPrompt = true,
markerToStrip = null,
} = {}) {
let cleaned = stripAnsi(stdout || "").replace(/\r/g, "");
if (stripMarkers) {
// Prefer the job-specific marker so user output that contains "__NCMCP_"
// (e.g. printf '__NCMCP_demo\n') is preserved.
const pattern = markerToStrip
? new RegExp(`^[^\r\n]*${markerToStrip}[^\r\n]*[\r\n]*`, "gm")
: /^[^\r\n]*__NCMCP_[^\r\n]*[\r\n]*/gm;
cleaned = cleaned.replace(pattern, "");
}
const normalizedPrompt = stripAnsi(String(expectedPrompt || "")).replace(/\r/g, "");
if (stripPrompt && normalizedPrompt && cleaned.endsWith(normalizedPrompt)) {
cleaned = cleaned.slice(0, cleaned.length - normalizedPrompt.length);
}
return trimOutput ? cleaned.trim() : cleaned;
}
function appendBoundedOutput(current, chunk, maxBufferedChars) {
const limit = Number.isFinite(maxBufferedChars) ? Math.max(0, Math.floor(maxBufferedChars)) : 0;
const currentText = String(current || "");
const chunkText = String(chunk || "");
if (limit > 0 && chunkText.length >= limit) {
return {
text: chunkText.slice(-limit),
dropped: currentText.length + chunkText.length - limit,
};
}
const combined = `${currentText}${chunkText}`;
if (limit <= 0 || combined.length <= limit) {
return { text: combined, dropped: 0 };
}
const dropped = combined.length - limit;
return {
text: combined.slice(dropped),
dropped,
};
}
function consumeVisibleText(carry, chunk) {
const input = `${carry || ""}${chunk || ""}`;
if (!input) {
return { visibleText: "", carry: "" };
}
let visibleText = "";
let index = 0;
while (index < input.length) {
const ch = input[index];
if (ch === "\r") {
// Preserve \r so consumers / serializers can collapse progress-bar
// redraws to the latest frame. \r\n becomes a single \n.
if (input[index + 1] === "\n") {
visibleText += "\n";
index += 2;
continue;
}
visibleText += "\r";
index += 1;
continue;
}
if (ch !== "\u001b") {
visibleText += ch;
index += 1;
continue;
}
if (index + 1 >= input.length) {
break;
}
const next = input[index + 1];
if (next === "[") {
let cursor = index + 2;
let complete = false;
while (cursor < input.length) {
const code = input.charCodeAt(cursor);
if (code >= 0x40 && code <= 0x7e) {
index = cursor + 1;
complete = true;
break;
}
cursor += 1;
}
if (!complete) break;
continue;
}
if (next === "]") {
let cursor = index + 2;
let complete = false;
while (cursor < input.length) {
const oscChar = input[cursor];
if (oscChar === "\u0007") {
index = cursor + 1;
complete = true;
break;
}
if (oscChar === "\u001b") {
if (cursor + 1 >= input.length) break;
if (input[cursor + 1] === "\\") {
index = cursor + 2;
complete = true;
break;
}
}
cursor += 1;
}
if (!complete) break;
continue;
}
visibleText += ch;
index += 1;
}
return {
visibleText,
carry: input.slice(index),
};
}
module.exports = {
createStatefulDecoder,
detectShellKind,
subscribeToPtyData,
hasExpectedPromptSuffix,
resolveEffectiveShellKind,
buildPendingInputClearPrefix,
buildWrappedCommand,
buildBashHistoryCleanup,
bashHistoryScratchNames,
findEndMarker,
normalizePtyOutput,
appendBoundedOutput,
consumeVisibleText,
stripAnsi,
};