mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e794b1300e | ||
|
|
a7c67033c6 | ||
|
|
45af6072fd | ||
|
|
84bef23e1f | ||
|
|
ddd2bcb08f | ||
|
|
d9b23e690a | ||
|
|
abe7433509 |
No files matched your search
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
|
||||
const { buildBashHistoryCleanup, bashHistoryScratchNames } = require("./ptyExecHelpers.cjs");
|
||||
const { buildBashHistoryCleanup, bashHistoryScratchNames, buildInlineHistoryDrop } = 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.
|
||||
@@ -14,9 +14,13 @@ function buildLiveShellProbe(marker) {
|
||||
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`;
|
||||
// Start display suppression in the PTY before any continuation is read,
|
||||
// independently of PS2 and echo mode. Keep every later physical line short.
|
||||
// The standalone first line crosses a prompt boundary, so delete its history
|
||||
// entry inline (buildInlineHistoryDrop) before an immediate-history hook
|
||||
// such as PROMPT_COMMAND='history -a' can persist it — same reason as the
|
||||
// wrapper input marker in ptyExecHelpers.cjs.
|
||||
return ` true ${marker}; printf '\\n%s\\n' '${marker}_I'; ${buildInlineHistoryDrop(marker)}\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`;
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -34,19 +34,27 @@ 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 probing = true;
|
||||
let pendingInput = '';
|
||||
pty.write = (data) => {
|
||||
if (String(data).includes('command sh -c')) return;
|
||||
if (data === '\x03') return;
|
||||
if (data === "\x03") return;
|
||||
pendingInput += String(data);
|
||||
if (!pendingInput.endsWith('\n')) return;
|
||||
if (!pendingInput.endsWith('\n') || pendingInput.endsWith('\\\n')) return;
|
||||
if (probing) {
|
||||
// Wait for the complete probe, including paced chunks and continuations.
|
||||
if (!pendingInput.includes("printf '%s'")) return;
|
||||
pendingInput = '';
|
||||
probing = false;
|
||||
queueMicrotask(() => pty.emit('data', `${job.marker}_P:sh\n${job.marker}_Q`));
|
||||
return;
|
||||
}
|
||||
if (!pendingInput.includes(`${job.marker}_E:`)) 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}`));
|
||||
};
|
||||
job = startPtyJob(pty, 'printf no-newline-output', { probeLiveShell: true, timeoutMs: 1000 });
|
||||
pty.emit('data', `${job.marker}_P:sh\n${job.marker}_Q`);
|
||||
job = startPtyJob(pty, 'printf no-newline-output', { probeLiveShell: true, timeoutMs: 3000 });
|
||||
const result = await job.resultPromise;
|
||||
assert.equal(result.exitCode, 0, JSON.stringify(result));
|
||||
assert.match(result.stdout, /no-newline-output/);
|
||||
|
||||
@@ -418,7 +418,7 @@ function startPtyJob(ptyStream, command, options) {
|
||||
function finish(stdout, exitCode, error) {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
if (usesLiveShellProbe && !foundStart && typeof onProbeAborted === "function") {
|
||||
if (!foundStart && typeof onProbeAborted === "function") {
|
||||
try {
|
||||
onProbeAborted(marker);
|
||||
} catch {
|
||||
|
||||
@@ -902,16 +902,15 @@ test("posix wrapper isolates set -e failures from the active shell", () => {
|
||||
assert.doesNotMatch(result.stdout, /SHOULD_NOT_PRINT/);
|
||||
});
|
||||
|
||||
test("posix wrapper types multi-line commands as one physical line (no PS2 leak) and preserves semantics", () => {
|
||||
test("posix wrapper marks every continuation and preserves multi-line command 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);
|
||||
// Every continuation carries the marker so its PS2 echo stays hidden.
|
||||
assert.ok(wrapped.trimEnd().split("\n").every(line => line.includes(marker)));
|
||||
|
||||
const result = spawnSync("sh", ["-c", wrapped], { encoding: "utf8" });
|
||||
assert.equal(result.error, undefined);
|
||||
@@ -926,10 +925,10 @@ test("long POSIX assignments preserve quotes, Unicode, and heredoc newlines on b
|
||||
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));
|
||||
assert.ok(wrapped.split('\n').every(line => Buffer.byteLength(line, 'utf8') <= 480));
|
||||
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`);
|
||||
assert.equal(result.stdout, `\n${marker}_I\n\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", () => {
|
||||
@@ -1377,6 +1376,22 @@ test("posix wrapper avoids history expansion in interactive zsh", (t) => {
|
||||
assert.doesNotMatch(result.stderr, /event not found/);
|
||||
});
|
||||
|
||||
|
||||
test("cancelled POSIX input without a live probe resets display suppression", async () => {
|
||||
const pty = new EventEmitter();
|
||||
const resets = [];
|
||||
pty.write = () => {};
|
||||
const job = startPtyJob(pty, "echo should-not-start", {
|
||||
shellKind: "posix", probeLiveShell: false, timeoutMs: 1000,
|
||||
expectedPrompt: "ready$ ", onProbeAborted: marker => resets.push(marker),
|
||||
});
|
||||
pty.emit("data", `${job.marker}_I\nCUSTOM> `);
|
||||
job.cancel();
|
||||
pty.emit("data", "\nready$ ");
|
||||
assert.equal((await job.resultPromise).error, "Cancelled");
|
||||
assert.deepEqual(resets, [job.marker]);
|
||||
});
|
||||
|
||||
test("startPtyJob types the wrapper one code point per write for strict bastions (#3146)", async (t) => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
const writes = [];
|
||||
|
||||
@@ -213,8 +213,39 @@ function buildBashHistoryCleanup(marker, keepDispatcher = false) {
|
||||
// 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`;
|
||||
const previous = `${entry}_prev`;
|
||||
const unsetNames = `${entry} ${previous}${keepDispatcher ? '' : ` ${dispatcher}`}`;
|
||||
// With cmdhist disabled each physical line is a separate history entry.
|
||||
// Keep deleting marked tail entries only while their numbers decrease; a
|
||||
// shadowed history function that makes no progress must fall back, not loop.
|
||||
return [
|
||||
`[ "\${BASH_VERSION-}" ]&&{ for ${dispatcher} in command builtin;do ${entry}=$($${dispatcher} printf x);[ "$${entry}" = x ]||continue;`,
|
||||
`${entry}=$($${dispatcher} history 1);while :;do case "$${entry}" in *${marker}*)`,
|
||||
`${entry}=\${${entry}#"\${${entry}%%[^[:space:]]*}"};${previous}=\${${entry}%%[[:space:]]*};$${dispatcher} history -d "$${previous}";`,
|
||||
`${entry}=$($${dispatcher} history 1);case "$${entry}" in *${marker}*)`,
|
||||
`${entry}=\${${entry}#"\${${entry}%%[^[:space:]]*}"};[ "\${${entry}%%[[:space:]]*}" -lt "$${previous}" ]||break;;*) break;;esac;;*) break;;esac;done;`,
|
||||
`case "$${entry}" in *${marker}*) continue;;esac;`,
|
||||
`$${dispatcher} unset ${unsetNames};break;done; } 2>/dev/null`,
|
||||
].join(` \\\n: "${marker}"; `);
|
||||
}
|
||||
|
||||
// Delete the just-read interactive Bash history entry for a standalone
|
||||
// internal line (the `_I` input marker) before a prompt-boundary
|
||||
// PROMPT_COMMAND='history -a' can persist it. Only an entry whose text
|
||||
// carries this invocation's marker is deleted, so a customization that
|
||||
// suppressed the line (HISTCONTROL=ignorespace) leaves the user's previous
|
||||
// entry untouched — never trust $HISTCMD here. Bash-guarded and
|
||||
// shadowing-safe (verified dispatcher, mirroring buildBashHistoryCleanup);
|
||||
// other POSIX shells leave their entry for the tail cleanup to handle.
|
||||
function buildInlineHistoryDrop(marker) {
|
||||
const { entry, dispatcher } = bashHistoryScratchNames(marker);
|
||||
const inlineEntry = `${entry}_i`;
|
||||
const inlineDispatcher = `${dispatcher}_i`;
|
||||
return [
|
||||
`[ "\${BASH_VERSION-}" ]&&{ for ${inlineDispatcher} in command builtin;do ${inlineEntry}=$($${inlineDispatcher} printf x);[ "$${inlineEntry}" = x ]||continue;`,
|
||||
`${inlineEntry}=$($${inlineDispatcher} history 1);${inlineEntry}=\${${inlineEntry}#"\${${inlineEntry}%%[^[:space:]]*}"};case "$${inlineEntry}" in *'${marker}'*)`,
|
||||
`$${inlineDispatcher} history -d "\${${inlineEntry}%%[[:space:]]*}";;esac;break;done; } 2>/dev/null`,
|
||||
].join(` \\\n: '${marker}'; `);
|
||||
}
|
||||
|
||||
function buildPosixWrapperBody(command, marker, startFormat) {
|
||||
@@ -223,9 +254,10 @@ function buildPosixWrapperBody(command, marker, startFormat) {
|
||||
let cmdAssign = commandLines.length > 1
|
||||
? `${marker}_cmd=$(printf '%s\\n' ${commandLines.map((line) => `'${escapePosixSingleQuoted(line)}'`).join(" ")})`
|
||||
: `${marker}_cmd='${escapePosixSingleQuoted(command)}'`;
|
||||
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
|
||||
if (Buffer.byteLength(cmdAssign, 'utf8') > 350) {
|
||||
// Reserve room below BusyBox's 512-byte editor limit for the assignment,
|
||||
// marker and continuation syntax. Write pacing cannot bypass this limit.
|
||||
// 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()) {
|
||||
@@ -234,7 +266,7 @@ function buildPosixWrapperBody(command, marker, startFormat) {
|
||||
for (const character of line) {
|
||||
const quoted = escapePosixSingleQuoted(character);
|
||||
const size = Buffer.byteLength(quoted, 'utf8');
|
||||
if (bytes + size > 512) {
|
||||
if (bytes + size > 300) {
|
||||
writes.push(`printf '%s' '${chunk}'`);
|
||||
chunk = '';
|
||||
bytes = 0;
|
||||
@@ -247,16 +279,27 @@ function buildPosixWrapperBody(command, marker, startFormat) {
|
||||
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}`;
|
||||
// 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
|
||||
// continuation. A leading newline separates it from concurrent PTY echo.
|
||||
// Because this line ends at a real prompt boundary, an immediate-history
|
||||
// hook (PROMPT_COMMAND='history -a') can persist it before the wrapper's
|
||||
// tail cleanup runs — so delete the entry inline while it is still only in
|
||||
// memory (buildInlineHistoryDrop).
|
||||
return `${marker}=0; printf '\\n%s\\n' '${marker}_I'; ${buildInlineHistoryDrop(marker)}\n` + [
|
||||
` : '${marker}'; ${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); }`,
|
||||
].join(`; \\\n: '${marker}'; `);
|
||||
}
|
||||
|
||||
function buildWrappedCommand(command, shellKind, marker, separateStartMarker = false) {
|
||||
// A live probe leaves its completion marker unterminated to hide the next
|
||||
// prompt. With terminal echo disabled, only the wrapper can end that line.
|
||||
// prompt. POSIX input also hides arbitrary PS2 prompts until a fresh start
|
||||
// marker line; keep the fish behavior controlled by separateStartMarker.
|
||||
const startFormat = separateStartMarker ? "\\n%s\\n" : "%s\\n";
|
||||
switch (shellKind) {
|
||||
case "powershell": {
|
||||
@@ -332,7 +375,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, startFormat)}\n`;
|
||||
return ` ${buildPosixWrapperBody(command, marker, "\\n%s\\n")}\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -501,6 +544,7 @@ module.exports = {
|
||||
buildPendingInputClearPrefix,
|
||||
buildWrappedCommand,
|
||||
buildBashHistoryCleanup,
|
||||
buildInlineHistoryDrop,
|
||||
bashHistoryScratchNames,
|
||||
findEndMarker,
|
||||
normalizePtyOutput,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { buildWrappedCommand } = require('./ptyExecHelpers.cjs');
|
||||
const { buildLiveShellProbe } = require('./liveShellProbe.cjs');
|
||||
|
||||
const marker = '__NCMCP_mttikd5b_ccbc892e865a115a80c88afdc77b96a6__';
|
||||
|
||||
for (const [customization, listHistory] of [
|
||||
[':', 'command builtin history'],
|
||||
['history() { builtin history "$@" | cat; }', 'command builtin history'],
|
||||
['command() { :; }', '\\builtin history'],
|
||||
]) {
|
||||
for (const probe of [false, true]) {
|
||||
test(`separate Bash history lines are removed (${customization}, probe=${probe})`, () => {
|
||||
const input = `HISTFILE=/dev/null; HISTCONTROL=; PS1=; PS2=\nshopt -u cmdhist\n${customization}\n${listHistory} -c\necho user_one\necho user_two\n`
|
||||
+ (probe ? buildLiveShellProbe(marker) : '')
|
||||
+ 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.match(result.stdout, /command_ok/);
|
||||
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'));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const probe of [false, true]) {
|
||||
test(`zsh ignores both logical input commands with HIST_IGNORE_SPACE (probe=${probe})`, (t) => {
|
||||
const input = 'HISTFILE=/dev/null; HISTSIZE=100; setopt HIST_IGNORE_SPACE\necho user_one\n'
|
||||
+ (probe ? buildLiveShellProbe(marker) : '')
|
||||
+ buildWrappedCommand('echo command_ok', 'posix', marker, true)
|
||||
+ '\necho user_two\nfc -l 1\nexit\n';
|
||||
const result = spawnSync('zsh', ['-f', '-i'], {
|
||||
input, encoding: 'utf8', env: { ...process.env, TERM: 'dumb' }, timeout: 5000,
|
||||
});
|
||||
if (result.error?.code === 'ENOENT') {
|
||||
t.skip('zsh is not installed');
|
||||
return;
|
||||
}
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /command_ok/);
|
||||
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'));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { buildWrappedCommand } = require('./ptyExecHelpers.cjs');
|
||||
const { buildLiveShellProbe } = require('./liveShellProbe.cjs');
|
||||
const { execViaPty } = require('./ptyExec.cjs');
|
||||
|
||||
const marker = '__NCMCP_mttikd5b_ccbc892e865a115a80c88afdc77b96a6__';
|
||||
const reportedCommand = "head -5 /etc/openwrt_release 2>/dev/null; echo '---'; uname -a";
|
||||
const literal = "quote' \\ $HOME `false` " + '\u4e2d\u6587\ud83d\ude42'.repeat(100);
|
||||
const cases = [
|
||||
[reportedCommand, /DISTRIB_RELEASE='24\.10\.6'/, 0],
|
||||
["printf '%s' \"it's quoted\"", "it's quoted", 0],
|
||||
[`cat <<'END_LITERAL'\n${literal}\n\nlast\nEND_LITERAL`, `${literal}\n\nlast`, 0],
|
||||
["printf '%s' '" + 'x'.repeat(340) + "'", 'x'.repeat(340), 0],
|
||||
['printf failed; exit 7', 'failed', 7],
|
||||
];
|
||||
|
||||
test('OpenWrt input lines fit its 512-byte editor, including probe and cleanup', () => {
|
||||
for (const source of [buildLiveShellProbe(marker), ...cases.map(([cmd]) => buildWrappedCommand(cmd, 'posix', marker, true))]) {
|
||||
for (const line of source.trimEnd().split('\n')) {
|
||||
assert.ok(Buffer.byteLength(line) <= 480, `input line has ${Buffer.byteLength(line)} bytes: ${line}`);
|
||||
assert.ok(line.includes(marker), 'each continuation must remain hidden by the terminal echo filter');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Point at a disposable OpenWrt 24.10.6 Dropbear instance with blank root
|
||||
// password enabled (-B). No existing router configuration is read or modified.
|
||||
test('real OpenWrt Dropbear PTY executes commands with and without the live probe', {
|
||||
skip: !process.env.NETCATTY_OPENWRT_SSH_PORT,
|
||||
timeout: 60000,
|
||||
}, async () => {
|
||||
const { Client } = require('ssh2');
|
||||
const client = new Client();
|
||||
await new Promise((resolve, reject) => client.once('ready', resolve).once('error', reject).connect({
|
||||
host: '127.0.0.1', port: Number(process.env.NETCATTY_OPENWRT_SSH_PORT), username: 'root', password: '',
|
||||
}));
|
||||
try {
|
||||
for (const probeLiveShell of [false, true]) {
|
||||
const stream = await new Promise((resolve, reject) => client.shell({ term: 'xterm', cols: 240, rows: 30 }, (error, value) => error ? reject(error) : resolve(value)));
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
let output = '';
|
||||
const timer = setTimeout(() => reject(new Error('OpenWrt prompt missing')), 5000);
|
||||
const onData = data => {
|
||||
output += data;
|
||||
if (output.includes(':~# ')) {
|
||||
clearTimeout(timer);
|
||||
stream.removeListener('data', onData);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
stream.on('data', onData);
|
||||
});
|
||||
for (const [command, expected, exitCode] of cases) {
|
||||
const result = await execViaPty(stream, command, {
|
||||
shellKind: 'posix', probeLiveShell, timeoutMs: 3000,
|
||||
});
|
||||
assert.equal(result.exitCode, exitCode, JSON.stringify({ probeLiveShell, result }));
|
||||
if (typeof expected === 'string') assert.equal(result.stdout, expected);
|
||||
else assert.match(result.stdout, expected);
|
||||
}
|
||||
} finally { stream.close(); }
|
||||
}
|
||||
} finally { client.end(); }
|
||||
});
|
||||
@@ -95,7 +95,7 @@ const _mcpLineMetas = new Map(); // sessionId -> trailing fragment metadata
|
||||
const _mcpPendingMetas = new Map(); // sessionId -> metadata from filtered-empty chunks
|
||||
const _mcpFlushTimers = new Map(); // sessionId -> delayed-flush timer
|
||||
const _mcpDroppingWrappedLine = new Set(); // sessionIds with a split marker echo line in progress
|
||||
const _mcpProbePrompts = new Map(); // sessionId -> probe marker awaiting its command
|
||||
const _mcpProbePrompts = new Map(); // sessionId -> input/probe marker awaiting command start
|
||||
const _mcpAbortedProbes = new Map(); // sessionId -> bounded set of cancelled probe markers
|
||||
const MAX_MCP_BUFFERED_LINE_CHARS = 64 * 1024;
|
||||
|
||||
@@ -122,7 +122,9 @@ function filterProbePromptFragment(sessionId, fragment) {
|
||||
if (_mcpProbePrompts.get(sessionId) === reset[1]) _mcpProbePrompts.delete(sessionId);
|
||||
return true;
|
||||
}
|
||||
const completion = fragment.match(/^\r?(__NCMCP_[A-Za-z0-9_]+)_Q/);
|
||||
// I starts suppression before multiline input; Q keeps it active after
|
||||
// the live shell probe. Both end only at the matching command start/reset.
|
||||
const completion = fragment.match(/^\r?(__NCMCP_[A-Za-z0-9_]+)_[IQ]/);
|
||||
if (completion && !_mcpAbortedProbes.get(sessionId)?.has(completion[1])) {
|
||||
_mcpProbePrompts.set(sessionId, completion[1]);
|
||||
}
|
||||
|
||||
@@ -1780,3 +1780,96 @@ test("real PTY multiline prompt is displayed only after the AI command", {
|
||||
preload.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("OpenWrt bounded wrapper continuations stay hidden across fragmented echoes", () => {
|
||||
const { buildWrappedCommand } = require('./bridges/ai/ptyExecHelpers.cjs');
|
||||
const preload = loadPreloadWithFakeElectron();
|
||||
try {
|
||||
const received = [];
|
||||
const sessionId = 'openwrt-echo';
|
||||
const marker = '__NCMCP_mttikd5b_ccbc892e865a115a80c88afdc77b96a6__';
|
||||
preload.api.onSessionData(sessionId, chunk => received.push(chunk));
|
||||
preload.handlers.get("netcatty:data")({}, { sessionId, data: `${marker}_I\n` });
|
||||
const wrapped = buildWrappedCommand("printf 'visible-output\\n'", 'posix', marker);
|
||||
const echo = wrapped.trimEnd().split('\n').map((line, index) => `${index ? '> ' : ''}${line}\r\n`).join('');
|
||||
const data = `${echo}${marker}_S\r\nvisible-output\r\n${marker}_E:0\r\n`;
|
||||
for (let offset = 0; offset < data.length; offset += 7) {
|
||||
preload.handlers.get('netcatty:data')({}, { sessionId, data: data.slice(offset, offset + 7) });
|
||||
}
|
||||
assert.equal(received.join(''), 'visible-output\r\n');
|
||||
} finally {
|
||||
preload.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test("ordinary text resembling an OpenWrt continuation is released", async () => {
|
||||
const preload = loadPreloadWithFakeElectron();
|
||||
try {
|
||||
const received = [];
|
||||
preload.api.onSessionData("ordinary-continuation", chunk => received.push(chunk));
|
||||
preload.handlers.get("netcatty:data")({}, { sessionId: "ordinary-continuation", data: "> : '" });
|
||||
await sleep(120);
|
||||
assert.equal(received.join(""), "> : '");
|
||||
} finally {
|
||||
preload.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
for (const echo of [true, false]) {
|
||||
test(`real PTY hides custom multiline secondary prompts (echo=${echo})`, {
|
||||
skip: process.env.NETCATTY_LIVE_FISH_TEST !== '1', timeout: 10000,
|
||||
}, async () => {
|
||||
const preload = loadPreloadWithFakeElectron();
|
||||
const terminal = require('node-pty').spawn('/bin/bash', ['--noprofile', '--norc', '--noediting'], {
|
||||
name: 'dumb', cols: 240, rows: 24,
|
||||
env: { ...process.env, TERM: 'dumb', PS1: 'READY> ', PS2: 'CUSTOM\nCONT> ', BASH_SILENCE_DEPRECATION_WARNING: '1' },
|
||||
});
|
||||
const received = [];
|
||||
let raw = '';
|
||||
const send = data => preload.handlers.get('netcatty:data')({}, { sessionId: 'custom-ps2', data });
|
||||
preload.api.onSessionData('custom-ps2', data => received.push(data));
|
||||
terminal.onData(data => { raw += data; send(data); });
|
||||
const ready = async () => {
|
||||
const deadline = Date.now() + 3000;
|
||||
while (!raw.includes('READY> ')) {
|
||||
if (Date.now() > deadline) throw new Error('Missing Bash prompt');
|
||||
await sleep(10);
|
||||
}
|
||||
raw = '';
|
||||
await sleep(120);
|
||||
};
|
||||
try {
|
||||
await ready();
|
||||
if (!echo) { terminal.write('stty -echo\r'); await ready(); }
|
||||
for (const probeLiveShell of [false, true]) {
|
||||
received.length = 0;
|
||||
const result = await require('./bridges/ai/ptyExec.cjs').execViaPty(terminal, "printf 'visible-result\\n'", {
|
||||
shellKind: 'posix', probeLiveShell, stripMarkers: true, timeoutMs: 3000,
|
||||
onProbeAborted: (marker) => send(`${marker}_R\n`),
|
||||
});
|
||||
assert.equal(result.exitCode, 0, JSON.stringify(result));
|
||||
await sleep(160);
|
||||
const display = received.join('');
|
||||
assert.match(display, /visible-result/);
|
||||
assert.doesNotMatch(display, /CUSTOM|CONT>|__NCMCP_/, JSON.stringify({ display, raw }));
|
||||
assert.match(display, /READY> /);
|
||||
}
|
||||
} finally { terminal.kill(); preload.cleanup(); }
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
test("aborted input releases custom prompts and ignores a late input marker", () => {
|
||||
const preload = loadPreloadWithFakeElectron();
|
||||
try {
|
||||
const received = [];
|
||||
preload.api.onSessionData("aborted-input", data => received.push(data));
|
||||
const send = data => preload.handlers.get("netcatty:data")({}, { sessionId: "aborted-input", data });
|
||||
send("__NCMCP_input___I\nCUSTOM\nCONT> ");
|
||||
send("__NCMCP_input___R\n");
|
||||
send("normal output\n");
|
||||
send("__NCMCP_input___I\nlate prompt\n");
|
||||
assert.equal(received.join(""), "normal output\nlate prompt\n");
|
||||
} finally { preload.cleanup(); }
|
||||
});
|
||||
@@ -18,8 +18,10 @@ class FakePty extends EventEmitter {
|
||||
|
||||
write(data) {
|
||||
this.writes.push(String(data));
|
||||
if (String(data).includes("command sh -c")) {
|
||||
const marker = String(data).match(/(__NCMCP_[A-Za-z0-9_]+__)/)[1];
|
||||
const input = this.writes.join("");
|
||||
if (!this.probeReplied && input.includes("command sh -c") && input.endsWith("_Q'\n")) {
|
||||
this.probeReplied = true;
|
||||
const marker = input.match(/(__NCMCP_[A-Za-z0-9_]+__)/)[1];
|
||||
queueMicrotask(() => this.emit("data", `${marker}_P:\n${marker}_Q`));
|
||||
}
|
||||
}
|
||||
@@ -52,9 +54,19 @@ function createFakeEvent() {
|
||||
};
|
||||
}
|
||||
|
||||
function extractMarker(writes) {
|
||||
const wrapper = writes.find((entry) => entry.includes("__NCMCP_") && !entry.includes("command sh -c"));
|
||||
assert.ok(wrapper, "expected wrapped command to be written to the PTY");
|
||||
async function waitForWrapper(writes) {
|
||||
const deadline = Date.now() + 5000;
|
||||
while (Date.now() < deadline) {
|
||||
const input = writes.join("");
|
||||
const wrapper = input.slice(input.lastIndexOf("\x0b\x15"));
|
||||
if (wrapper.includes("_cmd") && wrapper.includes("_E:") && wrapper.endsWith("\n") && !wrapper.endsWith("\\\n")) return wrapper;
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
}
|
||||
assert.fail("expected complete wrapped command to be written to the PTY");
|
||||
}
|
||||
|
||||
async function extractMarker(writes) {
|
||||
const wrapper = await waitForWrapper(writes);
|
||||
const match = wrapper.match(/(__NCMCP_[A-Za-z0-9_]+__)/);
|
||||
assert.ok(match, "expected command wrapper to contain an MCP marker");
|
||||
return match[1];
|
||||
@@ -144,7 +156,7 @@ test("worker AI background jobs start, poll, stop, and block overlapping exec",
|
||||
},
|
||||
]);
|
||||
|
||||
const marker = extractMarker(pty.writes);
|
||||
const marker = await extractMarker(pty.writes);
|
||||
pty.emit("data", `${marker}_S\r\nready\r\n`);
|
||||
await nextTick();
|
||||
|
||||
@@ -207,7 +219,7 @@ test("worker chat cancellation stops matching background jobs", async () => {
|
||||
chatSessionId: "chat-1",
|
||||
commandTimeoutMs: 5000,
|
||||
});
|
||||
const marker = extractMarker(pty.writes);
|
||||
const marker = await extractMarker(pty.writes);
|
||||
pty.emit("data", `${marker}_S\r\nrunning\r\n`);
|
||||
await nextTick();
|
||||
|
||||
@@ -261,11 +273,11 @@ test("worker background job probes unset remote shellKind before wrapping", asyn
|
||||
// Login fish is a soft hint (not pinned); wrapper should be fish-native.
|
||||
assert.equal(sessions.get("ssh-fish").shellKind, undefined);
|
||||
assert.equal(sessions.get("ssh-fish")._loginShellKind, "fish");
|
||||
const wrapper = pty.writes.find((entry) => entry.includes("__NCMCP_") && !entry.includes("command sh -c"));
|
||||
const wrapper = await waitForWrapper(pty.writes);
|
||||
assert.match(wrapper, /set -l __NCMCP_.*_cmd/);
|
||||
assert.doesNotMatch(wrapper, / sh -c '/);
|
||||
|
||||
const marker = extractMarker(pty.writes);
|
||||
const marker = await extractMarker(pty.writes);
|
||||
pty.emit("data", `${marker}_S\r\n${marker}_E:0\r\n`);
|
||||
await nextTick();
|
||||
});
|
||||
@@ -305,7 +317,7 @@ test("worker background job reserves the session while shellKind probe is pendin
|
||||
const first = await firstStart;
|
||||
assert.equal(first.ok, true);
|
||||
|
||||
const marker = extractMarker(pty.writes);
|
||||
const marker = await extractMarker(pty.writes);
|
||||
pty.emit("data", `${marker}_S\r\n${marker}_E:0\r\n`);
|
||||
await nextTick();
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user