mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-06 14:30:13 +00:00
Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b639aa301e | ||
|
|
2b98fdb860 | ||
|
|
f679b840b5 | ||
|
|
3ae4f40ffe | ||
|
|
b4e4dbb59a | ||
|
|
342d1e917e | ||
|
|
8d1ec46635 | ||
|
|
67079cc4e0 | ||
|
|
0683e344d8 | ||
|
|
dd5381fc90 | ||
|
|
e1502e0fa6 | ||
|
|
657343b30a | ||
|
|
7c9692d918 | ||
|
|
948083bfda | ||
|
|
53aa6849e0 | ||
|
|
8acbc9b2bd | ||
|
|
613644a7b6 | ||
|
|
2349310a1a | ||
|
|
e006d34d13 | ||
|
|
e9f8ce333d | ||
|
|
87ae6c800e | ||
|
|
0088cd35a0 | ||
|
|
81bd818577 | ||
|
|
180fd1b31f | ||
|
|
ac15e4c2e2 | ||
|
|
4ad06dd367 | ||
|
|
d65badbb6b | ||
|
|
9724ce7ae0 | ||
|
|
952cd8c499 | ||
|
|
272056fba2 | ||
|
|
8c34da7e32 | ||
|
|
b8e1b15faa | ||
|
|
c0ac0d2cd9 | ||
|
|
824a8e00b0 | ||
|
|
2749f7536e | ||
|
|
a345187058 | ||
|
|
00762ae6a6 | ||
|
|
1eb1f9f55f | ||
|
|
c806278ca8 | ||
|
|
b86a9b6b8b | ||
|
|
efc60a9c2a | ||
|
|
a2be077305 | ||
|
|
535af1e6ee | ||
|
|
c6ab350262 | ||
|
|
7386b0cec1 | ||
|
|
5af4aa487b |
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);
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:-\}/);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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([
|
||||
|
||||
Reference in new issue
Block a user