Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions electron/bridges/ai/ptyExec.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ function startPtyJob(ptyStream, command, options) {
expectedPrompt,
typedInput = false,
echoCommand,
onEchoSuppressionPrime,
maxBufferedChars = 0,
normalizeFinalOutput = true,
enforceWallTimeout = false,
Expand Down Expand Up @@ -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 {
Expand Down
41 changes: 41 additions & 0 deletions electron/bridges/ai/ptyExec.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
4 changes: 4 additions & 0 deletions electron/bridges/aiBridge/cattyExecHandlers.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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` });
Comment thread
binaricat marked this conversation as resolved.
Outdated
},
chatSessionId,
expectedPrompt: getFreshIdlePrompt(session),
typedInput: true,
Expand Down
2 changes: 2 additions & 0 deletions electron/bridges/mcpServerBridge/execHandlers.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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,
Expand Down
28 changes: 28 additions & 0 deletions electron/preloadDataBacklog.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
12 changes: 12 additions & 0 deletions electron/terminalWorker/aiExec.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 8 additions & 2 deletions electron/terminalWorker/aiExec.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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();

Expand Down
Loading