Compare commits

...
Author SHA1 Message Date
bincxz 9ce9a1722f fix(ai): bound multiline probe prompt suppression by job lifecycle 2026-09-05 14:39:31 +08:00
bincxz 7c10f9b4fc fix(ai): hide delayed partial probe completion markers 2026-09-05 14:25:39 +08:00
bincxz 4f88ea29aa fix(ci): retain snapshot fallback for missing Bullseye packages 2026-09-05 14:21:05 +08:00
bincxz 8c97793003 fix(ci): fall back between official Debian security mirrors 2026-09-05 14:17:23 +08:00
bincxz 95d41f3183 fix(ci): use Debian security endpoint for arm64 dependencies 2026-09-05 14:14:51 +08:00
bincxz 939f1d09f9 fix(ai): retain hidden probe lines through delayed prompt flushes 2026-09-05 13:44:39 +08:00
bincxz 9f50601650 fix(ai): separate probe output when terminal echo is disabled 2026-09-05 13:34:58 +08:00
bincxz 073722a9ef fix(ai): keep shell probing invisible and tolerate unavailable sh 2026-09-05 13:26:50 +08:00
bincxz d6db476564 fix(ai): probe the active PTY shell before choosing command syntax 2026-09-05 12:59:17 +08:00
bincxz a5b8c0a065 merge: update PR 3262 and remove unreliable fish output heuristics 2026-09-05 12:53:13 +08:00
netcatty-bot c608e1a0c7 fix: address Codex review on PR #3262 2026-09-03 10:03:07 +00:00
netcatty-bot c05022376e fix: address Codex review on PR #3262 2026-09-03 09:44:41 +00:00
netcatty-bot f3b121e973 fix: address Codex review on PR #3262 2026-09-03 09:06:20 +00:00
netcatty-bot 22894723fb fix: address Codex review on PR #3262 2026-09-03 08:40:55 +00:00
netcatty-bot effcc7eaf0 fix: address Codex review on PR #3262 2026-09-03 07:57:32 +00:00
netcatty-bot 421395bb44 fix: address Codex review on PR #3262 2026-09-03 07:37:51 +00:00
netcatty-bot acaf27a36c fix: address Codex review on PR #3262 2026-09-03 06:55:30 +00:00
netcatty-bot 210b1440b3 fix: address Codex review on PR #3262 2026-09-03 06:09:17 +00:00
netcatty-bot 166f2e8ea7 fix: address Codex review on PR #3262 2026-09-03 05:40:02 +00:00
netcatty-bot 89a68ca76f fix: address Codex review on PR #3262 2026-09-03 04:26:09 +00:00
netcatty-bot 30b4dfc213 fix: address Codex review on PR #3262 2026-09-03 03:45:36 +00:00
netcatty-bot f777370455 fix(#3261): automated AI fix 2026-09-03 03:29:39 +00:00
13 changed files with 542 additions and 21 deletions

No files matched your search

+5
View File
@@ -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 \
+30
View File
@@ -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 };
+164
View File
@@ -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();
}
});
+42 -3
View File
@@ -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,
+30
View File
@@ -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`)));
}
});
+8 -5
View File
@@ -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,
+2 -2
View File
@@ -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
View File
@@ -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;
}
+173
View File
@@ -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();
}
});
+14
View File
@@ -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,
+10 -6
View File
@@ -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",
);