Compare commits

...
4 changed files with 60 additions and 28 deletions

No files matched your search

+15 -7
View File
@@ -6,7 +6,17 @@ const { buildBashHistoryCleanup, bashHistoryScratchNames } = require("./ptyExecH
// 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"`;
+ `printf "\\n${marker}_P:%s\\n" "$name"`;
// Keep the initial probe short before the active shell is known. Quoted
// multiline cleanup input can leave a configured zsh editor waiting for
// continuation input, so defer that Bash-only work until after the reply.
return ` true ${marker}; printf '\\n%s\\n' '${marker}_I'\n : '${marker}'; command sh -c '${script}' 2>/dev/null; printf '%s' '${marker}_Q'\n`;
}
// This is sent after the short probe, except for zsh/fish replies. Keep the
// existing guarded cleanup and bounded physical lines for Bash's history and
// canonical-input behavior, including renamed Bash executables.
function buildLiveShellProbeCleanup(marker) {
// 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.
@@ -14,10 +24,7 @@ function buildLiveShellProbe(marker) {
const { dispatcher } = bashHistoryScratchNames(marker);
const clear = `[ -z "\${${dispatcher}-}" ]||$${dispatcher} unset ${dispatcher}`;
const fallback = `[ "\${${dispatcher}-}" = command ]||{ ${cleanup}; };${clear}`;
// Start display suppression in the PTY before any continuation is read,
// independently of PS2 and echo mode. Keep every later physical line short.
return ` true ${marker}; printf '\\n%s\\n' '${marker}_I'\n : '${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`;
return ` : '${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\n`;
}
// Interactive bash prints PS2 (`> ` by default) on backslash continuations.
@@ -35,11 +42,12 @@ function parseLiveShellProbe(output, marker) {
if (!normalized.startsWith(`${marker}_P:`)) continue;
const name = normalized.slice(marker.length + 3).trim().split("/").pop().replace(/^-/, "");
return {
shellName: name,
kind: name === "fish" ? "fish"
: /^(?:ba|da|z|k|a)?sh$/.test(name) ? "posix" : null,
};
}
return { kind: null };
return { kind: null, shellName: "" };
}
module.exports = { buildLiveShellProbe, parseLiveShellProbe, normalizeLiveShellProbeLine };
module.exports = { buildLiveShellProbe, buildLiveShellProbeCleanup, parseLiveShellProbe, normalizeLiveShellProbeLine };
+28 -11
View File
@@ -1,7 +1,7 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { EventEmitter } = require('node:events');
const { buildLiveShellProbe, parseLiveShellProbe } = require('./liveShellProbe.cjs');
const { buildLiveShellProbe, buildLiveShellProbeCleanup, parseLiveShellProbe } = require('./liveShellProbe.cjs');
const { startPtyJob } = require('./ptyExec.cjs');
test('live shell response excludes echoed commands, stale markers and partial lines', () => {
@@ -9,11 +9,11 @@ test('live shell response excludes echoed commands, stale markers and partial li
assert.equal(parseLiveShellProbe(buildLiveShellProbe(marker), marker), null);
assert.equal(parseLiveShellProbe(`${marker}_P:fi`, marker), null);
assert.equal(parseLiveShellProbe('__NCMCP_old___P:fish\n', marker), null);
assert.deepEqual(parseLiveShellProbe(`\r${marker}_P:/usr/bin/fish\r\n${marker}_Q`, marker), { kind: 'fish' });
assert.deepEqual(parseLiveShellProbe(`${marker}_P:-zsh\n${marker}_Q`, marker), { kind: 'posix' });
assert.deepEqual(parseLiveShellProbe(`${marker}_P:\n${marker}_Q`, marker), { kind: null });
assert.deepEqual(parseLiveShellProbe(`> ${marker}_P:bash\n> ${marker}_Q`, marker), { kind: 'posix' });
assert.deepEqual(parseLiveShellProbe(`> ${marker}_P:fish\n> ${marker}_Q`, marker), { kind: 'fish' });
assert.deepEqual(parseLiveShellProbe(`\r${marker}_P:/usr/bin/fish\r\n${marker}_Q`, marker), { kind: 'fish', shellName: 'fish' });
assert.deepEqual(parseLiveShellProbe(`${marker}_P:-zsh\n${marker}_Q`, marker), { kind: 'posix', shellName: 'zsh' });
assert.deepEqual(parseLiveShellProbe(`${marker}_P:\n${marker}_Q`, marker), { kind: null, shellName: '' });
assert.deepEqual(parseLiveShellProbe(`> ${marker}_P:bash\n> ${marker}_Q`, marker), { kind: 'posix', shellName: 'bash' });
assert.deepEqual(parseLiveShellProbe(`> ${marker}_P:fish\n> ${marker}_Q`, marker), { kind: 'fish', shellName: 'fish' });
});
test('probe waits for complete reply before choosing the first wrapper', async () => {
@@ -260,7 +260,7 @@ for (const [customization, listHistory] of [
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)
+ buildLiveShellProbe(marker) + buildLiveShellProbeCleanup(marker)
+ (executeCommand ? buildWrappedCommand('echo command_ok', 'posix', marker, true) : '')
+ '\nprintf \"\\n\"\n' + listHistory + '\nexit\n';
const result = spawnSync('/bin/bash', ['--noprofile', '--norc', '-i'], {
@@ -337,7 +337,7 @@ for (const invocationName of ['sh', 'renamed-bash']) {
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',
+ buildLiveShellProbe(marker) + buildLiveShellProbeCleanup(marker) + '\nprintf "\\n"\ncommand builtin history\nexit\n',
encoding: 'utf8', env: { ...process.env, TERM: 'dumb' }, timeout: 5000,
});
assert.equal(result.status, 0, result.stderr);
@@ -389,7 +389,7 @@ for (const historyControl of ['', 'ignorespace']) {
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)
+ buildLiveShellProbe(marker) + buildLiveShellProbeCleanup(marker)
+ buildWrappedCommand('echo command_ok', 'posix', marker, true)
+ '\ncommand history\nexit\n';
const result = spawnSync('/bin/bash', ['--noprofile', '--norc', '-i'], {
@@ -406,7 +406,7 @@ for (const historyControl of ['', 'ignorespace']) {
}
for (const stop of ['cancel', 'timeout']) {
test(`paced probe stops writing after ${stop}`, async () => {
test(`paced Bash probe cleanup stops writing after ${stop}`, async () => {
const pty = new EventEmitter();
const writes = [];
pty.write = data => writes.push(data);
@@ -414,8 +414,9 @@ for (const stop of ['cancel', 'timeout']) {
shellKind: 'posix', probeLiveShell: true, timeoutMs: stop === 'timeout' ? 70 : 1000,
enforceWallTimeout: stop === 'timeout',
});
pty.emit('data', `${job.marker}_P:bash\n${job.marker}_Q`);
await new Promise(resolve => setTimeout(resolve, 45));
assert.ok(writes.length >= 2, 'probe must have started a later chunk');
assert.ok(writes.length >= 3, 'cleanup must have started a later chunk');
if (stop === 'cancel') {
job.cancel();
pty.emit('close');
@@ -427,3 +428,19 @@ for (const stop of ['cancel', 'timeout']) {
assert.ok(writes.every(data => !data.includes('must_not_run')));
});
}
for (const shellName of ['zsh', '/bin/zsh', 'zsh-5.9', 'fish', 'bash', 'sh', 'renamed-bash']) {
test(`live probe defers Bash history input until shell reply (${shellName})`, async () => {
const pty = new EventEmitter();
const writes = [];
pty.write = (data) => writes.push(String(data));
const job = startPtyJob(pty, 'printf success', { shellKind: 'posix', probeLiveShell: true, timeoutMs: 1000 });
assert.doesNotMatch(writes.join(''), /history -d|printf success/);
pty.emit('data', `${job.marker}_P:${shellName}\n${job.marker}_Q`);
const deferred = writes.slice(1).join('');
const needsCleanup = shellName !== 'fish' && !/zsh/.test(shellName);
assert.equal(deferred.includes('\\command eval'), needsCleanup);
pty.emit('data', `${job.marker}_S\nsuccess\n${job.marker}_E:0\n`);
assert.equal((await job.resultPromise).exitCode, 0);
});
}
+12 -5
View File
@@ -28,7 +28,7 @@ const {
} = require("./ptyExecHelpers.cjs");
const { extractTrailingIdlePrompt } = require("./shellUtils.cjs");
const { buildLiveShellProbe, parseLiveShellProbe } = require("./liveShellProbe.cjs");
const { buildLiveShellProbe, buildLiveShellProbeCleanup, parseLiveShellProbe } = require("./liveShellProbe.cjs");
const DEFAULT_FOREGROUND_PTY_CAPTURE_CHARS = 1024 * 1024;
const END_MARKER_PROMPT_WAIT_MS = 30000;
@@ -560,7 +560,9 @@ function startPtyJob(ptyStream, command, options) {
probeOutput = "";
if (probe.kind) resolvedShellKind = probe.kind;
if (finished || cancelRequested) return;
writeWrappedCommand();
const isZsh = /^zsh(?:[-.][0-9].*)?$/i.test(probe.shellName);
const skipProbeCleanup = probe.kind === "fish" || isZsh;
writeWrappedCommand(skipProbeCleanup ? "" : buildLiveShellProbeCleanup(marker), isZsh);
return;
}
@@ -822,9 +824,14 @@ function startPtyJob(ptyStream, command, options) {
writeNext();
}
function writeWrappedCommand() {
const wrapped = buildWrappedCommand(command, resolvedShellKind, marker, probeLiveShell);
writeInput(`${(skipPendingInputClear ? "" : buildPendingInputClearPrefix(resolvedShellKind))}${wrapped}`);
function writeWrappedCommand(probeCleanup = "", isZsh = false) {
// The Bash-only cleanup is a no-op in zsh, but its long input still goes
// through the configured editor. Keep the existing bounded wrapper and
// omit only that unused fragment after a live zsh reply.
const wrapped = buildWrappedCommand(command, resolvedShellKind, marker, probeLiveShell, {
omitBashHistoryCleanup: isZsh,
});
writeInput(`${(skipPendingInputClear ? "" : buildPendingInputClearPrefix(resolvedShellKind))}${probeCleanup}${wrapped}`);
}
// Prime the renderer's display suppression before the first byte is typed
+5 -5
View File
@@ -243,7 +243,7 @@ function buildBashHistoryCleanup(marker, keepDispatcher = false) {
].join(` \\\n: "${marker}"; `);
}
function buildPosixWrapperBody(command, marker, startFormat) {
function buildPosixWrapperBody(command, marker, startFormat, { omitBashHistoryCleanup = false } = {}) {
const noPager = "PAGER=cat SYSTEMD_PAGER= GIT_PAGER=cat LESS= ";
const commandLines = String(command || "").replace(/\r\n?/g, "\n").split("\n");
let cmdAssign = commandLines.length > 1
@@ -273,7 +273,7 @@ function buildPosixWrapperBody(command, marker, startFormat) {
}
cmdAssign = `${marker}_cmd=$(${writes.join(`; \\\n: '${marker}'; `)})`;
}
const historyCleanup = buildBashHistoryCleanup(marker);
const historyCleanup = omitBashHistoryCleanup ? [] : [buildBashHistoryCleanup(marker)];
// OpenWrt builds BusyBox with a 512-byte interactive line editor. Keep
// every physical line below that limit, even for short user commands.
// Emit a short standalone input marker before the shell reads any PS2
@@ -282,12 +282,12 @@ function buildPosixWrapperBody(command, marker, startFormat) {
` : '${marker}'; ${cmdAssign}`,
`{ printf '${startFormat}' '${marker}_S'; trap ':' INT; ( ${noPager}eval "$${marker}_cmd" ); __NCMCP_rc=$?; trap - INT`,
`printf '%s\\n' '${marker}_E:'"$__NCMCP_rc"`,
historyCleanup,
...historyCleanup,
`(exit $__NCMCP_rc); }`,
].join(`; \\\n: '${marker}'; `);
}
function buildWrappedCommand(command, shellKind, marker, separateStartMarker = false) {
function buildWrappedCommand(command, shellKind, marker, separateStartMarker = false, options = {}) {
// A live probe leaves its completion marker unterminated to hide the next
// prompt. POSIX input also hides arbitrary PS2 prompts until a fresh start
// marker line; keep the fish behavior controlled by separateStartMarker.
@@ -366,7 +366,7 @@ function buildWrappedCommand(command, shellKind, marker, separateStartMarker = f
// can opt in by adding `HISTCONTROL=ignoreboth` to ~/.bashrc.
// Without that config the prefix is harmless; it just doesn't
// suppress history recording.
return ` ${buildPosixWrapperBody(command, marker, "\\n%s\\n")}\n`;
return ` ${buildPosixWrapperBody(command, marker, "\\n%s\\n", options)}\n`;
}
}
}