mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-01 19:32:33 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb932453bf | ||
|
|
703fa4e804 | ||
|
|
40f8ff5651 | ||
|
|
bd260f1325 | ||
|
|
b94e78d772 | ||
|
|
02e6f4bc56 | ||
|
|
96336ffd49 | ||
|
|
410107ea97 | ||
|
|
c9eaf1a467 | ||
|
|
f8b8a16a80 | ||
|
|
a44fa1e3f5 | ||
|
|
69a2f1183e | ||
|
|
807dfb152d | ||
|
|
33e07991d3 | ||
|
|
28e9982b6a | ||
|
|
774a50a324 | ||
|
|
517f3d9446 |
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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')));
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
Reference in new issue
Block a user