diff --git a/electron/bridges/ai/ptyExec.cjs b/electron/bridges/ai/ptyExec.cjs index c15f071e2..f9628fd17 100644 --- a/electron/bridges/ai/ptyExec.cjs +++ b/electron/bridges/ai/ptyExec.cjs @@ -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 { diff --git a/electron/bridges/ai/ptyExec.test.cjs b/electron/bridges/ai/ptyExec.test.cjs index 74fee605f..2ab352524 100644 --- a/electron/bridges/ai/ptyExec.test.cjs +++ b/electron/bridges/ai/ptyExec.test.cjs @@ -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(); +}); diff --git a/electron/bridges/aiBridge/cattyExecHandlers.cjs b/electron/bridges/aiBridge/cattyExecHandlers.cjs index 890057e4a..a9c2ca5fd 100644 --- a/electron/bridges/aiBridge/cattyExecHandlers.cjs +++ b/electron/bridges/aiBridge/cattyExecHandlers.cjs @@ -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, diff --git a/electron/bridges/mcpServerBridge/execHandlers.cjs b/electron/bridges/mcpServerBridge/execHandlers.cjs index 80816c99f..9747a2be5 100644 --- a/electron/bridges/mcpServerBridge/execHandlers.cjs +++ b/electron/bridges/mcpServerBridge/execHandlers.cjs @@ -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, diff --git a/electron/preloadDataBacklog.test.cjs b/electron/preloadDataBacklog.test.cjs index 0e6e03b4b..62d21fcc2 100644 --- a/electron/preloadDataBacklog.test.cjs +++ b/electron/preloadDataBacklog.test.cjs @@ -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(); diff --git a/electron/terminalWorker/aiExec.cjs b/electron/terminalWorker/aiExec.cjs index 4c93e9544..dc38fa061 100644 --- a/electron/terminalWorker/aiExec.cjs +++ b/electron/terminalWorker/aiExec.cjs @@ -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, diff --git a/electron/terminalWorker/aiExec.test.cjs b/electron/terminalWorker/aiExec.test.cjs index ca73781ce..985fa77ed 100644 --- a/electron/terminalWorker/aiExec.test.cjs +++ b/electron/terminalWorker/aiExec.test.cjs @@ -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();