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 � 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" � 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}`, /�/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, /�/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/); });