mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-06 14:30:13 +00:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ce9a1722f | ||
|
|
7c10f9b4fc | ||
|
|
4f88ea29aa | ||
|
|
8c97793003 | ||
|
|
95d41f3183 | ||
|
|
939f1d09f9 | ||
|
|
9f50601650 | ||
|
|
073722a9ef | ||
|
|
d6db476564 | ||
|
|
a5b8c0a065 | ||
|
|
c608e1a0c7 | ||
|
|
c05022376e | ||
|
|
f3b121e973 | ||
|
|
22894723fb | ||
|
|
effcc7eaf0 | ||
|
|
421395bb44 | ||
|
|
acaf27a36c | ||
|
|
210b1440b3 | ||
|
|
166f2e8ea7 | ||
|
|
89a68ca76f | ||
|
|
30b4dfc213 | ||
|
|
f777370455 |
No files matched your search
@@ -695,6 +695,11 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Security CDN endpoints can lack files advertised in their indexes.
|
||||
# Fall back per file, with the final Bullseye security snapshot for packages only.
|
||||
# Live signed indexes still select versions and verify every download.
|
||||
printf 'http://deb.debian.org/debian-security\tpriority:1\nhttp://security.debian.org/debian-security\tpriority:2\nhttp://snapshot.debian.org/archive/debian-security/20260901T000000Z/\tpriority:3 type:deb\n' > /etc/apt/security-mirrors.txt
|
||||
sed -i 's|http://deb.debian.org/debian-security|mirror+file:/etc/apt/security-mirrors.txt|g' /etc/apt/sources.list
|
||||
apt-get -o Acquire::Retries=4 update
|
||||
apt-get -o Acquire::Retries=4 install -y curl build-essential python3 git libfuse2 file rpm \
|
||||
libarchive-tools \
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"use strict";
|
||||
|
||||
// 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`;
|
||||
}
|
||||
|
||||
function parseLiveShellProbe(output, marker) {
|
||||
const lines = String(output).replace(/\r/g, "\n").split("\n");
|
||||
if (!lines.some((line) => line.startsWith(`${marker}_Q`))) return null;
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith(`${marker}_P:`)) continue;
|
||||
const name = line.slice(marker.length + 3).trim().split("/").pop().replace(/^-/, "");
|
||||
return {
|
||||
kind: name === "fish" ? "fish"
|
||||
: /^(?:ba|da|z|k|a)?sh$/.test(name) ? "posix" : null,
|
||||
};
|
||||
}
|
||||
return { kind: null };
|
||||
}
|
||||
|
||||
module.exports = { buildLiveShellProbe, parseLiveShellProbe };
|
||||
@@ -0,0 +1,164 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const { buildLiveShellProbe, parseLiveShellProbe } = require('./liveShellProbe.cjs');
|
||||
const { startPtyJob } = require('./ptyExec.cjs');
|
||||
|
||||
test('live shell response excludes echoed commands, stale markers and partial lines', () => {
|
||||
const marker = '__NCMCP_probe__';
|
||||
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 });
|
||||
});
|
||||
|
||||
test('probe waits for complete reply before choosing the first wrapper', async () => {
|
||||
const pty = new EventEmitter();
|
||||
const writes = [];
|
||||
pty.write = (data) => writes.push(data);
|
||||
const job = startPtyJob(pty, 'printf success', { shellKind: 'posix', probeLiveShell: true, timeoutMs: 1000 });
|
||||
assert.equal(writes.length, 1);
|
||||
assert.ok(!writes[0].includes('printf success'));
|
||||
pty.emit('data', `${job.marker}_P:fi`);
|
||||
assert.equal(writes.length, 1);
|
||||
pty.emit('data', `sh\r\n${job.marker}_Q`);
|
||||
assert.equal(writes.length, 2);
|
||||
assert.ok(writes[1].includes('function __ncmcp_int'));
|
||||
pty.emit('data', `${job.marker}_S\r\nsuccess\r\n${job.marker}_E:0\r\n`);
|
||||
assert.equal((await job.resultPromise).exitCode, 0);
|
||||
});
|
||||
|
||||
test('probe wrapper keeps the start marker separate when terminal echo is disabled', async () => {
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const pty = new EventEmitter();
|
||||
let job;
|
||||
pty.write = (data) => {
|
||||
if (String(data).includes('command sh -c')) return;
|
||||
if (data === '\x03') return;
|
||||
const script = String(data).replace(/^\x15\x0b/, '');
|
||||
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`);
|
||||
const result = await job.resultPromise;
|
||||
assert.equal(result.exitCode, 0, JSON.stringify(result));
|
||||
assert.match(result.stdout, /no-newline-output/);
|
||||
});
|
||||
|
||||
test('cancelled probe never injects user command after a late reply', async () => {
|
||||
const pty = new EventEmitter();
|
||||
const writes = [];
|
||||
pty.write = (data) => writes.push(data);
|
||||
const job = startPtyJob(pty, 'touch should-not-run', { probeLiveShell: true, timeoutMs: 1000 });
|
||||
job.cancel();
|
||||
pty.emit('data', `${job.marker}_P:fish\n${job.marker}_Q`);
|
||||
pty.emit('close');
|
||||
await job.resultPromise;
|
||||
assert.ok(writes.every((data) => !data.includes('touch should-not-run')));
|
||||
});
|
||||
|
||||
test('cancelling a probe completes when the idle prompt returns', async () => {
|
||||
const pty = new EventEmitter();
|
||||
pty.write = () => {};
|
||||
const job = startPtyJob(pty, 'should-not-run', {
|
||||
probeLiveShell: true, expectedPrompt: 'user@host:~$ ', timeoutMs: 1000,
|
||||
});
|
||||
job.cancel();
|
||||
pty.emit('data', 'user@host:~$ ');
|
||||
const result = await job.resultPromise;
|
||||
assert.equal(result.error, 'Cancelled');
|
||||
});
|
||||
|
||||
test('real PTY: first execution in startup fish and return to parent shells', {
|
||||
skip: process.env.NETCATTY_LIVE_FISH_TEST !== '1', timeout: 30000,
|
||||
}, async () => {
|
||||
const nodePty = require('node-pty');
|
||||
const { execViaPty } = require('./ptyExec.cjs');
|
||||
for (const [parent, startup] of [['/bin/bash', false], ['/bin/zsh', false], ['/bin/bash', true]]) {
|
||||
const fishCommand = "fish --no-config -C 'function fish_prompt; printf FISH_READY\\>\\ ; end'";
|
||||
const terminal = nodePty.spawn(parent, startup ? ['-c', `exec ${fishCommand}`] : parent.endsWith('bash') ? ['--noprofile', '--norc'] : ['-f'], {
|
||||
name: 'dumb', cols: 240, rows: 24,
|
||||
env: { ...process.env, TERM: 'dumb', PS1: 'PARENT_READY> ', BASH_SILENCE_DEPRECATION_WARNING: '1' },
|
||||
});
|
||||
let output = '';
|
||||
terminal.onData((data) => { output += data; });
|
||||
const waitFor = async (text) => {
|
||||
const deadline = Date.now() + 5000;
|
||||
while (!output.includes(text)) {
|
||||
if (Date.now() > deadline) throw new Error(`Missing ${text}: ${output.slice(-1500)}`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
output = '';
|
||||
};
|
||||
try {
|
||||
if (!startup) {
|
||||
await waitFor('PARENT_READY>');
|
||||
terminal.write('echo earlier-command\r');
|
||||
await waitFor('PARENT_READY>');
|
||||
terminal.write(`${fishCommand}\r`);
|
||||
}
|
||||
await waitFor('FISH_READY>');
|
||||
const result = await execViaPty(terminal, 'printf first-command-success', {
|
||||
loginShellHint: 'posix', probeLiveShell: true, stripMarkers: true, timeoutMs: 3000, enforceWallTimeout: true,
|
||||
});
|
||||
assert.equal(result.exitCode, 0, JSON.stringify(result));
|
||||
assert.match(result.stdout, /first-command-success/);
|
||||
if (startup) {
|
||||
await waitFor('FISH_READY>');
|
||||
terminal.write('set -gx PATH /nonexistent\r');
|
||||
await waitFor('FISH_READY>');
|
||||
const fallback = await execViaPty(terminal, 'printf fallback-success', {
|
||||
shellKind: 'fish', probeLiveShell: true, timeoutMs: 3000,
|
||||
});
|
||||
assert.equal(fallback.exitCode, 0, JSON.stringify(fallback));
|
||||
assert.match(fallback.stdout, /fallback-success/);
|
||||
continue;
|
||||
}
|
||||
await waitFor('FISH_READY>');
|
||||
terminal.write('exit\r');
|
||||
await waitFor('PARENT_READY>');
|
||||
const returned = await execViaPty(terminal, 'printf parent-command-success', {
|
||||
loginShellHint: 'fish', probeLiveShell: true, stripMarkers: true, timeoutMs: 3000,
|
||||
});
|
||||
assert.equal(returned.exitCode, 0, JSON.stringify(returned));
|
||||
assert.match(returned.stdout, /parent-command-success/);
|
||||
} finally {
|
||||
terminal.kill();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('real PTY: echo-disabled bash completes output without a trailing newline', {
|
||||
skip: process.env.NETCATTY_LIVE_FISH_TEST !== '1', timeout: 10000,
|
||||
}, async () => {
|
||||
const pty = require('node-pty').spawn('/bin/bash', ['--noprofile', '--norc', '--noediting'], {
|
||||
name: 'dumb', cols: 240, rows: 24,
|
||||
env: { ...process.env, TERM: 'dumb', PS1: 'NOECHO_READY> ', BASH_SILENCE_DEPRECATION_WARNING: '1' },
|
||||
});
|
||||
let output = '';
|
||||
pty.onData((data) => { output += data; });
|
||||
const ready = async () => {
|
||||
const deadline = Date.now() + 3000;
|
||||
while (!output.includes('NOECHO_READY>')) {
|
||||
if (Date.now() > deadline) throw new Error('No echo-disabled shell prompt');
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
output = '';
|
||||
};
|
||||
try {
|
||||
await ready();
|
||||
pty.write('stty -echo\n');
|
||||
await ready();
|
||||
const result = await require('./ptyExec.cjs').execViaPty(pty, 'printf noecho-success', {
|
||||
shellKind: 'posix', probeLiveShell: true, timeoutMs: 2000,
|
||||
});
|
||||
assert.equal(result.exitCode, 0, JSON.stringify(result));
|
||||
assert.match(result.stdout, /noecho-success/);
|
||||
assert.ok(!output.includes('command sh -c'), 'the terminal must actually suppress input echo');
|
||||
} finally {
|
||||
pty.kill();
|
||||
}
|
||||
});
|
||||
@@ -28,6 +28,8 @@ const {
|
||||
} = require("./ptyExecHelpers.cjs");
|
||||
const { extractTrailingIdlePrompt } = require("./shellUtils.cjs");
|
||||
|
||||
const { buildLiveShellProbe, parseLiveShellProbe } = require("./liveShellProbe.cjs");
|
||||
|
||||
const DEFAULT_FOREGROUND_PTY_CAPTURE_CHARS = 1024 * 1024;
|
||||
const END_MARKER_PROMPT_WAIT_MS = 30000;
|
||||
const promptRecoveryPendingPtys = new WeakSet();
|
||||
@@ -54,6 +56,8 @@ function startPtyJob(ptyStream, command, options) {
|
||||
timeoutMs = 60000,
|
||||
shellKind,
|
||||
loginShellHint,
|
||||
probeLiveShell = false,
|
||||
onProbeAborted,
|
||||
chatSessionId,
|
||||
abortSignal,
|
||||
expectedPrompt,
|
||||
@@ -65,7 +69,7 @@ function startPtyJob(ptyStream, command, options) {
|
||||
} = options || {};
|
||||
|
||||
const marker = `__NCMCP_${Date.now().toString(36)}_${crypto.randomBytes(16).toString('hex')}__`;
|
||||
const resolvedShellKind = resolveEffectiveShellKind(shellKind, expectedPrompt, {
|
||||
let resolvedShellKind = resolveEffectiveShellKind(shellKind, expectedPrompt, {
|
||||
loginShellHint,
|
||||
});
|
||||
const waitForReturnedPrompt = loginShellHint === "cmd"
|
||||
@@ -88,6 +92,10 @@ function startPtyJob(ptyStream, command, options) {
|
||||
const CANCEL_RETRY_MS = 5000;
|
||||
const CANCEL_WALL_TIMEOUT_MS = 30000;
|
||||
|
||||
const usesLiveShellProbe = probeLiveShell && ["posix", "fish"].includes(resolvedShellKind);
|
||||
let probingShell = usesLiveShellProbe;
|
||||
let probeOutput = "";
|
||||
|
||||
let output = "";
|
||||
let foundStart = false;
|
||||
let preStartOutput = "";
|
||||
@@ -405,6 +413,13 @@ function startPtyJob(ptyStream, command, options) {
|
||||
function finish(stdout, exitCode, error) {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
if (usesLiveShellProbe && !foundStart && typeof onProbeAborted === "function") {
|
||||
try {
|
||||
onProbeAborted(marker);
|
||||
} catch {
|
||||
// Display cleanup must never prevent command cancellation or completion.
|
||||
}
|
||||
}
|
||||
clearTimeout(timeoutId);
|
||||
clearTimeout(wallTimeoutId);
|
||||
clearStartupTimeout();
|
||||
@@ -513,6 +528,22 @@ function startPtyJob(ptyStream, command, options) {
|
||||
if (!text) return;
|
||||
if (!pendingEnd) armOutputTimeout();
|
||||
|
||||
if (probingShell) {
|
||||
probeOutput = (probeOutput + text).slice(-16384);
|
||||
if (cancelRequested && hasExpectedPromptSuffix(probeOutput, expectedPrompt)) {
|
||||
finish("", -1, "Cancelled");
|
||||
return;
|
||||
}
|
||||
const probe = parseLiveShellProbe(stripAnsi(probeOutput), marker);
|
||||
if (!probe) return;
|
||||
probingShell = false;
|
||||
probeOutput = "";
|
||||
if (probe.kind) resolvedShellKind = probe.kind;
|
||||
if (finished || cancelRequested) return;
|
||||
writeWrappedCommand();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!foundStart) {
|
||||
preStartOutput += text;
|
||||
const combined = pendingStart + text;
|
||||
@@ -698,8 +729,16 @@ function startPtyJob(ptyStream, command, options) {
|
||||
}
|
||||
}
|
||||
|
||||
const wrapped = buildWrappedCommand(command, resolvedShellKind, marker);
|
||||
ptyStream.write(`${buildPendingInputClearPrefix(resolvedShellKind)}${wrapped}`);
|
||||
function writeWrappedCommand() {
|
||||
const wrapped = buildWrappedCommand(command, resolvedShellKind, marker, probeLiveShell);
|
||||
ptyStream.write(`${buildPendingInputClearPrefix(resolvedShellKind)}${wrapped}`);
|
||||
}
|
||||
|
||||
if (probingShell) {
|
||||
ptyStream.write(`${buildPendingInputClearPrefix(resolvedShellKind)}${buildLiveShellProbe(marker)}`);
|
||||
} else {
|
||||
writeWrappedCommand();
|
||||
}
|
||||
|
||||
return {
|
||||
marker,
|
||||
|
||||
@@ -1240,3 +1240,33 @@ test("execViaChannel cancellation never exposes an incomplete UTF-8 character",
|
||||
assert.doesNotMatch(result.stdout, /�/u);
|
||||
assert.equal(result.error, "Cancelled");
|
||||
});
|
||||
|
||||
|
||||
test("cancelled live probe resets display once without injecting a command", async () => {
|
||||
for (const callbackThrows of [false, true]) {
|
||||
const pty = new EventEmitter();
|
||||
const writes = [];
|
||||
const resets = [];
|
||||
pty.write = (data) => writes.push(data);
|
||||
const job = startPtyJob(pty, "touch should-not-run", {
|
||||
shellKind: "posix",
|
||||
probeLiveShell: true,
|
||||
expectedPrompt: "user@host:~$ ",
|
||||
timeoutMs: 1000,
|
||||
onProbeAborted: (marker) => {
|
||||
resets.push(marker);
|
||||
if (callbackThrows) throw new Error("Renderer closed");
|
||||
},
|
||||
});
|
||||
job.cancel();
|
||||
pty.emit("data", "\r\nuser@host:~$ ");
|
||||
const result = await job.resultPromise;
|
||||
assert.equal(result.error, "Cancelled");
|
||||
assert.deepEqual(resets, [job.marker]);
|
||||
pty.emit("data", `${job.marker}_P:fish\n${job.marker}_Q`);
|
||||
job.cancel();
|
||||
assert.deepEqual(resets, [job.marker]);
|
||||
assert.ok(writes.every((data) => !data.includes("touch should-not-run")));
|
||||
assert.ok(writes.every((data) => !data.includes(`${job.marker}_R`)));
|
||||
}
|
||||
});
|
||||
@@ -198,18 +198,21 @@ function buildPendingInputClearPrefix(shellKind) {
|
||||
}
|
||||
}
|
||||
|
||||
function buildPosixWrapperBody(command, marker) {
|
||||
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
|
||||
? `${marker}_cmd=$(printf '%s\\n' ${commandLines.map((line) => `'${escapePosixSingleQuoted(line)}'`).join(" ")})`
|
||||
: `${marker}_cmd='${escapePosixSingleQuoted(command)}'`;
|
||||
return (
|
||||
`${marker}=0; ${cmdAssign}; { printf '%s\\n' '${marker}_S'; trap ':' INT; ( ${noPager}eval "$${marker}_cmd" ); __NCMCP_rc=$?; trap - INT; printf '%s\\n' '${marker}_E:'\"$__NCMCP_rc\"; (exit $__NCMCP_rc); }`
|
||||
`${marker}=0; ${cmdAssign}; { printf '${startFormat}' '${marker}_S'; trap ':' INT; ( ${noPager}eval "$${marker}_cmd" ); __NCMCP_rc=$?; trap - INT; printf '%s\\n' '${marker}_E:'\"$__NCMCP_rc\"; (exit $__NCMCP_rc); }`
|
||||
);
|
||||
}
|
||||
|
||||
function buildWrappedCommand(command, shellKind, 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.
|
||||
const startFormat = separateStartMarker ? "\\n%s\\n" : "%s\\n";
|
||||
switch (shellKind) {
|
||||
case "powershell": {
|
||||
const psPager = "$env:PAGER='cat'; $env:SYSTEMD_PAGER=''; $env:GIT_PAGER='cat'; $env:LESS=''; ";
|
||||
@@ -235,7 +238,7 @@ function buildWrappedCommand(command, shellKind, marker) {
|
||||
` set ${marker} 0; function __ncmcp_int --on-signal INT; printf '%s\\n' '${marker}_E:130'; functions -e __ncmcp_int; end; ` +
|
||||
`set -l ${marker}_cmd '${escapeFishSingleQuoted(command)}'; ` +
|
||||
`begin; set -gx PAGER cat; set -gx SYSTEMD_PAGER ''; set -gx GIT_PAGER cat; set -gx LESS ''; ` +
|
||||
`printf '%s\\n' '${marker}_S'; eval \$${marker}_cmd; set __NCMCP_rc $status; ` +
|
||||
`printf '${startFormat}' '${marker}_S'; eval \$${marker}_cmd; set __NCMCP_rc $status; ` +
|
||||
`functions -e __ncmcp_int; printf '%s\\n' '${marker}_E:'\$__NCMCP_rc; end\n`
|
||||
);
|
||||
|
||||
@@ -284,7 +287,7 @@ function buildWrappedCommand(command, shellKind, marker) {
|
||||
// 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`;
|
||||
return ` ${buildPosixWrapperBody(command, marker, startFormat)}\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,6 +176,11 @@ function registerCattyExecHandlers(ctx) {
|
||||
timeoutMs,
|
||||
shellKind: session.shellKind,
|
||||
loginShellHint: session._loginShellKind,
|
||||
probeLiveShell: true,
|
||||
onProbeAborted: (marker) => {
|
||||
const contents = electronModule?.webContents?.fromId?.(session.webContentsId);
|
||||
safeSend(contents, "netcatty:data", { sessionId, data: `${marker}_R\n` });
|
||||
},
|
||||
chatSessionId,
|
||||
expectedPrompt: getFreshIdlePrompt(session),
|
||||
typedInput: true,
|
||||
|
||||
@@ -484,13 +484,13 @@ function shutdownHost({ preserveScopedMetadata = false } = {}) {
|
||||
sessionIdleManager.clearAll();
|
||||
}
|
||||
|
||||
function echoCommandToSession(session, sessionId, command) {
|
||||
function echoCommandToSession(session, sessionId, command, { syntheticEcho = true } = {}) {
|
||||
if (!electronModule || !session?.webContentsId || !command) return;
|
||||
const contents = electronModule.webContents?.fromId?.(session.webContentsId);
|
||||
safeSend(contents, "netcatty:data", {
|
||||
sessionId,
|
||||
data: formatSyntheticEcho(command),
|
||||
syntheticEcho: true,
|
||||
syntheticEcho,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -146,6 +146,8 @@ function createExecHandlerApi(ctx) {
|
||||
timeoutMs: commandTimeoutMs,
|
||||
shellKind: session.shellKind,
|
||||
loginShellHint: session._loginShellKind,
|
||||
probeLiveShell: true,
|
||||
onProbeAborted: (marker) => echoCommandToSession(session, sessionId, `${marker}_R`, { syntheticEcho: false }),
|
||||
expectedPrompt: getFreshIdlePrompt(session),
|
||||
typedInput: true,
|
||||
echoCommand: (rawCommand) => echoCommandToSession(session, sessionId, rawCommand),
|
||||
@@ -313,6 +315,8 @@ function createExecHandlerApi(ctx) {
|
||||
timeoutMs,
|
||||
shellKind: session.shellKind,
|
||||
loginShellHint: session._loginShellKind,
|
||||
probeLiveShell: true,
|
||||
onProbeAborted: (marker) => echoCommandToSession(session, sessionId, `${marker}_R`, { syntheticEcho: false }),
|
||||
chatSessionId,
|
||||
expectedPrompt: getFreshIdlePrompt(session),
|
||||
typedInput: true,
|
||||
|
||||
+55
-5
@@ -95,6 +95,8 @@ 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 _mcpAbortedProbes = new Map(); // sessionId -> bounded set of cancelled probe markers
|
||||
const MAX_MCP_BUFFERED_LINE_CHARS = 64 * 1024;
|
||||
|
||||
function clearMcpSessionState(sessionId) {
|
||||
@@ -106,6 +108,31 @@ function clearMcpSessionState(sessionId) {
|
||||
_mcpLineMetas.delete(sessionId);
|
||||
_mcpPendingMetas.delete(sessionId);
|
||||
_mcpDroppingWrappedLine.delete(sessionId);
|
||||
_mcpProbePrompts.delete(sessionId);
|
||||
_mcpAbortedProbes.delete(sessionId);
|
||||
}
|
||||
|
||||
function filterProbePromptFragment(sessionId, fragment) {
|
||||
const reset = fragment.match(/(__NCMCP_(?:(?!__NCMCP_)[A-Za-z0-9_])+)_R/);
|
||||
if (reset) {
|
||||
const aborted = _mcpAbortedProbes.get(sessionId) || new Set();
|
||||
aborted.add(reset[1]);
|
||||
if (aborted.size > 32) aborted.delete(aborted.values().next().value);
|
||||
_mcpAbortedProbes.set(sessionId, aborted);
|
||||
if (_mcpProbePrompts.get(sessionId) === reset[1]) _mcpProbePrompts.delete(sessionId);
|
||||
return true;
|
||||
}
|
||||
const completion = fragment.match(/^\r?(__NCMCP_[A-Za-z0-9_]+)_Q/);
|
||||
if (completion && !_mcpAbortedProbes.get(sessionId)?.has(completion[1])) {
|
||||
_mcpProbePrompts.set(sessionId, completion[1]);
|
||||
}
|
||||
const marker = _mcpProbePrompts.get(sessionId);
|
||||
if (!marker) return false;
|
||||
const line = fragment.replace(/^\r/, "");
|
||||
if (line.startsWith(`${marker}_S`) || line.startsWith(`${marker}_E`)) {
|
||||
_mcpProbePrompts.delete(sessionId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Returns true if `s` ends with a non-empty prefix of "__NCMCP_"
|
||||
@@ -115,6 +142,14 @@ function _endsWithMarkerPrefix(s) {
|
||||
for (let i = 1; i < p.length; i++) {
|
||||
if (s.endsWith(p.slice(0, i))) return true;
|
||||
}
|
||||
// The shell-neutral live probe begins with this harmless builtin. Its
|
||||
// echo can arrive before the random marker (e.g. " tru"), so retain the
|
||||
// partial prefix just like a split marker. Ordinary text is still released
|
||||
// by the existing bounded flush if no marker follows.
|
||||
const probePrefix = " true __NCMCP_";
|
||||
for (let i = 2; i < probePrefix.length; i++) {
|
||||
if (s.endsWith(probePrefix.slice(0, i))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -141,7 +176,7 @@ function filterMcpChunk(sessionId, chunk, meta) {
|
||||
_mcpPendingMetas.delete(sessionId);
|
||||
|
||||
// Fast path: nothing suspicious in the combined data
|
||||
if (!_mcpDroppingWrappedLine.has(sessionId) && !data.includes("__NCMCP_") && !_endsWithMarkerPrefix(data)) {
|
||||
if (!_mcpDroppingWrappedLine.has(sessionId) && !_mcpProbePrompts.has(sessionId) && !data.includes("__NCMCP_") && !_endsWithMarkerPrefix(data)) {
|
||||
const deliveryMeta = held ? stateMeta : sameChunkMeta;
|
||||
return {
|
||||
data,
|
||||
@@ -169,7 +204,8 @@ function filterMcpChunk(sessionId, chunk, meta) {
|
||||
// echoes can wrap across PTY lines; wrapped fragments that don't
|
||||
// contain __NCMCP_ would otherwise leak through as garbage.
|
||||
const tail = data.slice(pos);
|
||||
if (droppedAny || tail.includes("__NCMCP_") || _endsWithMarkerPrefix(tail)) {
|
||||
const probePrompt = filterProbePromptFragment(sessionId, tail);
|
||||
if (probePrompt || droppedAny || tail.includes("__NCMCP_") || _endsWithMarkerPrefix(tail)) {
|
||||
let tailMeta = !held && tail === chunk ? sameChunkMeta : stateMeta;
|
||||
if (heldIngressAlreadyAcknowledged && !hasPluginPipelineIngress(tailMeta)) {
|
||||
tailMeta = { ...(tailMeta || {}), pluginPipelineIngressBytes: 0 };
|
||||
@@ -186,14 +222,19 @@ function filterMcpChunk(sessionId, chunk, meta) {
|
||||
_mcpLineMetas.delete(sessionId);
|
||||
_mcpDroppingWrappedLine.add(sessionId);
|
||||
}
|
||||
if (droppedAny) _mcpDroppingWrappedLine.add(sessionId);
|
||||
// The reserved prefix already identifies an internal line. Its
|
||||
// remaining marker or suffix may arrive after the timed flush.
|
||||
if (probePrompt || droppedAny || tail.includes("__NCMCP_")) {
|
||||
_mcpDroppingWrappedLine.add(sessionId);
|
||||
}
|
||||
} else {
|
||||
result += tail; // safe to display immediately
|
||||
}
|
||||
break;
|
||||
}
|
||||
const line = data.slice(pos, nlIdx + 1); // includes the \n
|
||||
if (droppedAny || line.includes("__NCMCP_")) {
|
||||
const probePrompt = filterProbePromptFragment(sessionId, line);
|
||||
if (probePrompt || droppedAny || line.includes("__NCMCP_")) {
|
||||
droppedAny = false;
|
||||
_mcpDroppingWrappedLine.delete(sessionId);
|
||||
} else {
|
||||
@@ -244,7 +285,16 @@ function flushMcpBufferedOutput(sessionId) {
|
||||
_mcpLineMetas.delete(sessionId);
|
||||
_mcpFlushTimers.delete(sessionId);
|
||||
if (_mcpDroppingWrappedLine.has(sessionId)) {
|
||||
_mcpDroppingWrappedLine.delete(sessionId);
|
||||
// Retain a bounded partial marker so a delayed Q/R suffix still updates
|
||||
// the probe lifecycle, rather than losing its identity at the timer.
|
||||
if (held && /^\r?__NCMCP_[A-Za-z0-9_]*$/.test(held) && held.length <= 256) {
|
||||
_mcpLineBufs.set(sessionId, held);
|
||||
if (heldMeta) _mcpLineMetas.set(sessionId, heldMeta);
|
||||
return;
|
||||
}
|
||||
// A timed flush is not a line boundary. Keep discarding a known
|
||||
// internal line until its newline arrives, including slow prompts
|
||||
// between the probe completion marker and an echo-disabled wrapper.
|
||||
if (heldMeta) _mcpPendingMetas.set(sessionId, mergeTerminalDataMeta(_mcpPendingMetas.get(sessionId), heldMeta));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1607,3 +1607,176 @@ test("startPluginConnection reopens a previously closed terminal data session",
|
||||
assert.equal(invoked[0].wasClosed, false);
|
||||
assert.equal(closedTerminalDataSessions.has("session-1"), false);
|
||||
});
|
||||
|
||||
|
||||
test("live-shell probe suppresses the intermediate prompt across chunks", () => {
|
||||
const preload = loadPreloadWithFakeElectron();
|
||||
try {
|
||||
const received = [];
|
||||
preload.api.onSessionData("probe-session", (chunk) => received.push(chunk));
|
||||
for (const data of [
|
||||
"__NCMCP_probe___P:bash\r\n",
|
||||
"__NCMCP_probe___Q",
|
||||
"user@host:~$ ",
|
||||
" __NCMCP_probe__=0; wrapped-command\r\n",
|
||||
"__NCMCP_probe___S\r\n",
|
||||
"file.txt\r\n",
|
||||
]) {
|
||||
preload.handlers.get("netcatty:data")({}, { sessionId: "probe-session", data });
|
||||
}
|
||||
assert.equal(received.join(""), "file.txt\r\n");
|
||||
} finally {
|
||||
preload.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test("live-shell probe buffers echoed builtin prefixes until the marker arrives", () => {
|
||||
const preload = loadPreloadWithFakeElectron();
|
||||
try {
|
||||
const received = [];
|
||||
preload.api.onSessionData("probe-echo", (chunk) => received.push(chunk));
|
||||
for (const data of [" tru", "e __NCM", "CP_probe__; probe-command\r\n", "file.txt\r\n"]) {
|
||||
preload.handlers.get("netcatty:data")({}, { sessionId: "probe-echo", data });
|
||||
}
|
||||
assert.equal(received.join(""), "file.txt\r\n");
|
||||
} finally {
|
||||
preload.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("ordinary text resembling the probe prefix is eventually delivered", async () => {
|
||||
const preload = loadPreloadWithFakeElectron();
|
||||
try {
|
||||
const received = [];
|
||||
preload.api.onSessionData("ordinary-text", (chunk) => received.push(chunk));
|
||||
preload.handlers.get("netcatty:data")({}, { sessionId: "ordinary-text", data: "ordinary tru" });
|
||||
await sleep(120);
|
||||
assert.equal(received.join(""), "ordinary tru");
|
||||
} finally {
|
||||
preload.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test("slow echo-disabled probe prompt stays hidden across delayed flushes", async () => {
|
||||
const preload = loadPreloadWithFakeElectron();
|
||||
try {
|
||||
const received = [];
|
||||
preload.api.onSessionData("slow-probe", (chunk) => received.push(chunk));
|
||||
const send = (data) => preload.handlers.get("netcatty:data")({}, { sessionId: "slow-probe", data });
|
||||
send("__NCMCP_probe___Q");
|
||||
await sleep(120);
|
||||
assert.equal(received.join(""), "");
|
||||
send("slow prompt> ");
|
||||
await sleep(120);
|
||||
assert.equal(received.join(""), "");
|
||||
send("\r\n__NCMCP_probe___S\r\nfile.txt\r\n");
|
||||
assert.equal(received.join(""), "file.txt\r\n");
|
||||
} finally {
|
||||
preload.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("split probe completion markers stay hidden across delayed flushes", async () => {
|
||||
for (const prefix of ["__NCMCP_", "__NCMCP_probe___"]) {
|
||||
const preload = loadPreloadWithFakeElectron();
|
||||
try {
|
||||
const received = [];
|
||||
preload.api.onSessionData("split-probe", (chunk) => received.push(chunk));
|
||||
const send = (data) => preload.handlers.get("netcatty:data")({}, { sessionId: "split-probe", data });
|
||||
send(prefix);
|
||||
await sleep(120);
|
||||
assert.equal(received.join(""), "");
|
||||
send("__NCMCP_probe___Q".slice(prefix.length) + "slow prompt> ");
|
||||
await sleep(120);
|
||||
assert.equal(received.join(""), "");
|
||||
send("\r\n__NCMCP_probe___S\r\nfile.txt\r\n");
|
||||
assert.equal(received.join(""), "file.txt\r\n");
|
||||
} finally {
|
||||
preload.cleanup();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("multiline probe prompts remain hidden until the matching command starts", async () => {
|
||||
const preload = loadPreloadWithFakeElectron();
|
||||
try {
|
||||
const received = [];
|
||||
preload.api.onSessionData("multiline-probe", (chunk) => received.push(chunk));
|
||||
const send = (data) => preload.handlers.get("netcatty:data")({}, { sessionId: "multiline-probe", data });
|
||||
send("__NCMCP_probe___");
|
||||
await sleep(120);
|
||||
send("Qfirst prompt line\r\nsecond prompt line\r\n> ");
|
||||
await sleep(120);
|
||||
send("__NCMCP_other___S\r\nstill prompt\r\n");
|
||||
assert.equal(received.join(""), "");
|
||||
send("\r\n__NCMCP_probe___S\r\nfile.txt\r\n");
|
||||
assert.equal(received.join(""), "file.txt\r\n");
|
||||
} finally {
|
||||
preload.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("aborted probes release multiline suppression and ignore a late completion", () => {
|
||||
const preload = loadPreloadWithFakeElectron();
|
||||
try {
|
||||
const received = [];
|
||||
preload.api.onSessionData("aborted-probe", (chunk) => received.push(chunk));
|
||||
const send = (data) => preload.handlers.get("netcatty:data")({}, { sessionId: "aborted-probe", data });
|
||||
send("__NCMCP_probe___Qfirst prompt\r\nsecond prompt\r\n");
|
||||
send("__NCMCP_probe___R\r\n");
|
||||
send("normal output\r\n");
|
||||
send("__NCMCP_buffered___Q");
|
||||
send("__NCMCP_buffered___R\r\n");
|
||||
send("after buffered cancellation\r\n");
|
||||
send("__NCMCP_late___R\r\n");
|
||||
send("__NCMCP_late___Q\r\nlate prompt\r\n");
|
||||
assert.equal(received.join(""), "normal output\r\nafter buffered cancellation\r\nlate prompt\r\n");
|
||||
} finally {
|
||||
preload.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("real PTY multiline prompt is displayed only after the AI command", {
|
||||
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: "FIRST_LINE\nLAST_LINE> ", BASH_SILENCE_DEPRECATION_WARNING: "1" },
|
||||
});
|
||||
let raw = "";
|
||||
const received = [];
|
||||
const send = (data) => preload.handlers.get("netcatty:data")({}, { sessionId: "real-multiline", data });
|
||||
preload.api.onSessionData("real-multiline", (data) => received.push(data));
|
||||
terminal.onData((data) => { raw += data; send(data); });
|
||||
const ready = async () => {
|
||||
const deadline = Date.now() + 3000;
|
||||
while (!raw.includes("LAST_LINE>")) {
|
||||
if (Date.now() > deadline) throw new Error("Missing multiline shell prompt");
|
||||
await sleep(10);
|
||||
}
|
||||
raw = "";
|
||||
};
|
||||
try {
|
||||
await ready();
|
||||
terminal.write("stty -echo\r");
|
||||
await ready();
|
||||
received.length = 0;
|
||||
const result = await require("./bridges/ai/ptyExec.cjs").execViaPty(terminal, "printf 'visible-result\\n'", {
|
||||
shellKind: "posix", probeLiveShell: true, 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.equal((display.match(/FIRST_LINE/g) || []).length, 1, display);
|
||||
assert.equal((display.match(/LAST_LINE/g) || []).length, 1, display);
|
||||
assert.equal(display.includes("__NCMCP_"), false, display);
|
||||
} finally {
|
||||
terminal.kill();
|
||||
preload.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -290,6 +290,13 @@ function createWorkerAiExecHandler({
|
||||
timeoutMs,
|
||||
shellKind: session.shellKind,
|
||||
loginShellHint: session._loginShellKind,
|
||||
probeLiveShell: true,
|
||||
onProbeAborted: (marker) => {
|
||||
event?.sender?.send?.("netcatty:data", {
|
||||
sessionId,
|
||||
data: `${marker}_R\n`,
|
||||
});
|
||||
},
|
||||
chatSessionId,
|
||||
expectedPrompt: getFreshIdlePrompt(session),
|
||||
typedInput: true,
|
||||
@@ -486,6 +493,13 @@ function createWorkerAiJobStartHandler({
|
||||
timeoutMs,
|
||||
shellKind: session.shellKind,
|
||||
loginShellHint: session._loginShellKind,
|
||||
probeLiveShell: true,
|
||||
onProbeAborted: (marker) => {
|
||||
event?.sender?.send?.("netcatty:data", {
|
||||
sessionId,
|
||||
data: `${marker}_R\n`,
|
||||
});
|
||||
},
|
||||
chatSessionId,
|
||||
expectedPrompt: getFreshIdlePrompt(session),
|
||||
typedInput: true,
|
||||
|
||||
@@ -18,6 +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];
|
||||
queueMicrotask(() => this.emit("data", `${marker}_P:\n${marker}_Q`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +53,7 @@ function createFakeEvent() {
|
||||
}
|
||||
|
||||
function extractMarker(writes) {
|
||||
const wrapper = writes.find((entry) => entry.includes("__NCMCP_"));
|
||||
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");
|
||||
const match = wrapper.match(/(__NCMCP_[A-Za-z0-9_]+__)/);
|
||||
assert.ok(match, "expected command wrapper to contain an MCP marker");
|
||||
@@ -257,7 +261,7 @@ 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_"));
|
||||
const wrapper = pty.writes.find((entry) => entry.includes("__NCMCP_") && !entry.includes("command sh -c"));
|
||||
assert.match(wrapper, /set -l __NCMCP_.*_cmd/);
|
||||
assert.doesNotMatch(wrapper, / sh -c '/);
|
||||
|
||||
@@ -342,7 +346,7 @@ test("worker chat cancel during shellKind probe aborts job start before PTY writ
|
||||
assert.equal(started.ok, false);
|
||||
assert.equal(started.error, "Cancelled");
|
||||
assert.equal(
|
||||
pty.writes.filter((entry) => entry.includes("__NCMCP_")).length,
|
||||
pty.writes.filter((entry) => entry.includes("__NCMCP_") && !entry.includes("command sh -c")).length,
|
||||
0,
|
||||
"cancelled pending start must not type a wrapper into the PTY",
|
||||
);
|
||||
@@ -378,7 +382,7 @@ test("worker exec keeps shell-selected defaults: powershell frees $(), dangerous
|
||||
});
|
||||
await nextTick();
|
||||
assert.ok(
|
||||
pty.writes.some((entry) => entry.includes("__NCMCP_")),
|
||||
pty.writes.some((entry) => entry.includes("__NCMCP_") && !entry.includes("command sh -c")),
|
||||
"expected the unblocked command to reach the PTY",
|
||||
);
|
||||
await allowedPromise.catch(() => {});
|
||||
@@ -417,7 +421,7 @@ test("worker exec honors an explicitly empty configured blocklist", async () =>
|
||||
await nextTick();
|
||||
|
||||
assert.ok(
|
||||
pty.writes.some((entry) => entry.includes("__NCMCP_")),
|
||||
pty.writes.some((entry) => entry.includes("__NCMCP_") && !entry.includes("command sh -c")),
|
||||
"disabled defaults must allow the command to reach the PTY wrapper",
|
||||
);
|
||||
await execution.catch(() => {});
|
||||
@@ -482,7 +486,7 @@ test("worker exec on an unclassified posix session still blocks command substitu
|
||||
assert.equal(blocked.ok, false);
|
||||
assert.match(blocked.error, /Command blocked by safety policy/);
|
||||
assert.equal(
|
||||
pty.writes.filter((entry) => entry.includes("__NCMCP_")).length,
|
||||
pty.writes.filter((entry) => entry.includes("__NCMCP_") && !entry.includes("command sh -c")).length,
|
||||
0,
|
||||
"blocked commands must not reach the PTY",
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user