Compare commits

...
Author SHA1 Message Date
bincxz b639aa301e fix(terminal): avoid recording cancelled startup lines 2026-08-14 14:23:03 +08:00
bincxz 2b98fdb860 fix(terminal): reconcile intercepted input 2026-08-14 14:11:09 +08:00
bincxz f679b840b5 test(terminal): cover backend startup sequencing 2026-08-14 14:04:17 +08:00
bincxz 3ae4f40ffe fix(terminal): supersede delayed automated writes 2026-08-14 13:56:52 +08:00
bincxz b4e4dbb59a fix(terminal): fence delayed input batches 2026-08-14 13:54:43 +08:00
bincxz 342d1e917e fix(ai): keep startup input fail closed 2026-08-14 13:52:06 +08:00
bincxz 8d1ec46635 fix(ai): guard delayed and startup input 2026-08-14 13:41:58 +08:00
bincxz 67079cc4e0 fix(ai): reject pending input before probing 2026-08-14 13:18:39 +08:00
bincxz 0683e344d8 fix(ai): preserve serial output encoding 2026-08-14 13:09:02 +08:00
bincxz dd5381fc90 fix(ai): track all unconfirmed terminal writes 2026-08-14 13:02:28 +08:00
bincxz e1502e0fa6 fix(ai): preserve serial transfer guard 2026-08-14 12:55:44 +08:00
bincxz 657343b30a fix(ai): fence raw session input states 2026-08-14 12:53:23 +08:00
bincxz 7c9692d918 fix(ai): fence all submitted input states 2026-08-14 12:48:58 +08:00
bincxz 948083bfda fix(ai): fail closed on active terminal input 2026-08-14 12:48:45 +08:00
bincxz 53aa6849e0 fix(ai): interrupt submitted state without input 2026-08-14 12:42:58 +08:00
bincxz 8acbc9b2bd fix(ai): keep submitted input fail-closed 2026-08-14 12:38:06 +08:00
bincxz 613644a7b6 fix(ai): distinguish risky submitted shell input 2026-08-14 12:34:40 +08:00
bincxz 2349310a1a fix(ai): avoid interrupt after completed input 2026-08-14 12:30:03 +08:00
bincxz e006d34d13 fix(ai): wire continuation state in worker 2026-08-14 12:26:18 +08:00
bincxz e9f8ce333d fix(ai): block shell continuation command splicing 2026-08-14 12:23:26 +08:00
bincxz 87ae6c800e fix(ai): preserve idle prompt in synthetic command echo 2026-08-14 12:03:06 +08:00
bincxz 0088cd35a0 fix(ai): discard queued writes when command injection fails 2026-08-14 11:43:56 +08:00
bincxz 81bd818577 fix(ai): fence deferred terminal writes by session 2026-08-14 11:41:14 +08:00
bincxz 180fd1b31f fix(ai): discard deferred writes after unsafe clear failures 2026-08-14 11:40:38 +08:00
bincxz ac15e4c2e2 fix(ai): serialize automated terminal writes during clearing 2026-08-14 11:37:57 +08:00
bincxz 4ad06dd367 fix(ai): lock terminal input during safe clearing 2026-08-14 11:35:03 +08:00
bincxz d65badbb6b fix(ai): recheck input before terminal interruption 2026-08-14 11:29:41 +08:00
bincxz 9724ce7ae0 fix(ai): bind input clearing to the recorded shell 2026-08-14 11:26:21 +08:00
bincxz 952cd8c499 fix(ai): fence pending input safety checks 2026-08-14 11:23:25 +08:00
bincxz 272056fba2 fix(ai): verify shell ownership before clearing input 2026-08-14 11:18:59 +08:00
bincxz 8c34da7e32 fix(ai): avoid interrupting unverified foreground input 2026-08-14 11:08:18 +08:00
bincxz b8e1b15faa fix(ai): harden pending input recovery 2026-08-14 11:04:07 +08:00
bincxz c0ac0d2cd9 fix(ai): safely clear tracked pending terminal input 2026-08-14 10:57:40 +08:00
bincxz 824a8e00b0 Merge origin/main into cursor/issue-2962-31682843555 2026-08-14 10:27:21 +08:00
netcatty-bot 2749f7536e fix: address Codex review on PR #2964 2026-08-13 15:43:51 +00:00
netcatty-bot a345187058 fix: address Codex review on PR #2964 2026-08-13 15:15:15 +00:00
netcatty-bot 00762ae6a6 fix: address Codex review on PR #2964 2026-08-13 14:47:18 +00:00
netcatty-bot 1eb1f9f55f fix: address Codex review on PR #2964 2026-08-13 14:15:15 +00:00
bincxz c806278ca8 fix: do not send Ctrl+C before the PTY wrapper
#2962

expectedPrompt means the live tail already ends at an idle prompt, so
ETX cannot clear unfinished input. On POSIX ptys VINTR can flush the
next write; the PowerShell wrapper starts with `$`, and a dropped first
byte prevents the start marker from arriving. Line-kill alone still
clears half-typed input without interrupting a user-started process.
2026-08-13 21:53:28 +08:00
bincxz b86a9b6b8b fix: clear PowerShell input without a leading Escape
#2962

A leading Escape is RevertLine only in Windows-mode PSReadLine. On Unix
pwsh (Emacs by default, Vi if configured) it starts a chord or enters
command mode, so the following Ctrl+U never clears the line and the
wrapper can splice onto leftover input.

Clear Emacs/Vi first (i + Ctrl+U/Ctrl+K), then ESC ESC for Windows/Emacs
RevertLine, then i + Backspace so Vi is back in insert and the literal i
Windows/Emacs just typed is discarded.
2026-08-13 21:01:30 +08:00
bincxz efc60a9c2a fix: restore vi insert mode before posix wrapper inject
#2962

With bash/zsh/fish vi editing, Escape leaves the line editor in command
mode. Ctrl+U/Ctrl+K then clear the buffer but do not return to insert, so
the wrapper is typed as vi commands (echo GOOD becomes o GOOD). Send i
before the line-kills: vi command mode enters insert, and emacs/vi-insert
treat i as a literal that the kills immediately discard.
2026-08-13 20:53:41 +08:00
bincxz a2be077305 fix: clear PowerShell pending input with Escape
#2962

Windows-mode PSReadLine binds RevertLine to Escape, not Ctrl+U/Ctrl+K.
Default Windows PowerShell therefore kept half-typed input and spliced
it onto the injected wrapper. Send Escape first, then the emacs/vi
line-kills so Unix pwsh still clears.
2026-08-13 20:44:35 +08:00
netcatty-bot 535af1e6ee fix: address Codex review on PR #2964 2026-08-13 10:34:23 +00:00
netcatty-bot c6ab350262 fix: address Codex review on PR #2964 2026-08-13 10:06:00 +00:00
netcatty-bot 7386b0cec1 fix: address Codex review on PR #2964 2026-08-13 09:37:05 +00:00
netcatty-bot 5af4aa487b fix(#2962): automated Cursor CLI fix 2026-08-13 08:54:42 +00:00
21 changed files with 2655 additions and 87 deletions

No files matched your search

@@ -1085,12 +1085,13 @@ test("startTelnet ignores stale replacement failures", async () => {
assert.deepEqual(statuses, []);
});
test("startTelnet runs a multi-line startup command in sequence when requested", async () => {
test("startTelnet queues a cancellable multi-line startup command without pre-recording it", async () => {
const writtenCommands: string[] = [];
const executedCommands: string[] = [];
let capturedOptions: Record<string, unknown> | null = null;
let autoLoginComplete: ((evt: { sessionId: string }) => void) | null = null;
let disposedAutoLoginCancelListener = false;
let startupWriteOptions: { automated?: boolean; lineDelayMs?: number } | undefined;
const terminalBackend = {
backendAvailable: () => true,
@@ -1119,8 +1120,13 @@ test("startTelnet runs a multi-line startup command in sequence when requested",
disposedAutoLoginCancelListener = true;
},
onChainProgress: () => noop,
writeToSession: (_sessionId: string, data: string) => {
writeToSession: (
_sessionId: string,
data: string,
options?: { automated?: boolean; lineDelayMs?: number },
) => {
writtenCommands.push(data);
startupWriteOptions = options;
},
resizeSession: noop,
};
@@ -1186,8 +1192,9 @@ test("startTelnet runs a multi-line startup command in sequence when requested",
// Wait long enough for both lines (delay before first + delay between).
await new Promise((resolve) => setTimeout(resolve, 200));
assert.deepEqual(writtenCommands, ["first cmd\r", "second cmd\r"]);
assert.deepEqual(executedCommands, ["first cmd", "second cmd"]);
assert.deepEqual(writtenCommands, ["first cmd\nsecond cmd\r"]);
assert.deepEqual(startupWriteOptions, { automated: true, lineDelayMs: 20 });
assert.deepEqual(executedCommands, []);
assert.equal(disposedAutoLoginCancelListener, true);
});
@@ -12,7 +12,6 @@ import { shouldSuppressHostStartupCommandOnReconnect } from "../restoredSessionG
const noop = () => undefined;
const ENCRYPTED_CREDENTIAL_PLACEHOLDER = "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==";
const armSudoPrompt = (
autofill: { armForCommand: (command: string) => void } | null,
command = "sudo whoami",
@@ -3119,15 +3118,15 @@ test("local session respects disabled bracketed paste for startup paste", async
});
test("local session can send multi-line startup snippets line by line", async () => {
const sessionWrites: Array<{ id: string; data: string; automated?: boolean }> = [];
const sessionWrites: Array<{ id: string; data: string; automated?: boolean; lineDelayMs?: number }> = [];
const terminalBackend = {
localAvailable: () => true,
startLocalSession: async () => "local-session",
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: (id: string, data: string, options?: { automated?: boolean }) => {
sessionWrites.push({ id, data, automated: options?.automated });
writeToSession: (id: string, data: string, options?: { automated?: boolean; lineDelayMs?: number }) => {
sessionWrites.push({ id, data, automated: options?.automated, lineDelayMs: options?.lineDelayMs });
},
resizeSession: noop,
};
@@ -3149,12 +3148,13 @@ test("local session can send multi-line startup snippets line by line", async ()
await createTerminalSessionStarters(ctx as never).startLocal(createTermStub() as never);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(sessionWrites, [{ id: "local-session", data: "first cmd\r", automated: true }]);
assert.deepEqual(sessionWrites, [
{ id: "local-session", data: "first cmd\nsecond cmd\r", automated: true, lineDelayMs: 0 },
]);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(sessionWrites, [
{ id: "local-session", data: "first cmd\r", automated: true },
{ id: "local-session", data: "second cmd\r", automated: true },
{ id: "local-session", data: "first cmd\nsecond cmd\r", automated: true, lineDelayMs: 0 },
]);
});
@@ -3197,15 +3197,15 @@ test("local session sends host startup commands in one write by default", async
});
test("local session can send host startup commands line by line", async () => {
const sessionWrites: Array<{ id: string; data: string; automated?: boolean }> = [];
const sessionWrites: Array<{ id: string; data: string; automated?: boolean; lineDelayMs?: number }> = [];
const terminalBackend = {
localAvailable: () => true,
startLocalSession: async () => "local-session",
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: (id: string, data: string, options?: { automated?: boolean }) => {
sessionWrites.push({ id, data, automated: options?.automated });
writeToSession: (id: string, data: string, options?: { automated?: boolean; lineDelayMs?: number }) => {
sessionWrites.push({ id, data, automated: options?.automated, lineDelayMs: options?.lineDelayMs });
},
resizeSession: noop,
};
@@ -3228,13 +3228,14 @@ test("local session can send host startup commands line by line", async () => {
await createTerminalSessionStarters(ctx as never).startLocal(createTermStub() as never);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(sessionWrites, [{ id: "local-session", data: "first host cmd\r", automated: true }]);
assert.deepEqual(sessionWrites, [
{ id: "local-session", data: "first host cmd\nsecond host cmd\r", automated: true, lineDelayMs: 0 },
]);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(sessionWrites, [
{ id: "local-session", data: "first host cmd\r", automated: true },
{ id: "local-session", data: "second host cmd\r", automated: true },
{ id: "local-session", data: "first host cmd\nsecond host cmd\r", automated: true, lineDelayMs: 0 },
]);
});
@@ -115,28 +115,24 @@ export const scheduleStartupCommand = (
};
}
// Line-by-line mode: wait before each line so prompt-driven sessions can
// react between steps.
let index = 0;
const runNext = () => {
// Line-by-line mode uses the backend-owned delayed-write queue. That queue
// is cancelled by any manual input, so later startup lines cannot splice
// into text the user begins typing between steps.
timeoutId = setTimeout(() => {
if (cancelled) return;
if (!sessionIsCurrent()) {
onSettled?.();
return;
}
const line = lines[index];
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, `${line}\r`, { automated: true });
markPromptLineBreakCommandPending(ctx.promptLineBreakStateRef, term, line);
ctx.onCommandExecuted?.(line, ctx.host.id, ctx.host.label, ctx.sessionId);
index += 1;
if (index < lines.length) {
timeoutId = setTimeout(runNext, delayMs);
} else {
onSettled?.();
}
};
timeoutId = setTimeout(runNext, delayMs);
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, `${lines.join("\n")}\r`, {
automated: true,
lineDelayMs: delayMs,
});
// The backend may cancel undispatched lines when the user takes over.
// Until it can acknowledge individual dispatches, do not record this
// batch as executed or arm prompt bookkeeping for lines that may never run.
onSettled?.();
}, delayMs);
return () => {
cancelled = true;
if (timeoutId) clearTimeout(timeoutId);
+150
View File
@@ -0,0 +1,150 @@
"use strict";
const { execFile } = require("node:child_process");
const { createSessionExecProbe, isWindowsOpenSshRemote } = require("./sessionShellKind.cjs");
const SAFE_MARKER = "__NETCATTY_FOREGROUND_SHELL__";
const PENDING_INPUT_ERROR = "Cannot safely execute while unsubmitted terminal input is present. Clear the current line and try again.";
const SHELL_NAME = /^(?:ba|z|fi|k|da|a|c|tc)?sh$/;
function isShellName(value) {
return SHELL_NAME.test(String(value || "").replace(/^.*\//, "").replace(/^-/, ""));
}
function capturePendingInputState(session) {
return {
pending: session?._hasPendingUserInput === true
|| session?._awaitingPrimaryPromptAfterUserSubmit === true,
revision: Number.isSafeInteger(session?._userInputRevision)
? session._userInputRevision
: 0,
};
}
function isPendingInputStateCurrent(session, captured) {
const current = capturePendingInputState(session);
return current.pending === captured?.pending && current.revision === captured?.revision;
}
function isUserInputRevisionCurrent(session, captured) {
return capturePendingInputState(session).revision === captured?.revision;
}
function acquireSessionInputGate(session, captured) {
if (!session || !isPendingInputStateCurrent(session, captured)) return null;
if (session._aiInputClearToken) return null;
const token = Symbol("ai-input-clear");
session._aiInputClearToken = token;
return (replayDeferred = false) => {
if (session._aiInputClearToken === token) {
delete session._aiInputClearToken;
const deferred = Array.isArray(session._aiInputClearDeferredWrites)
? session._aiInputClearDeferredWrites.splice(0)
: [];
delete session._aiInputClearDeferredWrites;
if (replayDeferred) {
for (const replay of deferred) {
try { replay(); } catch {}
}
}
}
};
}
function parseLocalProcessTable(stdout, rootPid) {
const rows = [];
for (const line of String(stdout || "").split("\n")) {
const match = line.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+(-?\d+)\s+(.+)$/);
if (!match) continue;
const row = {
pid: Number(match[1]),
ppid: Number(match[2]),
pgid: Number(match[3]),
tpgid: Number(match[4]),
command: match[5].trim(),
};
rows.push(row);
}
const root = rows.find((row) => row.pid === rootPid);
if (!root || root.tpgid <= 0 || root.pgid !== root.tpgid || !isShellName(root.command)) {
return false;
}
return !rows.some((row) => row.pid !== rootPid && row.pgid === root.pgid);
}
function verifyLocalForegroundShell(session) {
const rootPid = Number(session?.proc?.pid || session?.pty?.pid || 0);
if (!Number.isSafeInteger(rootPid) || rootPid <= 0 || process.platform === "win32") {
return Promise.resolve(false);
}
return new Promise((resolve) => {
execFile(
"ps",
["-A", "-o", "pid=,ppid=,pgid=,tpgid=,comm="],
{ timeout: 3000, maxBuffer: 4 * 1024 * 1024, windowsHide: true },
(error, stdout) => resolve(!error && parseLocalProcessTable(stdout, rootPid)),
);
});
}
function buildRemoteForegroundShellProbeCommand(targetShellPid) {
const script = [
'SELF=$$',
`TARGET=${Number(targetShellPid)}`,
'_conn=$(tr "\\0" "\\n" < /proc/$SELF/environ 2>/dev/null | sed -n "s/^SSH_CONNECTION=//p" | head -n1)',
'[ -n "$_conn" ] || exit 0',
'_target_conn=$(tr "\\0" "\\n" < /proc/$TARGET/environ 2>/dev/null | sed -n "s/^SSH_CONNECTION=//p" | head -n1)',
'[ "$_target_conn" = "$_conn" ] || exit 0',
'_target_tty=$(ps -p "$TARGET" -o tty= 2>/dev/null | tr -d "[:space:]")',
'[ -n "$_target_tty" ] && [ "$_target_tty" != "?" ] || exit 0',
'set -- $(ps -p "$TARGET" -o pgid=,tpgid=,comm= 2>/dev/null)',
'_pgid=$1; _tpgid=$2; _comm=$3',
'[ "$_pgid" = "$_tpgid" ] && [ "$_tpgid" -gt 0 ] 2>/dev/null || exit 0',
'case "$_comm" in sh|bash|zsh|fish|ksh|dash|ash|csh|tcsh|-sh|-bash|-zsh|-fish|-ksh|-dash|-ash|-csh|-tcsh) ;; *) exit 0 ;; esac',
"_other=$(ps -e -o pid=,pgid=,tty= 2>/dev/null | awk -v target=\"$TARGET\" -v pgid=\"$_pgid\" -v tty=\"$_target_tty\" '$1 != target && $2 == pgid && $3 == tty { print $1; exit }')",
'[ -z "$_other" ] || exit 0',
`echo ${SAFE_MARKER}`,
].join("\n");
return `exec sh -c '${script.replace(/'/g, "'\\''")}'`;
}
async function verifySessionForegroundShell(session, options = {}) {
if (!session || capturePendingInputState(session).pending !== true) return false;
if (typeof session._pendingInputSafetyProbe === "function") {
try {
return await session._pendingInputSafetyProbe() === true;
} catch {
return false;
}
}
if (session.protocol === "local" || session.type === "local") {
return verifyLocalForegroundShell(session);
}
if (isWindowsOpenSshRemote(session.remoteSshVersion)) return false;
const kind = session.shellKind || session._loginShellKind;
if (kind !== "posix" && kind !== "fish") return false;
const targetShellPid = Number(session.shellPid || 0);
if (!Number.isSafeInteger(targetShellPid) || targetShellPid <= 0) return false;
const execProbe = createSessionExecProbe(session);
if (!execProbe) return false;
const output = await execProbe(
buildRemoteForegroundShellProbeCommand(targetShellPid),
options.timeoutMs || 3000,
);
return String(output || "").split(/\r?\n/).some((line) => line.trim() === SAFE_MARKER);
}
module.exports = {
PENDING_INPUT_ERROR,
SAFE_MARKER,
acquireSessionInputGate,
buildRemoteForegroundShellProbeCommand,
capturePendingInputState,
isPendingInputStateCurrent,
isUserInputRevisionCurrent,
parseLocalProcessTable,
verifySessionForegroundShell,
};
@@ -0,0 +1,146 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { spawnSync } = require("node:child_process");
const { existsSync } = require("node:fs");
const nodePty = require("node-pty");
const {
parseLocalProcessTable,
verifySessionForegroundShell,
} = require("./pendingInputSafety.cjs");
function waitForText(ptyProcess, needle, timeoutMs = 5000) {
return new Promise((resolve, reject) => {
let output = "";
const timeoutId = setTimeout(() => {
disposable.dispose();
reject(new Error(`Timed out waiting for ${needle}: ${output}`));
}, timeoutMs);
const disposable = ptyProcess.onData((chunk) => {
output += chunk;
if (!output.includes(needle)) return;
clearTimeout(timeoutId);
disposable.dispose();
resolve(output);
});
});
}
test("parseLocalProcessTable accepts only the recorded foreground shell", () => {
assert.equal(parseLocalProcessTable([
"100 1 100 100 bash",
"200 100 200 200 python3",
].join("\n"), 100), true);
assert.equal(parseLocalProcessTable([
"100 1 100 200 bash",
"200 100 200 200 python3",
"250 100 250 250 script",
"300 250 300 300 bash",
].join("\n"), 100), false);
assert.equal(parseLocalProcessTable([
"100 1 100 100 bash",
"200 100 100 100 python3",
].join("\n"), 100), false);
assert.equal(parseLocalProcessTable("300 1 300 300 bash\n", 100), false);
});
test("verifySessionForegroundShell distinguishes an idle shell from a foreground child", async (t) => {
if (process.platform === "win32" || spawnSync("bash", ["--version"]).status !== 0) {
t.skip("POSIX bash is unavailable");
return;
}
const prompt = "verify-shell$";
const ptyProcess = nodePty.spawn("bash", ["--noprofile", "--norc", "-i"], {
cols: 100,
rows: 30,
env: { ...process.env, PS1: prompt },
});
const session = {
protocol: "local",
proc: ptyProcess,
_hasPendingUserInput: true,
};
try {
await waitForText(ptyProcess, prompt);
assert.equal(await verifySessionForegroundShell(session), true);
const noMonitorReady = waitForText(ptyProcess, prompt);
ptyProcess.write("set +m\r");
await noMonitorReady;
const childReady = waitForText(ptyProcess, "CHILD_READY");
ptyProcess.write("python3 -c 'import time; print(\"CHILD_READY\", flush=True); time.sleep(30)'\r");
await childReady;
assert.equal(await verifySessionForegroundShell(session), false);
} finally {
ptyProcess.kill();
}
});
test("verifySessionForegroundShell rejects a foreground bash child waiting for input", async (t) => {
if (process.platform === "win32" || spawnSync("bash", ["--version"]).status !== 0) {
t.skip("POSIX bash is unavailable");
return;
}
const ptyProcess = nodePty.spawn("bash", ["--noprofile", "--norc", "-i"], {
cols: 100,
rows: 30,
env: { ...process.env, PS1: "parent-shell$" },
});
const session = {
protocol: "local",
proc: ptyProcess,
_hasPendingUserInput: true,
};
try {
await waitForText(ptyProcess, "parent-shell$");
const childReady = waitForText(ptyProcess, "CHILD_SHELL_READY");
ptyProcess.write("bash -c 'printf CHILD_SHELL_READY; read value; printf CHILD_SHELL_ALIVE'\r");
await childReady;
assert.equal(await verifySessionForegroundShell(session), false);
const childAlive = waitForText(ptyProcess, "CHILD_SHELL_ALIVE");
ptyProcess.write("input\r");
await childAlive;
} finally {
ptyProcess.kill();
}
});
test("remote foreground probe distinguishes an SSH shell from its foreground child", async (t) => {
if (!existsSync("/proc/self/environ") || spawnSync("bash", ["--version"]).status !== 0) {
t.skip("Linux procfs or bash is unavailable");
return;
}
const sshConnection = `127.0.0.1 ${40_000 + (process.pid % 10_000)} 127.0.0.1 22`;
const env = { ...process.env, SSH_CONNECTION: sshConnection, PS1: "remote-shell$" };
const ptyProcess = nodePty.spawn("bash", ["--noprofile", "--norc", "-i"], {
cols: 100,
rows: 30,
env,
});
const session = {
protocol: "ssh",
shellPid: ptyProcess.pid,
_loginShellKind: "posix",
_hasPendingUserInput: true,
_shellKindExecProbe(command) {
const result = spawnSync("sh", ["-c", command], { encoding: "utf8", env });
return result.status === 0 ? result.stdout : null;
},
};
try {
await waitForText(ptyProcess, "remote-shell$");
assert.equal(await verifySessionForegroundShell(session), true);
const childReady = waitForText(ptyProcess, "REMOTE_CHILD_READY");
ptyProcess.write("python3 -c 'import time; print(\"REMOTE_CHILD_READY\", flush=True); time.sleep(30)'\r");
await childReady;
assert.equal(await verifySessionForegroundShell(session), false);
} finally {
ptyProcess.kill();
}
});
+204 -10
View File
@@ -45,6 +45,13 @@ function startPtyJob(ptyStream, command, options) {
chatSessionId,
abortSignal,
expectedPrompt,
pendingUserInput = false,
submittedInputAwaitingPrompt = false,
allowPendingInputClear = false,
pendingInputInterruptSafe = false,
isInputRevisionCurrent,
acquireInputGate,
onPendingInputCleared,
typedInput = false,
echoCommand,
maxBufferedChars = 0,
@@ -61,6 +68,7 @@ function startPtyJob(ptyStream, command, options) {
: DEFAULT_FOREGROUND_PTY_CAPTURE_CHARS;
const CANCEL_RETRY_MS = 5000;
const CANCEL_WALL_TIMEOUT_MS = 30000;
const INPUT_CLEAR_TIMEOUT_MS = 5000;
let output = "";
let foundStart = false;
@@ -77,6 +85,12 @@ function startPtyJob(ptyStream, command, options) {
let startupTimeoutId = null;
let promptFallbackTimer = null;
let cancelRetryTimerId = null;
let inputClearPrepTimerId = null;
let inputClearTimeoutId = null;
let waitingForInputClear = pendingUserInput === true;
let inputClearInterruptSent = false;
let commandStarted = false;
let releaseInputGate = null;
// Track one-shot timers scheduled inside requestCancel so finish() can
// clear them when the job exits early; otherwise they keep the Node
// event loop alive after the resultPromise has already resolved.
@@ -317,6 +331,9 @@ function startPtyJob(ptyStream, command, options) {
clearStartupTimeout();
clearPromptFallback();
clearCancelRetryTimer();
clearTimeout(inputClearPrepTimerId);
clearTimeout(inputClearTimeoutId);
releaseInputGateOnce(false);
// Clear any pending one-shot cancel timers so they do not keep the
// Node event loop alive after the job has resolved.
while (cancelOneShotTimers.length) {
@@ -411,6 +428,77 @@ function startPtyJob(ptyStream, command, options) {
}
}
function writeWrappedCommand() {
if (finished || commandStarted) return;
if (!inputRevisionIsCurrent()) {
finish(preStartOutput, -1, "Terminal input changed while command execution was being prepared. Try again.");
return;
}
commandStarted = true;
if (typedInput && typeof echoCommand === "function") {
try {
echoCommand(command);
} catch {
// Ignore synthetic echo failures.
}
}
if (writePtySafely(buildWrappedCommand(command, resolvedShellKind, marker))) {
releaseInputGateOnce(true);
}
}
function releaseInputGateOnce(replayDeferred = false) {
const release = releaseInputGate;
releaseInputGate = null;
if (typeof release !== "function") return;
try { release(replayDeferred); } catch {}
}
function inputRevisionIsCurrent() {
if (typeof isInputRevisionCurrent !== "function") return true;
try {
return isInputRevisionCurrent() === true;
} catch {
return false;
}
}
function writePtySafely(data) {
if (finished) return false;
try {
ptyStream.write(data);
return true;
} catch (error) {
finish(
foundStart ? output : preStartOutput,
-1,
`Stream error: ${error?.message || error}`,
);
return false;
}
}
function sendPendingInputInterrupt() {
if (finished || !waitingForInputClear || inputClearInterruptSent) return;
if (!inputRevisionIsCurrent()) {
finish(preStartOutput, -1, "Terminal input changed while command execution was being prepared. Try again.");
return;
}
inputClearInterruptSent = true;
clearTimeout(inputClearPrepTimerId);
if (!writePtySafely("\x03")) return;
inputClearTimeoutId = setTimeout(() => {
if (!waitingForInputClear || finished) return;
finish(
preStartOutput,
-1,
"Terminal input could not be cleared safely. Clear the current line and try again.",
);
}, INPUT_CLEAR_TIMEOUT_MS);
}
function onData(data) {
const bytes = Buffer.isBuffer(data)
? data
@@ -421,6 +509,41 @@ function startPtyJob(ptyStream, command, options) {
if (!text) return;
armOutputTimeout();
if (waitingForInputClear) {
preStartOutput += text;
if (preStartOutput.length > captureLimitChars) {
preStartOutput = preStartOutput.slice(-captureLimitChars);
}
if (cancelRequested && hasExpectedPromptSuffix(preStartOutput, expectedPrompt)) {
waitingForInputClear = false;
finish(preStartOutput, 130, "Cancelled");
return;
}
if (!inputClearInterruptSent) {
// An echoed `i` / editor mode repaint proves the shell has consumed
// the insert-mode byte. Give the editor one short turn to commit its
// keymap transition before Ctrl+C (fish repaints before the transition
// is fully active).
clearTimeout(inputClearPrepTimerId);
inputClearPrepTimerId = setTimeout(sendPendingInputInterrupt, 50);
return;
}
if (hasExpectedPromptSuffix(preStartOutput, expectedPrompt)) {
waitingForInputClear = false;
clearTimeout(inputClearTimeoutId);
try {
onPendingInputCleared?.();
} catch {
// Input tracking is advisory and must not block a safe execution.
}
// The prompt redraw proves the interrupt was processed. Queue the
// wrapper after the current PTY data callback returns so it cannot be
// flushed with the interrupt bytes.
queueMicrotask(writeWrappedCommand);
}
return;
}
if (!foundStart) {
preStartOutput += text;
const combined = pendingStart + text;
@@ -573,16 +696,68 @@ function startPtyJob(ptyStream, command, options) {
cleanupFns.push(() => abortSignal.removeEventListener("abort", onAbort));
}
if (typedInput && typeof echoCommand === "function") {
try {
echoCommand(command);
} catch {
// Ignore synthetic echo failures.
if (waitingForInputClear) {
if (allowPendingInputClear !== true) {
finish(
"",
-1,
"Cannot safely execute while terminal input may still be active. Clear the current line and try again.",
);
} else if (submittedInputAwaitingPrompt === true) {
finish(
"",
-1,
"Cannot safely execute while previously submitted terminal input is still active. Return to a fresh shell prompt and try again.",
);
} else if (!expectedPrompt || pendingInputInterruptSafe !== true) {
finish(
"",
-1,
"Cannot safely execute while unsubmitted terminal input is present. Clear the current line and try again.",
);
} else {
try {
releaseInputGate = acquireInputGate?.() || null;
} catch {
releaseInputGate = null;
}
if (typeof releaseInputGate !== "function") {
finish("", -1, "Cannot safely lock terminal input for clearing. Clear the current line and try again.");
return {
marker,
cancel,
getSnapshot: () => ({
stdout: "",
outputBaseOffset: 0,
totalOutputChars: 0,
outputTruncated: false,
status: "finished",
foundStart: false,
}),
resultPromise,
};
}
// Enter insert mode first, then Ctrl+C. In vi command mode fish binds
// Ctrl+C only in its insert keymap; readline/zle/PSReadLine also accept
// this sequence, and the interrupt discards the temporary literal `i`.
// Send the bytes separately: a terminal driver may flush queued input
// when it sees VINTR, so one combined write can drop `i` before fish
// switches to its insert keymap. Prefer an echo/repaint acknowledgement;
// use a short fallback for editors that change mode silently. This path
// runs only when input tracking says the user has unsubmitted bytes and
// the live terminal tail proves they are still on the cached editable
// prompt line. Wait for the prompt redraw before writing the wrapper to
// avoid VINTR flushing its bytes.
if (!inputRevisionIsCurrent()) {
finish("", -1, "Terminal input changed while command execution was being prepared. Try again.");
} else if (writePtySafely("i")) {
inputClearPrepTimerId = setTimeout(sendPendingInputInterrupt, 100);
}
}
} else {
writeWrappedCommand();
}
ptyStream.write(buildWrappedCommand(command, resolvedShellKind, marker));
return {
marker,
cancel,
@@ -614,7 +789,14 @@ function startPtyJob(ptyStream, command, options) {
* @param {number} [options.timeoutMs=60000] - Command timeout in milliseconds
* @param {string} [options.chatSessionId] - Chat session ID for scoped cancellation
* @param {AbortSignal} [options.abortSignal] - AbortSignal to cancel execution
* @param {string} [options.expectedPrompt] - Last observed idle prompt for exact fallback matching
* @param {string} [options.expectedPrompt] - Live editable prompt used for wrapper selection, prompt fallback, and safe pending-input cancellation.
* @param {boolean} [options.pendingUserInput=false] - Whether renderer input contains unsubmitted bytes.
* @param {boolean} [options.submittedInputAwaitingPrompt=false] - Whether Enter was submitted but shell state is not yet confirmed idle.
* @param {boolean} [options.allowPendingInputClear=false] - Internal-only opt-in for exercising pending-input clear mechanics; product callers fail closed.
* @param {boolean} [options.pendingInputInterruptSafe=false] - Whether the caller proved the shell owns foreground input.
* @param {() => boolean} [options.isInputRevisionCurrent] - Confirms the user has not typed since safety verification.
* @param {() => ((replayDeferred?: boolean) => void)|null} [options.acquireInputGate] - Atomically blocks renderer input until clearing finishes.
* @param {() => void} [options.onPendingInputCleared] - Called after Ctrl+C redraws the expected prompt.
* @param {boolean} [options.typedInput=false] - Emit synthetic command echo before execution
* @param {(command: string) => void} [options.echoCommand] - Callback used to display synthetic command echo
*/
@@ -843,6 +1025,7 @@ function execViaChannel(sshClient, command, options) {
* @param {Map} [options.trackForCancellation] - Map for cancellation tracking
* @param {string} [options.chatSessionId] - Chat session ID for scoped cancellation
* @param {AbortSignal} [options.abortSignal] - AbortSignal to cancel execution
* @param {boolean} [options.pendingUserInput=false] - Whether unsubmitted user bytes are present
*/
function execViaRawPty(serialPort, command, options) {
const {
@@ -852,8 +1035,19 @@ function execViaRawPty(serialPort, command, options) {
chatSessionId,
abortSignal,
encoding = "utf8", // Callers should pass the session's resolved encoding
pendingUserInput = false,
} = options || {};
if (pendingUserInput === true) {
return Promise.resolve({
ok: false,
stdout: "",
stderr: "",
exitCode: null,
error: "Cannot safely execute while unsubmitted terminal input is present. Clear the current line and try again.",
});
}
// Simple incrementing key for the cancellation map (no markers sent to device)
const cancelKey = `__NCRAW_${Date.now().toString(36)}_${(++execViaRawPty._seq).toString(36)}`;
@@ -1010,8 +1204,8 @@ function execViaRawPty(serialPort, command, options) {
cleanupFns.push(() => abortSignal.removeEventListener("abort", onAbort));
}
// Send the raw command followed by CR (network devices expect \r).
safeWrite(command + "\r");
// Raw serial / vendor CLIs do not share a portable line-erase binding.
safeWrite(`${command}\r`);
// Start a "no-response" fallback timer. If the device produces no output at
// all (e.g. silent mode-changing commands like "enable", "configure terminal",
+801 -5
View File
@@ -2,25 +2,31 @@ const test = require("node:test");
const assert = require("node:assert/strict");
const { spawnSync } = require("node:child_process");
const { EventEmitter } = require("node:events");
const { mkdtempSync, rmSync, realpathSync } = require("node:fs");
const { existsSync, mkdtempSync, rmSync, realpathSync } = require("node:fs");
const { tmpdir } = require("node:os");
const { join } = require("node:path");
const nodePty = require("node-pty");
const {
execViaPty,
execViaRawPty,
startPtyJob,
DEFAULT_FOREGROUND_PTY_CAPTURE_CHARS,
resolveEffectiveShellKind,
execViaChannel,
} = require("./ptyExec.cjs");
const { buildWrappedCommand } = require("./ptyExecHelpers.cjs");
const {
buildWrappedCommand,
} = require("./ptyExecHelpers.cjs");
getEditableIdlePrompt,
trackSessionIdlePrompt,
trackSessionPendingUserInput,
} = require("./shellUtils.cjs");
const { capturePendingInputState } = require("./pendingInputSafety.cjs");
class ShellBackedPty extends EventEmitter {
write(data) {
if (data === "\x03") return;
const result = spawnSync("sh", ["-c", String(data)], { encoding: "utf8" });
const script = String(data);
const result = spawnSync("sh", ["-c", script], { encoding: "utf8" });
queueMicrotask(() => {
this.emit("data", Buffer.from(result.stdout));
});
@@ -31,6 +37,796 @@ function markerFromWrite(data) {
return String(data).match(/(__NCMCP_[a-z0-9]+_[0-9a-f]+__)/i)?.[1] || null;
}
test("execViaPty writes the wrapper directly when user input is already submitted", async () => {
const writes = [];
class CapturePty extends EventEmitter {
write(data) {
writes.push(String(data));
const marker = markerFromWrite(data);
if (!marker) return;
queueMicrotask(() => {
this.emit("data", Buffer.from(`${marker}_S\nok\n${marker}_E:0\n`));
});
}
}
const result = await execViaPty(new CapturePty(), "uname -a", {
shellKind: "posix",
timeoutMs: 1000,
});
assert.equal(result.ok, true);
assert.equal(writes.length, 1);
assert.match(writes[0], /uname -a/);
});
test("execViaPty cancels pending input, waits for prompt redraw, then writes the wrapper (#2962)", async () => {
const writes = [];
let cleared = 0;
class CapturePty extends EventEmitter {
write(data) {
writes.push(String(data));
if (data === "i") {
queueMicrotask(() => this.emit("data", Buffer.from("i")));
return;
}
if (data === "\x03") {
queueMicrotask(() => this.emit("data", Buffer.from("^C\r\nuser@host:~$")));
return;
}
const marker = markerFromWrite(data);
if (!marker) return;
queueMicrotask(() => {
this.emit("data", Buffer.from(`${marker}_S\nok\n${marker}_E:0\n`));
});
}
}
const result = await execViaPty(new CapturePty(), "uname -a", {
shellKind: "posix",
timeoutMs: 1000,
expectedPrompt: "user@host:~$",
pendingUserInput: true,
pendingInputInterruptSafe: true,
allowPendingInputClear: true,
acquireInputGate: () => () => {},
onPendingInputCleared: () => {
cleared += 1;
},
});
assert.equal(result.ok, true);
assert.equal(writes.length, 3);
assert.equal(writes[0], "i");
assert.equal(writes[1], "\x03");
assert.match(writes[2], /uname -a/);
assert.equal(cleared, 1);
});
test("submitted shell state is rejected without writing any character", async () => {
const writes = [];
class CapturePty extends EventEmitter {
write(data) {
writes.push(String(data));
if (data === "\x03") {
queueMicrotask(() => this.emit("data", Buffer.from("^C\r\nuser@host:~$")));
return;
}
const marker = markerFromWrite(data);
if (marker) {
queueMicrotask(() => this.emit("data", Buffer.from(`${marker}_S\nok\n${marker}_E:0\n`)));
}
}
}
const result = await execViaPty(new CapturePty(), "uname -a", {
shellKind: "posix",
timeoutMs: 1000,
expectedPrompt: "user@host:~$",
pendingUserInput: true,
submittedInputAwaitingPrompt: true,
pendingInputInterruptSafe: true,
allowPendingInputClear: true,
acquireInputGate: () => () => {},
});
assert.equal(result.ok, false);
assert.match(result.error, /terminal input/i);
assert.deepEqual(writes, []);
});
test("execViaPty refuses pending input when no editable prompt is proven", async () => {
const writes = [];
const result = await execViaPty({
on() {},
removeListener() {},
write(data) {
writes.push(String(data));
},
}, "uname -a", {
shellKind: "posix",
timeoutMs: 1000,
pendingUserInput: true,
});
assert.equal(result.ok, false);
assert.match(result.error, /terminal input may still be active/i);
assert.deepEqual(writes, []);
});
test("product-default exec refuses verified pending input without writing", async () => {
const writes = [];
const result = await execViaPty({
on() {},
removeListener() {},
write(data) {
writes.push(String(data));
},
}, "uname -a", {
shellKind: "posix",
timeoutMs: 1000,
expectedPrompt: "user@host:~$",
pendingUserInput: true,
pendingInputInterruptSafe: true,
acquireInputGate: () => () => {},
});
assert.equal(result.ok, false);
assert.match(result.error, /terminal input may still be active/i);
assert.deepEqual(writes, []);
});
test("cancelling during pending-input clear never writes the agent wrapper", async () => {
const writes = [];
const pty = new EventEmitter();
pty.write = (data) => {
writes.push(String(data));
};
const job = startPtyJob(pty, "touch should-not-run", {
shellKind: "posix",
expectedPrompt: "user@host:~$",
pendingUserInput: true,
pendingInputInterruptSafe: true,
allowPendingInputClear: true,
acquireInputGate: () => () => {},
timeoutMs: 1000,
});
job.cancel();
pty.emit("data", Buffer.from("^C\r\nuser@host:~$"));
const result = await job.resultPromise;
assert.equal(result.ok, false);
assert.equal(result.error, "Cancelled");
assert.equal(writes.some((write) => write.includes("should-not-run")), false);
});
test("a delayed pending-input interrupt write failure resolves as a stream error", async () => {
let writes = 0;
const pty = new EventEmitter();
pty.write = () => {
writes += 1;
if (writes === 2) throw new Error("terminal closed");
queueMicrotask(() => pty.emit("data", Buffer.from("i")));
};
const result = await execViaPty(pty, "uname -a", {
shellKind: "posix",
expectedPrompt: "user@host:~$",
pendingUserInput: true,
pendingInputInterruptSafe: true,
allowPendingInputClear: true,
acquireInputGate: () => () => {},
timeoutMs: 1000,
});
assert.equal(result.ok, false);
assert.match(result.error, /Stream error: terminal closed/);
assert.equal(writes, 2);
});
test("pending-input clear stops before Ctrl+C when the user types during its handshake", async () => {
const writes = [];
let revisionCurrent = true;
const pty = new EventEmitter();
pty.write = (data) => {
writes.push(String(data));
if (data === "i") {
revisionCurrent = false;
queueMicrotask(() => pty.emit("data", Buffer.from("i")));
}
};
const result = await execViaPty(pty, "uname -a", {
shellKind: "posix",
expectedPrompt: "user@host:~$",
pendingUserInput: true,
pendingInputInterruptSafe: true,
allowPendingInputClear: true,
acquireInputGate: () => () => {},
isInputRevisionCurrent: () => revisionCurrent,
timeoutMs: 1000,
});
assert.equal(result.ok, false);
assert.match(result.error, /input changed/i);
assert.deepEqual(writes, ["i"]);
});
test("a delayed wrapper write failure resolves as a stream error", async () => {
let writes = 0;
const gateReleases = [];
const pty = new EventEmitter();
pty.write = (data) => {
writes += 1;
if (writes === 1) {
queueMicrotask(() => pty.emit("data", Buffer.from("i")));
return;
}
if (writes === 2) {
queueMicrotask(() => pty.emit("data", Buffer.from("^C\r\nuser@host:~$")));
return;
}
throw new Error("terminal closed");
};
const result = await execViaPty(pty, "uname -a", {
shellKind: "posix",
expectedPrompt: "user@host:~$",
pendingUserInput: true,
pendingInputInterruptSafe: true,
allowPendingInputClear: true,
acquireInputGate: () => (replayDeferred) => gateReleases.push(replayDeferred),
timeoutMs: 1000,
});
assert.equal(result.ok, false);
assert.match(result.error, /Stream error: terminal closed/);
assert.equal(writes, 3);
assert.deepEqual(gateReleases, [false]);
});
test("execViaPty clears shell state before synthetic echo and wrapper write", async () => {
const writes = [];
const echoes = [];
class CapturePty extends EventEmitter {
write(data) {
writes.push(String(data));
if (data === "i") {
queueMicrotask(() => this.emit("data", Buffer.from("i")));
return;
}
if (data === "\x03") {
queueMicrotask(() => this.emit("data", Buffer.from("^C\r\nuser@host:~$")));
return;
}
const marker = markerFromWrite(data);
if (!marker) return;
queueMicrotask(() => {
this.emit("data", Buffer.from(`${marker}_S\nok\n${marker}_E:0\n`));
});
}
}
const result = await execViaPty(new CapturePty(), "uname -a", {
shellKind: "posix",
timeoutMs: 1000,
expectedPrompt: "user@host:~$",
pendingUserInput: true,
pendingInputInterruptSafe: true,
allowPendingInputClear: true,
acquireInputGate: () => () => {},
typedInput: true,
echoCommand: (cmd) => {
echoes.push({ cmd, writesSoFar: writes.slice() });
},
});
assert.equal(result.ok, true);
assert.equal(echoes.length, 1);
assert.equal(echoes[0].cmd, "uname -a");
assert.deepEqual(echoes[0].writesSoFar, ["i", "\x03"]);
assert.equal(writes.length, 3);
assert.equal(writes[0], "i");
assert.equal(writes[1], "\x03");
assert.match(writes[2], /uname -a/);
});
function waitForPtyText(ptyProcess, needle, timeoutMs = 5000) {
return new Promise((resolve, reject) => {
let output = "";
const timeoutId = setTimeout(() => {
disposable.dispose();
reject(new Error(`Timed out waiting for PTY text: ${needle}\n${output}`));
}, timeoutMs);
const disposable = ptyProcess.onData((chunk) => {
output += chunk;
if (!output.includes(needle)) return;
clearTimeout(timeoutId);
disposable.dispose();
resolve(output);
});
});
}
test("execViaPty does not interrupt a foreground child that prints the cached prompt", async (t) => {
const bash = spawnSync("bash", ["--version"], { encoding: "utf8" });
if (bash.error || bash.status !== 0) {
t.skip("bash is unavailable");
return;
}
const expectedPrompt = "user@host:~$";
const ptyProcess = nodePty.spawn("bash", ["--noprofile", "--norc", "-i"], {
cols: 120,
rows: 30,
env: { ...process.env, PS1: expectedPrompt },
});
try {
await waitForPtyText(ptyProcess, expectedPrompt);
const childPrompt = waitForPtyText(ptyProcess, expectedPrompt);
ptyProcess.write(
`python3 -c 'print("${expectedPrompt}", flush=True); input(); print("CHILD_ALIVE", flush=True)'\r`,
);
await childPrompt;
ptyProcess.write("USER_DATA");
const result = await execViaPty(ptyProcess, "echo AGENT_MUST_NOT_RUN", {
shellKind: "posix",
expectedPrompt,
pendingUserInput: true,
timeoutMs: 1000,
});
assert.equal(result.ok, false);
assert.match(result.error, /terminal input/i);
const childAlive = waitForPtyText(ptyProcess, "CHILD_ALIVE");
ptyProcess.write("\r");
const output = await childAlive;
assert.match(output, /CHILD_ALIVE/);
assert.doesNotMatch(output, /AGENT_MUST_NOT_RUN/);
} finally {
ptyProcess.kill();
}
});
test("real bash continuation cannot consume an agent command after Enter (#2962)", {
skip: !existsSync("/bin/bash"),
}, async () => {
const workDir = mkdtempSync(join(tmpdir(), "netcatty-2962-continuation-"));
const agentPath = join(workDir, "agent");
const expectedPrompt = "user@host:~$";
const session = {};
const ptyProcess = nodePty.spawn("/bin/bash", ["--noprofile", "--norc", "-i"], {
cols: 120,
rows: 30,
cwd: workDir,
env: { ...process.env, PS1: expectedPrompt },
});
const outputTracker = ptyProcess.onData((chunk) => trackSessionIdlePrompt(session, chunk));
try {
await waitForPtyText(ptyProcess, expectedPrompt);
const continuation = waitForPtyText(ptyProcess, "> ");
const unfinished = "printf __SPLICED__ && \\\r";
trackSessionPendingUserInput(session, unfinished);
ptyProcess.write(unfinished);
await continuation;
const pendingInputState = capturePendingInputState(session);
assert.equal(pendingInputState.pending, true);
assert.equal(getEditableIdlePrompt(session), "");
const result = await execViaPty(ptyProcess, `touch '${agentPath}'`, {
shellKind: "posix",
expectedPrompt: getEditableIdlePrompt(session),
pendingUserInput: pendingInputState.pending,
submittedInputAwaitingPrompt: true,
pendingInputInterruptSafe: true,
acquireInputGate: () => () => {},
timeoutMs: 1000,
});
assert.equal(result.ok, false);
assert.match(result.error, /terminal input/i);
assert.equal(existsSync(agentPath), false, "agent command must not be consumed by PS2");
const primaryPrompt = waitForPtyText(ptyProcess, expectedPrompt);
trackSessionPendingUserInput(session, "\x03");
ptyProcess.write("\x03");
await primaryPrompt;
assert.equal(existsSync(agentPath), false);
} finally {
outputTracker.dispose();
try { ptyProcess.kill(); } catch {}
rmSync(workDir, { recursive: true, force: true });
}
});
test("real bash refuses continuation when PS2 is identical to PS1 (#2962)", {
skip: !existsSync("/bin/bash"),
}, async () => {
const workDir = mkdtempSync(join(tmpdir(), "netcatty-2962-spoofed-ps2-"));
const agentPath = join(workDir, "agent");
const splicedPath = join(workDir, "spliced");
const expectedPrompt = "user@host:~$";
const session = {};
const ptyProcess = nodePty.spawn("/bin/bash", ["--noprofile", "--norc", "-i"], {
cols: 120,
rows: 30,
cwd: workDir,
env: { ...process.env, PS1: expectedPrompt, PS2: expectedPrompt },
});
const outputTracker = ptyProcess.onData((chunk) => trackSessionIdlePrompt(session, chunk));
try {
await waitForPtyText(ptyProcess, expectedPrompt);
const continuation = waitForPtyText(ptyProcess, expectedPrompt);
const unfinished = `touch '${splicedPath}' && \\\r`;
trackSessionPendingUserInput(session, unfinished);
ptyProcess.write(unfinished);
await continuation;
const pendingInputState = capturePendingInputState(session);
assert.equal(pendingInputState.pending, true);
assert.equal(getEditableIdlePrompt(session), expectedPrompt);
const result = await execViaPty(ptyProcess, `touch '${agentPath}'`, {
shellKind: "posix",
expectedPrompt: getEditableIdlePrompt(session),
pendingUserInput: pendingInputState.pending,
submittedInputAwaitingPrompt: true,
pendingInputInterruptSafe: true,
acquireInputGate: () => () => {},
timeoutMs: 5000,
});
assert.equal(result.ok, false);
assert.match(result.error, /terminal input/i);
assert.equal(existsSync(agentPath), false, "agent command must not enter PS2");
assert.equal(existsSync(splicedPath), false, "unfinished PS2 command must remain unexecuted");
} finally {
outputTracker.dispose();
try { ptyProcess.kill(); } catch {}
rmSync(workDir, { recursive: true, force: true });
}
});
test("real bash partial read cannot consume any pending-state safety bytes (#2962)", {
skip: !existsSync("/bin/bash"),
}, async () => {
const workDir = mkdtempSync(join(tmpdir(), "netcatty-2962-read-n1-"));
const agentPath = join(workDir, "agent");
const followUpPath = join(workDir, "read-follow-up");
const expectedPrompt = "user@host:~$";
const session = {};
const ptyProcess = nodePty.spawn("/bin/bash", ["--noprofile", "--norc", "-i"], {
cols: 120,
rows: 30,
cwd: workDir,
env: { ...process.env, PS1: expectedPrompt },
});
const outputTracker = ptyProcess.onData((chunk) => trackSessionIdlePrompt(session, chunk));
try {
await waitForPtyText(ptyProcess, expectedPrompt);
const readPrompt = waitForPtyText(ptyProcess, expectedPrompt);
const readCommand = `read -n3 -p "$PS1"; touch '${followUpPath}'\r`;
trackSessionPendingUserInput(session, readCommand);
ptyProcess.write(readCommand);
await readPrompt;
trackSessionPendingUserInput(session, "ab");
ptyProcess.write("ab");
const pendingInputState = capturePendingInputState(session);
const result = await execViaPty(ptyProcess, `touch '${agentPath}'`, {
shellKind: "posix",
expectedPrompt: getEditableIdlePrompt(session),
pendingUserInput: pendingInputState.pending,
submittedInputAwaitingPrompt: true,
pendingInputInterruptSafe: true,
acquireInputGate: () => () => {},
timeoutMs: 5000,
});
assert.equal(result.ok, false);
assert.match(result.error, /terminal input/i);
assert.equal(existsSync(agentPath), false);
assert.equal(existsSync(followUpPath), false, "safety path must not complete the read");
} finally {
outputTracker.dispose();
try { ptyProcess.kill(); } catch {}
rmSync(workDir, { recursive: true, force: true });
}
});
test("real bash primary prompt remains fail-closed after submitted input (#2962)", {
skip: !existsSync("/bin/bash"),
}, async () => {
const workDir = mkdtempSync(join(tmpdir(), "netcatty-2962-primary-prompt-"));
const agentPath = join(workDir, "agent");
const expectedPrompt = "user@host:~$";
const session = {};
const ptyProcess = nodePty.spawn("/bin/bash", ["--noprofile", "--norc", "-i"], {
cols: 120,
rows: 30,
cwd: workDir,
env: { ...process.env, PS1: expectedPrompt },
});
const outputTracker = ptyProcess.onData((chunk) => trackSessionIdlePrompt(session, chunk));
try {
await waitForPtyText(ptyProcess, expectedPrompt);
const primaryPrompt = waitForPtyText(ptyProcess, expectedPrompt);
trackSessionPendingUserInput(session, "true\r");
ptyProcess.write("true\r");
await primaryPrompt;
const pendingInputState = capturePendingInputState(session);
const result = await execViaPty(ptyProcess, `touch '${agentPath}'`, {
shellKind: "posix",
expectedPrompt: getEditableIdlePrompt(session),
pendingUserInput: pendingInputState.pending,
submittedInputAwaitingPrompt: true,
pendingInputInterruptSafe: true,
acquireInputGate: () => () => {},
timeoutMs: 5000,
});
assert.equal(result.ok, false);
assert.match(result.error, /terminal input/i);
assert.equal(existsSync(agentPath), false);
} finally {
outputTracker.dispose();
try { ptyProcess.kill(); } catch {}
rmSync(workDir, { recursive: true, force: true });
}
});
test("real bash foreground cat never receives an agent wrapper after Enter (#2962)", {
skip: !existsSync("/bin/bash"),
}, async () => {
const workDir = mkdtempSync(join(tmpdir(), "netcatty-2962-foreground-cat-"));
const agentPath = join(workDir, "agent");
const expectedPrompt = "user@host:~$";
const session = {};
const ptyProcess = nodePty.spawn("/bin/bash", ["--noprofile", "--norc", "-i"], {
cols: 120,
rows: 30,
cwd: workDir,
env: { ...process.env, PS1: expectedPrompt },
});
const outputTracker = ptyProcess.onData((chunk) => trackSessionIdlePrompt(session, chunk));
try {
await waitForPtyText(ptyProcess, expectedPrompt);
trackSessionPendingUserInput(session, "cat\r");
ptyProcess.write("cat\r");
await new Promise((resolve) => setTimeout(resolve, 50));
const pendingInputState = capturePendingInputState(session);
const result = await execViaPty(ptyProcess, `touch '${agentPath}'`, {
shellKind: "posix",
expectedPrompt: getEditableIdlePrompt(session),
pendingUserInput: pendingInputState.pending,
pendingInputInterruptSafe: false,
timeoutMs: 1000,
});
assert.equal(result.ok, false);
assert.match(result.error, /terminal input may still be active/i);
assert.equal(existsSync(agentPath), false);
} finally {
outputTracker.dispose();
try { ptyProcess.kill(); } catch {}
rmSync(workDir, { recursive: true, force: true });
}
});
test("real bash SIGINT trap cannot receive an agent wrapper (#2962)", {
skip: !existsSync("/bin/bash"),
}, async () => {
const workDir = mkdtempSync(join(tmpdir(), "netcatty-2962-sigint-trap-"));
const agentPath = join(workDir, "agent");
const trapPath = join(workDir, "trap-follow-up");
const expectedPrompt = "user@host:~$";
const session = {};
const ptyProcess = nodePty.spawn("/bin/bash", ["--noprofile", "--norc", "-i"], {
cols: 120,
rows: 30,
cwd: workDir,
env: { ...process.env, PS1: expectedPrompt },
});
const outputTracker = ptyProcess.onData((chunk) => trackSessionIdlePrompt(session, chunk));
try {
await waitForPtyText(ptyProcess, expectedPrompt);
const trapReady = waitForPtyText(ptyProcess, expectedPrompt);
ptyProcess.write(`trap 'printf "$PS1"; read x; touch "${trapPath}"' INT\r`);
await trapReady;
trackSessionPendingUserInput(session, "UNFINISHED");
ptyProcess.write("UNFINISHED");
await new Promise((resolve) => setTimeout(resolve, 30));
const pendingInputState = capturePendingInputState(session);
const result = await execViaPty(ptyProcess, `touch '${agentPath}'`, {
shellKind: "posix",
expectedPrompt: getEditableIdlePrompt(session),
pendingUserInput: pendingInputState.pending,
pendingInputInterruptSafe: true,
acquireInputGate: () => () => {},
timeoutMs: 1000,
});
assert.equal(result.ok, false);
assert.match(result.error, /terminal input may still be active/i);
assert.equal(existsSync(agentPath), false);
assert.equal(existsSync(trapPath), false);
} finally {
outputTracker.dispose();
try { ptyProcess.kill(); } catch {}
rmSync(workDir, { recursive: true, force: true });
}
});
async function verifyRealEditorPendingInputClear({
shellPath,
args,
shellKind,
setup,
initialPrompt,
editorMode = "vi",
}) {
const workDir = mkdtempSync(join(tmpdir(), "netcatty-2962-"));
const splicedPath = join(workDir, "spliced");
const agentPath = join(workDir, "agent");
const expectedPrompt = "user@host:~$";
let ptyProcess;
try {
ptyProcess = nodePty.spawn(shellPath, args, {
cols: 120,
rows: 30,
cwd: workDir,
env: {
...process.env,
PS1: expectedPrompt,
},
});
await waitForPtyText(ptyProcess, initialPrompt || expectedPrompt);
if (shellKind === "fish") {
const promptReady = waitForPtyText(ptyProcess, expectedPrompt);
ptyProcess.write(`function fish_prompt; echo -n '${expectedPrompt}'; end\r`);
await promptReady;
}
if (setup) {
const setupReady = waitForPtyText(ptyProcess, expectedPrompt);
ptyProcess.write(`${setup}\r`);
await setupReady;
}
// Reproduce the reviewer finding: leave a command suffix with the cursor
// at the beginning. The old key-kill prefix left the suffix executable;
// the new path must cancel the complete editor state.
ptyProcess.write(`abc; touch '${splicedPath}'`);
if (editorMode === "vi") {
ptyProcess.write("\x1b");
await new Promise((resolve) => setTimeout(resolve, 50));
ptyProcess.write("0");
} else {
ptyProcess.write("\x01");
}
await new Promise((resolve) => setTimeout(resolve, 50));
const result = await execViaPty(ptyProcess, `touch '${agentPath}'`, {
shellKind,
expectedPrompt,
pendingUserInput: true,
pendingInputInterruptSafe: true,
allowPendingInputClear: true,
acquireInputGate: () => () => {},
timeoutMs: 5000,
});
assert.equal(result.ok, true, `${result.error || ""}\n${JSON.stringify(result.stdout)}`);
assert.equal(existsSync(agentPath), true, "agent command must execute");
assert.equal(existsSync(splicedPath), false, "unfinished user suffix must not execute");
} finally {
try {
ptyProcess?.kill();
} catch {}
rmSync(workDir, { recursive: true, force: true });
}
}
test("real bash vi editor cannot splice unfinished input into agent execution (#2962)", {
skip: !existsSync("/bin/bash"),
}, async () => {
await verifyRealEditorPendingInputClear({
shellPath: "/bin/bash",
args: ["--noprofile", "--norc", "-i"],
shellKind: "posix",
setup: "set -o vi",
});
});
test("real bash emacs editor cannot splice a right-side unfinished suffix (#2962)", {
skip: !existsSync("/bin/bash"),
}, async () => {
await verifyRealEditorPendingInputClear({
shellPath: "/bin/bash",
args: ["--noprofile", "--norc", "-i"],
shellKind: "posix",
editorMode: "emacs",
});
});
test("real zsh vi editor cannot splice unfinished input into agent execution (#2962)", {
skip: !existsSync("/bin/zsh"),
}, async () => {
await verifyRealEditorPendingInputClear({
shellPath: "/bin/zsh",
args: ["-f", "-i"],
shellKind: "posix",
setup: "bindkey -v",
});
});
test("real fish vi editor cannot splice unfinished input into agent execution (#2962)", {
skip: !existsSync("/opt/homebrew/bin/fish") && !existsSync("/usr/bin/fish"),
}, async () => {
const shellPath = existsSync("/opt/homebrew/bin/fish")
? "/opt/homebrew/bin/fish"
: "/usr/bin/fish";
await verifyRealEditorPendingInputClear({
shellPath,
args: ["--no-config", "--interactive"],
shellKind: "fish",
setup: "fish_vi_key_bindings",
initialPrompt: "> ",
});
});
test("execViaRawPty does not prepend Ctrl+U before device commands", async () => {
const writes = [];
class CaptureRawPort extends EventEmitter {
write(data) {
writes.push(String(data));
queueMicrotask(() => {
this.emit("data", Buffer.from("ok\r\nRouter#"));
});
}
}
const result = await execViaRawPty(new CaptureRawPort(), "show version", {
timeoutMs: 1000,
idleMs: 20,
});
assert.equal(result.ok, true);
assert.equal(writes.length, 1);
assert.equal(writes[0], "show version\r");
assert.equal(writes[0].includes("\x15"), false);
});
test("execViaRawPty refuses pending device input without writing any bytes", async () => {
const writes = [];
const port = new EventEmitter();
port.write = (data) => writes.push(String(data));
const result = await execViaRawPty(port, "show version", {
pendingUserInput: true,
timeoutMs: 1000,
});
assert.equal(result.ok, false);
assert.match(result.error, /terminal input/i);
assert.deepEqual(writes, []);
});
test("execViaPty completes when command output has no trailing newline", async () => {
const result = await execViaPty(new ShellBackedPty(), "printf 'abc'", {
shellKind: "posix",
+38 -11
View File
@@ -77,18 +77,30 @@ function classifyShellKindFromRemotePath(shellPath) {
/**
* Silent remote probe: force POSIX sh so fish/zsh login shells can still run it
* when sshd invokes the command through the user's login shell (`$SHELL -c`).
* Prints a single line: absolute login-shell path (or empty).
* Prints the remotely resolved path first (when different), then the original
* login-shell path. The parser prefers the first classifiable path. Keeping the
* original alias lets BusyBox `/bin/ash` and `/bin/sh` remain classifiable when
* readlink resolves them to the otherwise ambiguous `/bin/busybox` executable.
*/
function buildRemoteLoginShellProbeCommand() {
const script = [
'SH="$(getent passwd "$(id -un)" 2>/dev/null | cut -d: -f7)"',
'[ -n "$SH" ] || SH="${SHELL:-}"',
'if [ -n "$SH" ]; then',
' R=""',
' if command -v readlink >/dev/null 2>&1; then R=$(readlink -f "$SH" 2>/dev/null || true); fi',
' if [ -z "$R" ] && command -v realpath >/dev/null 2>&1; then R=$(realpath "$SH" 2>/dev/null || true); fi',
` [ -n "$R" ] && [ "$R" != "$SH" ] && printf "${PROBE_OUTPUT_MARKER}%s\\n" "$R"`,
'fi',
`printf "${PROBE_OUTPUT_MARKER}%s\\n" "$SH"`,
].join("; ");
].join("\n");
return `exec sh -c ${quoteShellArg(script)}`;
}
function parseRemoteLoginShellProbeOutput(stdout) {
/**
* @returns {{ kind: string, shellPath: string } | null}
*/
function parseRemoteLoginShellProbe(stdout) {
const lines = String(stdout || "")
.replace(/\r/g, "")
.split("\n")
@@ -97,12 +109,17 @@ function parseRemoteLoginShellProbeOutput(stdout) {
for (const line of lines) {
if (!line.startsWith(PROBE_OUTPUT_MARKER)) continue;
const kind = classifyShellKindFromRemotePath(line.slice(PROBE_OUTPUT_MARKER.length));
if (kind) return kind;
const shellPath = line.slice(PROBE_OUTPUT_MARKER.length).trim();
const kind = classifyShellKindFromRemotePath(shellPath);
if (kind) return { kind, shellPath };
}
return null;
}
function parseRemoteLoginShellProbeOutput(stdout) {
return parseRemoteLoginShellProbe(stdout)?.kind ?? null;
}
/**
* Silent Windows OpenSSH probe. Force `cmd.exe` so ERRORLEVEL works under both
* DefaultShell=cmd and DefaultShell=powershell (sshd still invokes console PE
@@ -224,10 +241,15 @@ function withProbeTimeout(promise, timeoutMs) {
*
* Always mark the probe settled so we do not re-probe every AI exec.
*/
function applyProbedShellKind(session, kind) {
function applyProbedShellKind(session, kind, shellPath) {
if (!kind) return session.shellKind;
session._shellKindProbeSettled = true;
session._loginShellKind = kind;
const trimmedPath = String(shellPath || "").trim();
if (trimmedPath) {
// Soft login-shell path hint (not the active nested interactive editor).
session._loginShellPath = trimmedPath;
}
// Soft hint only; never pin session.shellKind from a remote login probe.
return session.shellKind;
}
@@ -250,7 +272,7 @@ function isShellKindProbeSettled(session) {
* marker probe, then fall back to the Windows reg probe for hosts whose banner
* was not recorded on the session.
*
* @returns {Promise<{ kind: string|null, settleWithoutKind?: boolean }>}
* @returns {Promise<{ kind: string|null, shellPath?: string, settleWithoutKind?: boolean }>}
*/
async function probeRemoteLoginShellKind(execProbe, timeoutMs, session) {
const preferWindows = isWindowsOpenSshRemote(session?.remoteSshVersion);
@@ -278,8 +300,8 @@ async function probeRemoteLoginShellKind(execProbe, timeoutMs, session) {
execProbe(buildRemoteLoginShellProbeCommand(), timeoutMs),
timeoutMs,
);
const kind = parseRemoteLoginShellProbeOutput(stdout);
if (kind) return { kind };
const parsed = parseRemoteLoginShellProbe(stdout);
if (parsed) return { kind: parsed.kind, shellPath: parsed.shellPath };
// Timed out / probe returned null — leave unsettled for a later retry.
// Do not stack a second full-timeout Windows probe in the same attempt.
@@ -362,7 +384,7 @@ async function ensureSessionShellKind(session, options = {}) {
try {
const probed = await probeRemoteLoginShellKind(execProbe, timeoutMs, session);
if (probed.kind) {
return applyProbedShellKind(session, probed.kind);
return applyProbedShellKind(session, probed.kind, probed.shellPath);
}
if (probed.settleWithoutKind) {
markShellKindProbeSettled(session);
@@ -401,6 +423,7 @@ async function ensureSessionShellKindForExec(session, options = {}) {
chatSessionId = null,
execProbe,
timeoutMs,
verifyPendingInput,
} = options;
let cancelled = false;
@@ -422,6 +445,9 @@ async function ensureSessionShellKindForExec(session, options = {}) {
try {
await ensureSessionShellKind(session, { execProbe, timeoutMs });
const pendingInputInterruptSafe = typeof verifyPendingInput === "function"
? await verifyPendingInput()
: false;
if (cancelled) {
return {
ok: false,
@@ -432,7 +458,7 @@ async function ensureSessionShellKindForExec(session, options = {}) {
error: "Cancelled",
};
}
return { ok: true, shellKind: session.shellKind };
return { ok: true, shellKind: session.shellKind, pendingInputInterruptSafe };
} finally {
if (pendingMarker && trackForCancellation) {
trackForCancellation.delete(pendingMarker);
@@ -449,6 +475,7 @@ module.exports = {
isWindowsOpenSshRemote,
classifyShellKindFromRemotePath,
buildRemoteLoginShellProbeCommand,
parseRemoteLoginShellProbe,
buildRemoteWindowsLoginShellProbeCommand,
parseRemoteLoginShellProbeOutput,
parseRemoteWindowsLoginShellProbeOutput,
@@ -145,6 +145,36 @@ test("parseRemoteLoginShellProbeOutput reads classifiable probe output lines", (
assert.equal(parseRemoteLoginShellProbeOutput(" \n"), null);
});
test("posix login shell probe stores path without pinning shellKind", async () => {
const session = { protocol: "ssh" };
await ensureSessionShellKind(session, {
execProbe: async () => `${PROBE_OUTPUT_MARKER}/bin/dash\n`,
});
assert.equal(session._loginShellKind, "posix");
assert.equal(session._loginShellPath, "/bin/dash");
assert.equal(session.shellKind, undefined);
});
test("BusyBox-resolved login shell falls back to its original alias and probes once", async () => {
let probes = 0;
const session = { protocol: "ssh" };
const probe = async () => {
probes += 1;
return (
`${PROBE_OUTPUT_MARKER}/bin/busybox\n`
+ `${PROBE_OUTPUT_MARKER}/bin/ash\n`
);
};
await ensureSessionShellKind(session, { execProbe: probe });
await ensureSessionShellKind(session, { execProbe: probe });
assert.equal(session._loginShellKind, "posix");
assert.equal(session._loginShellPath, "/bin/ash");
assert.equal(session._shellKindProbeSettled, true);
assert.equal(probes, 1);
});
test("probe command is fish-parseable and forces POSIX sh", () => {
const command = buildRemoteLoginShellProbeCommand();
// Outer form: fish and bash both accept `exec sh -c '...'` when sshd
@@ -152,6 +182,14 @@ test("probe command is fish-parseable and forces POSIX sh", () => {
assert.match(command, /^exec sh -c '/);
assert.match(command, /getent passwd/);
assert.match(command, new RegExp(PROBE_OUTPUT_MARKER));
// Resolve remote /bin/sh on the host so the client never uses local realpath.
assert.match(command, /readlink -f/);
assert.match(command, /realpath/);
assert.equal(
command.match(new RegExp(PROBE_OUTPUT_MARKER, "g"))?.length,
2,
"probe must preserve the original alias after the remotely resolved path",
);
// ${SHELL:-} lives inside the single-quoted sh script body, not as an
// outer-shell expansion — fish must not see it unquoted.
assert.match(command, /\$\{SHELL:-\}/);
+79
View File
@@ -137,6 +137,15 @@ function trackSessionIdlePrompt(session, chunk) {
const nextTail = `${session._promptTrackTail || ""}${chunk}`.slice(-MAX_PROMPT_TRACK_TAIL);
session._promptTrackTail = nextTail;
// While the user is editing, the current line contains the last known
// prompt followed by their input. Input ending in `$`, `#`, or `>` can look
// like a fresh prompt, so keep the proven prompt until the line is submitted
// or interrupted.
if (
session._hasPendingUserInput === true
|| session._awaitingPrimaryPromptAfterUserSubmit === true
) return "";
const prompt = extractTrailingIdlePrompt(nextTail);
if (prompt) {
session.lastIdlePrompt = prompt;
@@ -172,6 +181,74 @@ function getFreshIdlePrompt(session) {
return normalizedTail.endsWith(normalizedCached) ? cached : "";
}
// Return the cached prompt while the user is still editing the same physical
// prompt line. Keyboard input is echoed by the PTY after the prompt, so an
// exact suffix check stops working as soon as unfinished input exists. A
// submitted command adds a line break, which moves the cached prompt off the
// current line and makes this check fail closed.
function getEditableIdlePrompt(session) {
if (!session) return "";
const hasPendingInput = session._hasPendingUserInput === true;
const awaitingPrimaryPrompt = session._awaitingPrimaryPromptAfterUserSubmit === true;
if (!hasPendingInput && !awaitingPrimaryPrompt) return getFreshIdlePrompt(session);
const cached = stripAnsi(String(session.lastIdlePrompt || "")).replace(/\r/g, "\n");
const tail = stripAnsi(String(session._promptTrackTail || "")).replace(/\r/g, "\n");
if (!tail) return "";
// After Enter, only a complete recognized primary prompt is safe to use.
// A continuation prompt (for example bash's `> `) is deliberately rejected,
// while foreground-process ownership is verified separately before any
// interrupt is sent. This also permits a legitimate prompt changed by `cd`.
if (awaitingPrimaryPrompt && !hasPendingInput) {
return extractTrailingIdlePrompt(tail);
}
if (!cached) return "";
const cachedLine = cached.split("\n").pop() || "";
const currentLine = tail.split("\n").pop() || "";
return cachedLine && currentLine.startsWith(cachedLine) ? session.lastIdlePrompt : "";
}
// Track whether renderer keyboard input contains bytes that have not been
// submitted to the terminal yet. This is deliberately conservative: cursor
// keys and backspace keep the flag set. Enter moves the session into an unsafe
// "awaiting primary prompt" state because the submitted text may have opened a
// continuation prompt; Ctrl+C is the only unconditional reset.
// Programmatic writes and terminal report responses do not represent user
// command-line input and must not affect the state.
function trackSessionPendingUserInput(session, data, options = {}) {
if (!session || options.terminalReport === true) {
return session?._hasPendingUserInput === true;
}
const text = String(data || "");
if (!text) return session._hasPendingUserInput === true;
session._userInputRevision = (Number.isSafeInteger(session._userInputRevision)
? session._userInputRevision
: 0) + 1;
// Ctrl+D is an editing key on a non-empty readline/zle line (delete-char),
// not an unconditional submission boundary. Preserve the prior state when
// it is the only input byte; otherwise the surrounding text is pending.
if (/^\x04+$/.test(text)) return session._hasPendingUserInput === true;
let lastBoundary = -1;
for (const boundary of ["\r", "\n", "\x03"]) {
lastBoundary = Math.max(lastBoundary, text.lastIndexOf(boundary));
}
if (lastBoundary === -1) {
session._hasPendingUserInput = true;
return session._hasPendingUserInput;
}
const trailingInput = text.slice(lastBoundary + 1).length > 0;
const boundary = text[lastBoundary];
session._hasPendingUserInput = trailingInput;
session._awaitingPrimaryPromptAfterUserSubmit = true;
return session._hasPendingUserInput;
}
// ── URL helpers ──
function isLocalhostHostname(hostname) {
@@ -962,10 +1039,12 @@ module.exports = {
formatSyntheticEcho,
extractTrailingIdlePrompt,
getFreshIdlePrompt,
getEditableIdlePrompt,
isDefaultPowerShellPromptLine,
isDefaultCmdPromptLine,
isDefaultPosixPromptLine,
trackSessionIdlePrompt,
trackSessionPendingUserInput,
looksLikeIdleAutoLogout,
isLocalhostHostname,
extractFirstNonLocalhostUrl,
+83 -1
View File
@@ -6,6 +6,7 @@ const {
buildWindowsShellCommandLine,
extractTrailingIdlePrompt,
formatSyntheticEcho,
getEditableIdlePrompt,
getFreshIdlePrompt,
isDefaultPowerShellPromptLine,
isDefaultCmdPromptLine,
@@ -22,6 +23,7 @@ const {
mergeWindowsPath,
readWindowsRegistryPath,
trackSessionIdlePrompt,
trackSessionPendingUserInput,
} = require("./shellUtils.cjs");
const fs = require("node:fs");
const os = require("node:os");
@@ -34,7 +36,6 @@ test("formatSyntheticEcho normalizes multi-line commands to CRLF so xterm doesn'
);
// Already-CRLF input is not doubled.
assert.equal(formatSyntheticEcho("a\r\nb"), "a\r\nb\r\n");
// Single-line commands keep the original shape.
assert.equal(formatSyntheticEcho("npm test"), "npm test\r\n");
});
@@ -620,6 +621,87 @@ test("getFreshIdlePrompt returns empty string when the session has no cached pro
);
});
test("getEditableIdlePrompt keeps a cached prompt while unfinished input is on the same line", () => {
const session = {
lastIdlePrompt: "user@host:~$",
_promptTrackTail: "login\r\nuser@host:~$ls\x1b[D",
_hasPendingUserInput: true,
};
assert.equal(getEditableIdlePrompt(session), "user@host:~$");
session._promptTrackTail = "user@host:~$ls\r\nprocess output";
assert.equal(getEditableIdlePrompt(session), "");
});
test("trackSessionPendingUserInput follows unsubmitted renderer input boundaries", () => {
const session = {};
assert.equal(trackSessionPendingUserInput(session, "\x04"), false);
assert.equal(session._userInputRevision, 1);
assert.equal(trackSessionPendingUserInput(session, "ls"), true);
assert.equal(session._userInputRevision, 2);
assert.equal(trackSessionPendingUserInput(session, "\x1b[D"), true);
assert.equal(trackSessionPendingUserInput(session, "\x04"), true);
assert.equal(trackSessionPendingUserInput(session, "\r"), false);
assert.equal(session._awaitingPrimaryPromptAfterUserSubmit, true);
assert.equal(trackSessionPendingUserInput(session, "echo one\rnext"), true);
assert.equal(session._awaitingPrimaryPromptAfterUserSubmit, true);
assert.equal(trackSessionPendingUserInput(session, "\x03"), false);
assert.equal(session._awaitingPrimaryPromptAfterUserSubmit, true);
assert.equal(
trackSessionPendingUserInput(session, "ignored", { automated: true }),
true,
);
assert.equal(session._userInputRevision, 8);
assert.equal(
trackSessionPendingUserInput(session, "ignored", { terminalReport: true }),
true,
);
});
test("all submitted commands remain unsafe until shell ownership is reverified", () => {
const session = {};
assert.equal(trackSessionPendingUserInput(session, "true\r"), false);
assert.equal(session._awaitingPrimaryPromptAfterUserSubmit, true);
trackSessionPendingUserInput(session, "\x03");
assert.equal(trackSessionPendingUserInput(session, "read\r"), false);
assert.equal(session._awaitingPrimaryPromptAfterUserSubmit, true);
trackSessionPendingUserInput(session, "\x03");
assert.equal(trackSessionPendingUserInput(session, "printf x && \\\r"), false);
assert.equal(session._awaitingPrimaryPromptAfterUserSubmit, true);
});
test("submitted continuation input remains unsafe until a primary prompt is proven", () => {
const session = {};
trackSessionIdlePrompt(session, "user@host:~$");
assert.equal(trackSessionPendingUserInput(session, "printf danger && \\\r"), false);
assert.equal(session._awaitingPrimaryPromptAfterUserSubmit, true);
trackSessionIdlePrompt(session, "printf danger && \\\r\n> ");
assert.equal(getEditableIdlePrompt(session), "");
session._promptTrackTail += "\r\nuser@host:/tmp$";
assert.equal(getEditableIdlePrompt(session), "user@host:/tmp$");
});
test("trackSessionIdlePrompt does not replace a proven prompt with unfinished input", () => {
for (const [prompt, input] of [
["user@host:~$", " echo $"],
["root@host:/#", " printf #"],
["PS C:\\Users\\alice>", " Write-Output >"],
]) {
const session = {};
trackSessionIdlePrompt(session, prompt);
session._hasPendingUserInput = true;
assert.equal(trackSessionIdlePrompt(session, input), "");
assert.equal(session.lastIdlePrompt, prompt);
assert.equal(getEditableIdlePrompt(session), prompt);
}
});
test("getFreshIdlePrompt and trackSessionIdlePrompt round-trip through a real PTY-like flow", () => {
// (1) Remote PowerShell prompt arrives — lastIdlePrompt is captured.
const session = {};
@@ -2,8 +2,21 @@
// Module-level require on purpose: code inside registerCattyExecHandlers
// runs under `with (ctx)` where bare `require` resolves to ctx.require
// (based in electron/bridges/). Requiring here keeps the path unambiguous.
const { formatSyntheticEcho } = require("../ai/shellUtils.cjs");
const { ensureSessionShellKindForExec } = require("../ai/sessionShellKind.cjs");
const {
formatSyntheticEcho,
getEditableIdlePrompt,
} = require("../ai/shellUtils.cjs");
const {
ensureSessionShellKindForExec,
} = require("../ai/sessionShellKind.cjs");
const {
PENDING_INPUT_ERROR,
acquireSessionInputGate,
capturePendingInputState,
isPendingInputStateCurrent,
isUserInputRevisionCurrent,
verifySessionForegroundShell,
} = require("../ai/pendingInputSafety.cjs");
function getWorkerExecutionMeta(mcpServerBridge, sessionId, chatSessionId) {
return mcpServerBridge.getSessionMeta?.(sessionId, chatSessionId) || {};
@@ -140,7 +153,15 @@ function registerCattyExecHandlers(ctx) {
};
}
const ptyStream = session.stream || session.pty || session.proc;
const ptyStream = session.stream || session.pty || session.proc || session.serialPort;
const pendingInputState = capturePendingInputState(session);
if (
session.protocol === "serial"
&& (session.ymodemActive || session.zmodemSentry?.isActive?.())
) {
releaseLock();
return { ok: false, error: "Serial file transfer is already in progress" };
}
// Network devices (switches/routers) connected via SSH: use raw execution.
// Their vendor CLIs don't run a POSIX shell, so shell-wrapped commands fail.
@@ -151,10 +172,16 @@ function registerCattyExecHandlers(ctx) {
timeoutMs,
trackForCancellation: mcpServerBridge.activePtyExecs,
chatSessionId,
encoding: "utf8", // SSH PTY streams use UTF-8, not latin1
encoding: sessionProtocol === "serial" ? (session.serialEncoding || "utf8") : "utf8",
pendingUserInput: pendingInputState.pending,
}));
}
if (pendingInputState.pending) {
releaseLock();
return { ok: false, error: PENDING_INPUT_ERROR };
}
// Prefer PTY stream (visible in terminal)
if (ptyStream && typeof ptyStream.write === "function") {
const timeoutMs = mcpServerBridge.getCommandTimeoutMs ? mcpServerBridge.getCommandTimeoutMs() : 60000;
@@ -166,8 +193,12 @@ function registerCattyExecHandlers(ctx) {
const probed = await ensureSessionShellKindForExec(session, {
trackForCancellation: mcpServerBridge.activePtyExecs,
chatSessionId,
verifyPendingInput: () => verifySessionForegroundShell(session),
});
if (!probed.ok) return probed;
if (!isPendingInputStateCurrent(session, pendingInputState)) {
return { ok: false, error: "Terminal input changed while command execution was being prepared. Try again." };
}
return execViaPty(ptyStream, command, {
stripMarkers: true,
trackForCancellation: mcpServerBridge.activePtyExecs,
@@ -175,7 +206,16 @@ function registerCattyExecHandlers(ctx) {
shellKind: session.shellKind,
loginShellHint: session._loginShellKind,
chatSessionId,
expectedPrompt: getFreshIdlePrompt(session),
expectedPrompt: getEditableIdlePrompt(session),
pendingUserInput: pendingInputState.pending,
submittedInputAwaitingPrompt: session._awaitingPrimaryPromptAfterUserSubmit === true,
pendingInputInterruptSafe: probed.pendingInputInterruptSafe,
isInputRevisionCurrent: () => isUserInputRevisionCurrent(session, pendingInputState),
acquireInputGate: () => acquireSessionInputGate(session, pendingInputState),
onPendingInputCleared: () => {
session._hasPendingUserInput = false;
session._awaitingPrimaryPromptAfterUserSubmit = false;
},
typedInput: true,
echoCommand: (rawCommand) => {
const contents = electronModule?.webContents?.fromId?.(session.webContentsId);
@@ -224,6 +264,7 @@ function registerCattyExecHandlers(ctx) {
trackForCancellation: mcpServerBridge.activePtyExecs,
chatSessionId,
encoding: session.serialEncoding || "utf8",
pendingUserInput: pendingInputState.pending,
}));
}
@@ -84,3 +84,223 @@ test("catty AI exec proxies to the terminal worker when the real session lives i
["release", "ssh-1", "token-1"],
]);
});
test("catty raw exec refuses awaiting network-device input without writing", async () => {
const ipcMain = createFakeIpcMain();
const writes = [];
const session = {
protocol: "ssh",
stream: { write: (data) => writes.push(String(data)), on() {}, removeListener() {} },
_awaitingPrimaryPromptAfterUserSubmit: true,
};
const mcpServerBridge = {
getPermissionMode: () => "auto",
reserveSessionExecution: () => ({ ok: true, token: "token-1" }),
releaseSessionExecution() {},
getSessionMeta: () => ({ protocol: "ssh", deviceType: "network" }),
checkCommandSafety: () => ({ blocked: false }),
getCommandTimeoutMs: () => 1000,
activePtyExecs: new Map(),
};
registerCattyExecHandlers({
ipcMain,
validateSender: () => true,
sessions: new Map([["device-1", session]]),
terminalWorkerManager: null,
mcpServerBridge,
electronModule: {},
safeSend() {},
require(id) {
if (id === "./ai/ptyExec.cjs") return require("../ai/ptyExec.cjs");
return require(id);
},
});
const result = await ipcMain.handlers.get("netcatty:ai:exec")(
{ sender: { id: 7 } },
{ sessionId: "device-1", command: "show version", chatSessionId: "chat-1" },
);
assert.equal(result.ok, false);
assert.match(result.error, /unsubmitted terminal input/i);
assert.deepEqual(writes, []);
});
test("catty shell exec refuses pending input before probing", async () => {
const ipcMain = createFakeIpcMain();
const writes = [];
let probeCalls = 0;
const session = {
protocol: "ssh",
stream: { write: (data) => writes.push(String(data)) },
shellKind: "posix",
_hasPendingUserInput: true,
_pendingInputSafetyProbe: async () => {
probeCalls += 1;
return true;
},
};
const mcpServerBridge = {
getPermissionMode: () => "auto",
reserveSessionExecution: () => ({ ok: true, token: "token-1" }),
releaseSessionExecution() {},
getSessionMeta: () => ({ protocol: "ssh" }),
checkCommandSafety: () => ({ blocked: false }),
getCommandTimeoutMs: () => 1000,
activePtyExecs: new Map(),
};
registerCattyExecHandlers({
ipcMain,
validateSender: () => true,
sessions: new Map([["shell-pending", session]]),
terminalWorkerManager: null,
mcpServerBridge,
electronModule: {},
safeSend() {},
execViaPty() {
throw new Error("pending input must be rejected before PTY execution");
},
});
const result = await ipcMain.handlers.get("netcatty:ai:exec")(
{ sender: { id: 7 } },
{ sessionId: "shell-pending", command: "pwd", chatSessionId: "chat-1" },
);
assert.equal(result.ok, false);
assert.match(result.error, /unsubmitted terminal input/i);
assert.deepEqual(writes, []);
assert.equal(probeCalls, 0);
});
test("catty raw exec refuses awaiting serial input without writing", async () => {
const ipcMain = createFakeIpcMain();
const writes = [];
const session = {
protocol: "serial",
serialPort: { write: (data) => writes.push(String(data)), on() {}, removeListener() {} },
_awaitingPrimaryPromptAfterUserSubmit: true,
};
const mcpServerBridge = {
getPermissionMode: () => "auto",
reserveSessionExecution: () => ({ ok: true, token: "token-1" }),
releaseSessionExecution() {},
getSessionMeta: () => ({ protocol: "serial" }),
checkCommandSafety: () => ({ blocked: false }),
getCommandTimeoutMs: () => 1000,
activePtyExecs: new Map(),
};
registerCattyExecHandlers({
ipcMain,
validateSender: () => true,
sessions: new Map([["serial-1", session]]),
terminalWorkerManager: null,
mcpServerBridge,
electronModule: {},
safeSend() {},
require(id) {
if (id === "./ai/ptyExec.cjs") return require("../ai/ptyExec.cjs");
return require(id);
},
});
const result = await ipcMain.handlers.get("netcatty:ai:exec")(
{ sender: { id: 7 } },
{ sessionId: "serial-1", command: "show version", chatSessionId: "chat-1" },
);
assert.equal(result.ok, false);
assert.match(result.error, /terminal input/i);
assert.deepEqual(writes, []);
});
test("catty raw serial exec preserves the configured output encoding", async () => {
const ipcMain = createFakeIpcMain();
const session = {
protocol: "serial",
serialEncoding: "gb18030",
serialPort: { write() {} },
};
let receivedOptions;
const mcpServerBridge = {
getPermissionMode: () => "auto",
reserveSessionExecution: () => ({ ok: true, token: "token-1" }),
releaseSessionExecution() {},
getSessionMeta: () => ({ protocol: "serial" }),
checkCommandSafety: () => ({ blocked: false }),
getCommandTimeoutMs: () => 1000,
activePtyExecs: new Map(),
};
registerCattyExecHandlers({
ipcMain,
validateSender: () => true,
sessions: new Map([["serial-encoding", session]]),
terminalWorkerManager: null,
mcpServerBridge,
electronModule: {},
safeSend() {},
require(id) {
if (id === "./ai/ptyExec.cjs") {
return {
execViaRawPty(_stream, _command, options) {
receivedOptions = options;
return { ok: true, stdout: "" };
},
};
}
return require(id);
},
});
const result = await ipcMain.handlers.get("netcatty:ai:exec")(
{ sender: { id: 7 } },
{ sessionId: "serial-encoding", command: "show version", chatSessionId: "chat-1" },
);
assert.equal(result.ok, true);
assert.equal(receivedOptions.encoding, "gb18030");
});
test("catty serial exec refuses active file transfer without writing", async () => {
const ipcMain = createFakeIpcMain();
const writes = [];
const session = {
protocol: "serial",
serialPort: { write: (data) => writes.push(String(data)), on() {}, removeListener() {} },
ymodemActive: true,
};
const mcpServerBridge = {
getPermissionMode: () => "auto",
reserveSessionExecution: () => ({ ok: true, token: "token-1" }),
releaseSessionExecution() {},
getSessionMeta: () => ({ protocol: "serial" }),
checkCommandSafety: () => ({ blocked: false }),
getCommandTimeoutMs: () => 1000,
activePtyExecs: new Map(),
};
registerCattyExecHandlers({
ipcMain,
validateSender: () => true,
sessions: new Map([["serial-transfer", session]]),
terminalWorkerManager: null,
mcpServerBridge,
electronModule: {},
safeSend() {},
require(id) {
if (id === "./ai/ptyExec.cjs") return require("../ai/ptyExec.cjs");
return require(id);
},
});
const result = await ipcMain.handlers.get("netcatty:ai:exec")(
{ sender: { id: 7 } },
{ sessionId: "serial-transfer", command: "show version", chatSessionId: "chat-1" },
);
assert.equal(result.ok, false);
assert.match(result.error, /file transfer/i);
assert.deepEqual(writes, []);
});
@@ -8,6 +8,7 @@ const test = require("node:test");
const { createBackgroundJobApi } = require("./mcpServerBridge/backgroundJobs.cjs");
const { createExecHandlerApi } = require("./mcpServerBridge/execHandlers.cjs");
const { PROBE_OUTPUT_MARKER } = require("./ai/sessionShellKind.cjs");
const { execViaPty, execViaRawPty } = require("./ai/ptyExec.cjs");
class FakePty extends EventEmitter {
constructor() {
@@ -74,6 +75,8 @@ function createExecHandlerTestContext({ sessions, backgroundJobs }) {
beginChatExecution() {
return { ok: true, release() {} };
},
execViaPty,
execViaRawPty,
getFreshIdlePrompt() {
return "";
},
@@ -226,3 +229,212 @@ test("MCP terminal_start chat cancel during shellKind probe aborts before PTY wr
assert.equal(job.pendingShellProbe, false);
assert.equal(ctx.activeSessionExecutions.size, 0);
});
test("MCP raw exec refuses awaiting network-device input without writing", async () => {
const pty = new FakePty();
const sessions = new Map([["device-1", {
protocol: "ssh",
stream: pty,
_awaitingPrimaryPromptAfterUserSubmit: true,
}]]);
const ctx = createExecHandlerTestContext({ sessions, backgroundJobs: new Map() });
ctx.getSessionMeta = () => ({ protocol: "ssh", deviceType: "network" });
const api = createExecHandlerApi(ctx);
const result = await api.handleExec({
sessionId: "device-1",
command: "show version",
chatSessionId: "chat-1",
});
assert.equal(result.ok, false);
assert.match(result.error, /unsubmitted terminal input/i);
assert.deepEqual(pty.writes, []);
});
test("MCP raw exec refuses awaiting serial input without writing", async () => {
const serialPort = new FakePty();
const sessions = new Map([["serial-1", {
protocol: "serial",
serialPort,
_awaitingPrimaryPromptAfterUserSubmit: true,
}]]);
const ctx = createExecHandlerTestContext({ sessions, backgroundJobs: new Map() });
ctx.getSessionMeta = () => ({ protocol: "serial" });
const api = createExecHandlerApi(ctx);
const result = await api.handleExec({
sessionId: "serial-1",
command: "show version",
chatSessionId: "chat-1",
});
assert.equal(result.ok, false);
assert.match(result.error, /terminal input/i);
assert.deepEqual(serialPort.writes, []);
});
test("MCP raw serial exec preserves the configured output encoding", async () => {
const serialPort = new FakePty();
const sessions = new Map([["serial-encoding", {
protocol: "serial",
serialEncoding: "gb18030",
serialPort,
}]]);
const ctx = createExecHandlerTestContext({ sessions, backgroundJobs: new Map() });
ctx.getSessionMeta = () => ({ protocol: "serial" });
let receivedOptions;
ctx.execViaRawPty = (_stream, _command, options) => {
receivedOptions = options;
return { ok: true, stdout: "" };
};
const api = createExecHandlerApi(ctx);
const result = await api.handleExec({
sessionId: "serial-encoding",
command: "show version",
chatSessionId: "chat-1",
});
assert.equal(result.ok, true);
assert.equal(receivedOptions.encoding, "gb18030");
});
test("MCP serial exec refuses active file transfer without writing", async () => {
const serialPort = new FakePty();
const sessions = new Map([["serial-transfer", {
protocol: "serial",
serialPort,
zmodemSentry: { isActive: () => true },
}]]);
const ctx = createExecHandlerTestContext({ sessions, backgroundJobs: new Map() });
ctx.getSessionMeta = () => ({ protocol: "serial" });
const api = createExecHandlerApi(ctx);
const result = await api.handleExec({
sessionId: "serial-transfer",
command: "show version",
chatSessionId: "chat-1",
});
assert.equal(result.ok, false);
assert.match(result.error, /file transfer/i);
assert.deepEqual(serialPort.writes, []);
});
test("MCP shell exec refuses verified pending input without writing", async () => {
const pty = new FakePty();
let probeCalls = 0;
pty.write = function write(data) {
const text = String(data);
this.writes.push(text);
if (text === "i") {
queueMicrotask(() => this.emit("data", Buffer.from("i")));
} else if (text === "\x03") {
queueMicrotask(() => this.emit("data", Buffer.from("^C\r\nuser@host:~$")));
} else {
const marker = text.match(/(__NCMCP_[A-Za-z0-9_]+__)/)?.[1];
if (marker) {
queueMicrotask(() => this.emit("data", Buffer.from(`${marker}_S\r\nok\r\n${marker}_E:0\r\n`)));
}
}
};
const session = {
protocol: "local",
stream: pty,
shellKind: "posix",
lastIdlePrompt: "user@host:~$",
_promptTrackTail: "user@host:~$ls",
_hasPendingUserInput: true,
_pendingInputSafetyProbe: async () => {
probeCalls += 1;
return true;
},
};
const ctx = createExecHandlerTestContext({
sessions: new Map([["shell-1", session]]),
backgroundJobs: new Map(),
});
const api = createExecHandlerApi(ctx);
const result = await api.handleExec({
sessionId: "shell-1",
command: "uname -a",
chatSessionId: "chat-1",
});
assert.equal(result.ok, false);
assert.match(result.error, /unsubmitted terminal input/i);
assert.deepEqual(pty.writes, []);
assert.equal(session._hasPendingUserInput, true);
assert.equal(probeCalls, 0);
});
test("MCP shell exec refuses when user input changes during foreground verification", async () => {
const pty = new FakePty();
let releaseProbe;
const probe = new Promise((resolve) => { releaseProbe = resolve; });
const session = {
protocol: "ssh",
stream: pty,
shellKind: undefined,
lastIdlePrompt: "user@host:~$",
_promptTrackTail: "user@host:~$",
_hasPendingUserInput: false,
_userInputRevision: 1,
_shellKindExecProbe: () => probe,
};
const ctx = createExecHandlerTestContext({
sessions: new Map([["shell-race", session]]),
backgroundJobs: new Map(),
});
const api = createExecHandlerApi(ctx);
const pending = api.handleExec({
sessionId: "shell-race",
command: "uname -a",
chatSessionId: "chat-1",
});
await nextTick();
session._hasPendingUserInput = false;
session._userInputRevision = 2;
releaseProbe(`${PROBE_OUTPUT_MARKER}/bin/bash\n`);
const result = await pending;
assert.equal(result.ok, false);
assert.match(result.error, /input changed/i);
assert.deepEqual(pty.writes, []);
});
test("MCP terminal_start refuses when user input changes during verification", async () => {
const pty = new FakePty();
let releaseProbe;
const probe = new Promise((resolve) => { releaseProbe = resolve; });
const session = {
protocol: "ssh",
stream: pty,
shellKind: undefined,
_hasPendingUserInput: false,
_userInputRevision: 1,
_shellKindExecProbe: () => probe,
};
const backgroundJobs = new Map();
const ctx = createExecHandlerTestContext({
sessions: new Map([["shell-race", session]]),
backgroundJobs,
});
const api = createExecHandlerApi(ctx);
const pending = api.handleJobStart({
sessionId: "shell-race",
command: "sleep 1",
chatSessionId: "chat-1",
});
await nextTick();
session._hasPendingUserInput = false;
session._userInputRevision = 2;
releaseProbe(`${PROBE_OUTPUT_MARKER}/bin/bash\n`);
const result = await pending;
assert.equal(result.ok, false);
assert.match(result.error, /input changed/i);
assert.deepEqual(pty.writes, []);
});
@@ -5,6 +5,15 @@ const {
ensureSessionShellKind,
ensureSessionShellKindForExec,
} = require("../ai/sessionShellKind.cjs");
const { getEditableIdlePrompt } = require("../ai/shellUtils.cjs");
const {
PENDING_INPUT_ERROR,
acquireSessionInputGate,
capturePendingInputState,
isPendingInputStateCurrent,
isUserInputRevisionCurrent,
verifySessionForegroundShell,
} = require("../ai/pendingInputSafety.cjs");
function createExecHandlerApi(ctx) {
with (ctx) {
@@ -63,7 +72,8 @@ function createExecHandlerApi(ctx) {
}
const sshClient = session.conn || session.sshClient;
const ptyStream = session.stream || session.pty || session.proc;
const ptyStream = session.stream || session.pty || session.proc || session.serialPort;
const pendingInputState = capturePendingInputState(session);
return {
ok: true,
context: {
@@ -75,6 +85,7 @@ function createExecHandlerApi(ctx) {
isNetworkDevice,
sshClient,
ptyStream,
pendingInputState,
},
};
}
@@ -91,6 +102,7 @@ function createExecHandlerApi(ctx) {
isNetworkDevice,
sshClient,
ptyStream,
pendingInputState,
} = resolved.context;
const reservation = reserveSessionExecution(sessionId, "exec");
if (!reservation.ok) return reservation;
@@ -119,6 +131,14 @@ function createExecHandlerApi(ctx) {
return { ok: false, error: err?.message || String(err) };
}
};
if (
sessionProtocol === "serial"
&& (session.ymodemActive || session.zmodemSentry?.isActive?.())
) {
releaseSessionExecution(sessionId, sessionToken);
executionLock.release();
return { ok: false, error: "Serial file transfer is already in progress" };
}
// Network devices (switches/routers) connected via SSH: use raw execution.
// Their vendor CLIs (Huawei VRP, Cisco IOS, etc.) don't run a POSIX shell,
@@ -129,10 +149,17 @@ function createExecHandlerApi(ctx) {
timeoutMs: commandTimeoutMs,
trackForCancellation: activePtyExecs,
chatSessionId: params?.chatSessionId,
encoding: "utf8", // SSH PTY streams use UTF-8, not latin1
encoding: sessionProtocol === "serial" ? (session.serialEncoding || "utf8") : "utf8",
pendingUserInput: pendingInputState.pending,
}));
}
if (pendingInputState.pending) {
releaseSessionExecution(sessionId, sessionToken);
executionLock.release();
return { ok: false, error: PENDING_INPUT_ERROR };
}
// Prefer the interactive PTY so the user sees command/output in-session.
if (ptyStream && typeof ptyStream.write === "function") {
// Probe remote login shell once when shellKind is unset so fish
@@ -142,14 +169,27 @@ function createExecHandlerApi(ctx) {
const probed = await ensureSessionShellKindForExec(session, {
trackForCancellation: activePtyExecs,
chatSessionId,
verifyPendingInput: () => verifySessionForegroundShell(session),
});
if (!probed.ok) return probed;
if (!isPendingInputStateCurrent(session, pendingInputState)) {
return { ok: false, error: "Terminal input changed while command execution was being prepared. Try again." };
}
return execViaPty(ptyStream, command, {
trackForCancellation: activePtyExecs,
timeoutMs: commandTimeoutMs,
shellKind: session.shellKind,
loginShellHint: session._loginShellKind,
expectedPrompt: getFreshIdlePrompt(session),
expectedPrompt: getEditableIdlePrompt(session),
pendingUserInput: pendingInputState.pending,
submittedInputAwaitingPrompt: session._awaitingPrimaryPromptAfterUserSubmit === true,
pendingInputInterruptSafe: probed.pendingInputInterruptSafe,
isInputRevisionCurrent: () => isUserInputRevisionCurrent(session, pendingInputState),
acquireInputGate: () => acquireSessionInputGate(session, pendingInputState),
onPendingInputCleared: () => {
session._hasPendingUserInput = false;
session._awaitingPrimaryPromptAfterUserSubmit = false;
},
typedInput: true,
echoCommand: (rawCommand) => echoCommandToSession(session, sessionId, rawCommand),
chatSessionId,
@@ -192,6 +232,7 @@ function createExecHandlerApi(ctx) {
trackForCancellation: activePtyExecs,
chatSessionId: params?.chatSessionId,
encoding: session.serialEncoding || "utf8",
pendingUserInput: pendingInputState.pending,
}));
}
@@ -211,6 +252,7 @@ function createExecHandlerApi(ctx) {
isNetworkDevice,
sessionProtocol,
ptyStream,
pendingInputState,
} = resolved.context;
if (isNetworkDevice || sessionProtocol === "serial") {
@@ -226,6 +268,11 @@ function createExecHandlerApi(ctx) {
error: "Background execution requires a writable PTY-backed terminal session.",
};
}
if (pendingInputState.pending) {
return { ok: false, error: PENDING_INPUT_ERROR };
}
const reservation = reserveSessionExecution(sessionId, "job");
if (!reservation.ok) return reservation;
@@ -267,7 +314,7 @@ function createExecHandlerApi(ctx) {
backgroundJobs.set(jobId, job);
// Probe so fish login shells are not mis-wrapped as posix (#1854).
return Promise.resolve(ensureSessionShellKind(session)).then(() => {
return Promise.resolve(ensureSessionShellKind(session)).then(async () => {
if (probeCancelRequested || job.status === "stopping") {
job.status = "cancelled";
job.error = "Cancelled";
@@ -283,6 +330,23 @@ function createExecHandlerApi(ctx) {
};
}
const pendingInputInterruptSafe = await verifySessionForegroundShell(session);
if (!isPendingInputStateCurrent(session, pendingInputState)) {
job.status = "failed";
job.error = "Terminal input changed while command execution was being prepared. Try again.";
job.updatedAt = Date.now();
job.pendingShellProbe = false;
releaseSessionExecution(sessionId, sessionToken);
return { ok: false, error: job.error, jobId, sessionId, status: "failed" };
}
if (probeCancelRequested || job.status === "stopping") {
job.status = "cancelled";
job.error = "Cancelled";
job.updatedAt = Date.now();
job.pendingShellProbe = false;
releaseSessionExecution(sessionId, sessionToken);
return { ok: false, error: "Cancelled", jobId, sessionId, status: "cancelled" };
}
let handle;
try {
handle = startPtyJob(ptyStream, command, {
@@ -294,7 +358,16 @@ function createExecHandlerApi(ctx) {
shellKind: session.shellKind,
loginShellHint: session._loginShellKind,
chatSessionId,
expectedPrompt: getFreshIdlePrompt(session),
expectedPrompt: getEditableIdlePrompt(session),
pendingUserInput: pendingInputState.pending,
submittedInputAwaitingPrompt: session._awaitingPrimaryPromptAfterUserSubmit === true,
pendingInputInterruptSafe,
isInputRevisionCurrent: () => isUserInputRevisionCurrent(session, pendingInputState),
acquireInputGate: () => acquireSessionInputGate(session, pendingInputState),
onPendingInputCleared: () => {
session._hasPendingUserInput = false;
session._awaitingPrimaryPromptAfterUserSubmit = false;
},
typedInput: true,
echoCommand: (rawCommand) => echoCommandToSession(session, sessionId, rawCommand),
maxBufferedChars: MAX_BACKGROUND_JOB_OUTPUT_CHARS,
+89 -10
View File
@@ -56,7 +56,11 @@ const ptyProcessTree = require("./ptyProcessTree.cjs");
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
const { detectShellKind } = require("./ai/ptyExec.cjs");
const { stripAnsi, trackSessionIdlePrompt } = require("./ai/shellUtils.cjs");
const {
stripAnsi,
trackSessionIdlePrompt,
trackSessionPendingUserInput,
} = require("./ai/shellUtils.cjs");
const { createZmodemSentry } = require("./zmodemHelper.cjs");
const { discoverShells } = require("./shellDiscovery.cjs");
const moshHandshake = require("./moshHandshake.cjs");
@@ -1426,6 +1430,10 @@ function pauseSshOutputForInterrupt(session, trace) {
}
function clearPendingAutomatedWrites(session) {
if (!session) return;
session._automatedWriteGeneration = (Number.isSafeInteger(session._automatedWriteGeneration)
? session._automatedWriteGeneration
: 0) + 1;
const timers = session?.pendingAutomatedWriteTimers;
if (!Array.isArray(timers) || timers.length === 0) return;
for (const timer of timers) clearTimeout(timer);
@@ -1455,10 +1463,13 @@ function splitTerminalInputIntoLineWrites(data) {
function getAutomatedLineDelayMs(payload) {
if (!payload?.automated) return 0;
const lineDelayMs = Number(payload.lineDelayMs);
return Number.isFinite(lineDelayMs) && lineDelayMs > 0 ? Math.min(lineDelayMs, 2000) : 0;
return Number.isFinite(lineDelayMs) && lineDelayMs > 0 ? Math.min(lineDelayMs, 10000) : 0;
}
function shouldBlockSessionInput(session, data) {
if (session._aiInputClearToken) {
return true;
}
if (session.ymodemActive) {
if (data === '\x03') {
cancelActiveYmodemSession(session);
@@ -1477,7 +1488,12 @@ function shouldBlockSessionInput(session, data) {
return false;
}
function writeToSessionNow(payload, data, logRewrite = payload.logRewrite) {
function writeToSessionNow(
payload,
data,
logRewrite = payload.logRewrite,
inputStateAlreadyTracked = false,
) {
const session = sessions.get(payload.sessionId);
const trace = payload.interruptTrace || null;
if (!session) {
@@ -1488,6 +1504,13 @@ function writeToSessionNow(payload, data, logRewrite = payload.logRewrite) {
return;
}
if (shouldBlockSessionInput(session, data)) {
if (payload.automated === true) {
session._aiInputClearDeferredWrites ??= [];
session._aiInputClearDeferredWrites.push(() => {
if (sessions.get(payload.sessionId) !== session || session.closed) return;
writeToSessionNow(payload, data, logRewrite);
});
}
logTerminalInterruptDebug("write-session-blocked-by-transfer", {
sessionId: payload.sessionId,
dataCode: data === "\x03" ? "ETX" : undefined,
@@ -1500,6 +1523,7 @@ function writeToSessionNow(payload, data, logRewrite = payload.logRewrite) {
}
try {
let didWrite = false;
if (session.type === 'telnet-native' && !payload.automated) {
session.autoLogin?.handleUserInput();
}
@@ -1528,6 +1552,7 @@ function writeToSessionNow(payload, data, logRewrite = payload.logRewrite) {
}, trace);
}
const writeResult = session.stream.write(outgoing);
didWrite = true;
if (
session.blockUntargetedCwdProbe
&& !payload.automated
@@ -1548,6 +1573,7 @@ function writeToSessionNow(payload, data, logRewrite = payload.logRewrite) {
}
} else if (session.proc) {
session.proc.write(outgoing);
didWrite = true;
} else if (session.socket) {
// Telnet only: any 0xFF byte going out the wire must be doubled, or
// the peer will treat it as the start of an IAC command sequence and
@@ -1562,8 +1588,17 @@ function writeToSessionNow(payload, data, logRewrite = payload.logRewrite) {
wireData = telnetProtocol.escapeIacForWire(wireData);
}
session.socket.write(wireData);
didWrite = true;
} else if (session.serialPort) {
session.serialPort.write(outgoing);
didWrite = true;
}
if (didWrite && !inputStateAlreadyTracked) {
trackSessionPendingUserInput(session, data, {
automated: payload.automated === true,
terminalReport: isTerminalReportSequence(data),
});
}
} catch (err) {
logTerminalInterruptDebug("write-session-error", {
@@ -1583,6 +1618,8 @@ function writeToSessionWithInterception(
data,
logRewrite = payload.logRewrite,
expectedSession = sessions.get(payload.sessionId),
inputStateAlreadyTracked = false,
writeGuard = null,
) {
const bypass = payload?.sensitive === true || isTerminalReportSequence(data);
const hasInterceptor = Boolean(
@@ -1590,14 +1627,30 @@ function writeToSessionWithInterception(
&& terminalDataPipeline.has?.(payload.sessionId, "input"),
);
const previous = terminalInputPipelineBarriers.get(payload.sessionId);
const reconcileInputState = inputStateAlreadyTracked
&& !isTerminalReportSequence(data)
&& (hasInterceptor || Boolean(previous));
if (reconcileInputState && expectedSession) {
// The interceptor may delay or change boundaries (including Ctrl+D into
// ordinary bytes). Keep the session unsafe throughout the await, then
// track the bytes that are actually delivered.
expectedSession._hasPendingUserInput = true;
}
if (!hasInterceptor && !previous) {
writeToSessionNow(payload, data, logRewrite);
if (typeof writeGuard === "function" && !writeGuard()) return;
writeToSessionNow(payload, data, logRewrite, inputStateAlreadyTracked);
return;
}
const writeIfCurrent = (nextData) => {
const current = sessions.get(payload.sessionId);
if (!current || current !== expectedSession || current.closed) return;
writeToSessionNow(payload, nextData, logRewrite);
if (typeof writeGuard === "function" && !writeGuard()) return;
writeToSessionNow(
payload,
nextData,
logRewrite,
inputStateAlreadyTracked && !reconcileInputState,
);
};
const write = async () => {
if (!hasInterceptor) {
@@ -1627,6 +1680,17 @@ function writeToSession(event, payload) {
const session = sessions.get(payload.sessionId);
if (!session) return;
if (session._aiInputClearToken) {
if (payload.automated === true) {
session._aiInputClearDeferredWrites ??= [];
session._aiInputClearDeferredWrites.push(() => {
if (sessions.get(payload.sessionId) !== session || session.closed) return;
writeToSession(event, payload);
});
}
return;
}
try {
reportOpenedSessionActivity?.({ sessionId: payload.sessionId, phase: "touch" });
} catch {
@@ -1640,20 +1704,35 @@ function writeToSession(event, payload) {
return;
}
const terminalReport = isTerminalReportSequence(payload.data);
const lineDelayMs = getAutomatedLineDelayMs(payload);
if (payload.automated === true && !terminalReport) {
clearPendingAutomatedWrites(session);
}
trackSessionPendingUserInput(session, payload.data, {
automated: payload.automated === true,
terminalReport,
});
const lineChunks = lineDelayMs > 0 ? splitTerminalInputIntoLineWrites(payload.data) : [payload.data];
if (lineDelayMs > 0 && lineChunks.length > 1) {
clearPendingAutomatedWrites(session);
session.pendingAutomatedWriteTimers = [];
const expectedSession = session;
const batchGeneration = session._automatedWriteGeneration;
const batchIsCurrent = () => (
sessions.get(payload.sessionId) === expectedSession
&& !expectedSession.closed
&& expectedSession._automatedWriteGeneration === batchGeneration
);
lineChunks.forEach((chunk, index) => {
const sendChunk = () => {
const current = sessions.get(payload.sessionId);
if (!current) return;
if (!batchIsCurrent()) return;
writeToSessionWithInterception(
{ ...payload, lineDelayMs: undefined },
chunk,
index === 0 ? payload.logRewrite : undefined,
current,
expectedSession,
true,
batchIsCurrent,
);
};
if (index === 0) {
@@ -1666,7 +1745,7 @@ function writeToSession(event, payload) {
return;
}
writeToSessionWithInterception(payload, payload.data, payload.logRewrite, session);
writeToSessionWithInterception(payload, payload.data, payload.logRewrite, session, true);
}
function drainPendingOutputForInterrupt(sessionId, session, trace) {
@@ -109,3 +109,76 @@ test("[GUARD] Ctrl+C between automated lines SHOULD cancel pending lines", async
assert.deepEqual(calls, ["echo one\r", "\x03"], "Ctrl+C should cancel queued line writes");
});
test("manual partial input cancels later startup lines", async () => {
const calls = [];
const session = {
stream: { signal() {}, write(data) { calls.push(data); } },
};
const sessions = new Map([["ssh-1", session]]);
initBridge(sessions);
terminalBridge.writeToSession(
{ sender: {} },
{
sessionId: "ssh-1",
data: "echo one\necho two\recho three\r",
automated: true,
lineDelayMs: 20,
},
);
assert.deepEqual(calls, ["echo one\r"]);
terminalBridge.writeToSession({ sender: {} }, { sessionId: "ssh-1", data: "danger" });
await delay(80);
assert.deepEqual(calls, ["echo one\r", "danger"]);
assert.equal(session._hasPendingUserInput, true);
});
test("delayed startup lines never cross into a replacement session id", async () => {
const oldCalls = [];
const newCalls = [];
const oldSession = {
stream: { write(data) { oldCalls.push(data); } },
};
const sessions = new Map([["same-id", oldSession]]);
initBridge(sessions);
terminalBridge.writeToSession(
{ sender: {} },
{ sessionId: "same-id", data: "first\nsecond\r", automated: true, lineDelayMs: 20 },
);
assert.deepEqual(oldCalls, ["first\r"]);
sessions.set("same-id", {
stream: { write(data) { newCalls.push(data); } },
});
await delay(60);
assert.deepEqual(oldCalls, ["first\r"]);
assert.deepEqual(newCalls, []);
});
test("a new single-line automated write invalidates an older delayed batch", async () => {
const calls = [];
const sessions = new Map([["ssh-1", {
stream: { write(data) { calls.push(data); } },
}]]);
initBridge(sessions);
terminalBridge.writeToSession(
{ sender: {} },
{ sessionId: "ssh-1", data: "old-one\nold-two\r", automated: true, lineDelayMs: 25 },
);
assert.deepEqual(calls, ["old-one\r"]);
await delay(5);
terminalBridge.writeToSession(
{ sender: {} },
{ sessionId: "ssh-1", data: "new-only\r", automated: true, lineDelayMs: 25 },
);
await delay(60);
assert.deepEqual(calls, ["old-one\r", "new-only\r"]);
});
@@ -2,6 +2,10 @@ const test = require("node:test");
const assert = require("node:assert/strict");
const terminalBridge = require("./terminalBridge.cjs");
const {
acquireSessionInputGate,
capturePendingInputState,
} = require("./ai/pendingInputSafety.cjs");
const {
FLOW_HIGH_WATER_MARK,
} = require("../../infrastructure/config/terminalFlowConstants.cjs");
@@ -375,6 +379,97 @@ test("SSH ordinary input is written without sending INT", () => {
assert.deepEqual(calls, [["write", "cat\r"]]);
});
test("terminal writes track whether the user left unsubmitted input", () => {
const session = {
stream: {
write() {},
},
};
const sessions = new Map([["ssh-1", session]]);
initBridge(sessions);
terminalBridge.writeToSession({ sender: {} }, { sessionId: "ssh-1", data: "ls" });
assert.equal(session._hasPendingUserInput, true);
terminalBridge.writeToSession({ sender: {} }, {
sessionId: "ssh-1",
data: "ignored",
automated: true,
});
assert.equal(session._hasPendingUserInput, true);
terminalBridge.writeToSession({ sender: {} }, { sessionId: "ssh-1", data: "\r" });
assert.equal(session._hasPendingUserInput, false);
terminalBridge.writeToSession({ sender: {} }, { sessionId: "ssh-1", data: "one\rnext" });
assert.equal(session._hasPendingUserInput, true);
terminalBridge.writeToSession({ sender: {} }, { sessionId: "ssh-1", data: "\x03" });
assert.equal(session._hasPendingUserInput, false);
});
test("terminal input gate blocks user bytes and defers automated writes", async () => {
const calls = [];
const session = {
_hasPendingUserInput: true,
stream: { write(data) { calls.push(data); } },
};
const releaseGate = acquireSessionInputGate(session, { pending: true, revision: 0 });
assert.equal(typeof releaseGate, "function");
initBridge(new Map([["ssh-1", session]]));
terminalBridge.writeToSession({ sender: {} }, { sessionId: "ssh-1", data: "\r" });
terminalBridge.writeToSession({ sender: {} }, {
sessionId: "ssh-1",
data: "automated\r",
automated: true,
});
assert.deepEqual(calls, []);
assert.equal(session._userInputRevision, undefined);
releaseGate(true);
await delay(5);
assert.deepEqual(calls, ["automated\r"]);
assert.equal(session._awaitingPrimaryPromptAfterUserSubmit, true);
const abortGate = acquireSessionInputGate(session, capturePendingInputState(session));
assert.equal(typeof abortGate, "function");
terminalBridge.writeToSession({ sender: {} }, {
sessionId: "ssh-1",
data: "must-not-replay\r",
automated: true,
});
abortGate(false);
await delay(5);
assert.deepEqual(calls, ["automated\r"]);
});
test("terminal input gate never replays an old session write into its replacement", async () => {
const oldWrites = [];
const newWrites = [];
const oldSession = {
_hasPendingUserInput: true,
stream: { write(data) { oldWrites.push(data); } },
};
const newSession = {
stream: { write(data) { newWrites.push(data); } },
};
const sessions = new Map([["ssh-1", oldSession]]);
initBridge(sessions);
const releaseGate = acquireSessionInputGate(oldSession, { pending: true, revision: 0 });
terminalBridge.writeToSession({ sender: {} }, {
sessionId: "ssh-1",
data: "queued-command\r",
automated: true,
});
sessions.set("ssh-1", newSession);
releaseGate(true);
await delay(5);
assert.deepEqual(oldWrites, []);
assert.deepEqual(newWrites, []);
});
test("local Ctrl+C behavior is unchanged", () => {
const calls = [];
const sessions = new Map();
@@ -24,15 +24,82 @@ function createHarness() {
},
},
});
return { writes, intercepted };
return { writes, intercepted, session: sessions.get("session-1") };
}
test("ordinary terminal input uses the worker-owned interceptor before transport encoding", async () => {
const h = createHarness();
terminalBridge.writeToSession(null, { sessionId: "session-1", data: "hello" });
assert.equal(h.session._hasPendingUserInput, true);
assert.equal(h.session._userInputRevision, 1);
assert.deepEqual(h.writes, []);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(h.intercepted.map((entry) => entry.data), ["hello"]);
assert.deepEqual(h.writes, ["HELLO"]);
assert.equal(h.session._userInputRevision, 2);
});
test("an unresolved input interceptor marks the session unsafe before agent capture", async () => {
const writes = [];
let releaseInput;
const session = {
type: "local",
proc: { write(data) { writes.push(String(data)); } },
};
terminalBridge.init({
sessions: new Map([["session-1", session]]),
electronModule: {},
terminalDataPipeline: {
has() { return true; },
interceptInput() {
return new Promise((resolve) => { releaseInput = resolve; });
},
},
});
terminalBridge.writeToSession(null, { sessionId: "session-1", data: "danger\r" });
assert.equal(session._hasPendingUserInput, true);
assert.equal(session._awaitingPrimaryPromptAfterUserSubmit, true);
assert.equal(session._userInputRevision, 1);
assert.deepEqual(writes, []);
await new Promise((resolve) => setImmediate(resolve));
releaseInput("danger\r");
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(writes, ["danger\r"]);
assert.equal(session._userInputRevision, 2);
});
test("an intercepted Ctrl+D stays unsafe and tracks the transformed bytes", async () => {
const writes = [];
let releaseInput;
const session = {
type: "local",
proc: { write(data) { writes.push(String(data)); } },
};
terminalBridge.init({
sessions: new Map([["session-1", session]]),
electronModule: {},
terminalDataPipeline: {
has() { return true; },
interceptInput() {
return new Promise((resolve) => { releaseInput = resolve; });
},
},
});
terminalBridge.writeToSession(null, { sessionId: "session-1", data: "\x04" });
assert.equal(session._hasPendingUserInput, true);
assert.deepEqual(writes, []);
await new Promise((resolve) => setImmediate(resolve));
releaseInput("transformed\r");
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(writes, ["transformed\r"]);
assert.equal(session._hasPendingUserInput, false);
assert.equal(session._awaitingPrimaryPromptAfterUserSubmit, true);
assert.equal(session._userInputRevision, 2);
});
test("host-classified sensitive input bypasses interceptors and preserves original bytes", async () => {
@@ -92,6 +159,42 @@ test("input remains ordered when an interceptor is disabled during an in-flight
assert.deepEqual(writes, ["FIRST", "second"]);
});
test("manual interruption invalidates delayed lines already queued behind an interceptor", async () => {
const writes = [];
let releaseFirst;
let first = true;
terminalBridge.init({
sessions: new Map([["session-1", {
type: "local",
proc: { write(data) { writes.push(String(data)); } },
}]]),
electronModule: {},
terminalDataPipeline: {
has() { return true; },
interceptInput(_sessionId, data) {
if (first) {
first = false;
return new Promise((resolve) => { releaseFirst = resolve; });
}
return Promise.resolve(data);
},
},
});
terminalBridge.writeToSession(null, {
sessionId: "session-1",
data: "first\nsecond\r",
automated: true,
lineDelayMs: 5,
});
await new Promise((resolve) => setTimeout(resolve, 15));
terminalBridge.writeToSession(null, { sessionId: "session-1", data: "\x03" });
releaseFirst("first\r");
await new Promise((resolve) => setTimeout(resolve, 15));
assert.deepEqual(writes, ["\x03"]);
});
test("a pending input transform cannot write into a reused session id", async () => {
const oldWrites = [];
const newWrites = [];
+70 -4
View File
@@ -7,11 +7,19 @@ const {
execViaChannel,
execViaRawPty,
} = require("../bridges/ai/ptyExec.cjs");
const { getFreshIdlePrompt, formatSyntheticEcho } = require("../bridges/ai/shellUtils.cjs");
const { getEditableIdlePrompt, formatSyntheticEcho } = require("../bridges/ai/shellUtils.cjs");
const {
ensureSessionShellKind,
ensureSessionShellKindForExec,
} = require("../bridges/ai/sessionShellKind.cjs");
const {
PENDING_INPUT_ERROR,
acquireSessionInputGate,
capturePendingInputState,
isPendingInputStateCurrent,
isUserInputRevisionCurrent,
verifySessionForegroundShell,
} = require("../bridges/ai/pendingInputSafety.cjs");
const DEFAULT_BACKGROUND_JOB_TIMEOUT_MS = 60 * 60 * 1000;
const DEFAULT_BACKGROUND_JOB_POLL_INTERVAL_MS = 30 * 1000;
@@ -251,7 +259,14 @@ function createWorkerAiExecHandler({
};
}
const ptyStream = session.stream || session.pty || session.proc;
const ptyStream = session.stream || session.pty || session.proc || session.serialPort;
const pendingInputState = capturePendingInputState(session);
if (
sessionProtocol === "serial"
&& (session.ymodemActive || session.zmodemSentry?.isActive?.())
) {
return { ok: false, error: "Serial file transfer is already in progress" };
}
if (isNetworkDevice && ptyStream && typeof ptyStream.write === "function") {
return execViaRawPty(ptyStream, command, {
@@ -259,9 +274,14 @@ function createWorkerAiExecHandler({
trackForCancellation: activePtyExecs,
chatSessionId,
encoding: sessionProtocol === "serial" ? (session.serialEncoding || "utf8") : "utf8",
pendingUserInput: pendingInputState.pending,
});
}
if (pendingInputState.pending) {
return { ok: false, error: PENDING_INPUT_ERROR };
}
if (ptyStream && typeof ptyStream.write === "function") {
// Remote sessions may not set shellKind at connect time; probe once so
// fish login shells get the fish wrapper (issue #1854). Cancellable so
@@ -269,8 +289,12 @@ function createWorkerAiExecHandler({
const probed = await ensureSessionShellKindForExec(session, {
trackForCancellation: activePtyExecs,
chatSessionId,
verifyPendingInput: () => verifySessionForegroundShell(session),
});
if (!probed.ok) return probed;
if (!isPendingInputStateCurrent(session, pendingInputState)) {
return { ok: false, error: "Terminal input changed while command execution was being prepared. Try again." };
}
return execViaPty(ptyStream, command, {
stripMarkers: true,
trackForCancellation: activePtyExecs,
@@ -278,7 +302,16 @@ function createWorkerAiExecHandler({
shellKind: session.shellKind,
loginShellHint: session._loginShellKind,
chatSessionId,
expectedPrompt: getFreshIdlePrompt(session),
expectedPrompt: getEditableIdlePrompt(session),
pendingUserInput: pendingInputState.pending,
submittedInputAwaitingPrompt: session._awaitingPrimaryPromptAfterUserSubmit === true,
pendingInputInterruptSafe: probed.pendingInputInterruptSafe,
isInputRevisionCurrent: () => isUserInputRevisionCurrent(session, pendingInputState),
acquireInputGate: () => acquireSessionInputGate(session, pendingInputState),
onPendingInputCleared: () => {
session._hasPendingUserInput = false;
session._awaitingPrimaryPromptAfterUserSubmit = false;
},
typedInput: true,
echoCommand: (rawCommand) => {
event?.sender?.send?.("netcatty:data", {
@@ -313,6 +346,7 @@ function createWorkerAiExecHandler({
trackForCancellation: activePtyExecs,
chatSessionId,
encoding: session.serialEncoding || "utf8",
pendingUserInput: pendingInputState.pending,
});
}
@@ -372,6 +406,12 @@ function createWorkerAiJobStartHandler({
};
}
const pendingInputState = capturePendingInputState(session);
if (pendingInputState.pending) {
return { ok: false, error: PENDING_INPUT_ERROR };
}
const jobId = createWorkerBackgroundJobId();
const startedAt = Date.now();
activeSessionJobs.set(sessionId, jobId);
@@ -440,12 +480,38 @@ function createWorkerAiJobStartHandler({
);
let handle;
try {
const pendingInputInterruptSafe = await verifySessionForegroundShell(session);
if (!isPendingInputStateCurrent(session, pendingInputState)) {
job.status = "failed";
job.error = "Terminal input changed while command execution was being prepared. Try again.";
job.updatedAt = Date.now();
job.pendingShellProbe = false;
if (activeSessionJobs.get(sessionId) === jobId) activeSessionJobs.delete(sessionId);
return { ok: false, error: job.error, jobId, sessionId, status: "failed" };
}
if (probeCancelRequested || job.status === "stopping") {
job.status = "cancelled";
job.error = "Cancelled";
job.updatedAt = Date.now();
job.pendingShellProbe = false;
if (activeSessionJobs.get(sessionId) === jobId) activeSessionJobs.delete(sessionId);
return { ok: false, error: "Cancelled", jobId, sessionId, status: "cancelled" };
}
handle = startPtyJob(ptyStream, command, {
timeoutMs,
shellKind: session.shellKind,
loginShellHint: session._loginShellKind,
chatSessionId,
expectedPrompt: getFreshIdlePrompt(session),
expectedPrompt: getEditableIdlePrompt(session),
pendingUserInput: pendingInputState.pending,
submittedInputAwaitingPrompt: session._awaitingPrimaryPromptAfterUserSubmit === true,
pendingInputInterruptSafe,
isInputRevisionCurrent: () => isUserInputRevisionCurrent(session, pendingInputState),
acquireInputGate: () => acquireSessionInputGate(session, pendingInputState),
onPendingInputCleared: () => {
session._hasPendingUserInput = false;
session._awaitingPrimaryPromptAfterUserSubmit = false;
},
typedInput: true,
echoCommand: (rawCommand) => {
event?.sender?.send?.("netcatty:data", {
+90
View File
@@ -97,6 +97,96 @@ function createDeferredShellProbeConn(stdout = `${PROBE_OUTPUT_MARKER}/usr/bin/f
};
}
test("worker raw exec refuses awaiting device input without writing", async () => {
const pty = new FakePty();
const sessions = new Map([["device-1", {
protocol: "ssh",
stream: pty,
_awaitingPrimaryPromptAfterUserSubmit: true,
}]]);
const ipcMain = createFakeIpcMain();
registerWorkerAiExecHandlers(ipcMain, { sessions });
const result = await ipcMain.handlers.get("netcatty:ai:exec")(createFakeEvent(), {
sessionId: "device-1",
command: "show version",
sessionMeta: { protocol: "ssh", deviceType: "network" },
});
assert.equal(result.ok, false);
assert.match(result.error, /terminal input/i);
assert.deepEqual(pty.writes, []);
});
test("worker serial fallback refuses awaiting input without writing", async () => {
const serialPort = new FakePty();
const sessions = new Map([["serial-1", {
protocol: "serial",
serialPort,
_awaitingPrimaryPromptAfterUserSubmit: true,
}]]);
const ipcMain = createFakeIpcMain();
registerWorkerAiExecHandlers(ipcMain, { sessions });
const result = await ipcMain.handlers.get("netcatty:ai:exec")(createFakeEvent(), {
sessionId: "serial-1",
command: "show version",
sessionMeta: { protocol: "serial" },
});
assert.equal(result.ok, false);
assert.match(result.error, /terminal input/i);
assert.deepEqual(serialPort.writes, []);
});
test("worker serial exec refuses active file transfer without writing", async () => {
const serialPort = new FakePty();
const sessions = new Map([["serial-transfer", {
protocol: "serial",
serialPort,
ymodemActive: true,
}]]);
const ipcMain = createFakeIpcMain();
registerWorkerAiExecHandlers(ipcMain, { sessions });
const result = await ipcMain.handlers.get("netcatty:ai:exec")(createFakeEvent(), {
sessionId: "serial-transfer",
command: "show version",
sessionMeta: { protocol: "serial" },
});
assert.equal(result.ok, false);
assert.match(result.error, /file transfer/i);
assert.deepEqual(serialPort.writes, []);
});
test("worker exec refuses a submitted shell continuation without writing", async () => {
const pty = new FakePty();
let probeCalls = 0;
const sessions = new Map([["ssh-continuation", {
protocol: "ssh",
stream: pty,
shellKind: "posix",
lastIdlePrompt: "user@host:~$",
_promptTrackTail: "user@host:~$printf danger && \\\r\n> ",
_awaitingPrimaryPromptAfterUserSubmit: true,
_pendingInputSafetyProbe: async () => {
probeCalls += 1;
return true;
},
}]]);
const ipcMain = createFakeIpcMain();
registerWorkerAiExecHandlers(ipcMain, { sessions });
const result = await ipcMain.handlers.get("netcatty:ai:exec")(createFakeEvent(), {
sessionId: "ssh-continuation",
command: "echo AGENT_MUST_NOT_RUN",
});
assert.equal(result.ok, false);
assert.match(result.error, /terminal input/i);
assert.deepEqual(pty.writes, []);
assert.equal(probeCalls, 0);
});
test("worker AI background jobs start, poll, stop, and block overlapping exec", async () => {
const pty = new FakePty();
const sessions = new Map([