fix(#3384): automated AI fix

This commit is contained in:
netcatty-bot committed 2026-09-14 03:02:12 +00:00
1 parent b46910d46e
commit 990bb791a7
7 files changed
+111 -2

No files matched your search

+16
View File
@@ -64,6 +64,7 @@ function startPtyJob(ptyStream, command, options) {
expectedPrompt,
typedInput = false,
echoCommand,
onEchoSuppressionPrime,
maxBufferedChars = 0,
normalizeFinalOutput = true,
enforceWallTimeout = false,
@@ -812,6 +813,21 @@ function startPtyJob(ptyStream, command, options) {
writeInput(`${buildPendingInputClearPrefix(resolvedShellKind)}${wrapped}`);
}
// Prime the renderer's display suppression before the first byte is typed
// (issue #3384). Shells whose line editor echoes input, such as BusyBox ash
// on OpenWrt, break long echoed lines at the terminal width with CR/LF; the
// wrapped fragments no longer contain the marker and would leak through the
// per-line echo filter as visible "variables" until the wrapper's own
// _I printf runs. Delivering the _I line over the data channel up front
// suppresses the whole echo; the _S output releases it, and finish() sends
// the _R reset (onProbeAborted) when the command never starts.
if (typeof onEchoSuppressionPrime === "function") {
try {
onEchoSuppressionPrime(marker);
} catch {
// Display suppression must never prevent the command from starting.
}
}
if (probingShell) {
writeInput(`${buildPendingInputClearPrefix(resolvedShellKind)}${buildLiveShellProbe(marker)}`);
} else {
+41
View File
@@ -1420,3 +1420,44 @@ test("startPtyJob types the wrapper one code point per write for strict bastions
assert.equal(result.ok, false);
assert.equal(result.error, "Cancelled");
});
test("echo suppression prime is delivered before the first typed input (#3384)", async () => {
for (const probeLiveShell of [false, true]) {
const pty = new EventEmitter();
const events = [];
pty.write = (data) => {
if (data === "\x03") return;
events.push(`write:${data}`);
};
const job = startPtyJob(pty, "echo prime-order", {
shellKind: "posix",
probeLiveShell,
timeoutMs: 1000,
onEchoSuppressionPrime: (marker) => events.push(`prime:${marker}`),
});
assert.equal(events[0], `prime:${job.marker}`, "prime precedes every typed byte");
assert.ok((events[1] || "").startsWith("write:"), "first write follows the prime");
assert.equal(events.filter((event) => event.startsWith("prime:")).length, 1);
if (probeLiveShell) {
pty.emit("data", `${job.marker}_P:sh\n${job.marker}_Q`);
}
pty.emit("data", `${job.marker}_S\ndone\n${job.marker}_E:0\n`);
const result = await job.resultPromise;
assert.equal(result.ok, true, JSON.stringify(result));
}
});
test("a throwing echo suppression prime callback still types the command (#3384)", () => {
const writes = [];
const pty = new EventEmitter();
pty.write = (data) => writes.push(String(data));
const job = startPtyJob(pty, "echo resilient", {
shellKind: "posix",
timeoutMs: 1000,
onEchoSuppressionPrime: () => {
throw new Error("Renderer closed");
},
});
assert.ok(writes.some((data) => data.includes("echo resilient")));
job.cancel();
});
@@ -182,6 +182,10 @@ function registerCattyExecHandlers(ctx) {
const contents = electronModule?.webContents?.fromId?.(session.webContentsId);
safeSend(contents, "netcatty:data", { sessionId, data: `${marker}_R\n` });
},
onEchoSuppressionPrime: (marker) => {
const contents = electronModule?.webContents?.fromId?.(session.webContentsId);
safeSend(contents, "netcatty:data", { sessionId, data: `${marker}_I\n` });
},
chatSessionId,
expectedPrompt: getFreshIdlePrompt(session),
typedInput: true,
@@ -149,6 +149,7 @@ function createExecHandlerApi(ctx) {
probeLiveShell: true,
bastionKeystrokes: remoteDisallowsExecChannelProbe(session.remoteSshVersion),
onProbeAborted: (marker) => echoCommandToSession(session, sessionId, `${marker}_R`, { syntheticEcho: false }),
onEchoSuppressionPrime: (marker) => echoCommandToSession(session, sessionId, `${marker}_I`, { syntheticEcho: false }),
expectedPrompt: getFreshIdlePrompt(session),
typedInput: true,
echoCommand: (rawCommand) => echoCommandToSession(session, sessionId, rawCommand),
@@ -319,6 +320,7 @@ function createExecHandlerApi(ctx) {
probeLiveShell: true,
bastionKeystrokes: remoteDisallowsExecChannelProbe(session.remoteSshVersion),
onProbeAborted: (marker) => echoCommandToSession(session, sessionId, `${marker}_R`, { syntheticEcho: false }),
onEchoSuppressionPrime: (marker) => echoCommandToSession(session, sessionId, `${marker}_I`, { syntheticEcho: false }),
chatSessionId,
expectedPrompt: getFreshIdlePrompt(session),
typedInput: true,
+28
View File
@@ -1802,6 +1802,34 @@ test("OpenWrt bounded wrapper continuations stay hidden across fragmented echoes
}
});
test("primed suppression hides BusyBox ash echo wrapped mid-marker (#3384)", () => {
const preload = loadPreloadWithFakeElectron();
try {
const received = [];
const sessionId = 'ash-wrap';
const marker = '__NCMCP_mttikd5b_ccbc892e865a115a80c88afdc77b96a6__';
preload.api.onSessionData(sessionId, chunk => received.push(chunk));
// The exec bridge primes display suppression over the data channel before
// the wrapper is typed, mirroring onEchoSuppressionPrime in ptyExec.cjs.
preload.handlers.get('netcatty:data')({}, { sessionId, data: `${marker}_I\n` });
// BusyBox ash's line editor breaks the echoed first wrapper line at the
// terminal width, so the second PTY line carries no complete __NCMCP_
// marker and the per-line echo filter alone cannot drop it.
const firstLine = ` ${marker}=0; printf '\\n%s\\n' '${marker}_I'`;
const secondLine = ` : '${marker}'; ${marker}_cmd='echo visible-output'; \\`;
const echo = `${firstLine.slice(0, 55)}\r\n${firstLine.slice(55)}\r\n`
+ `${secondLine.slice(0, 70)}\r\n${secondLine.slice(70)}\r\n`
+ `> : '${marker}'; printf '%s\\n' '${marker}_S'\r\n`;
const data = `${echo}\n${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();
+12
View File
@@ -298,6 +298,12 @@ function createWorkerAiExecHandler({
data: `${marker}_R\n`,
});
},
onEchoSuppressionPrime: (marker) => {
event?.sender?.send?.("netcatty:data", {
sessionId,
data: `${marker}_I\n`,
});
},
chatSessionId,
expectedPrompt: getFreshIdlePrompt(session),
typedInput: true,
@@ -502,6 +508,12 @@ function createWorkerAiJobStartHandler({
data: `${marker}_R\n`,
});
},
onEchoSuppressionPrime: (marker) => {
event?.sender?.send?.("netcatty:data", {
sessionId,
data: `${marker}_I\n`,
});
},
chatSessionId,
expectedPrompt: getFreshIdlePrompt(session),
typedInput: true,
+8 -2
View File
@@ -145,6 +145,7 @@ test("worker AI background jobs start, poll, stop, and block overlapping exec",
assert.equal(started.command, "npm test");
assert.equal(started.status, "running");
assert.equal(started.outputMode, "foreground-mirrored");
const marker = await extractMarker(pty.writes);
assert.deepEqual(event.rendererMessages, [
{
channel: "netcatty:data",
@@ -154,9 +155,14 @@ test("worker AI background jobs start, poll, stop, and block overlapping exec",
syntheticEcho: true,
},
},
{
channel: "netcatty:data",
payload: {
sessionId: "ssh-1",
data: `${marker}_I\n`,
},
},
]);
const marker = await extractMarker(pty.writes);
pty.emit("data", `${marker}_S\r\nready\r\n`);
await nextTick();