Compare commits

...
Author SHA1 Message Date
bincxz bb932453bf fix(ai): clear canonical pending input without a control command 2026-09-07 02:30:10 +08:00
bincxz 703fa4e804 fix(ai): bound long command assignments for canonical terminals 2026-09-07 02:22:13 +08:00
bincxz 40f8ff5651 fix(ai): suspend wait timers during paced terminal input 2026-09-07 01:38:37 +08:00
netcatty-bot bd260f1325 fix: address Codex review on PR #3298 2026-09-06 17:27:51 +00:00
bincxz b94e78d772 fix(ai): deliver shell probes without input overruns 2026-09-07 00:47:00 +08:00
netcatty-bot 02e6f4bc56 fix: address Codex review on PR #3298 2026-09-06 16:40:29 +00:00
netcatty-bot 96336ffd49 fix: address Codex review on PR #3298 2026-09-06 15:54:21 +00:00
netcatty-bot 410107ea97 fix: address Codex review on PR #3298 2026-09-06 15:32:02 +00:00
netcatty-bot c9eaf1a467 fix: address Codex review on PR #3298 2026-09-06 15:10:04 +00:00
netcatty-bot f8b8a16a80 fix: address Codex review on PR #3298 2026-09-06 14:42:26 +00:00
bincxz a44fa1e3f5 fix(ai): verify history cleanup across dispatcher fallbacks 2026-09-06 22:13:55 +08:00
netcatty-bot 69a2f1183e fix: address Codex review on PR #3298 2026-09-06 13:56:23 +00:00
bincxz 807dfb152d fix(ai): bypass custom builtin definitions in history cleanup 2026-09-06 21:20:13 +08:00
bincxz 33e07991d3 fix(ai): keep probe cleanup nonfatal in errexit shells 2026-09-06 21:10:52 +08:00
bincxz 28e9982b6a fix(ai): clean Bash probes without external shell detection 2026-09-06 20:50:45 +08:00
bincxz 774a50a324 fix(ai): bypass custom eval when cleaning probe history 2026-09-06 20:29:06 +08:00
bincxz 517f3d9446 fix(ai): keep live shell probes out of Bash history 2026-09-06 20:13:50 +08:00
6 changed files with 456 additions and 38 deletions

No files matched your search

+13 -6
View File
@@ -1,16 +1,23 @@
"use strict";
const { buildBashHistoryCleanup, bashHistoryScratchNames } = require("./ptyExecHelpers.cjs");
// Both fish and POSIX shells accept this command. Inspect the parent of a
// short-lived sh in the interactive PTY, rather than the SSH login shell.
function buildLiveShellProbe(marker) {
const script = 'if test -r "/proc/$PPID/comm"; then IFS= read -r name < "/proc/$PPID/comm"; else name=$(ps -p "$PPID" -o comm= 2>/dev/null); fi; '
+ `printf "${marker}_P:%s\\n" "$name"`;
// Put the job marker near the start of the echo, as the existing wrappers
// do, so preload can suppress it before a long command is split into chunks.
// The builtin completion marker still runs if sh cannot launch. Leave it
// unterminated so preload hides the intermediate prompt and wrapper echo
// on the same marker-bearing line, including across output chunks.
return ` true ${marker}; command sh -c '${script}' 2>/dev/null; printf '%s' '${marker}_Q'\n`;
// command eval bypasses an eval customization; plain eval is the fallback
// when command itself is shadowed. Never invoke a shadowed builtin after the
// command path already succeeded. Both eval bodies remain Bash-guarded.
const cleanup = buildBashHistoryCleanup(marker, true);
const { dispatcher } = bashHistoryScratchNames(marker);
const clear = `[ -z "\${${dispatcher}-}" ]||$${dispatcher} unset ${dispatcher}`;
const fallback = `[ "\${${dispatcher}-}" = command ]||{ ${cleanup}; };${clear}`;
// Continuation lines stay within canonical input limits. Each echo carries
// the marker so the renderer also hides continuation prompts.
return ` true ${marker}; command sh -c '${script}' 2>/dev/null; \\\n: '${marker}'; \\command eval '${cleanup}' 2>/dev/null || true; \\\n: '${marker}'; \\eval '${fallback}' 2>/dev/null || true; \\\n: '${marker}'; \\command eval '${clear}' 2>/dev/null || true; printf '%s' '${marker}_Q'\n`;
}
function parseLiveShellProbe(output, marker) {
+256 -1
View File
@@ -34,10 +34,14 @@ test('probe wrapper keeps the start marker separate when terminal echo is disabl
const { spawnSync } = require('node:child_process');
const pty = new EventEmitter();
let job;
let pendingInput = '';
pty.write = (data) => {
if (String(data).includes('command sh -c')) return;
if (data === '\x03') return;
const script = String(data).replace(/^\x15\x0b/, '');
pendingInput += String(data);
if (!pendingInput.endsWith('\n')) return;
const script = pendingInput.replace(/^\x0b\x15/, '');
pendingInput = '';
const result = spawnSync('/bin/sh', ['-c', script], { encoding: 'utf8' });
queueMicrotask(() => pty.emit('data', `${job.marker}_QPROMPT> ${result.stdout}`));
};
@@ -162,3 +166,254 @@ test('real PTY: echo-disabled bash completes output without a trailing newline',
pty.kill();
}
});
for (const editing of [true, false]) {
test(`real PTY: pending input clearing leaves no control command (editing=${editing})`, {
skip: process.env.NETCATTY_LIVE_FISH_TEST !== '1', timeout: 5000,
}, async () => {
const pty = require('node-pty').spawn('/bin/bash', ['--noprofile', '--norc', ...(editing ? [] : ['--noediting'])], {
name: 'dumb', cols: 240, rows: 24,
env: { ...process.env, TERM: 'dumb', PS1: 'CLEAR_READY> ', HISTFILE: '/dev/null', BASH_SILENCE_DEPRECATION_WARNING: '1' },
});
let output = '';
pty.onData(data => { output += data; });
try {
const waitForPrompt = async () => {
const deadline = Date.now() + 3000;
while (!output.includes('CLEAR_READY>')) {
if (Date.now() > deadline) throw new Error(`Missing prompt: ${output}`);
await new Promise(resolve => setTimeout(resolve, 10));
}
};
await waitForPrompt();
output = '';
// Leave text on both sides of the readline cursor. Without editing, all
// bytes remain pending canonical input and must still be discarded.
pty.write('left-right\x1b[5D');
await new Promise(resolve => setTimeout(resolve, 50));
const { buildPendingInputClearPrefix } = require('./ptyExecHelpers.cjs');
pty.write(buildPendingInputClearPrefix('posix') + "printf 'CLEAR_SUCCESS\\n'\n");
await waitForPrompt();
assert.match(output, /CLEAR_SUCCESS\r\n/);
assert.doesNotMatch(output, /command not found|syntax error/);
} finally {
pty.kill();
}
});
}
test('real PTY: canonical bash executes a long literal command without truncation', {
skip: process.env.NETCATTY_LIVE_FISH_TEST !== '1', timeout: 15000,
}, async () => {
const pty = require('node-pty').spawn('/bin/bash', ['--noprofile', '--norc', '--noediting'], {
name: 'dumb', cols: 240, rows: 24,
env: { ...process.env, TERM: 'dumb', PS1: 'CANONICAL_READY> ', HISTFILE: '/dev/null', BASH_SILENCE_DEPRECATION_WARNING: '1' },
});
let output = '';
pty.onData((data) => { output += data; });
try {
const deadline = Date.now() + 3000;
while (!output.includes('CANONICAL_READY>')) {
if (Date.now() > deadline) throw new Error('Missing canonical shell prompt');
await new Promise((resolve) => setTimeout(resolve, 10));
}
const literal = 'x'.repeat(12000);
const result = await require('./ptyExec.cjs').execViaPty(pty, `printf '%s' '${literal}'`, {
shellKind: 'posix', probeLiveShell: true, timeoutMs: 1500,
});
assert.equal(result.exitCode, 0, JSON.stringify({ ...result, stdout: result.stdout?.slice(-200) }));
assert.equal(result.stdout.trim(), literal);
} finally {
pty.kill();
}
});
// The second element is a history-listing invocation that still reaches the
// real history builtin under that customization.
for (const [customization, listHistory] of [
[':', 'command builtin history'],
['HISTCONTROL=ignorespace', 'command builtin history'],
["alias history='history 10'", 'command builtin history'],
['history() { :; }', 'command builtin history'],
['history() { builtin history "$@" | cat; }', 'command builtin history'],
["alias eval=':'", 'command builtin history'],
['eval() { :; }', 'command builtin history'],
['PATH=/nonexistent', 'command builtin history'],
["alias builtin=':'", 'command builtin history'],
['builtin() { :; }', 'command builtin history'],
["alias command=':'", '\\builtin history'],
['command() { :; }', '\\builtin history'],
]) {
for (const executeCommand of [false, true]) {
test(`bash probe keeps user history clean (${customization}, wrapper=${executeCommand})`, () => {
const { spawnSync } = require('node:child_process');
const { buildWrappedCommand } = require('./ptyExecHelpers.cjs');
const marker = '__NCMCP_HISTORY_PROBE__';
const input = `HISTFILE=/dev/null; HISTCONTROL=; PS1=; PS2=\n${customization}\n${listHistory} -c\necho user_one\necho user_two\n`
+ buildLiveShellProbe(marker)
+ (executeCommand ? buildWrappedCommand('echo command_ok', 'posix', marker, true) : '')
+ '\nprintf \"\\n\"\n' + listHistory + '\nexit\n';
const result = spawnSync('/bin/bash', ['--noprofile', '--norc', '-i'], {
input, encoding: 'utf8', env: { ...process.env, TERM: 'dumb' }, timeout: 5000,
});
assert.equal(result.status, 0, result.stderr);
assert.ok(result.stdout.includes(`${marker}_Q`), result.stdout);
const entries = result.stdout.split('\n').filter(line => /^\s*\d+\s/.test(line));
assert.ok(entries.some(line => line.includes('echo user_one')), entries.join('\n'));
assert.ok(entries.some(line => line.includes('echo user_two')), entries.join('\n'));
assert.ok(entries.every(line => !line.includes(marker)), entries.join('\n'));
});
}
}
test('real PTY: probe and execution leave only user commands for arrow recall', {
skip: process.env.NETCATTY_LIVE_FISH_TEST !== '1', timeout: 10000,
}, async () => {
const terminal = require('node-pty').spawn('/bin/bash', ['--noprofile', '--norc'], {
name: 'dumb', cols: 240, rows: 24,
env: { ...process.env, TERM: 'dumb', HISTFILE: '/dev/null', HISTCONTROL: '',
PS1: 'HISTORY_READY> ', BASH_SILENCE_DEPRECATION_WARNING: '1' },
});
let output = '';
terminal.onData(data => { output += data; });
const waitFor = async text => {
const deadline = Date.now() + 3000;
while (!output.includes(text)) {
if (Date.now() > deadline) throw new Error(`Missing ${text}: ${output.slice(-1500)}`);
await new Promise(resolve => setTimeout(resolve, 10));
}
const result = output;
output = '';
return result;
};
try {
await waitFor('HISTORY_READY>');
terminal.write('builtin history -c\r');
await waitFor('HISTORY_READY>');
terminal.write('echo user_history_one\r');
await waitFor('HISTORY_READY>');
terminal.write('echo user_history_two\r');
await waitFor('HISTORY_READY>');
const result = await require('./ptyExec.cjs').execViaPty(terminal, 'printf agent_success', {
shellKind: 'posix', probeLiveShell: true, timeoutMs: 2000,
});
assert.equal(result.exitCode, 0, JSON.stringify(result));
assert.match(result.stdout, /agent_success/);
await waitFor('HISTORY_READY>');
terminal.write('\x1b[A\r');
const recalled = await waitFor('HISTORY_READY>');
assert.match(recalled, /user_history_two/);
assert.doesNotMatch(recalled, /__NCMCP_/);
terminal.write('builtin history\r');
const history = await waitFor('HISTORY_READY>');
assert.match(history, /echo user_history_one/);
assert.match(history, /echo user_history_two/);
assert.doesNotMatch(history, /__NCMCP_/);
} finally {
terminal.kill();
}
});
for (const invocationName of ['sh', 'renamed-bash']) {
test(`Bash invoked as ${invocationName} cleans probe history`, () => {
const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const directory = require('../tempDirBridge.cjs').getTempFilePath('probe-bash');
fs.mkdirSync(directory, { mode: 0o700 });
try {
const shell = path.join(directory, invocationName);
fs.symlinkSync('/bin/bash', shell);
const marker = '__NCMCP_RENAMED_BASH__';
const result = spawnSync(shell, ['--noprofile', '--norc', '-i'], {
input: 'HISTFILE=/dev/null; HISTCONTROL=; PS1=; PS2=\nbuiltin history -c\necho preserve_user_history\n'
+ buildLiveShellProbe(marker) + '\nprintf "\\n"\ncommand builtin history\nexit\n',
encoding: 'utf8', env: { ...process.env, TERM: 'dumb' }, timeout: 5000,
});
assert.equal(result.status, 0, result.stderr);
assert.ok(result.stdout.includes(`${marker}_Q`), result.stdout);
const entries = result.stdout.split('\n').filter(line => /^\s*\d+\s/.test(line));
assert.ok(entries.some(line => line.includes('echo preserve_user_history')), result.stdout);
assert.ok(entries.every(line => !line.includes(marker)), result.stdout);
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
});
}
for (const [shell, args] of [
['/bin/dash', ['-i']],
['/bin/zsh', ['-f', '-i']],
['/bin/bash', ['--noprofile', '--norc', '-i']],
]) {
test(`probe preserves an interactive errexit session in ${shell}`, (t) => {
const { spawnSync } = require('node:child_process');
const marker = '__NCMCP_ERREXIT_PROBE__';
const result = spawnSync(shell, args, {
input: 'set -e\n' + buildLiveShellProbe(marker) + '\necho shell_survived\nexit\n',
encoding: 'utf8', env: { ...process.env, TERM: 'dumb', HISTFILE: '/dev/null' }, timeout: 5000,
});
if (result.error?.code === 'ENOENT') return t.skip(`${shell} is unavailable`);
assert.ifError(result.error);
assert.equal(result.status, 0, result.stderr);
assert.ok(result.stdout.includes(`${marker}_Q`), result.stdout);
assert.ok(result.stdout.includes('shell_survived'), result.stdout);
});
}
for (const shell of ['/bin/dash', '/bin/zsh']) {
test(`history cleanup keeps the execution wrapper portable in ${shell}`, { skip: !require('node:fs').existsSync(shell) }, () => {
const { spawnSync } = require('node:child_process');
const { buildWrappedCommand } = require('./ptyExecHelpers.cjs');
const marker = '__NCMCP_PORTABLE_HISTORY__';
const result = spawnSync(shell, ['-c', buildWrappedCommand('echo portable-history-ok', 'posix', marker, true)], { encoding: 'utf8', timeout: 5000 });
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /portable-history-ok/);
assert.ok(result.stdout.includes(`${marker}_E:0`));
});
}
for (const historyControl of ['', 'ignorespace']) {
test(`successful cleanup does not invoke a shadowed builtin (${historyControl || 'default'})`, () => {
const { spawnSync } = require('node:child_process');
const { buildWrappedCommand } = require('./ptyExecHelpers.cjs');
const marker = '__NCMCP_FALLBACK_GUARD__';
const input = `HISTFILE=/dev/null; HISTCONTROL=${historyControl}; PS1=; PS2=\nbuiltin() { printf UNEXPECTED_FALLBACK; }\ncommand history -c\necho user_one\n`
+ buildLiveShellProbe(marker)
+ buildWrappedCommand('echo command_ok', 'posix', marker, true)
+ '\ncommand history\nexit\n';
const result = spawnSync('/bin/bash', ['--noprofile', '--norc', '-i'], {
input, encoding: 'utf8', env: { ...process.env, TERM: 'dumb' }, timeout: 5000,
});
assert.equal(result.status, 0, result.stderr);
assert.ok(result.stdout.includes(`${marker}_Q`), result.stdout);
assert.match(result.stdout, /command_ok/);
assert.doesNotMatch(result.stdout, /UNEXPECTED_FALLBACK/);
const entries = result.stdout.split('\n').filter(line => /^\s*\d+\s/.test(line));
assert.ok(entries.some(line => line.includes('echo user_one')), entries.join('\n'));
assert.ok(entries.every(line => !line.includes(marker)), entries.join('\n'));
});
}
for (const stop of ['cancel', 'timeout']) {
test(`paced probe stops writing after ${stop}`, async () => {
const pty = new EventEmitter();
const writes = [];
pty.write = data => writes.push(data);
const job = startPtyJob(pty, 'echo must_not_run', {
shellKind: 'posix', probeLiveShell: true, timeoutMs: stop === 'timeout' ? 70 : 1000,
enforceWallTimeout: stop === 'timeout',
});
await new Promise(resolve => setTimeout(resolve, 45));
assert.ok(writes.length >= 2, 'probe must have started a later chunk');
if (stop === 'cancel') {
job.cancel();
pty.emit('close');
}
await job.resultPromise;
const count = writes.length;
await new Promise(resolve => setTimeout(resolve, 100));
assert.equal(writes.length, count);
assert.ok(writes.every(data => !data.includes('must_not_run')));
});
}
+55 -4
View File
@@ -94,6 +94,7 @@ function startPtyJob(ptyStream, command, options) {
const usesLiveShellProbe = probeLiveShell && ["posix", "fish"].includes(resolvedShellKind);
let probingShell = usesLiveShellProbe;
let deliveringInput = false;
let probeOutput = "";
let output = "";
@@ -200,6 +201,9 @@ function startPtyJob(ptyStream, command, options) {
// Foreground execs use the configured timeoutMs as the deadline (matching
// the pre-PR behavior); background jobs use a fixed 30s since their main
// timeout is much longer (1 hour) and meant for the actual command.
// The timer is armed once input delivery completes (see writeInput) so the
// paced-typing time for oversized probes/wrappers is excluded from the
// startup budget instead of counting against it.
const BG_STARTUP_TIMEOUT_MS = 30000;
function armStartupTimeout() {
const startupMs = maxBufferedChars > 0 ? BG_STARTUP_TIMEOUT_MS : timeoutMs;
@@ -526,7 +530,7 @@ function startPtyJob(ptyStream, command, options) {
: Buffer.from(String(data ?? ""));
const text = outputDecoder.write(bytes);
if (!text) return;
if (!pendingEnd) armOutputTimeout();
if (!pendingEnd && !deliveringInput) armOutputTimeout();
if (probingShell) {
probeOutput = (probeOutput + text).slice(-16384);
@@ -652,7 +656,6 @@ function startPtyJob(ptyStream, command, options) {
armOutputTimeout();
armWallTimeout();
armStartupTimeout();
unsubscribe = subscribeToPtyData(ptyStream, onData);
@@ -729,13 +732,61 @@ function startPtyJob(ptyStream, command, options) {
}
}
let inputWriteTimer = null;
let inputWriteGeneration = 0;
function stopInputWrite() {
inputWriteGeneration += 1;
clearTimeout(inputWriteTimer);
inputWriteTimer = null;
}
cleanupFns.push(stopInputWrite);
function writeInput(text) {
stopInputWrite();
// Each delivery gets a fresh wait budget, including the transition from
// probe to wrapper. Echo may be disabled while we are still typing.
clearStartupTimeout();
clearTimeout(timeoutId);
deliveringInput = true;
const generation = inputWriteGeneration;
let offset = 0;
// Readline may overrun its input queue when long probes arrive in one
// write. Yield between bounded chunks; cancelled/replaced jobs must never
// finish typing an old command into a subsequent prompt.
const chunkSize = usesLiveShellProbe && text.length > 1024 ? 128 : text.length;
const writeNext = () => {
if (finished || cancelRequested || generation !== inputWriteGeneration) return;
let end = Math.min(offset + chunkSize, text.length);
// Do not split a UTF-16 surrogate pair across independently encoded writes.
if (end < text.length && /[\uD800-\uDBFF]/.test(text[end - 1])) end -= 1;
ptyStream.write(text.slice(offset, end));
offset = end;
if (offset < text.length && generation === inputWriteGeneration && !finished && !cancelRequested) {
inputWriteTimer = setTimeout(() => {
try { writeNext(); } catch (error) {
finish(preStartOutput, -1, `Terminal input failed: ${error.message}`);
}
}, 30);
} else {
// Input delivery is complete: only now does the startup deadline
// begin, so paced typing time never consumes the startup budget.
if (!finished && !cancelRequested && generation === inputWriteGeneration) {
deliveringInput = false;
armOutputTimeout();
if (!foundStart) armStartupTimeout();
}
}
};
writeNext();
}
function writeWrappedCommand() {
const wrapped = buildWrappedCommand(command, resolvedShellKind, marker, probeLiveShell);
ptyStream.write(`${buildPendingInputClearPrefix(resolvedShellKind)}${wrapped}`);
writeInput(`${buildPendingInputClearPrefix(resolvedShellKind)}${wrapped}`);
}
if (probingShell) {
ptyStream.write(`${buildPendingInputClearPrefix(resolvedShellKind)}${buildLiveShellProbe(marker)}`);
writeInput(`${buildPendingInputClearPrefix(resolvedShellKind)}${buildLiveShellProbe(marker)}`);
} else {
writeWrappedCommand();
}
+16 -5
View File
@@ -26,7 +26,7 @@ const {
class ShellBackedPty extends EventEmitter {
write(data) {
if (data === "\x03") return;
const script = String(data).replace(/^\x15\x0b/, "");
const script = String(data).replace(/^\x0b\x15/, "");
const result = spawnSync("sh", ["-c", script], { encoding: "utf8" });
queueMicrotask(() => {
this.emit("data", Buffer.from(result.stdout));
@@ -365,8 +365,8 @@ test("loginShellHint selects fish/posix/powershell/cmd without pinning confirmed
});
test("pending-input clear prefix covers interactive shells and skips raw devices", () => {
assert.equal(buildPendingInputClearPrefix("posix"), "\x15\x0b");
assert.equal(buildPendingInputClearPrefix("fish"), "\x15\x0b");
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"), "");
@@ -849,7 +849,7 @@ test("startPtyJob keeps the clear prefix for non-PowerShell sessions", async ()
expectedPrompt: "$ ",
});
assert.equal(writes.length, 1);
assert.ok(writes[0].startsWith("\x15\x0b"));
assert.ok(writes[0].startsWith("\x0b\x15"));
assert.match(writes[0], /__NCMCP_/);
job.cancel();
pty.emit("data", Buffer.from("$ "));
@@ -921,6 +921,17 @@ test("posix wrapper types multi-line commands as one physical line (no PS2 leak)
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);
@@ -1295,7 +1306,7 @@ test("execViaChannel cancellation never exposes an incomplete UTF-8 character",
assert.equal(result.error, "Cancelled");
});
for (const customization of [":", "HISTCONTROL=ignorespace", "alias history='history 10'", "history() { :; }"]) {
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);
+54 -22
View File
@@ -194,34 +194,64 @@ function buildPendingInputClearPrefix(shellKind) {
// i+Backspace leaves every mode on an empty editable line.
return "\x1bggd2147483647d\x1br\x1b\x1bi\x08";
default:
return "\x15\x0b";
// Kill the suffix before the prefix. Canonical/no-editing terminals do
// not bind Ctrl+K; the trailing Ctrl+U must erase that literal byte too.
return "\x0b\x15";
}
}
function bashHistoryScratchNames(marker) {
const suffix = String(marker || "").toLowerCase().replace(/[^a-z0-9]/g, "").slice(-12) || "dflt";
return { entry: `__nc_h_${suffix}`, dispatcher: `__nc_d_${suffix}` };
}
function buildBashHistoryCleanup(marker, keepDispatcher = false) {
// Expanded dispatcher names bypass aliases. Verify that a dispatcher really
// executes builtins before trusting an empty history read from a no-op function.
// After deletion, verify the entry is gone: a shadowed history function may
// delete only in a subshell. Stop as soon as the real dispatcher succeeds.
// Invocation-specific scratch names avoid readonly user variables. Clear the
// history-bearing scratch through the verified dispatcher, never plain unset.
const { entry, dispatcher } = bashHistoryScratchNames(marker);
const unsetNames = keepDispatcher ? entry : `${entry} ${dispatcher}`;
return `[ "\${BASH_VERSION-}" ]&&{ for ${dispatcher} in command builtin;do ${entry}=$($${dispatcher} printf x);[ "$${entry}" = x ]||continue;${entry}=$($${dispatcher} history 1);case "$${entry}" in *${marker}*) ${entry}=\${${entry}#"\${${entry}%%[^[:space:]]*}"};$${dispatcher} history -d "\${${entry}%%[[:space:]]*}";${entry}=$($${dispatcher} history 1);case "$${entry}" in *${marker}*) continue;;esac;;esac;$${dispatcher} unset ${unsetNames};break;done; } 2>/dev/null`;
}
function buildPosixWrapperBody(command, marker, startFormat) {
const noPager = "PAGER=cat SYSTEMD_PAGER= GIT_PAGER=cat LESS= ";
const commandLines = String(command || "").replace(/\r\n?/g, "\n").split("\n");
const cmdAssign = commandLines.length > 1
let cmdAssign = commandLines.length > 1
? `${marker}_cmd=$(printf '%s\\n' ${commandLines.map((line) => `'${escapePosixSingleQuoted(line)}'`).join(" ")})`
: `${marker}_cmd='${escapePosixSingleQuoted(command)}'`;
// Issue #3265: without HISTCONTROL=ignorespace the wrapper line is recorded
// in bash history. Pressing arrow-up then makes readline redraw that huge
// marker-containing line, and the preload __NCMCP_ filter suppresses the
// redraw fragments that contain the marker — readline's row accounting
// diverges from what the terminal actually rendered, so every subsequent
// history-navigation redraw leaves fragments of the AI command on screen.
// Match the latest entry before deleting it, preserving user history when
// HISTCONTROL=ignorespace skips the wrapper. Read the entry's actual number:
// older Bash versions can expose HISTCMD as the next history number.
// builtin history bypasses user aliases and functions. The unset-safe guard
// leaves non-Bash shells alone, including shells with nounset enabled.
// Use ^ for the Bash bracket negation: ! triggers interactive zsh history
// expansion before the Bash-only guard can run.
const historyCleanup =
`[ -n "\${BASH_VERSION-}" ] && { ${marker}_h=$(builtin history 1 2>/dev/null); case "$${marker}_h" in *${marker}*) ${marker}_h=\${${marker}_h#"\${${marker}_h%%[^[:space:]]*}"}; builtin history -d "\${${marker}_h%%[[:space:]]*}" 2>/dev/null ;; esac; }`;
return (
`${marker}=0; ${cmdAssign}; { printf '${startFormat}' '${marker}_S'; trap ':' INT; ( ${noPager}eval "$${marker}_cmd" ); __NCMCP_rc=$?; trap - INT; printf '%s\\n' '${marker}_E:'\"$__NCMCP_rc\"; ${historyCleanup}; (exit $__NCMCP_rc); }`
);
if (Buffer.byteLength(cmdAssign, 'utf8') > 650) {
// Canonical PTYs limit bytes per physical input line, regardless of write
// pacing. Emit bounded quoted pieces inside one command substitution; each
// continuation keeps the marker visible to the terminal echo filter.
const writes = [];
for (const [index, line] of commandLines.entries()) {
let chunk = '';
let bytes = 0;
for (const character of line) {
const quoted = escapePosixSingleQuoted(character);
const size = Buffer.byteLength(quoted, 'utf8');
if (bytes + size > 512) {
writes.push(`printf '%s' '${chunk}'`);
chunk = '';
bytes = 0;
}
chunk += quoted;
bytes += size;
}
writes.push(`printf '${index < commandLines.length - 1 ? '%s\\n' : '%s'}' '${chunk}'`);
}
cmdAssign = `${marker}_cmd=$(${writes.join(`; \\\n: '${marker}'; `)})`;
}
const historyCleanup = buildBashHistoryCleanup(marker);
const prefix = `${marker}=0; ${cmdAssign}; { printf '${startFormat}' '${marker}_S'; trap ':' INT; ( ${noPager}eval "$${marker}_cmd" ); __NCMCP_rc=$?; trap - INT; printf '%s\\n' '${marker}_E:'\"$__NCMCP_rc\"`;
const suffix = `${historyCleanup}; (exit $__NCMCP_rc); }`;
const separator = prefix.length + suffix.length + 2 > 1000
? `; \\\n: '${marker}'; ` : "; ";
return `${prefix}${separator}${suffix}`;
}
function buildWrappedCommand(command, shellKind, marker, separateStartMarker = false) {
@@ -259,7 +289,7 @@ function buildWrappedCommand(command, shellKind, marker, separateStartMarker = f
case "posix":
default: {
// Single-line compound command with early marker.
// Compound command with an early marker on each physical line.
//
// Layout: __NCMCP_xxx=0; { ... MARKER_S; eval command; MARKER_E; }
//
@@ -276,7 +306,7 @@ function buildWrappedCommand(command, shellKind, marker, separateStartMarker = f
// keeps shell syntax errors inside the eval call so the wrapper
// can still emit the end marker and return a non-zero exit code.
//
// 3) Single-line { ... } is parsed fully before execution, so SIGINT
// 3) The complete { ... } group is parsed before execution, so SIGINT
// cannot cause bash to flush the end marker from the input buffer.
// trap ':' INT lets child processes receive SIGINT normally while
// preventing the shell from aborting the compound command.
@@ -470,6 +500,8 @@ module.exports = {
resolveEffectiveShellKind,
buildPendingInputClearPrefix,
buildWrappedCommand,
buildBashHistoryCleanup,
bashHistoryScratchNames,
findEndMarker,
normalizePtyOutput,
appendBoundedOutput,
@@ -0,0 +1,62 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { EventEmitter } = require('node:events');
const { startPtyJob } = require('./ptyExec.cjs');
const { buildLiveShellProbe } = require('./liveShellProbe.cjs');
const { buildWrappedCommand } = require('./ptyExecHelpers.cjs');
for (const background of [true, false]) {
test(`paced ${background ? 'background' : 'silent foreground'} delivery does not consume startup time`, async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const pty = new EventEmitter();
const writes = [];
pty.write = (data) => writes.push(String(data));
const command = `echo ${'x'.repeat(background ? 150000 : 12000)}`;
const job = startPtyJob(pty, command, {
shellKind: 'posix', probeLiveShell: true,
timeoutMs: background ? 3600000 : 500,
maxBufferedChars: background ? 1024 : 0,
});
const advance = (ms) => {
for (let elapsed = 0; elapsed < ms; elapsed += 30) t.mock.timers.tick(30);
};
try {
const probeLength = 2 + buildLiveShellProbe(job.marker).length;
while (writes.join('').length < probeLength && !writes.includes('\x03')) advance(30);
assert.ok(!writes.includes('\x03'), 'probe delivery was interrupted');
pty.emit('data', `${job.marker}_P:sh\n${job.marker}_Q`);
// The background case crosses the old probe timer; the foreground
// case has no echo to refresh its inactivity timer during delivery.
advance(background ? 32010 : 1200);
assert.ok(!writes.includes('\x03'), 'wrapper interrupted before delivery completed');
const totalLength = probeLength + 2 + buildWrappedCommand(command, 'posix', job.marker, true).length;
while (writes.join('').length < totalLength && !writes.includes('\x03')) advance(30);
assert.ok(!writes.includes('\x03'), 'delivery did not finish');
pty.emit('data', `${job.marker}_S\nOK\n${job.marker}_E:0\n`);
assert.equal((await job.resultPromise).exitCode, 0);
} finally {
pty.emit('close');
t.mock.timers.reset();
}
});
}
test('completed delivery still has a bounded wait for a missing probe reply', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const pty = new EventEmitter();
const writes = [];
pty.write = (data) => writes.push(String(data));
const job = startPtyJob(pty, 'echo never', {
shellKind: 'posix', probeLiveShell: true, timeoutMs: 500,
});
const length = 2 + buildLiveShellProbe(job.marker).length;
while (writes.join('').length < length) t.mock.timers.tick(30);
// Unrelated output must not keep a never-started command alive forever.
for (let i = 0; i < 6; i++) {
pty.emit('data', 'unrelated output\n');
t.mock.timers.tick(100);
}
const result = await job.resultPromise;
assert.match(result.error, /Command startup timed out/);
assert.ok(writes.includes('\x03'));
});