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
1379 lines
49 KiB
JavaScript
1379 lines
49 KiB
JavaScript
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, "[32mPS C:\\Users\\alice>[0m"),
|
||
"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/);
|
||
});
|