Files
NetMesh/electron/bridges/ai/ptyExec.test.cjs

1379 lines
49 KiB
JavaScript
Raw Normal View History

const test = require("node:test");
const assert = require("node:assert/strict");
const { spawnSync } = require("node:child_process");
const { EventEmitter } = require("node:events");
const { mkdtempSync, rmSync, realpathSync } = require("node:fs");
const { tmpdir } = require("node:os");
const { join } = require("node:path");
const {
execViaPty,
startPtyJob,
DEFAULT_FOREGROUND_PTY_CAPTURE_CHARS,
resolveEffectiveShellKind,
execViaChannel,
execViaRawPty,
} = require("./ptyExec.cjs");
const {
buildPendingInputClearPrefix,
buildWrappedCommand,
} = require("./ptyExecHelpers.cjs");
const {
getFreshIdlePrompt,
trackSessionIdlePrompt,
} = require("./shellUtils.cjs");
class ShellBackedPty extends EventEmitter {
write(data) {
if (data === "\x03") return;
const script = String(data).replace(/^\x0b\x15/, "");
const result = spawnSync("sh", ["-c", script], { encoding: "utf8" });
queueMicrotask(() => {
this.emit("data", Buffer.from(result.stdout));
});
}
}
function markerFromWrite(data) {
return String(data).match(/(__NCMCP_[a-z0-9]+_[0-9a-f]+__)/i)?.[1] || null;
}
test("execViaPty completes when command output has no trailing newline", async () => {
const result = await execViaPty(new ShellBackedPty(), "printf 'abc'", {
shellKind: "posix",
timeoutMs: 1000,
});
assert.equal(result.ok, true);
assert.equal(result.stdout, "abc");
assert.equal(result.exitCode, 0);
});
test("foreground PTY capture bounds a single 20 MiB output chunk and keeps its tail", async () => {
class LargeOutputPty extends EventEmitter {
write(data) {
const marker = markerFromWrite(data);
if (!marker) return;
queueMicrotask(() => {
this.emit("data", Buffer.from(
`${marker}_S\n${"x".repeat(20 * 1024 * 1024)}TAIL\n${marker}_E:0\n`,
));
});
}
}
const result = await execViaPty(new LargeOutputPty(), "large-output", {
shellKind: "posix",
timeoutMs: 1_000,
});
assert.equal(result.ok, true);
assert.ok(result.stdout.length <= DEFAULT_FOREGROUND_PTY_CAPTURE_CHARS);
assert.match(result.stdout, /TAIL$/u);
assert.equal(result.outputTruncated, true);
assert.ok(result.outputBaseOffset > 0);
});
test("foreground PTY capture preserves UTF-8 and markers split across chunks", async () => {
class SplitOutputPty extends EventEmitter {
write(data) {
const marker = markerFromWrite(data);
if (!marker) return;
queueMicrotask(() => {
const start = Buffer.from(`${marker}_S\n`);
const content = Buffer.from("中文回夝", "utf8");
const end = Buffer.from(`\n${marker}_E:0\n`);
this.emit("data", start.subarray(0, 7));
this.emit("data", start.subarray(7));
this.emit("data", content.subarray(0, 2));
this.emit("data", content.subarray(2));
this.emit("data", end.subarray(0, 9));
this.emit("data", end.subarray(9));
});
}
}
const result = await execViaPty(new SplitOutputPty(), "split-output", {
shellKind: "posix",
timeoutMs: 1_000,
});
assert.equal(result.ok, true);
assert.equal(result.stdout, "中文回夝");
});
test("foreground PTY timeout returns only a bounded tail", async () => {
class TimedOutPty extends EventEmitter {
signal() {}
write(data) {
const marker = markerFromWrite(data);
if (!marker || this.started) return;
this.started = true;
queueMicrotask(() => {
this.emit("data", Buffer.from(`${marker}_S\n${"y".repeat(20 * 1024 * 1024)}`));
});
}
}
const result = await execViaPty(new TimedOutPty(), "timeout-output", {
shellKind: "posix",
timeoutMs: 5,
enforceWallTimeout: true,
});
assert.equal(result.ok, false);
assert.match(result.error, /timed out/i);
assert.ok(result.stdout.length <= DEFAULT_FOREGROUND_PTY_CAPTURE_CHARS);
assert.equal(result.outputTruncated, true);
});
test("foreground PTY cancellation returns only a bounded tail", async () => {
class CancelledPty extends EventEmitter {
signal() {}
write(data) {
const marker = markerFromWrite(data);
if (!marker || this.started) return;
this.started = true;
queueMicrotask(() => {
this.emit("data", Buffer.from(`${marker}_S\n${"z".repeat(20 * 1024 * 1024)}`));
});
}
}
const pty = new CancelledPty();
const job = startPtyJob(pty, "cancel-output", {
shellKind: "posix",
timeoutMs: 1_000,
expectedPrompt: "$ ",
});
await new Promise((resolve) => setImmediate(resolve));
job.cancel();
pty.emit("data", Buffer.from("$ "));
const result = await job.resultPromise;
assert.equal(result.ok, false);
assert.match(result.error, /cancelled/i);
assert.ok(result.stdout.length <= DEFAULT_FOREGROUND_PTY_CAPTURE_CHARS);
assert.equal(result.outputTruncated, true);
});
test("background PTY jobs preserve output that has no trailing newline", async () => {
const job = startPtyJob(new ShellBackedPty(), "printf 'abc'", {
shellKind: "posix",
timeoutMs: 1000,
maxBufferedChars: 1024,
});
const result = await job.resultPromise;
assert.equal(result.ok, true);
assert.equal(result.stdout, "abc");
assert.equal(result.exitCode, 0);
});
test("background PowerShell jobs exclude a changed prompt from results and snapshots", async () => {
class CapturePty extends EventEmitter {
write() {}
}
const pty = new CapturePty();
const job = startPtyJob(pty, "Set-Location C:\\tmp; Write-Output 'DONE'", {
shellKind: "unknown",
loginShellHint: "cmd",
timeoutMs: 1000,
expectedPrompt: "PS C:\\Users\\alice>",
maxBufferedChars: 1024,
normalizeFinalOutput: false,
});
const endMarker = `${job.marker}_E:0`;
pty.emit("data", Buffer.from(`${job.marker}_S\r\nDONE\r\n${endMarker.slice(0, 4)}`));
const partialSnapshot = job.getSnapshot();
assert.equal(partialSnapshot.stdout, "DONE\n");
assert.equal(partialSnapshot.totalOutputChars, "DONE\n".length);
assert.doesNotMatch(partialSnapshot.stdout, /__NC/);
pty.emit("data", Buffer.from(`${endMarker.slice(4)}\r\nPS C:\\tmp>`));
const result = await job.resultPromise;
const snapshot = job.getSnapshot();
assert.equal(result.ok, true);
assert.equal(result.stdout, "DONE\n");
assert.equal(snapshot.stdout, "DONE\n");
assert.ok(snapshot.totalOutputChars >= partialSnapshot.totalOutputChars);
assert.doesNotMatch(result.stdout, /PS C:\\\\tmp>/);
assert.doesNotMatch(snapshot.stdout, /PS C:\\\\tmp>/);
});
test("uses PowerShell wrapping when a session with no confirmed shell sees a PowerShell prompt", () => {
// SSH sessions don't set shellKind (sshBridge never assigns one), which
// is exactly the issue #841 case the override targets.
assert.equal(
resolveEffectiveShellKind(undefined, "PS C:\\Users\\alice>"),
"powershell",
);
});
test("uses cmd wrapping when a session with no confirmed shell sees a cmd.exe prompt", () => {
// Windows OpenSSH defaults to cmd.exe; without this override AI types a
// posix wrapper into cmd and hangs until Stop (issue #2959).
assert.equal(
resolveEffectiveShellKind(undefined, "C:\\Users\\alice>"),
"cmd",
);
assert.equal(resolveEffectiveShellKind("unknown", "C:\\>"), "cmd");
});
test("uses PowerShell wrapping when shellKind is 'unknown'", () => {
assert.equal(
resolveEffectiveShellKind("unknown", "PS C:\\Users\\alice>"),
"powershell",
);
});
test("does NOT override an explicit non-PowerShell shell kind even if the prompt looks like PowerShell", () => {
// Defends against a malicious remote process spoofing a `PS ...>` line
// on a real bash/zsh/cmd/fish/raw session to coerce a single
// mis-wrapped command.
assert.equal(
resolveEffectiveShellKind("posix", "PS C:\\Users\\alice>"),
"posix",
);
assert.equal(
resolveEffectiveShellKind("fish", "PS C:\\Users\\alice>"),
"fish",
);
assert.equal(
resolveEffectiveShellKind("cmd", "PS C:\\Users\\alice>"),
"cmd",
);
assert.equal(
resolveEffectiveShellKind("raw", "PS C:\\Users\\alice>"),
"raw",
);
});
test("keeps powershell wrapping for an explicit powershell session even when nested into a non-PS shell", () => {
// After `wsl` or similar, a confirmed PowerShell session may show a
// posix prompt. We currently keep PowerShell wrapping (the user's
// configured shell is the source of truth). Reverse detection would
// be a separate feature; this test locks the current behavior so a
// future change is intentional.
assert.equal(
resolveEffectiveShellKind("powershell", "alice@host:~$"),
"powershell",
);
assert.equal(
resolveEffectiveShellKind("powershell", ""),
"powershell",
);
});
test("recognizes a PowerShell prompt that has trailing whitespace", () => {
assert.equal(
resolveEffectiveShellKind(undefined, "PS C:\\Users\\alice> "),
"powershell",
);
});
test("recognizes a bare PowerShell prompt without a working directory", () => {
assert.equal(resolveEffectiveShellKind(undefined, "PS>"), "powershell");
});
test("recognizes PowerShell on Linux/macOS prompts (`PS /home/alice>`)", () => {
assert.equal(
resolveEffectiveShellKind(undefined, "PS /home/alice>"),
"powershell",
);
});
test("ignores ANSI-coloured PowerShell prompts when detecting the shell", () => {
assert.equal(
resolveEffectiveShellKind(undefined, "PS C:\\Users\\alice>"),
"powershell",
);
});
test("treats a CR-redrawn last line as the effective prompt, not the doubled string", () => {
// PSReadLine / ConPTY emit `\r` to repaint the current line. Without
// CR-as-newline normalization the regex would match a doubled prompt
// string that never round-trips through the live PTY tail.
assert.equal(
resolveEffectiveShellKind(undefined, "PS C:\\old>\rPS C:\\new>"),
"powershell",
);
});
test("rejects spoofed `PS >` (literal space then `>`) — default PowerShell never emits this", () => {
assert.equal(resolveEffectiveShellKind(undefined, "PS >"), "posix");
});
test("falls back to posix when neither shell kind nor prompt is informative", () => {
assert.equal(resolveEffectiveShellKind(undefined, ""), "posix");
assert.equal(resolveEffectiveShellKind(null, undefined), "posix");
});
test("does not misclassify command output that happens to contain 'PS'", () => {
assert.equal(resolveEffectiveShellKind(undefined, "PSO>"), "posix");
assert.equal(resolveEffectiveShellKind(undefined, "ZIPS>"), "posix");
});
test("loginShellHint selects fish/posix/powershell/cmd without pinning confirmed shellKind", () => {
assert.equal(
resolveEffectiveShellKind(undefined, "user@host:~$", { loginShellHint: "fish" }),
"fish",
);
assert.equal(
resolveEffectiveShellKind(undefined, "user@host:~$", { loginShellHint: "posix" }),
"posix",
);
assert.equal(
resolveEffectiveShellKind(undefined, "", { loginShellHint: "powershell" }),
"powershell",
);
assert.equal(
resolveEffectiveShellKind(undefined, "", { loginShellHint: "cmd" }),
"cmd",
);
// Live PowerShell prompt still wins over a posix/fish login hint.
assert.equal(
resolveEffectiveShellKind(undefined, "PS C:\\Users\\alice>", { loginShellHint: "posix" }),
"powershell",
);
assert.equal(
resolveEffectiveShellKind(undefined, "PS C:\\Users\\alice>", { loginShellHint: "fish" }),
"powershell",
);
// Live opposing Windows prompt wins over a Windows DefaultShell soft hint.
assert.equal(
resolveEffectiveShellKind(undefined, "C:\\Users\\alice>", { loginShellHint: "powershell" }),
"cmd",
);
assert.equal(
resolveEffectiveShellKind(undefined, "PS C:\\Users\\alice>", { loginShellHint: "cmd" }),
"powershell",
);
// Live POSIX prompt (e.g. WSL nested from Windows OpenSSH) overrides a
// PowerShell/cmd soft hint so AI does not type a Windows wrapper into bash.
assert.equal(
resolveEffectiveShellKind(undefined, "user@host:~$", { loginShellHint: "powershell" }),
"posix",
);
assert.equal(
resolveEffectiveShellKind(undefined, "alice@wsl:/mnt/c$", { loginShellHint: "cmd" }),
"posix",
);
// Confirmed shellKind is never overridden by a login hint.
assert.equal(
resolveEffectiveShellKind("posix", "user@host:~$", { loginShellHint: "fish" }),
"posix",
);
});
test("pending-input clear prefix covers interactive shells and skips raw devices", () => {
assert.equal(buildPendingInputClearPrefix("posix"), "\x0b\x15");
assert.equal(buildPendingInputClearPrefix("fish"), "\x0b\x15");
assert.equal(buildPendingInputClearPrefix("powershell"), "\x1bggd2147483647d\x1br\x1b\x1bi\x08");
assert.equal(buildPendingInputClearPrefix("cmd"), "\x1b");
assert.equal(buildPendingInputClearPrefix("raw"), "");
});
test("consecutive jobs wait for the PowerShell prompt after a split end marker", async () => {
const writes = [];
class CapturePty extends EventEmitter {
write(data) {
writes.push(String(data));
}
}
const pty = new CapturePty();
const session = { _loginShellKind: "cmd" };
pty.on("data", (data) => trackSessionIdlePrompt(session, String(data)));
trackSessionIdlePrompt(session, "Microsoft Windows...\r\nPS C:\\Users\\alice>");
for (const probe of ["PROBE_1", "PROBE_2"]) {
const job = startPtyJob(pty, `Write-Output '${probe}'`, {
shellKind: session.shellKind,
loginShellHint: session._loginShellKind,
timeoutMs: 20,
expectedPrompt: getFreshIdlePrompt(session),
});
const write = writes.at(-1);
assert.match(write, /\$__NCMCP_/);
assert.doesNotMatch(write, /cmd \/d \/s \/c/i);
pty.emit(
"data",
Buffer.from(`${job.marker}_S\r\n${probe}\r\n${job.marker}_E:0\r\n`),
);
let settled = false;
job.resultPromise.then(() => { settled = true; });
await new Promise((resolve) => setTimeout(resolve, 80));
assert.equal(settled, false);
pty.emit("data", Buffer.from("PS C:\\Users\\alice>"));
const result = await job.resultPromise;
assert.equal(result.ok, true);
assert.equal(result.exitCode, 0);
assert.equal(result.stdout, probe);
}
});
test("non-target shells still finish at the end marker without waiting for a prompt", async () => {
class CapturePty extends EventEmitter {
write() {}
}
const pty = new CapturePty();
const job = startPtyJob(pty, "printf done", {
shellKind: "posix",
timeoutMs: 20,
expectedPrompt: "alice@host:~$",
});
pty.emit("data", Buffer.from(`${job.marker}_S\r\ndone\r\n${job.marker}_E:0\r\n`));
let settled = false;
job.resultPromise.then(() => { settled = true; });
await new Promise((resolve) => setImmediate(resolve));
if (!settled) job.cancel();
assert.equal(settled, true);
});
test("cancel retries stop after an end marker while the prompt is delayed", async () => {
const writes = [];
class CapturePty extends EventEmitter {
write(data) {
writes.push(String(data));
}
}
const pty = new CapturePty();
const job = startPtyJob(pty, "Start-Sleep 10", {
shellKind: "unknown",
loginShellHint: "cmd",
timeoutMs: 1000,
expectedPrompt: "PS C:\\Users\\alice>",
});
job.cancel();
assert.equal(writes.filter((write) => write === "\x03").length, 1);
pty.emit("data", Buffer.from(`${job.marker}_S\r\n${job.marker}_E:130\r\n`));
await new Promise((resolve) => setTimeout(resolve, 200));
assert.equal(writes.filter((write) => write === "\x03").length, 1);
pty.emit("data", Buffer.from("PS C:\\Users\\alice>"));
const result = await job.resultPromise;
assert.equal(result.ok, false);
assert.equal(result.error, "Cancelled");
assert.equal(result.stdout, "");
assert.doesNotMatch(result.stdout, /__NCMCP_/);
});
test("cancelled output strips an end marker delivered with the prompt", async () => {
const writes = [];
class CapturePty extends EventEmitter {
write(data) {
writes.push(String(data));
}
}
const pty = new CapturePty();
const job = startPtyJob(pty, "Start-Sleep 10", {
shellKind: "unknown",
loginShellHint: "cmd",
timeoutMs: 1000,
expectedPrompt: "PS C:\\Users\\alice>",
});
pty.emit("data", Buffer.from(`${job.marker}_S\r\n`));
job.cancel();
pty.emit(
"data",
Buffer.from(`${job.marker}_E:130\r\nPS C:\\Users\\alice>`),
);
const result = await job.resultPromise;
assert.equal(result.ok, false);
assert.equal(result.error, "Cancelled");
assert.equal(result.stdout, "");
assert.doesNotMatch(result.stdout, /__NCMCP_/);
await new Promise((resolve) => setTimeout(resolve, 200));
assert.equal(writes.filter((write) => write === "\x03").length, 1);
});
test("cancel after an end marker keeps waiting without interrupting the prompt", async () => {
const writes = [];
class CapturePty extends EventEmitter {
write(data) {
writes.push(String(data));
}
}
const pty = new CapturePty();
const job = startPtyJob(pty, "Write-Output 'DONE'", {
shellKind: "unknown",
loginShellHint: "cmd",
timeoutMs: 1000,
expectedPrompt: "PS C:\\Users\\alice>",
});
pty.emit("data", Buffer.from(`${job.marker}_S\r\nDONE\r\n${job.marker}_E:0\r\n`));
job.cancel();
let settled = false;
job.resultPromise.then(() => { settled = true; });
await new Promise((resolve) => setImmediate(resolve));
assert.equal(settled, false);
assert.equal(writes.filter((write) => write === "\x03").length, 0);
pty.emit("data", Buffer.from("PS C:\\Users\\alice>"));
const result = await job.resultPromise;
assert.equal(result.ok, true);
assert.equal(result.stdout, "DONE");
});
test("stream termination after an end marker preserves the completed result", async (t) => {
for (const termination of ["close", "error", "exit"]) {
await t.test(termination, async () => {
class CapturePty extends EventEmitter {
write() {}
onExit(callback) {
this.exitCallback = callback;
return { dispose() {} };
}
}
const pty = new CapturePty();
const job = startPtyJob(pty, "Write-Output 'DONE'", {
shellKind: "unknown",
loginShellHint: "cmd",
timeoutMs: 1000,
expectedPrompt: "PS C:\\Users\\alice>",
});
pty.emit("data", Buffer.from(`${job.marker}_S\r\nDONE\r\n${job.marker}_E:7\r\n`));
if (termination === "error") {
pty.emit("error", new Error("disconnected"));
} else if (termination === "exit") {
pty.exitCallback();
} else {
pty.emit("close");
}
const result = await job.resultPromise;
assert.equal(result.ok, false);
assert.equal(result.exitCode, 7);
assert.equal(result.stdout, "DONE");
assert.doesNotMatch(result.stdout, /__NCMCP_/);
assert.equal(result.error, undefined);
});
}
});
test("a foreground wall deadline returns on time but blocks writes until the prompt returns", async () => {
const writes = [];
class CapturePty extends EventEmitter {
write(data) {
writes.push(String(data));
}
}
const pty = new CapturePty();
const session = { _loginShellKind: "cmd" };
pty.on("data", (data) => trackSessionIdlePrompt(session, String(data)));
trackSessionIdlePrompt(session, "PS C:\\Users\\alice>");
const first = startPtyJob(pty, "Write-Output 'DONE'", {
shellKind: session.shellKind,
loginShellHint: session._loginShellKind,
timeoutMs: 30,
expectedPrompt: getFreshIdlePrompt(session),
enforceWallTimeout: true,
});
pty.emit("data", Buffer.from(`${first.marker}_S\r\nDONE\r\n${first.marker}_E:0\r\n`));
const deadlineGuard = Symbol("deadline guard");
const firstResult = await Promise.race([
first.resultPromise,
new Promise((resolve) => setTimeout(() => resolve(deadlineGuard), 300)),
]);
assert.notEqual(firstResult, deadlineGuard);
assert.equal(firstResult.ok, true);
assert.equal(firstResult.exitCode, 0);
assert.equal(firstResult.stdout, "DONE");
assert.equal(writes.filter((write) => write === "\x03").length, 0);
assert.throws(
() => startPtyJob(pty, "Write-Output 'TOO_EARLY'", {
shellKind: session.shellKind,
loginShellHint: session._loginShellKind,
timeoutMs: 1000,
expectedPrompt: getFreshIdlePrompt(session),
}),
(error) => (
error?.code === "SHELL_PROMPT_PENDING"
&& /waiting for the shell prompt/i.test(error.message)
),
);
assert.equal(writes.length, 1);
pty.emit("data", Buffer.from("PS C:\\Users\\alice>"));
const second = startPtyJob(pty, "Write-Output 'NEXT'", {
shellKind: session.shellKind,
loginShellHint: session._loginShellKind,
timeoutMs: 1000,
expectedPrompt: getFreshIdlePrompt(session),
});
const secondWrite = writes.at(-1);
assert.match(secondWrite, /\$__NCMCP_/);
assert.doesNotMatch(secondWrite, /cmd \/d \/s \/c/i);
pty.emit(
"data",
Buffer.from(`${second.marker}_S\r\nNEXT\r\n${second.marker}_E:0\r\nPS C:\\Users\\alice>`),
);
const secondResult = await second.resultPromise;
assert.equal(secondResult.ok, true);
assert.equal(secondResult.stdout, "NEXT");
assert.equal(writes.filter((write) => write === "\x03").length, 0);
});
test("startPtyJob clears PowerShell input in Windows, Emacs, and Vi editor states", async () => {
class PowerShellLinePty extends EventEmitter {
constructor(editMode, { legacyVi = false } = {}) {
super();
this.editMode = editMode;
this.legacyVi = legacyVi;
this.pendingInput = "";
this.cursor = 0;
this.viInsertMode = true;
this.viReplacePending = false;
this.viChord = "";
this.viChordDigits = "";
this.emacsChord = false;
this.submittedLines = [];
this.writes = [];
}
setPendingInput(text, { cursor = text.length, viInsertMode = true } = {}) {
this.pendingInput = text;
this.cursor = cursor;
this.viInsertMode = viInsertMode;
this.viReplacePending = false;
this.viChord = "";
this.viChordDigits = "";
}
insert(text) {
this.pendingInput = `${this.pendingInput.slice(0, this.cursor)}${text}${this.pendingInput.slice(this.cursor)}`;
this.cursor += text.length;
}
applyEditKey(key) {
if (this.editMode === "windows") {
if (key === "\x1b") {
this.pendingInput = "";
this.cursor = 0;
} else if (key === "\x08") {
if (this.cursor > 0) {
this.pendingInput = `${this.pendingInput.slice(0, this.cursor - 1)}${this.pendingInput.slice(this.cursor)}`;
this.cursor -= 1;
}
} else {
this.insert(key);
}
return;
}
if (this.editMode === "emacs") {
if (this.emacsChord) {
this.emacsChord = false;
if (key.toLowerCase() === "r") {
this.pendingInput = "";
this.cursor = 0;
}
} else if (key === "\x1b") {
this.emacsChord = true;
} else if (key === "\x15") {
this.pendingInput = this.pendingInput.slice(this.cursor);
this.cursor = 0;
} else if (key === "\x0b") {
this.pendingInput = this.pendingInput.slice(0, this.cursor);
} else if (key === "\x08") {
if (this.cursor > 0) {
this.pendingInput = `${this.pendingInput.slice(0, this.cursor - 1)}${this.pendingInput.slice(this.cursor)}`;
this.cursor -= 1;
}
} else {
this.insert(key);
}
return;
}
if (this.viReplacePending) {
this.viReplacePending = false;
return;
}
if (this.viChord) {
const chord = this.viChord;
if (chord === "d" && /[0-9]/.test(key)) {
this.viChordDigits += key;
return;
}
this.viChord = "";
if (chord === "g" && key === "g") {
this.cursor = 0;
} else if (chord === "d" && key === "G" && !this.legacyVi) {
this.pendingInput = this.pendingInput.slice(0, this.cursor);
} else if (chord === "d" && key === "d") {
// PSReadLine 2.0's dd clears the whole buffer. In 2.1+, dd clears
// the requested number of logical lines.
if (this.legacyVi) {
this.pendingInput = "";
this.cursor = 0;
} else {
const requestedLines = Number(this.viChordDigits || "1");
const lineStart = this.pendingInput.lastIndexOf("\n", Math.max(0, this.cursor - 1)) + 1;
let lineEnd = lineStart;
let remaining = requestedLines;
while (remaining > 0 && lineEnd < this.pendingInput.length) {
const newline = this.pendingInput.indexOf("\n", lineEnd);
if (newline === -1) {
lineEnd = this.pendingInput.length;
break;
}
lineEnd = newline + 1;
remaining -= 1;
}
this.pendingInput = `${this.pendingInput.slice(0, lineStart)}${this.pendingInput.slice(lineEnd)}`;
this.cursor = Math.min(lineStart, this.pendingInput.length);
}
}
this.viChordDigits = "";
return;
}
if (!this.viInsertMode) {
if (key === "i") {
this.viInsertMode = true;
} else if (key === "r") {
this.viReplacePending = true;
} else if (key === "g" || key === "d") {
this.viChord = key;
}
return;
}
if (key === "\x1b") {
this.viInsertMode = false;
} else if (key === "\x08") {
if (this.cursor > 0) {
this.pendingInput = `${this.pendingInput.slice(0, this.cursor - 1)}${this.pendingInput.slice(this.cursor)}`;
this.cursor -= 1;
}
} else {
this.insert(key);
}
}
write(data) {
const text = String(data);
this.writes.push(text);
const clearPrefix = buildPendingInputClearPrefix("powershell");
assert.ok(text.startsWith(clearPrefix));
for (const key of clearPrefix) this.applyEditKey(key);
const wrapper = text.slice(clearPrefix.length);
const submittedLine = this.viInsertMode
? `${this.pendingInput.slice(0, this.cursor)}${wrapper}${this.pendingInput.slice(this.cursor)}`
: this.pendingInput;
this.submittedLines.push(submittedLine);
this.pendingInput = "";
this.cursor = 0;
const marker = submittedLine.match(/__NCMCP_[0-9a-z]+_[0-9a-f]+__/)?.[0];
assert.ok(marker);
queueMicrotask(() => {
this.emit("data", Buffer.from(`${marker}_S\r\n${marker}_E:0\r\nPS C:\\Users\\alice>`));
});
}
}
const legacyPreviousPrefixPty = new PowerShellLinePty("vi", { legacyVi: true });
legacyPreviousPrefixPty.setPendingInput("first line\n; Write-Output 'USER_SECOND'");
for (const key of "\x1bggdG\x1br\x1b\x1bi\x08") legacyPreviousPrefixPty.applyEditKey(key);
assert.match(legacyPreviousPrefixPty.pendingInput, /USER_SECOND/);
const editorCases = [
{ editMode: "windows", cursorAtStart: false, viInsertMode: true, multiline: false },
{ editMode: "emacs", cursorAtStart: true, viInsertMode: true, multiline: false },
{ editMode: "vi", cursorAtStart: true, viInsertMode: true, multiline: false },
{ editMode: "vi", cursorAtStart: true, viInsertMode: false, multiline: false },
{ editMode: "vi", cursorAtStart: false, viInsertMode: true, multiline: true },
{ editMode: "vi", cursorAtStart: false, viInsertMode: false, multiline: true },
{ editMode: "vi", cursorAtStart: false, viInsertMode: true, multiline: true, legacyVi: true },
{ editMode: "vi", cursorAtStart: false, viInsertMode: false, multiline: true, legacyVi: true },
];
for (const { editMode, cursorAtStart, viInsertMode, multiline, legacyVi = false } of editorCases) {
const pty = new PowerShellLinePty(editMode, { legacyVi });
const commands = ["Write-Output 'one'", "Write-Output 'two'"];
for (const [index, command] of commands.entries()) {
if (index === 1) {
// Model input accepted by PSReadLine before its echo reaches the
// tracked PTY output. The leading semicolon makes a retained suffix
// executable after the wrapper, matching the dangerous Vi edge case.
const pendingInput = multiline
? "; Write-Output 'USER'\nWrite-Output 'USER_SECOND'"
: cursorAtStart
? "; Write-Output 'USER'"
: "Write-Output 'USER'; ";
pty.setPendingInput(pendingInput, {
cursor: cursorAtStart ? 0 : pendingInput.length,
viInsertMode,
});
}
const job = startPtyJob(pty, command, {
shellKind: "unknown",
loginShellHint: "cmd",
timeoutMs: 50,
expectedPrompt: "PS C:\\Users\\alice>",
});
await job.resultPromise;
}
assert.equal(pty.writes.length, 2);
assert.equal(pty.submittedLines.length, 2);
for (const [index, submittedLine] of pty.submittedLines.entries()) {
assert.ok(pty.writes[index].startsWith("\x1bggd2147483647d\x1br\x1b\x1bi\x08$__NCMCP_"));
assert.ok(submittedLine.startsWith("$__NCMCP_"));
assert.doesNotMatch(submittedLine, /Write-Output 'USER'/);
assert.doesNotMatch(submittedLine, /Write-Output 'USER_SECOND'/);
assert.doesNotMatch(submittedLine, /cmd \/d \/s \/c/i);
}
}
});
test("startPtyJob keeps the clear prefix for non-PowerShell sessions", async () => {
const writes = [];
class CapturePty extends EventEmitter {
write(data) {
writes.push(String(data));
}
}
const pty = new CapturePty();
const job = startPtyJob(pty, "echo hi", {
shellKind: "posix",
timeoutMs: 50,
expectedPrompt: "$ ",
});
assert.equal(writes.length, 1);
assert.ok(writes[0].startsWith("\x0b\x15"));
assert.match(writes[0], /__NCMCP_/);
job.cancel();
pty.emit("data", Buffer.from("$ "));
await job.resultPromise;
});
test("execViaRawPty does not prepend a line-clear before device commands", async () => {
const writes = [];
const port = new EventEmitter();
port.write = (data) => {
writes.push(String(data));
};
const abort = new AbortController();
const resultPromise = execViaRawPty(port, "show version", {
timeoutMs: 200,
idleMs: 20,
abortSignal: abort.signal,
});
assert.deepEqual(writes, ["show version\r"]);
abort.abort();
const result = await resultPromise;
assert.equal(result.ok, false);
});
test("cmd wrapper uses interactive cmd variable expansion", () => {
const wrapped = buildWrappedCommand("ipconfig /all", "cmd", "__NCMCP_TEST__");
assert.match(wrapped, /"%__NCMCP_TEST___CMD%"/);
assert.doesNotMatch(wrapped, /"%%__NCMCP_TEST___CMD%%"/);
});
// Issue #1850: agent-generated commands run inside a subshell so that
// shell-terminating constructs (set -e + failure, exit, ...) end only the
// subshell, never the user's active login shell / SSH session.
test("posix wrapper isolates set -e failures from the active shell", () => {
const marker = "__NCMCP_TEST__";
const wrapped = buildWrappedCommand(
"set -e\ncd /nonexistent-dir-1850\necho SHOULD_NOT_PRINT",
"posix",
marker,
);
const result = spawnSync("sh", ["-c", `${wrapped}printf 'PARENT_STILL_ALIVE\\n'`], {
encoding: "utf8",
});
assert.equal(result.error, undefined);
assert.equal(result.status, 0);
assert.match(result.stdout, new RegExp(`${marker}_S`));
assert.match(result.stdout, new RegExp(`${marker}_E:[1-9]`));
assert.match(result.stdout, /PARENT_STILL_ALIVE/);
assert.doesNotMatch(result.stdout, /SHOULD_NOT_PRINT/);
});
test("posix wrapper types multi-line commands as one physical line (no PS2 leak) and preserves semantics", () => {
const marker = "__NCMCP_TEST__";
const wrapped = buildWrappedCommand(
"echo first\necho \"it's quoted\"\n\necho last",
"posix",
marker,
);
// A single physical line: the interactive shell must never show PS2
// ("> ") continuation echoes, which would leak past the preload filter.
assert.equal(wrapped.indexOf("\n"), wrapped.length - 1);
const result = spawnSync("sh", ["-c", wrapped], { encoding: "utf8" });
assert.equal(result.error, undefined);
assert.match(result.stdout, /first\n/);
assert.match(result.stdout, /it's quoted\n/);
assert.match(result.stdout, /last\n/);
assert.match(result.stdout, new RegExp(`${marker}_E:0`));
});
test("long POSIX assignments preserve quotes, Unicode, and heredoc newlines on bounded input lines", () => {
const marker = "__NCMCP_LONG_LITERAL_TEST__";
const literal = "quote' \\ $HOME `false` " + "\u4e2d\u6587\ud83d\ude42".repeat(600);
const command = `cat <<'SMOKE_END'\n${literal}\n\nsecond line\nSMOKE_END\nprintf tail`;
const wrapped = buildWrappedCommand(command, "posix", marker, true);
assert.ok(wrapped.split('\n').every(line => Buffer.byteLength(line, 'utf8') < 1000));
const result = spawnSync('sh', ['-c', wrapped], { encoding: 'utf8' });
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, `\n${marker}_S\n${literal}\n\nsecond line\ntail${marker}_E:0\n`);
});
test("posix wrapper isolates explicit exit from the active shell and reports its code", () => {
const marker = "__NCMCP_TEST__";
const wrapped = buildWrappedCommand("exit 7", "posix", marker);
const result = spawnSync("sh", ["-c", `${wrapped}printf 'PARENT_STILL_ALIVE\\n'`], {
encoding: "utf8",
});
assert.equal(result.error, undefined);
assert.equal(result.status, 0);
assert.match(result.stdout, new RegExp(`${marker}_E:7`));
assert.match(result.stdout, /PARENT_STILL_ALIVE/);
});
test("posix wrapper keeps cd contained in the subshell (documented trade-off)", () => {
const marker = "__NCMCP_TEST__";
const cwd = realpathSync(mkdtempSync(join(tmpdir(), "netcatty-pty-cd-")));
try {
const wrapped = buildWrappedCommand("cd / && pwd", "posix", marker);
const result = spawnSync("sh", ["-c", `${wrapped}pwd`], {
encoding: "utf8",
cwd,
});
assert.equal(result.error, undefined);
assert.equal(result.status, 0);
assert.match(result.stdout, new RegExp(`${marker}_E:0`));
const lines = result.stdout.trim().split("\n");
// The command itself sees the cd take effect (pwd inside prints /)...
assert.ok(lines.includes("/"), `expected command pwd "/" in: ${result.stdout}`);
// ...but the active shell's cwd is untouched (trailing pwd prints cwd).
assert.equal(lines[lines.length - 1], cwd);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
// Issue #3265: with plain HISTCONTROL the wrapper line is recorded in bash
// history; arrow-up recall then redraws the marker text, the preload __NCMCP_
// filter suppresses the redraw fragments containing it, and readline's row
// accounting desyncs from the rendered screen — leaving AI command residue
// when the user navigates history. The wrapper must delete its own entry.
test("posix wrapper removes its own entry from bash history", () => {
const marker = "__NCMCP_TEST__";
const wrapped = buildWrappedCommand("echo HISTORY_PROBE", "posix", marker);
const result = spawnSync(
"bash",
["--norc", "-c", 'set -o history; HISTCONTROL=; HISTFILE=/dev/null; history -s "echo user_old"; ' + wrapped + "history"],
{ encoding: "utf8" },
);
assert.equal(result.error, undefined);
assert.match(result.stdout, new RegExp(`${marker}_E:0`));
assert.match(result.stdout, /HISTORY_PROBE/);
// The user's own entry survives...
assert.match(result.stdout, /echo user_old/);
// ...and the wrapper entry is gone (deletion happens in the same line).
const historyListing = result.stdout.split("\n").filter((line) => /^\s*\d+\s/.test(line));
assert.ok(historyListing.length > 0, "expected a history listing");
assert.ok(
historyListing.every((line) => !line.includes(marker)),
`wrapper entry still in history: ${historyListing.join(" | ")}`,
);
// The cleanup is silent: no stderr leakage into the terminal.
assert.equal(result.stderr, "");
});
test("posix wrapper history cleanup never deletes the user's previous entry", () => {
// HISTCONTROL=ignorespace suppresses the leading-space wrapper entry, so
// $HISTCMD points at the user's previous command. The marker match must
// fail and leave that entry alone.
const marker = "__NCMCP_TEST__";
const wrapped = buildWrappedCommand("echo HISTORY_PROBE", "posix", marker);
const result = spawnSync(
"bash",
["--norc", "-c", 'set -o history; HISTCONTROL=ignorespace; HISTFILE=/dev/null; history -s "echo user_old"; ' + wrapped + "history"],
{ encoding: "utf8" },
);
assert.equal(result.error, undefined);
assert.match(result.stdout, new RegExp(`${marker}_E:0`));
assert.match(result.stdout, /echo user_old/);
const historyListing = result.stdout.split("\n").filter((line) => /^\s*\d+\s/.test(line));
assert.ok(historyListing.length > 0, "expected a history listing");
assert.ok(
historyListing.every((line) => !line.includes(marker)),
`wrapper entry still in history: ${historyListing.join(" | ")}`,
);
assert.equal(result.stderr, "");
});
test("execViaChannel registers a pending-cancel marker before the SSH channel opens", () => {
// Regression for the IPC-transit race surfaced by codex on #1101
// problem 3: if `cancelPtyExecsForSession` runs while we're still
// waiting on `sshClient.exec`'s callback, the cancel finds nothing in
// `activePtyExecs` and the channel opens anyway. The fix registers a
// pending marker synchronously so the cancel has something to act on.
const track = new Map();
let execCallback;
const fakeClient = {
exec(_command, callback) {
// Capture but do not invoke yet <20> simulates the channel-open
// delay where the race window lives.
execCallback = callback;
},
};
void execViaChannel(fakeClient, "echo hi", {
trackForCancellation: track,
chatSessionId: "chat-1",
timeoutMs: 5_000,
});
assert.equal(track.size, 1, "pending marker should be registered before the channel opens");
const entry = Array.from(track.values())[0];
assert.equal(entry.chatSessionId, "chat-1");
assert.equal(typeof entry.cancel, "function");
// Drain the callback so the timeout the test set doesn't fire later.
execCallback(new Error("test teardown"), null);
});
test("execViaChannel drops the pending marker and resolves cleanly when sshClient.exec throws synchronously", async () => {
const track = new Map();
const fakeClient = {
exec() {
throw new Error("client destroyed");
},
};
const result = await execViaChannel(fakeClient, "echo hi", {
trackForCancellation: track,
chatSessionId: "chat-throw",
});
assert.equal(result.ok, false);
assert.equal(result.error, "client destroyed");
assert.equal(track.size, 0, "pending marker must be removed even on sync throw");
});
test("execViaChannel short-circuits when cancel fires before the SSH channel opens", async () => {
const track = new Map();
let execCallback;
let invalidations = 0;
const fakeClient = {
exec(_command, callback) {
execCallback = callback;
},
destroy() { invalidations += 1; },
};
const resultPromise = execViaChannel(fakeClient, "sleep 5", {
trackForCancellation: track,
chatSessionId: "chat-2",
timeoutMs: 5_000,
});
// Cancel while still waiting for the channel-open callback.
assert.equal(track.size, 1);
for (const entry of track.values()) {
if (entry.chatSessionId === "chat-2") entry.cancel();
}
const result = await Promise.race([
resultPromise,
new Promise((_, reject) => setTimeout(() => reject(new Error("pending cancel did not settle")), 25)),
]);
assert.equal(result.ok, false);
assert.equal(result.error, "Cancelled");
assert.equal(track.size, 0, "pending marker should be removed as soon as cancel settles");
assert.equal(invalidations, 1, "pending cancel must invalidate the uncancellable channel-open request");
// Now the channel "opens" <20> even though `sshClient.exec` would
// hand us a working stream, we must short-circuit because the user
// already cancelled.
const fakeExecStream = {
closed: false,
close() { this.closed = true; },
stderr: { on() {} },
on() {},
};
execCallback(null, fakeExecStream);
assert.equal(fakeExecStream.closed, true, "should close the now-unwanted stream");
});
test("execViaChannel times out while SSH never opens the exec channel", async () => {
const track = new Map();
let execCallback;
let invalidations = 0;
const fakeClient = {
exec(_command, callback) {
execCallback = callback;
},
destroy() { invalidations += 1; },
};
const result = await Promise.race([
execViaChannel(fakeClient, "echo hi", {
trackForCancellation: track,
chatSessionId: "chat-opening-timeout",
timeoutMs: 5,
}),
new Promise((_, reject) => setTimeout(() => reject(new Error("opening timeout did not settle")), 50)),
]);
assert.equal(result.ok, false);
assert.match(result.error, /timed out/);
assert.equal(track.size, 0);
assert.equal(invalidations, 1);
const lateStream = {
closed: false,
close() { this.closed = true; },
stderr: { on() {} },
on() {},
};
execCallback(null, lateStream);
assert.equal(lateStream.closed, true, "late exec channels must be closed after opening timeout");
});
test("execViaChannel terminates the command when combined output exceeds its hard limit", async () => {
const track = new Map();
const execStream = new EventEmitter();
execStream.stderr = new EventEmitter();
execStream.closed = false;
execStream.close = () => { execStream.closed = true; };
execStream.destroy = () => { execStream.closed = true; };
const fakeClient = {
exec(_command, callback) {
callback(null, execStream);
},
};
const resultPromise = execViaChannel(fakeClient, "yes", {
trackForCancellation: track,
chatSessionId: "chat-output-limit",
timeoutMs: 5_000,
maxOutputBytes: 5,
});
execStream.emit("data", Buffer.from("abc"));
execStream.stderr.emit("data", Buffer.from("de"));
execStream.emit("data", Buffer.from("f"));
const result = await resultPromise;
assert.equal(result.ok, false);
assert.equal(result.stdout, "abc");
assert.equal(result.stderr, "de");
assert.equal(result.exitCode, -1);
assert.match(result.error, /output exceeded.*5 byte/i);
assert.equal(execStream.closed, true, "the noisy remote process must be terminated");
assert.equal(track.size, 0, "the cancelled exec channel must not remain tracked");
assert.equal(execStream.listenerCount("data"), 0);
assert.equal(execStream.stderr.listenerCount("data"), 0);
assert.equal(execStream.listenerCount("close"), 0);
});
test("execViaChannel settles and releases listeners when the SSH channel errors", async () => {
const track = new Map();
const execStream = new EventEmitter();
execStream.stderr = new EventEmitter();
execStream.closed = false;
execStream.close = () => { execStream.closed = true; };
execStream.destroy = () => { execStream.closed = true; };
const fakeClient = {
exec(_command, callback) {
callback(null, execStream);
},
};
const resultPromise = execViaChannel(fakeClient, "echo hi", {
trackForCancellation: track,
chatSessionId: "chat-channel-error",
timeoutMs: 5_000,
});
execStream.emit("data", Buffer.from("partial"));
execStream.emit("error", new Error("channel fault"));
const result = await resultPromise;
assert.equal(result.ok, false);
assert.equal(result.stdout, "partial");
assert.equal(result.stderr, "");
assert.equal(result.exitCode, -1);
assert.match(result.error, /channel fault/i);
assert.equal(execStream.closed, true);
assert.equal(track.size, 0);
assert.equal(execStream.listenerCount("data"), 0);
assert.equal(execStream.stderr.listenerCount("data"), 0);
assert.equal(execStream.stderr.listenerCount("error"), 0);
assert.equal(execStream.listenerCount("close"), 0);
assert.equal(execStream.listenerCount("error"), 0);
});
function createExecChannelHarness() {
const execStream = new EventEmitter();
execStream.stderr = new EventEmitter();
execStream.closed = false;
execStream.close = () => { execStream.closed = true; };
execStream.destroy = () => { execStream.closed = true; };
return {
execStream,
fakeClient: {
exec(_command, callback) {
callback(null, execStream);
},
},
};
}
test("execViaChannel preserves UTF-8 split across stdout chunks", async () => {
const { execStream, fakeClient } = createExecChannelHarness();
const resultPromise = execViaChannel(fakeClient, "printf unicode", { timeoutMs: 5_000 });
const bytes = Buffer.from("中文", "utf8");
execStream.emit("data", bytes.subarray(0, 2));
execStream.emit("data", bytes.subarray(2, 4));
execStream.emit("data", bytes.subarray(4));
execStream.emit("close", 0);
assert.deepEqual(await resultPromise, {
ok: true,
stdout: "中文",
stderr: "",
exitCode: 0,
});
});
test("execViaChannel decodes interleaved stdout and stderr independently", async () => {
const { execStream, fakeClient } = createExecChannelHarness();
const resultPromise = execViaChannel(fakeClient, "printf unicode", { timeoutMs: 5_000 });
const stdoutBytes = Buffer.from("中", "utf8");
const stderrBytes = Buffer.from("文", "utf8");
execStream.emit("data", stdoutBytes.subarray(0, 2));
execStream.stderr.emit("data", stderrBytes.subarray(0, 1));
execStream.emit("data", stdoutBytes.subarray(2));
execStream.stderr.emit("data", stderrBytes.subarray(1));
execStream.emit("close", 0);
assert.deepEqual(await resultPromise, {
ok: true,
stdout: "中",
stderr: "文",
exitCode: 0,
});
});
test("execViaChannel omits an incomplete UTF-8 character at the output limit", async () => {
const { execStream, fakeClient } = createExecChannelHarness();
const resultPromise = execViaChannel(fakeClient, "printf unicode", {
timeoutMs: 5_000,
maxOutputBytes: 2,
});
execStream.emit("data", Buffer.from("中", "utf8"));
const result = await resultPromise;
assert.equal(result.ok, false);
assert.equal(result.stdout, "");
assert.equal(result.stderr, "");
assert.doesNotMatch(`${result.stdout}${result.stderr}`, /<2F>/u);
assert.match(result.error, /output exceeded.*2 byte/i);
assert.equal(execStream.closed, true);
});
test("execViaChannel cancellation never exposes an incomplete UTF-8 character", async () => {
const track = new Map();
const { execStream, fakeClient } = createExecChannelHarness();
const resultPromise = execViaChannel(fakeClient, "printf unicode", {
timeoutMs: 5_000,
trackForCancellation: track,
chatSessionId: "chat-unicode-cancel",
});
execStream.emit("data", Buffer.from("中", "utf8").subarray(0, 2));
const entry = [...track.values()].find((candidate) => (
candidate.chatSessionId === "chat-unicode-cancel"
));
entry.cancel();
const result = await resultPromise;
assert.equal(result.ok, false);
assert.equal(result.stdout, "");
assert.doesNotMatch(result.stdout, /<2F>/u);
assert.equal(result.error, "Cancelled");
});
for (const customization of [":", "HISTCONTROL=ignorespace", "alias history='history 10'", 'history() { builtin history "$@" | cat; }', "history() { :; }", "alias command=':'", "command() { :; }", "readonly __nc_h=USER", "readonly __nc_d=USER"]) {
test(`posix wrapper bypasses history customization: ${customization}`, () => {
const marker = "__NCMCP_CUSTOM_HISTORY__";
const wrapped = buildWrappedCommand("echo HISTORY_PROBE", "posix", marker);
const result = spawnSync("bash", ["--noprofile", "--norc", "-i"], {
input: `HISTFILE=/dev/null; HISTCONTROL=; PS1=; PS2=
${customization}
builtin history -c
echo user_old
${wrapped}builtin history
exit
`,
encoding: "utf8",
env: { ...process.env, TERM: "dumb" },
});
assert.equal(result.status, 0);
assert.match(result.stdout, new RegExp(`${marker}_E:0`));
const entries = result.stdout.split("\n").filter((line) => /^\s*\d+\s/.test(line));
assert.ok(entries.some((line) => line.includes("echo user_old")));
assert.ok(entries.every((line) => !line.includes(marker)), entries.join("\n"));
});
}
test("cancelled live probe resets display once without injecting a command", async () => {
for (const callbackThrows of [false, true]) {
const pty = new EventEmitter();
const writes = [];
const resets = [];
pty.write = (data) => writes.push(data);
const job = startPtyJob(pty, "touch should-not-run", {
shellKind: "posix",
probeLiveShell: true,
expectedPrompt: "user@host:~$ ",
timeoutMs: 1000,
onProbeAborted: (marker) => {
resets.push(marker);
if (callbackThrows) throw new Error("Renderer closed");
},
});
job.cancel();
pty.emit("data", "\r\nuser@host:~$ ");
const result = await job.resultPromise;
assert.equal(result.error, "Cancelled");
assert.deepEqual(resets, [job.marker]);
pty.emit("data", `${job.marker}_P:fish\n${job.marker}_Q`);
job.cancel();
assert.deepEqual(resets, [job.marker]);
assert.ok(writes.every((data) => !data.includes("touch should-not-run")));
assert.ok(writes.every((data) => !data.includes(`${job.marker}_R`)));
}
});
test("posix wrapper avoids history expansion in interactive zsh", (t) => {
const marker = "__NCMCP_ZSH_HISTORY__";
const wrapped = buildWrappedCommand("echo HISTORY_PROBE", "posix", marker);
const result = spawnSync("zsh", ["-f", "-i"], {
input: `${wrapped}exit\n`,
encoding: "utf8",
env: { ...process.env, TERM: "dumb" },
});
if (result.error?.code === "ENOENT") {
t.skip("zsh is not installed");
return;
}
assert.equal(result.error, undefined);
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, new RegExp(`${marker}_E:0`));
assert.match(result.stdout, /HISTORY_PROBE/);
assert.doesNotMatch(result.stderr, /event not found/);
});