Compare commits

...
Author SHA1 Message Date
bincxz cd0d8a9e18 fix(#3252): recognize manual line clearing 2026-09-03 13:51:41 +08:00
bincxz 394d7e9df7 fix(#3252): restore execution after manual commands 2026-09-03 13:46:15 +08:00
bincxz 6f9d46b079 fix(#3252): gate unverified manual input 2026-09-03 13:36:38 +08:00
bincxz 47b4f4227e fix(#3252): distrust manual prompt echoes 2026-09-03 13:31:20 +08:00
bincxz 7c608bd626 fix(#3252): track modified Enter input 2026-09-03 13:21:47 +08:00
bincxz 7355dad458 fix(#3252): reject manual prompt impersonation 2026-09-03 13:15:50 +08:00
bincxz 03d9ff8543 fix(#3252): preserve terminal input semantics 2026-09-03 13:07:14 +08:00
bincxz 597f60b11c fix(#3252): accept inline completion after output 2026-09-03 12:56:27 +08:00
bincxz 4040149821 fix(#3252): require standalone completion markers 2026-09-03 12:44:26 +08:00
bincxz 8f74b156d0 fix(#3252): ignore interceptor-consumed input 2026-09-03 12:40:41 +08:00
bincxz 10bbd70dd5 fix(#3252): preserve input across delayed prompts 2026-09-03 12:37:51 +08:00
bincxz b6fd7b7255 fix(#3252): reject reserved nested-shell input 2026-09-03 12:30:16 +08:00
bincxz cec8b49a43 fix(#3252): fail closed on ambiguous prompt state 2026-09-03 12:26:42 +08:00
bincxz 1605861cb4 fix(#3252): ignore prompt-shaped command output 2026-09-03 12:22:30 +08:00
bincxz b59cafea88 fix(#3252): retain fish hint across prompt gaps 2026-09-03 12:10:56 +08:00
bincxz 2ce77e12aa fix(#3252): preserve shell and protocol input hints 2026-09-03 12:06:26 +08:00
bincxz 27a68fd36b fix(#3252): serialize pending terminal input 2026-09-03 11:56:09 +08:00
bincxz 45ebd70deb fix(#3252): reserve terminal input before interception 2026-09-03 11:42:34 +08:00
bincxz 4c7643f3b2 fix(#3252): keep partial input pending 2026-09-03 11:37:59 +08:00
bincxz ac0c1dd0f1 fix(#3252): reject echoed pending input 2026-09-03 11:32:45 +08:00
bincxz 0ee573f2f7 fix(#3252): expire shell hint after input 2026-09-03 11:29:57 +08:00
bincxz 42e6d67cdf fix(#3252): preserve shell across prompt redraw 2026-09-03 11:27:30 +08:00
bincxz 3523b87a80 fix(#3252): track idle input and await prompt 2026-09-03 11:14:07 +08:00
bincxz 4d39c7573e fix(#3252): scope PowerShell clear prefix to cmd startup 2026-09-03 10:45:06 +08:00
netcatty-bot aa0f8d3c79 fix: address Codex review on PR #3254 2026-09-02 18:39:50 +00:00
netcatty-bot 1f65c5e1a6 fix(#3252): automated AI fix 2026-09-02 18:07:58 +00:00
21 changed files with 1679 additions and 75 deletions

No files matched your search

@@ -998,7 +998,7 @@ test("multi-character plain text goes out as per-character writes (#3077)", asyn
assert.ok(writeLoopIdx >= 0);
const writeLoop = source.slice(writeLoopIdx, writeLoopIdx + 320);
assert.match(writeLoop, /for \(const chunk of/);
assert.match(writeLoop, /writeToSession\(id, chunk, \{ sensitive \}\)/);
assert.match(writeLoop, /writeToSession\(id, chunk, writeOptions\)/);
// IME commits split the committed glyph, not the Kitty CSI-u encoding.
assert.match(
@@ -1018,7 +1018,7 @@ test("multi-character plain text goes out as per-character writes (#3077)", asyn
// Negotiated Kitty paths keep their single write: CSI-u associated text and
// forwarded key sequences must never be split across writes.
const compositionIdx = source.indexOf(
"handleTerminalInputData(encoded, { source: \"kitty\" })",
"handleTerminalInputData(encoded, { source: \"kitty\", logicalData: sanitizedText })",
);
assert.ok(compositionIdx >= 0);
assert.doesNotMatch(source.slice(0, compositionIdx + 60), /handleTerminalInputData\(encoded, \{ perCharacterWrites/);
@@ -205,7 +205,11 @@ import {
type TerminalBackendApi = {
openExternalAvailable: () => boolean;
openExternal: (url: string) => Promise<void>;
writeToSession: (sessionId: string, data: string) => void;
writeToSession: (
sessionId: string,
data: string,
options?: { sensitive?: boolean; logicalData?: string | null },
) => void;
interruptSession?: (sessionId: string, trace?: NetcattyTerminalInterruptTrace) => void;
signalPluginConnection?: (
sessionId: string,
@@ -1110,7 +1114,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
/**
* User-facing input represented by a transport-specific wire payload.
* `null` means the payload has no text/editing semantics (for example a
* Win32 key-up record). The wire `data` is still written unchanged.
* Win32 key-up record). An explicitly provided `undefined` means its
* editing effect is unknown and must be tracked conservatively. The wire
* `data` is still written unchanged.
*/
logicalData?: string | null;
/** Skip string broadcast when peers will re-resolve from a key chord. */
@@ -1255,8 +1261,11 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
// When backspaceBehavior is configured, remap the Backspace key output
const outData = mapTerminalBackspaceInput(dataToWrite, ctx.host.backspaceBehavior);
ctx.onOutputTriggerUserInputRef?.current?.(outData);
const writeOptions = options && Object.prototype.hasOwnProperty.call(options, "logicalData")
? { sensitive, logicalData: options.logicalData }
: { sensitive };
for (const chunk of getTextInputWireChunks(outData, options?.perCharacterWrites === true)) {
ctx.terminalBackend.writeToSession(id, chunk, { sensitive });
ctx.terminalBackend.writeToSession(id, chunk, writeOptions);
}
// Local echo for serial connections only when explicitly enabled
@@ -1329,7 +1338,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
let imeTextInputDeferredKittyEvent: KittyKeyboardEvent | null = null;
let win32InputModePendingEvent: {
event: KittyKeyboardEvent;
logicalData: string | null;
logicalData: string | null | undefined;
} | null = null;
const win32InputModeForwardedKeys = new Map<string, KittyKeyboardForwardedPress>();
const kittyForwardedKeys = new Map<string, KittyKeyboardForwardedPress>();
@@ -1410,7 +1419,13 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
pressEvent,
[],
);
handleTerminalInputData(sequence, { source: "kitty" });
handleTerminalInputData(sequence, {
source: "kitty",
logicalData: resolveWin32InputLogicalData(
pressEvent,
term.modes.applicationCursorKeysMode,
),
});
const forwarded = broadcastKittyInput({
kind: "key",
event: pressEvent,
@@ -1486,7 +1501,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
? null
: encodeKittyCompositionText(kittyKeyboardMode, sanitizedText);
if (encoded) {
handleTerminalInputData(encoded, { source: "kitty" });
handleTerminalInputData(encoded, { source: "kitty", logicalData: sanitizedText });
} else {
if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) {
suppressNextTerminalDataBroadcast = true;
@@ -1537,7 +1552,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
? encodeKittyKeyEvent(kittyKeyboardMode, event)
: null;
if (sequence) {
handleTerminalInputData(sequence, { source: "kitty" });
handleTerminalInputData(sequence, { source: "kitty", logicalData: null });
return true;
}
return false;
@@ -2089,7 +2104,13 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
kittyEvent,
[],
);
handleTerminalInputData(kittySequenceForKeyDown, { source: "kitty" });
handleTerminalInputData(kittySequenceForKeyDown, {
source: "kitty",
logicalData: resolveWin32InputLogicalData(
kittyEvent,
term.modes.applicationCursorKeysMode,
),
});
const forwarded = broadcastKittyInput({
kind: "key",
event: kittyEvent,
@@ -2280,7 +2301,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
};
const writeWin32InputModeEvent = (
event: KittyKeyboardEvent,
logicalData: string | null,
logicalData: string | null | undefined,
) => {
const pending = { event, logicalData };
win32InputModePendingEvent = pending;
@@ -2332,7 +2353,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
(input) => {
if (input.kind !== "key" || !kittyKeyboardProtocolEnabled) return;
const sequence = encodeKittyKeyEvent(kittyKeyboardMode, input.event);
if (sequence) handleTerminalInputData(sequence, { source: "kitty" });
if (sequence) handleTerminalInputData(sequence, { source: "kitty", logicalData: null });
},
kittyKeyboardLockState,
);
@@ -2437,7 +2458,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
? null
: encodeKittyCompositionText(kittyKeyboardMode, sanitizedData);
if (encoded) {
handleTerminalInputData(encoded, { source: "kitty" });
handleTerminalInputData(encoded, { source: "kitty", logicalData: sanitizedData });
} else {
if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) {
suppressNextTerminalDataBroadcast = true;
@@ -2490,10 +2511,13 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
interruptSession: ctx.terminalBackend.interruptSession
? (id) => ctx.terminalBackend.interruptSession?.(id)
: undefined,
writeDisposed: (id, data) => ctx.terminalBackend.writeToSession(
writeDisposed: (id, data, logicalData) => ctx.terminalBackend.writeToSession(
id,
mapTerminalBackspaceInput(data, ctx.host.backspaceBehavior),
{ sensitive: ctx.passwordPromptActiveRef?.current === true },
{
sensitive: ctx.passwordPromptActiveRef?.current === true,
...(logicalData !== undefined ? { logicalData } : {}),
},
),
writeActive: (data, logicalData) => handleTerminalInputData(data, {
source: "kitty",
@@ -596,7 +596,7 @@ test("Win32 input broadcast preserves native records for Win32 targets", () => {
data: shiftEnterRecord,
kittyEncoded: false,
urgentInterrupt: false,
logicalData: null,
logicalData: undefined,
});
const normalizedShiftEnter = {
@@ -614,7 +614,7 @@ test("Win32 input broadcast preserves native records for Win32 targets", () => {
data: "",
kittyEncoded: false,
urgentInterrupt: false,
logicalData: null,
logicalData: undefined,
win32Event: shiftEnter.event,
},
);
@@ -634,7 +634,7 @@ test("Win32 input broadcast preserves native records for Win32 targets", () => {
});
});
test("Win32 input keeps plain Enter bookkeeping without misclassifying modified Enter", () => {
test("Win32 input tracks modified Enter conservatively without calling it a submission", () => {
const options = () => ({
kittyProtocolEnabled: false,
kittyMode: createKittyKeyboardModeState(),
@@ -659,9 +659,45 @@ test("Win32 input keeps plain Enter bookkeeping without misclassifying modified
}, options())?.logicalData;
assert.equal(resolveEnter({}), "\r");
assert.equal(resolveEnter({ shiftKey: true }), null);
assert.equal(resolveEnter({ ctrlKey: true }), null);
assert.equal(resolveEnter({ altKey: true }), null);
assert.equal(resolveEnter({ shiftKey: true }), undefined);
assert.equal(resolveEnter({ ctrlKey: true }), undefined);
assert.equal(resolveEnter({ altKey: true }), undefined);
});
test("Kitty broadcast targets keep plain Enter bookkeeping semantics", () => {
const kittyMode = createKittyKeyboardModeState();
setKittyKeyboardModeFlags(kittyMode, 8 | 2);
const writes: Array<{ data: string; logicalData?: string | null }> = [];
const handler = createKittyKeyboardBroadcastHandler({
resolveOptions: () => ({
kittyProtocolEnabled: true,
kittyMode,
applicationCursorMode: false,
encodedKeys: new Set<string>(),
}),
getSessionId: () => "kitty-target",
isConnected: () => true,
isRuntimeDisposed: () => false,
writeDisposed: () => {},
writeActive: (data, logicalData) => writes.push({ data, logicalData }),
});
handler({
kind: "key",
fallbackToLegacy: true,
event: { type: "keydown", key: "Enter", code: "Enter" },
});
handler({
kind: "win32",
data: "\x1b[13;28;13;1;0;1_",
fallbackToLegacy: true,
event: { type: "keydown", key: "Enter", code: "Enter" },
});
assert.deepEqual(writes, [
{ data: "\x1b[13u", logicalData: "\r" },
{ data: "\x1b[13u", logicalData: "\r" },
]);
});
test("Win32 key-up broadcast remains a native release with no editing semantics", () => {
@@ -701,9 +737,12 @@ test("Win32 key-up broadcast remains a native release with no editing semantics"
});
});
test("Win32 broadcast targets encode normalized keys and release them during sensitive input", () => {
test("Win32 broadcast targets track modified Enter and release it during sensitive input", () => {
const encodedKeys = new Set<string>();
const delivered: Array<{ type?: string; logicalData: string | null }> = [];
const delivered: Array<{
type?: string;
logicalData: string | null | undefined;
}> = [];
const writes: string[] = [];
let sensitive = false;
const handler = createKittyKeyboardBroadcastHandler({
@@ -736,7 +775,7 @@ test("Win32 broadcast targets encode normalized keys and release them during sen
});
assert.deepEqual(delivered, [
{ type: "keydown", logicalData: null },
{ type: "keydown", logicalData: undefined },
{ type: "keyup", logicalData: null },
]);
assert.deepEqual(writes, []);
@@ -222,16 +222,16 @@ type ResolveKittyKeyboardBroadcastOptions = {
export const resolveWin32InputLogicalData = (
event: KittyKeyboardEvent,
applicationCursorMode: boolean,
): string | null => {
): string | null | undefined => {
if (event.type === "keyup") return null;
if (
event.key === "Enter" &&
(event.shiftKey || event.altKey || event.ctrlKey || event.metaKey)
) {
// The native record carries semantics that a legacy CR cannot express.
// Treating a modified Enter as CR would make Netcatty record a command
// submission even when the TUI only inserted a line break.
return null;
// Leave it undefined so the transport conservatively tracks the wire
// record as pending input without claiming that a command was submitted.
return undefined;
}
return encodeLegacyKeyboardEvent(event, applicationCursorMode);
};
@@ -369,9 +369,12 @@ export const createKittyKeyboardBroadcastHandler = (options: {
isConnected: () => boolean;
isRuntimeDisposed: () => boolean;
interruptSession?: (sessionId: string) => void;
writeDisposed: (sessionId: string, data: string) => void;
writeDisposed: (sessionId: string, data: string, logicalData?: string | null) => void;
writeActive: (data: string, logicalData?: string | null) => void;
writeWin32Event?: (event: KittyKeyboardEvent, logicalData: string | null) => void;
writeWin32Event?: (
event: KittyKeyboardEvent,
logicalData: string | null | undefined,
) => void;
}): KittyKeyboardBroadcastHandler => (input, dispatchOptions) => {
const sessionId = options.getSessionId();
if (!sessionId || !options.isConnected()) return;
@@ -379,7 +382,8 @@ export const createKittyKeyboardBroadcastHandler = (options: {
(input.kind === "key" || input.kind === "win32") &&
input.event.type === "keyup";
if (!isPairedRelease && options.isSensitiveInput?.() === true) return;
const resolved = resolveKittyKeyboardBroadcastInput(input, options.resolveOptions());
const resolveOptions = options.resolveOptions();
const resolved = resolveKittyKeyboardBroadcastInput(input, resolveOptions);
if (!resolved) return;
if (resolved.urgentInterrupt && options.interruptSession) {
dispatchOptions?.beforeUrgentInterrupt?.();
@@ -390,16 +394,27 @@ export const createKittyKeyboardBroadcastHandler = (options: {
if (!options.isRuntimeDisposed()) {
options.writeWin32Event?.(
resolved.win32Event,
resolved.logicalData ?? null,
resolved.logicalData,
);
}
return;
}
let logicalData = resolved.logicalData;
if (logicalData === undefined) {
if (input.kind === "key" || input.kind === "win32") {
logicalData = resolveWin32InputLogicalData(
input.event,
resolveOptions.applicationCursorMode,
);
} else if (input.kind === "text") {
logicalData = input.text;
}
}
if (options.isRuntimeDisposed()) {
options.writeDisposed(sessionId, resolved.data);
options.writeDisposed(sessionId, resolved.data, logicalData);
return;
}
options.writeActive(resolved.data, resolved.logicalData);
options.writeActive(resolved.data, logicalData);
};
const handlers = new Map<string, KittyKeyboardBroadcastHandler>();
@@ -147,7 +147,7 @@ test("runtime routes Shift+Enter text through the shared input handler", () => {
);
assert.match(
source,
/const sanitizedData = sanitizeTerminalInput\(data\);[\s\S]*const encoded = term\.modes\.win32InputMode\s*\? null\s*:\s*encodeKittyCompositionText\(kittyKeyboardMode, sanitizedData\);[\s\S]*if \(encoded\) \{[\s\S]*handleTerminalInputData\(encoded, \{ source: "kitty" \}\);[\s\S]*\} else \{[\s\S]*handleTerminalInputData\(sanitizedData, \{\s*perCharacterWrites: shouldSplitImeTextInputForWire\(sanitizedData\),?\s*\}\);[\s\S]*broadcastKittyInput\(\{ kind: "text", text: sanitizedData \}\);/,
/const sanitizedData = sanitizeTerminalInput\(data\);[\s\S]*const encoded = term\.modes\.win32InputMode\s*\? null\s*:\s*encodeKittyCompositionText\(kittyKeyboardMode, sanitizedData\);[\s\S]*if \(encoded\) \{[\s\S]*handleTerminalInputData\(encoded, \{ source: "kitty", logicalData: sanitizedData \}\);[\s\S]*\} else \{[\s\S]*handleTerminalInputData\(sanitizedData, \{\s*perCharacterWrites: shouldSplitImeTextInputForWire\(sanitizedData\),?\s*\}\);[\s\S]*broadcastKittyInput\(\{ kind: "text", text: sanitizedData \}\);/,
);
assert.match(
source,
@@ -157,6 +157,10 @@ test("runtime routes Shift+Enter text through the shared input handler", () => {
source,
/const broadcastInput: KittyKeyboardBroadcastInput = \{\s*kind: "win32",\s*data,\s*event: win32Input\.event,[\s\S]*handleTerminalInputData\(data, \{\s*logicalData: win32Input\.logicalData,\s*skipBroadcast: true,/s,
);
assert.match(
source,
/handleTerminalInputData\(kittySequenceForKeyDown, \{\s*source: "kitty",\s*logicalData: resolveWin32InputLogicalData\(/s,
);
assert.match(
source,
/const hasForwardedWin32KeyDown = win32InputModeForwardedKeys\.delete\(identity\);[\s\S]*if \(term\.modes\.win32InputMode\) \{[\s\S]*releaseForwardedKittyPress\([\s\S]*if \(!hasForwardedWin32KeyDown\) \{[\s\S]*win32InputModePendingEvent = null;[\s\S]*return false;[\s\S]*logicalData: null,[\s\S]*return true;[\s\S]*releaseForwardedKittyPress/s,
@@ -189,7 +193,7 @@ test("runtime routes Shift+Enter text through the shared input handler", () => {
);
assert.match(
source,
/flushKittyKeyboardBroadcastReleases\(\s+kittyForwardedKeys,[\s\S]*encodeKittyKeyEvent\(kittyKeyboardMode, input\.event\)[\s\S]*handleTerminalInputData\(sequence, \{ source: "kitty" \}\)/,
/flushKittyKeyboardBroadcastReleases\(\s+kittyForwardedKeys,[\s\S]*encodeKittyKeyEvent\(kittyKeyboardMode, input\.event\)[\s\S]*handleTerminalInputData\(sequence, \{ source: "kitty", logicalData: null \}\)/,
);
assert.doesNotMatch(source, /writeToSession\(id, textToSend\)/);
});
@@ -38,7 +38,7 @@ test("password-prompt input is classified before prompt state reset and cannot b
);
assert.match(
runtimeSource,
/for \(const chunk of getTextInputWireChunks\(outData, options\?\.perCharacterWrites === true\)\) \{\s*ctx\.terminalBackend\.writeToSession\(id, chunk, \{ sensitive \}\);\s*\}/u,
/const writeOptions = options && Object\.prototype\.hasOwnProperty\.call\(options, "logicalData"\)[\s\S]*?for \(const chunk of getTextInputWireChunks\(outData, options\?\.perCharacterWrites === true\)\) \{\s*ctx\.terminalBackend\.writeToSession\(id, chunk, writeOptions\);\s*\}/u,
);
assert.match(
runtimeSource,
@@ -67,6 +67,7 @@ test("confirmed sudo credentials and preload transport preserve the sensitive ma
/writeToSession\(id, data, \{ automated: true, sensitive: true \}\)/u,
);
assert.match(preloadSource, /sensitive: options\?\.sensitive === true/u);
assert.match(preloadSource, /logicalData: options\?\.logicalData === null/u);
});
test("OSC 52 clipboard replies bypass plugin input interception as sensitive host data", () => {
+144 -11
View File
@@ -26,6 +26,13 @@ const {
consumeVisibleText,
stripAnsi,
} = require("./ptyExecHelpers.cjs");
const {
confirmSessionIdlePrompt,
extractTrailingIdlePrompt,
getSessionInputVersion,
markSessionPtyPromptMarkerEnded,
registerSessionPtyPromptMarker,
} = require("./shellUtils.cjs");
const DEFAULT_FOREGROUND_PTY_CAPTURE_CHARS = 1024 * 1024;
@@ -43,20 +50,70 @@ function startPtyJob(ptyStream, command, options) {
timeoutMs = 60000,
shellKind,
loginShellHint,
activeShellHint,
observedShellHint,
chatSessionId,
abortSignal,
expectedPrompt,
inputLineKnownEmpty = false,
inputLineRequiresManualClear = false,
typedInput = false,
echoCommand,
maxBufferedChars = 0,
normalizeFinalOutput = true,
enforceWallTimeout = false,
promptTrackingSession,
} = options || {};
const marker = `__NCMCP_${Date.now().toString(36)}_${crypto.randomBytes(16).toString('hex')}__`;
const resolvedShellKind = resolveEffectiveShellKind(shellKind, expectedPrompt, {
loginShellHint,
activeShellHint,
});
const isCmdToPowerShell = loginShellHint === "cmd" && (
resolvedShellKind === "powershell"
|| observedShellHint === "powershell"
);
const hasAmbiguousShellTransition = Boolean(
observedShellHint
&& (
resolvedShellKind !== observedShellHint
|| (loginShellHint && loginShellHint !== observedShellHint)
),
);
// This transition identifies the reporter's Windows startup-command path,
// but it does not reveal the configured PSReadLine edit mode. If terminal
// input followed the prompt, no clear-key sequence is portable across
// Windows/Emacs/Vi; fail without writing instead of corrupting either line.
if (
inputLineRequiresManualClear
|| (!inputLineKnownEmpty && (isCmdToPowerShell || hasAmbiguousShellTransition))
) {
const error = "Terminal has pending input; submit or clear it before running an agent command";
const result = {
ok: false,
stdout: "",
stderr: "",
exitCode: -1,
error,
outputBaseOffset: 0,
totalOutputChars: 0,
outputTruncated: false,
};
return {
marker,
cancel: () => {},
getSnapshot: () => ({
stdout: "",
outputBaseOffset: 0,
totalOutputChars: 0,
outputTruncated: false,
status: "finished",
foundStart: false,
}),
resultPromise: Promise.resolve(result),
};
}
const captureLimitChars = maxBufferedChars > 0
? maxBufferedChars
: DEFAULT_FOREGROUND_PTY_CAPTURE_CHARS;
@@ -77,6 +134,7 @@ function startPtyJob(ptyStream, command, options) {
let wallTimeoutId = null;
let startupTimeoutId = null;
let promptFallbackTimer = null;
let endMarkerWaitTimer = null;
let cancelRetryTimerId = null;
// Track one-shot timers scheduled inside requestCancel so finish() can
// clear them when the job exits early; otherwise they keep the Node
@@ -87,6 +145,7 @@ function startPtyJob(ptyStream, command, options) {
let unsubscribe = null;
const cleanupFns = [];
let pendingStart = "";
let pendingEnd = null;
let resolveResult;
const outputDecoder = new StringDecoder("utf8");
const resultPromise = new Promise((resolve) => {
@@ -100,6 +159,13 @@ function startPtyJob(ptyStream, command, options) {
}
}
function clearEndMarkerWait() {
if (endMarkerWaitTimer) {
clearTimeout(endMarkerWaitTimer);
endMarkerWaitTimer = null;
}
}
function clearCancelRetryTimer() {
if (cancelRetryTimerId) {
clearTimeout(cancelRetryTimerId);
@@ -107,6 +173,12 @@ function startPtyJob(ptyStream, command, options) {
}
}
function clearCancelOneShotTimers() {
while (cancelOneShotTimers.length) {
clearTimeout(cancelOneShotTimers.pop());
}
}
function armOutputTimeout() {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
@@ -178,6 +250,10 @@ function startPtyJob(ptyStream, command, options) {
function requestCancel() {
if (finished || cancelRequested) return;
if (pendingEnd) {
finish(pendingEnd.stdout, pendingEnd.exitCode);
return;
}
cancelRequested = true;
clearPromptFallback();
clearCancelRetryTimer();
@@ -221,6 +297,7 @@ function startPtyJob(ptyStream, command, options) {
function schedulePromptFallback() {
clearPromptFallback();
if (!hasExpectedPromptSuffix(output, expectedPrompt)) return;
const promptInputVersion = getSessionInputVersion(promptTrackingSession);
// Background jobs use a much longer delay (30s) so commands that open
// child shells / REPLs with the same prompt have time to print past
// their initial prompt and avoid being misdetected as completed.
@@ -228,6 +305,7 @@ function startPtyJob(ptyStream, command, options) {
const delayMs = maxBufferedChars > 0 ? 30000 : 250;
promptFallbackTimer = setTimeout(() => {
if (!hasExpectedPromptSuffix(output, expectedPrompt)) return;
confirmSessionIdlePrompt(promptTrackingSession, output, promptInputVersion);
finish(output, null, null);
}, delayMs);
}
@@ -235,8 +313,31 @@ function startPtyJob(ptyStream, command, options) {
function checkEnd() {
const found = findEndMarker(output, marker, { allowInline: true });
if (!found) return;
markSessionPtyPromptMarkerEnded(promptTrackingSession, marker);
const stdout = output.slice(0, found.endIdx);
finish(stdout, found.exitCode);
pendingEnd = { stdout, exitCode: found.exitCode };
clearTimeout(timeoutId);
timeoutId = null;
clearTimeout(wallTimeoutId);
wallTimeoutId = null;
clearStartupTimeout();
clearCancelRetryTimer();
clearCancelOneShotTimers();
if (!expectedPrompt || extractTrailingIdlePrompt(output)) {
finish(stdout, found.exitCode);
return;
}
// A terminal often delivers the definitive end marker and the redrawn
// prompt in separate chunks. Keep the execution lock until that prompt
// arrives so an immediate follow-up command cannot select its wrapper from
// a transient marker-only tail. A bounded fallback prevents a permanently
// changed/custom prompt from holding the lock forever; the last confirmed
// active-shell hint keeps the next execution from reverting to login cmd.
if (!endMarkerWaitTimer) {
endMarkerWaitTimer = setTimeout(() => {
finish(stdout, found.exitCode);
}, 30000);
}
}
// Carry buffer for incomplete marker lines split across chunks.
@@ -317,12 +418,11 @@ function startPtyJob(ptyStream, command, options) {
clearTimeout(wallTimeoutId);
clearStartupTimeout();
clearPromptFallback();
clearEndMarkerWait();
clearCancelRetryTimer();
// 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) {
clearTimeout(cancelOneShotTimers.pop());
}
clearCancelOneShotTimers();
unsubscribe?.();
for (const fn of cleanupFns) {
try {
@@ -420,7 +520,7 @@ function startPtyJob(ptyStream, command, options) {
: Buffer.from(String(data ?? ""));
const text = outputDecoder.write(bytes);
if (!text) return;
armOutputTimeout();
if (!pendingEnd) armOutputTimeout();
if (!foundStart) {
preStartOutput += text;
@@ -502,13 +602,21 @@ function startPtyJob(ptyStream, command, options) {
}
appendToOutput(text);
// Parse a completed command before treating a redrawn prompt as a
// cancellation boundary. The marker and prompt can arrive in one PTY
// chunk; finishing from the prompt first would expose the internal marker.
checkEnd();
if (finished) return;
if (!cancelRequested) {
schedulePromptFallback();
} else if (hasExpectedPromptSuffix(output, expectedPrompt)) {
finish(output, 130, "Cancelled");
finish(
pendingEnd?.stdout ?? output,
pendingEnd?.exitCode ?? 130,
"Cancelled",
);
return;
}
checkEnd();
}
if (abortSignal?.aborted) {
@@ -521,6 +629,8 @@ function startPtyJob(ptyStream, command, options) {
};
}
cleanupFns.push(registerSessionPtyPromptMarker(promptTrackingSession, marker));
armOutputTimeout();
armWallTimeout();
armStartupTimeout();
@@ -544,8 +654,12 @@ function startPtyJob(ptyStream, command, options) {
}
if (typeof ptyStream.on === "function") {
const onClose = () => finish(foundStart ? output : preStartOutput, null, cancelRequested ? "Cancelled" : "Stream closed unexpectedly");
const onError = (err) => finish(foundStart ? output : preStartOutput, -1, cancelRequested ? "Cancelled" : `Stream error: ${err?.message || err}`);
const onClose = () => pendingEnd
? finish(pendingEnd.stdout, pendingEnd.exitCode)
: finish(foundStart ? output : preStartOutput, null, cancelRequested ? "Cancelled" : "Stream closed unexpectedly");
const onError = (err) => pendingEnd
? finish(pendingEnd.stdout, pendingEnd.exitCode)
: finish(foundStart ? output : preStartOutput, -1, cancelRequested ? "Cancelled" : `Stream error: ${err?.message || err}`);
ptyStream.on("close", onClose);
ptyStream.on("end", onClose);
ptyStream.on("error", onError);
@@ -556,7 +670,9 @@ function startPtyJob(ptyStream, command, options) {
});
}
if (typeof ptyStream.onExit === "function") {
const disposable = ptyStream.onExit(() => finish(foundStart ? output : preStartOutput, null, cancelRequested ? "Cancelled" : "Process exited"));
const disposable = ptyStream.onExit(() => pendingEnd
? finish(pendingEnd.stdout, pendingEnd.exitCode)
: finish(foundStart ? output : preStartOutput, null, cancelRequested ? "Cancelled" : "Process exited"));
cleanupFns.push(() => {
try {
disposable?.dispose?.();
@@ -583,7 +699,19 @@ function startPtyJob(ptyStream, command, options) {
}
const wrapped = buildWrappedCommand(command, resolvedShellKind, marker);
ptyStream.write(`${buildPendingInputClearPrefix(resolvedShellKind)}${wrapped}`);
// A live PowerShell prompt overriding a cmd login hint is the Windows
// OpenSSH + startup-command shape from #3252. Input tracking already proved
// this prompt's editable line is untouched, so mode-dependent clear keys are
// unnecessary. Other PowerShell paths retain the legacy fallback.
const pendingInputClearPrefix = isCmdToPowerShell
? ""
: buildPendingInputClearPrefix(resolvedShellKind);
try {
ptyStream.write(`${pendingInputClearPrefix}${wrapped}`);
} catch (error) {
finish("", -1, `Failed to write command: ${error?.message || error}`);
throw error;
}
return {
marker,
@@ -617,8 +745,13 @@ function startPtyJob(ptyStream, command, options) {
* @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.activeShellHint] - Last shell kind confirmed by an interactive prompt
* @param {string} [options.observedShellHint] - Last observed shell kind, including while terminal input is pending
* @param {boolean} [options.inputLineKnownEmpty] - No terminal input has followed the observed prompt
* @param {boolean} [options.inputLineRequiresManualClear] - Manual input cannot be proven complete
* @param {boolean} [options.typedInput=false] - Emit synthetic command echo before execution
* @param {(command: string) => void} [options.echoCommand] - Callback used to display synthetic command echo
* @param {object} [options.promptTrackingSession] - Session whose prompt tracker should ignore command output lookalikes
*/
function execViaPty(ptyStream, command, options) {
return startPtyJob(ptyStream, command, options).resultPromise;
+585 -1
View File
@@ -18,6 +18,16 @@ const {
buildPendingInputClearPrefix,
buildWrappedCommand,
} = require("./ptyExecHelpers.cjs");
const {
getFreshIdlePrompt,
getSessionActiveShellHint,
getSessionLastObservedShellKind,
hasUnverifiedManualSessionInput,
isSessionInputLineKnownEmpty,
markSessionInputPending,
reserveSessionInput,
trackSessionIdlePrompt,
} = require("./shellUtils.cjs");
class ShellBackedPty extends EventEmitter {
write(data) {
@@ -280,6 +290,20 @@ test("loginShellHint selects fish/posix/powershell/cmd without pinning confirmed
resolveEffectiveShellKind(undefined, "user@host:~$", { loginShellHint: "fish" }),
"fish",
);
assert.equal(
resolveEffectiveShellKind(undefined, "user@host:~$", {
loginShellHint: "fish",
activeShellHint: "posix",
}),
"fish",
);
assert.equal(
resolveEffectiveShellKind(undefined, "", {
loginShellHint: "fish",
activeShellHint: "posix",
}),
"fish",
);
assert.equal(
resolveEffectiveShellKind(undefined, "user@host:~$", { loginShellHint: "posix" }),
"posix",
@@ -320,6 +344,20 @@ test("loginShellHint selects fish/posix/powershell/cmd without pinning confirmed
resolveEffectiveShellKind(undefined, "alice@wsl:/mnt/c$", { loginShellHint: "cmd" }),
"posix",
);
assert.equal(
resolveEffectiveShellKind(undefined, "", {
loginShellHint: "cmd",
activeShellHint: "powershell",
}),
"powershell",
);
assert.equal(
resolveEffectiveShellKind(undefined, "C:\\Users\\alice>", {
loginShellHint: "cmd",
activeShellHint: "powershell",
}),
"cmd",
);
// Confirmed shellKind is never overridden by a login hint.
assert.equal(
resolveEffectiveShellKind("posix", "user@host:~$", { loginShellHint: "fish" }),
@@ -335,7 +373,529 @@ test("pending-input clear prefix covers interactive shells and skips raw devices
assert.equal(buildPendingInputClearPrefix("raw"), "");
});
test("startPtyJob writes the clear prefix before the wrapper", async () => {
test("consecutive jobs stay in PowerShell when the end marker and prompt arrive separately", async () => {
const writes = [];
class CapturePty extends EventEmitter {
write(data) {
writes.push(String(data));
}
}
const pty = new CapturePty();
const session = { _loginShellKind: "cmd" };
pty.on("data", (data) => trackSessionIdlePrompt(session, String(data)));
trackSessionIdlePrompt(session, "Microsoft Windows...\r\nPS C:\\Users\\alice>");
for (const probe of ["PROBE_1", "PROBE_2"]) {
const job = startPtyJob(pty, `Write-Output '${probe}'`, {
shellKind: session.shellKind,
loginShellHint: session._loginShellKind,
activeShellHint: getSessionActiveShellHint(session),
observedShellHint: getSessionLastObservedShellKind(session),
timeoutMs: 50,
expectedPrompt: getFreshIdlePrompt(session),
inputLineKnownEmpty: isSessionInputLineKnownEmpty(session),
promptTrackingSession: session,
});
const write = writes.at(-1);
assert.ok(write.startsWith("$__NCMCP_"));
assert.doesNotMatch(write, /cmd \/d \/s \/c/i);
pty.emit(
"data",
Buffer.from(`${job.marker}_S\r\n${probe}\r\n${job.marker}_E:0\r\n`),
);
let settled = false;
job.resultPromise.then(() => { settled = true; });
await new Promise((resolve) => setTimeout(resolve, 300));
assert.equal(settled, false);
pty.emit("data", Buffer.from("PS C:\\Users\\alice>"));
const result = await job.resultPromise;
assert.equal(result.ok, true);
assert.equal(result.exitCode, 0);
assert.equal(result.stdout, probe);
}
});
test("command output that looks like a prompt cannot replace the active shell", async () => {
const writes = [];
class CapturePty extends EventEmitter {
write(data) {
writes.push(String(data));
}
}
const pty = new CapturePty();
const session = { _loginShellKind: "cmd" };
pty.on("data", (data) => trackSessionIdlePrompt(session, String(data)));
trackSessionIdlePrompt(session, "PS C:\\Users\\alice>");
const job = startPtyJob(pty, "Write-Output 'alice@host:~$'", {
loginShellHint: session._loginShellKind,
activeShellHint: getSessionActiveShellHint(session),
observedShellHint: getSessionLastObservedShellKind(session),
timeoutMs: 1000,
expectedPrompt: getFreshIdlePrompt(session),
inputLineKnownEmpty: isSessionInputLineKnownEmpty(session),
promptTrackingSession: session,
});
// Real terminals echo the wrapper before its start marker. That echo contains
// a literal `${marker}_E:` fragment, but not the numeric exit code that proves
// the command actually ended.
pty.emit("data", Buffer.from(`${writes[0]}${job.marker}_S\r\nalice@host:~$`));
assert.equal(getSessionLastObservedShellKind(session), "powershell");
assert.equal(getFreshIdlePrompt(session), "");
assert.equal(isSessionInputLineKnownEmpty(session), false);
pty.emit("data", Buffer.from(`\r\n${job.marker}_E:0\r\n`));
pty.emit("data", Buffer.from("PS C:\\Users\\alice>"));
const result = await job.resultPromise;
assert.equal(result.ok, true);
assert.equal(result.stdout, "alice@host:~$");
assert.equal(getSessionActiveShellHint(session), "powershell");
assert.equal(isSessionInputLineKnownEmpty(session), true);
assert.ok(writes.at(0).startsWith("$__NCMCP_"));
});
test("partial input in a nested shell is rejected when its wrapper is ambiguous", async () => {
const writes = [];
class CapturePty extends EventEmitter {
write(data) {
writes.push(String(data));
}
}
const pty = new CapturePty();
const session = { _loginShellKind: "posix" };
trackSessionIdlePrompt(session, "PS C:\\Users\\alice>");
markSessionInputPending(session, "Write-Output 'USER'");
trackSessionIdlePrompt(session, "Write-Output 'USER'");
const job = startPtyJob(pty, "Write-Output 'PROBE'", {
loginShellHint: session._loginShellKind,
activeShellHint: getSessionActiveShellHint(session),
observedShellHint: getSessionLastObservedShellKind(session),
timeoutMs: 50,
expectedPrompt: getFreshIdlePrompt(session),
inputLineKnownEmpty: isSessionInputLineKnownEmpty(session),
});
assert.equal(writes.length, 0);
const result = await job.resultPromise;
assert.equal(result.ok, false);
assert.match(result.error, /pending input/i);
});
test("reserved input in a nested shell is rejected despite a still-fresh prompt", async () => {
const writes = [];
class CapturePty extends EventEmitter {
write(data) {
writes.push(String(data));
}
}
const pty = new CapturePty();
const session = { _loginShellKind: "posix" };
trackSessionIdlePrompt(session, "PS C:\\Users\\alice>");
reserveSessionInput(session);
assert.equal(getFreshIdlePrompt(session), "PS C:\\Users\\alice>");
assert.equal(getSessionLastObservedShellKind(session), "powershell");
const job = startPtyJob(pty, "Write-Output 'PROBE'", {
loginShellHint: session._loginShellKind,
activeShellHint: getSessionActiveShellHint(session),
observedShellHint: getSessionLastObservedShellKind(session),
timeoutMs: 50,
expectedPrompt: getFreshIdlePrompt(session),
inputLineKnownEmpty: isSessionInputLineKnownEmpty(session),
});
assert.equal(writes.length, 0);
const result = await job.resultPromise;
assert.equal(result.ok, false);
assert.match(result.error, /pending input/i);
});
test("a stream close after the end marker preserves the completed result", async () => {
class CapturePty extends EventEmitter {
write() {}
}
const pty = new CapturePty();
const job = startPtyJob(pty, "Write-Output 'DONE'", {
shellKind: "powershell",
timeoutMs: 1000,
expectedPrompt: "PS C:\\Users\\alice>",
});
pty.emit("data", Buffer.from(`${job.marker}_S\r\nDONE\r\n${job.marker}_E:0\r\n`));
pty.emit("close");
const result = await job.resultPromise;
assert.equal(result.ok, true);
assert.equal(result.exitCode, 0);
assert.equal(result.stdout, "DONE");
assert.doesNotMatch(result.stdout, /__NCMCP_/);
});
test("manual Ctrl+C prompt fallback restores clean input for the next PowerShell job", async () => {
const writes = [];
class CapturePty extends EventEmitter {
write(data) {
writes.push(String(data));
}
}
const pty = new CapturePty();
const session = { _loginShellKind: "cmd" };
pty.on("data", (data) => trackSessionIdlePrompt(session, String(data)));
trackSessionIdlePrompt(session, "PS C:\\Users\\alice>");
const first = startPtyJob(pty, "Start-Sleep 60", {
loginShellHint: session._loginShellKind,
activeShellHint: getSessionActiveShellHint(session),
observedShellHint: getSessionLastObservedShellKind(session),
timeoutMs: 1000,
expectedPrompt: getFreshIdlePrompt(session),
inputLineKnownEmpty: isSessionInputLineKnownEmpty(session),
promptTrackingSession: session,
});
pty.emit("data", Buffer.from(`${first.marker}_S\r\n`));
markSessionInputPending(session, "\x03");
pty.emit("data", Buffer.from("PS C:\\Users\\alice>"));
await first.resultPromise;
assert.equal(isSessionInputLineKnownEmpty(session), true);
const second = startPtyJob(pty, "Write-Output 'NEXT'", {
loginShellHint: session._loginShellKind,
activeShellHint: getSessionActiveShellHint(session),
observedShellHint: getSessionLastObservedShellKind(session),
timeoutMs: 1000,
expectedPrompt: getFreshIdlePrompt(session),
inputLineKnownEmpty: isSessionInputLineKnownEmpty(session),
promptTrackingSession: session,
});
assert.equal(writes.length, 2);
assert.ok(writes[1].startsWith("$__NCMCP_"));
pty.emit("data", Buffer.from(`${second.marker}_S\r\nNEXT\r\n${second.marker}_E:0\r\nPS C:\\Users\\alice>`));
const result = await second.resultPromise;
assert.equal(result.ok, true);
assert.equal(result.stdout, "NEXT");
});
test("input submitted after an end marker survives the delayed old prompt", async () => {
const writes = [];
class CapturePty extends EventEmitter {
write(data) {
writes.push(String(data));
}
}
const pty = new CapturePty();
const session = { _loginShellKind: "cmd" };
pty.on("data", (data) => trackSessionIdlePrompt(session, String(data)));
trackSessionIdlePrompt(session, "PS C:\\Users\\alice>");
const first = startPtyJob(pty, "Write-Output 'ONE'", {
loginShellHint: session._loginShellKind,
activeShellHint: getSessionActiveShellHint(session),
observedShellHint: getSessionLastObservedShellKind(session),
timeoutMs: 1000,
expectedPrompt: getFreshIdlePrompt(session),
inputLineKnownEmpty: isSessionInputLineKnownEmpty(session),
promptTrackingSession: session,
});
pty.emit("data", Buffer.from(`${first.marker}_S\r\nONE\r\n${first.marker}_E:0\r\n`));
markSessionInputPending(session, "Start-Sleep 60\r");
pty.emit("data", Buffer.from("PS C:\\Users\\alice>"));
const firstResult = await first.resultPromise;
assert.equal(firstResult.ok, true);
assert.equal(isSessionInputLineKnownEmpty(session), false);
const second = startPtyJob(pty, "Write-Output 'TWO'", {
loginShellHint: session._loginShellKind,
activeShellHint: getSessionActiveShellHint(session),
observedShellHint: getSessionLastObservedShellKind(session),
timeoutMs: 1000,
expectedPrompt: getFreshIdlePrompt(session),
inputLineKnownEmpty: isSessionInputLineKnownEmpty(session),
promptTrackingSession: session,
});
assert.equal(writes.length, 1);
const secondResult = await second.resultPromise;
assert.equal(secondResult.ok, false);
assert.match(secondResult.error, /pending input/i);
});
test("fish wrapper echo cannot impersonate completion before manual Ctrl+C", async () => {
const writes = [];
class CapturePty extends EventEmitter {
write(data) {
writes.push(String(data));
}
}
const pty = new CapturePty();
const session = { _loginShellKind: "fish" };
pty.on("data", (data) => trackSessionIdlePrompt(session, String(data)));
trackSessionIdlePrompt(session, "alice@host:~$");
const first = startPtyJob(pty, "sleep 60", {
loginShellHint: session._loginShellKind,
activeShellHint: getSessionActiveShellHint(session),
observedShellHint: getSessionLastObservedShellKind(session),
timeoutMs: 1000,
expectedPrompt: getFreshIdlePrompt(session),
inputLineKnownEmpty: isSessionInputLineKnownEmpty(session),
promptTrackingSession: session,
});
assert.match(writes[0], new RegExp(`${first.marker}_E:130`));
pty.emit("data", Buffer.from(`${writes[0]}${first.marker}_S\r\n`));
markSessionInputPending(session, "\x03");
pty.emit("data", Buffer.from(`${first.marker}_E:130\r\nalice@host:~$`));
const firstResult = await first.resultPromise;
assert.equal(firstResult.exitCode, 130);
assert.equal(isSessionInputLineKnownEmpty(session), true);
const second = startPtyJob(pty, "echo NEXT", {
loginShellHint: session._loginShellKind,
activeShellHint: getSessionActiveShellHint(session),
observedShellHint: getSessionLastObservedShellKind(session),
timeoutMs: 1000,
expectedPrompt: getFreshIdlePrompt(session),
inputLineKnownEmpty: isSessionInputLineKnownEmpty(session),
promptTrackingSession: session,
});
assert.equal(writes.length, 2);
assert.match(writes[1], /function __ncmcp_int/);
pty.emit("data", Buffer.from(`${second.marker}_S\r\nNEXT\r\n${second.marker}_E:0\r\nalice@host:~$`));
const secondResult = await second.resultPromise;
assert.equal(secondResult.ok, true);
assert.equal(secondResult.stdout, "NEXT");
});
test("no-newline output still confirms a changed PowerShell prompt for the next job", async () => {
const writes = [];
class CapturePty extends EventEmitter {
write(data) {
writes.push(String(data));
}
}
const pty = new CapturePty();
const session = { _loginShellKind: "cmd" };
pty.on("data", (data) => trackSessionIdlePrompt(session, String(data)));
trackSessionIdlePrompt(session, "PS C:\\old>");
const first = startPtyJob(pty, "Write-Host -NoNewline X; Set-Location C:\\new", {
loginShellHint: session._loginShellKind,
activeShellHint: getSessionActiveShellHint(session),
observedShellHint: getSessionLastObservedShellKind(session),
timeoutMs: 1000,
expectedPrompt: getFreshIdlePrompt(session),
inputLineKnownEmpty: isSessionInputLineKnownEmpty(session),
promptTrackingSession: session,
});
pty.emit("data", Buffer.from(`${first.marker}_S\r\nX${first.marker}_E:0\r\nPS C:\\new>`));
const firstResult = await first.resultPromise;
assert.equal(firstResult.ok, true);
assert.equal(firstResult.stdout, "X");
assert.equal(getFreshIdlePrompt(session), "PS C:\\new>");
assert.equal(getSessionActiveShellHint(session), "powershell");
assert.equal(isSessionInputLineKnownEmpty(session), true);
const second = startPtyJob(pty, "Write-Output 'NEXT'", {
loginShellHint: session._loginShellKind,
activeShellHint: getSessionActiveShellHint(session),
observedShellHint: getSessionLastObservedShellKind(session),
timeoutMs: 1000,
expectedPrompt: getFreshIdlePrompt(session),
inputLineKnownEmpty: isSessionInputLineKnownEmpty(session),
promptTrackingSession: session,
});
assert.equal(writes.length, 2);
assert.ok(writes[1].startsWith("$__NCMCP_"));
assert.doesNotMatch(writes[1], /cmd \/d \/s \/c/i);
pty.emit("data", Buffer.from(`${second.marker}_S\r\nNEXT\r\n${second.marker}_E:0\r\nPS C:\\new>`));
const secondResult = await second.resultPromise;
assert.equal(secondResult.ok, true);
assert.equal(secondResult.stdout, "NEXT");
});
test("a completed marker cannot be changed into a timeout while awaiting the prompt", async () => {
const writes = [];
class CapturePty extends EventEmitter {
write(data) {
writes.push(String(data));
}
}
const pty = new CapturePty();
const job = startPtyJob(pty, "Write-Output 'PROBE'", {
shellKind: "powershell",
timeoutMs: 20,
expectedPrompt: "PS C:\\Users\\alice>",
enforceWallTimeout: true,
});
pty.emit("data", Buffer.from(`${job.marker}_S\r\n${job.marker}_E:0\r\n`));
let settled = false;
job.resultPromise.then(() => { settled = true; });
await new Promise((resolve) => setTimeout(resolve, 40));
assert.equal(settled, false);
assert.equal(writes.includes("\x03"), false);
pty.emit("data", Buffer.from("PS C:\\Users\\alice>"));
const result = await job.resultPromise;
assert.equal(result.ok, true);
assert.equal(result.exitCode, 0);
});
test("cancel retries stop after the command end marker while the prompt is delayed", async () => {
const writes = [];
class CapturePty extends EventEmitter {
write(data) {
writes.push(String(data));
}
}
const pty = new CapturePty();
const job = startPtyJob(pty, "Start-Sleep 10", {
shellKind: "powershell",
timeoutMs: 1000,
expectedPrompt: "PS C:\\Users\\alice>",
});
job.cancel();
assert.equal(writes.filter((write) => write === "\x03").length, 1);
pty.emit("data", Buffer.from(`${job.marker}_S\r\n${job.marker}_E:130\r\n`));
await new Promise((resolve) => setTimeout(resolve, 200));
assert.equal(writes.filter((write) => write === "\x03").length, 1);
pty.emit("data", Buffer.from("PS C:\\Users\\alice>"));
const result = await job.resultPromise;
assert.equal(result.ok, false);
assert.equal(result.error, "Cancelled");
assert.equal(result.stdout, "");
assert.doesNotMatch(result.stdout, /__NCMCP_/);
});
test("cancelled output strips an end marker delivered with the prompt", async () => {
const writes = [];
class CapturePty extends EventEmitter {
write(data) {
writes.push(String(data));
}
}
const pty = new CapturePty();
const job = startPtyJob(pty, "Start-Sleep 10", {
shellKind: "powershell",
timeoutMs: 1000,
expectedPrompt: "PS C:\\Users\\alice>",
});
pty.emit("data", Buffer.from(`${job.marker}_S\r\n`));
job.cancel();
pty.emit(
"data",
Buffer.from(`${job.marker}_E:130\r\nPS C:\\Users\\alice>`),
);
const result = await job.resultPromise;
assert.equal(result.ok, false);
assert.equal(result.error, "Cancelled");
assert.equal(result.stdout, "");
assert.doesNotMatch(result.stdout, /__NCMCP_/);
await new Promise((resolve) => setTimeout(resolve, 200));
assert.equal(writes.filter((write) => write === "\x03").length, 1);
});
test("tracked pending input is not overwritten when PowerShell edit mode is unknown", async () => {
const writes = [];
class CapturePty extends EventEmitter {
write(data) {
writes.push(String(data));
}
}
const pty = new CapturePty();
const session = { _loginShellKind: "cmd" };
trackSessionIdlePrompt(session, "PS C:\\Users\\alice>");
markSessionInputPending(
session,
"Write-Output 'ONE'\rWrite-Output 'USER'",
);
trackSessionIdlePrompt(session, "\r\nPS C:\\Users\\alice>");
assert.equal(getFreshIdlePrompt(session), "PS C:\\Users\\alice>");
assert.equal(isSessionInputLineKnownEmpty(session), false);
const job = startPtyJob(pty, "Write-Output 'PROBE'", {
shellKind: session.shellKind,
loginShellHint: session._loginShellKind,
activeShellHint: getSessionActiveShellHint(session),
observedShellHint: getSessionLastObservedShellKind(session),
timeoutMs: 50,
expectedPrompt: getFreshIdlePrompt(session),
inputLineKnownEmpty: isSessionInputLineKnownEmpty(session),
});
assert.equal(writes.length, 0);
const result = await job.resultPromise;
assert.equal(result.ok, false);
assert.match(result.error, /pending input/i);
});
test("a completed manual command allows the next same-shell agent execution", async () => {
const writes = [];
class CapturePty extends EventEmitter {
write(data) {
writes.push(String(data));
}
}
const pty = new CapturePty();
const session = { _loginShellKind: "cmd" };
trackSessionIdlePrompt(session, "PS C:\\Users\\alice>");
markSessionInputPending(session, "Write-Output 'manual'\r", { source: "manual" });
trackSessionIdlePrompt(session, "\r\nmanual\r\nPS C:\\Users\\alice>");
assert.equal(getSessionActiveShellHint(session), "powershell");
assert.equal(isSessionInputLineKnownEmpty(session), true);
const job = startPtyJob(pty, "Write-Output 'PROBE'", {
shellKind: session.shellKind,
loginShellHint: session._loginShellKind,
activeShellHint: getSessionActiveShellHint(session),
observedShellHint: getSessionLastObservedShellKind(session),
timeoutMs: 50,
expectedPrompt: getFreshIdlePrompt(session),
inputLineKnownEmpty: isSessionInputLineKnownEmpty(session),
inputLineRequiresManualClear: hasUnverifiedManualSessionInput(session),
});
assert.equal(writes.length, 1);
assert.ok(writes[0].startsWith("$__NCMCP_"));
assert.match(writes[0], /PROBE/);
assert.doesNotMatch(writes[0], /cmd \/d \/s \/c/i);
pty.emit("data", Buffer.from(`${job.marker}_S\r\nPROBE\r\n${job.marker}_E:0\r\nPS C:\\Users\\alice>`));
const result = await job.resultPromise;
assert.equal(result.ok, true);
assert.equal(result.stdout, "PROBE");
});
test("echoed pending input cannot erase the PowerShell safety decision", async () => {
const writes = [];
class CapturePty extends EventEmitter {
write(data) {
writes.push(String(data));
}
}
const pty = new CapturePty();
const session = { _loginShellKind: "cmd" };
trackSessionIdlePrompt(session, "PS C:\\Users\\alice>");
markSessionInputPending(session);
trackSessionIdlePrompt(session, "Write-Output 'USER'");
assert.equal(getFreshIdlePrompt(session), "");
assert.equal(getSessionActiveShellHint(session), "");
assert.equal(getSessionLastObservedShellKind(session), "powershell");
const job = startPtyJob(pty, "Write-Output 'PROBE'", {
shellKind: session.shellKind,
loginShellHint: session._loginShellKind,
activeShellHint: getSessionActiveShellHint(session),
observedShellHint: getSessionLastObservedShellKind(session),
timeoutMs: 50,
expectedPrompt: getFreshIdlePrompt(session),
inputLineKnownEmpty: isSessionInputLineKnownEmpty(session),
});
assert.equal(writes.length, 0);
const result = await job.resultPromise;
assert.equal(result.ok, false);
assert.match(result.error, /pending input/i);
});
test("startPtyJob writes the clear prefix even when expectedPrompt matches a clean idle prompt", async () => {
const writes = [];
class CapturePty extends EventEmitter {
write(data) {
@@ -349,6 +909,9 @@ test("startPtyJob writes the clear prefix before the wrapper", async () => {
expectedPrompt: "$ ",
});
assert.equal(writes.length, 1);
// A fresh cached prompt only proves the output tail ends with it — it cannot
// prove the PTY input buffer is empty (echo may lag or be disabled), so the
// clear prefix must always be sent (#2962).
assert.ok(writes[0].startsWith("\x15\x0b"));
assert.match(writes[0], /__NCMCP_/);
job.cancel();
@@ -356,6 +919,27 @@ test("startPtyJob writes the clear prefix before the wrapper", async () => {
await job.resultPromise;
});
test("startPtyJob still writes the clear prefix when no idle prompt is confirmed", async () => {
const writes = [];
class CapturePty extends EventEmitter {
write(data) {
writes.push(String(data));
}
}
const pty = new CapturePty();
const job = startPtyJob(pty, "echo hi", {
shellKind: "posix",
timeoutMs: 50,
});
assert.equal(writes.length, 1);
assert.ok(writes[0].startsWith("\x15\x0b"));
assert.match(writes[0], /__NCMCP_/);
const markerMatch = writes[0].match(/__NCMCP_[0-9a-z]+_[0-9a-f]+__/);
assert.ok(markerMatch);
pty.emit("data", Buffer.from(`${markerMatch[0]}_S\r\n${markerMatch[0]}_E:0\r\n`));
await job.resultPromise;
});
test("execViaRawPty does not prepend a line-clear before device commands", async () => {
const writes = [];
const port = new EventEmitter();
+22 -9
View File
@@ -152,6 +152,8 @@ const LOGIN_SHELL_HINTS = new Set(["posix", "fish", "powershell", "cmd"]);
function resolveEffectiveShellKind(shellKind, expectedPrompt, options = {}) {
const baseKind = shellKind || "";
const hint = options.loginShellHint || "";
const activeHint = options.activeShellHint || "";
const softHint = LOGIN_SHELL_HINTS.has(activeHint) ? activeHint : hint;
if (SHELL_KINDS_OPEN_TO_PROMPT_OVERRIDE.has(baseKind)) {
if (isPowerShellPrompt(expectedPrompt)) {
return "powershell";
@@ -159,19 +161,30 @@ function resolveEffectiveShellKind(shellKind, expectedPrompt, options = {}) {
if (isCmdPrompt(expectedPrompt)) {
return "cmd";
}
// Windows OpenSSH DefaultShell soft hint + nested WSL/bash: live
// `user@host:...$` must win so AI does not type a PS/cmd wrapper into
// a POSIX shell and hang on markers. Fish/posix soft hints stay put —
// those login shells already share this prompt family.
if (
isPosixPrompt(expectedPrompt)
&& (hint === "powershell" || hint === "cmd")
) {
return "posix";
if (isPosixPrompt(expectedPrompt)) {
// A classic POSIX-shaped prompt cannot distinguish fish from bash/zsh.
// Keep an authoritative fish/posix login probe instead of replacing it
// with the prompt classifier's necessarily generic posix label.
if (hint === "fish" || hint === "posix") return hint;
// Windows OpenSSH DefaultShell soft hint + nested WSL/bash: live
// `user@host:...$` must win so AI does not type a PS/cmd wrapper into
// a POSIX shell and hang on markers.
if (softHint === "powershell" || softHint === "cmd") return "posix";
}
}
if (baseKind) return baseKind;
// A generic POSIX prompt classification cannot disprove an authoritative
// fish login probe, including during the brief gap before that prompt is
// redrawn. Preserve fish until an unambiguous PowerShell/cmd prompt wins.
if (hint === "fish" && activeHint === "posix") return "fish";
// The last recognized interactive prompt is a stronger soft hint than the
// login shell. It bridges the brief marker-to-prompt redraw gap between two
// executions without pinning the session: a newer recognized prompt still
// wins above when the user enters or exits a nested shell.
if (LOGIN_SHELL_HINTS.has(activeHint)) return activeHint;
// Soft login-shell hint from remote probe (not a permanent pin).
if (LOGIN_SHELL_HINTS.has(hint)) return hint;
+259 -2
View File
@@ -105,6 +105,18 @@ function extractTrailingIdlePrompt(output) {
return "";
}
function classifyIdlePromptShellKind(prompt) {
const line = stripAnsi(prompt)
.replace(/\r/g, "\n")
.split("\n")
.pop()
.replace(/\s+$/, "");
if (isDefaultPowerShellPromptLine(line)) return "powershell";
if (isDefaultCmdPromptLine(line)) return "cmd";
if (isDefaultPosixPromptLine(line)) return "posix";
return "";
}
// bash and csh/tcsh print a banner to the terminal right before exiting due to
// the shell's TMOUT idle-timeout setting ("timed out waiting for input:
// auto-logout" / "auto-logout"). That exit is a clean shell exit — numeric
@@ -139,13 +151,246 @@ function trackSessionIdlePrompt(session, chunk) {
const prompt = extractTrailingIdlePrompt(nextTail);
if (prompt) {
session.lastIdlePrompt = prompt;
session.lastIdlePromptAt = Date.now();
const activeMarkers = session._activePtyPromptMarkers;
let hasTrustedAutomatedCompletion = false;
if (activeMarkers instanceof Map && activeMarkers.size > 0) {
for (const [marker, state] of activeMarkers) {
// During an automated command, ordinary output can end in text that
// happens to look exactly like another shell's prompt. Trust it only
// after this job's private end marker has been observed. The tail check
// covers an end marker and prompt delivered in the same transport chunk,
// before the PTY job's own listener has a chance to update the state.
if (state?.inputAfterEnd) return "";
if (!state?.ended && !hasCompletedPtyPromptMarker(nextTail, marker)) return "";
}
hasTrustedAutomatedCompletion = true;
} else if (
Object.prototype.hasOwnProperty.call(session, "_manualPromptKindAtSubmit")
&& session._manualInterruptCanConfirmPrompt !== true
) {
// A manual command can print text that looks like another shell's
// default prompt before the real prompt returns. Only let manual output
// confirm the shell kind that was already established when Enter was
// pressed. Automated startup commands and marker-backed agent commands
// may still establish a genuine shell transition.
const promptKind = classifyIdlePromptShellKind(prompt);
if (!session._manualPromptKindAtSubmit || promptKind !== session._manualPromptKindAtSubmit) {
session._untrustedManualPrompt = prompt;
session._untrustedManualPromptKind = promptKind;
return "";
}
}
confirmSessionIdlePrompt(session, nextTail, undefined, {
trustedCompletion: hasTrustedAutomatedCompletion,
});
}
return prompt;
}
function getSessionInputVersion(session) {
return Number(session?._terminalInputVersion || 0);
}
function confirmSessionIdlePrompt(session, output, expectedInputVersion, options = {}) {
if (!session) return "";
const prompt = extractTrailingIdlePrompt(output);
if (!prompt) return "";
session.lastIdlePrompt = prompt;
session.lastIdlePromptAt = Date.now();
const manualClearConfirmed = session._manualClearCanConfirmPrompt === true;
const manualCompletionMatchesPrompt = (
Object.prototype.hasOwnProperty.call(session, "_manualPromptKindAtSubmit")
&& session._manualPromptKindAtSubmit
&& !session._untrustedManualPrompt
&& classifyIdlePromptShellKind(prompt) === session._manualPromptKindAtSubmit
);
const inputUnchanged = expectedInputVersion === undefined
|| getSessionInputVersion(session) === expectedInputVersion;
if (
inputUnchanged
&& !hasReservedSessionInput(session)
&& (
session._inputSinceIdlePrompt !== true
|| session._submittedInputAwaitingPrompt === true
|| manualClearConfirmed
)
) {
session._inputSinceIdlePrompt = false;
session._submittedInputAwaitingPrompt = false;
delete session._manualPromptKindAtSubmit;
delete session._untrustedManualPrompt;
delete session._untrustedManualPromptKind;
delete session._manualInterruptCanConfirmPrompt;
delete session._manualClearCanConfirmPrompt;
if (
options.trustedCompletion === true
|| manualCompletionMatchesPrompt
|| manualClearConfirmed
) {
delete session._manualInputRequiresClear;
}
}
session._activeShellKindHint = classifyIdlePromptShellKind(prompt);
return prompt;
}
function hasCompletedPtyPromptMarker(output, marker) {
const token = `${marker}_E:`;
let searchFrom = 0;
while (searchFrom < output.length) {
const markerIndex = output.indexOf(token, searchFrom);
if (markerIndex === -1) return false;
// The real marker may immediately follow command output that deliberately
// omitted its final newline. Requiring the numeric status to terminate the
// line still rejects marker-shaped text embedded in an echoed wrapper.
if (/^\d+(?=\r|\n|$)/.test(output.slice(markerIndex + token.length))) return true;
searchFrom = markerIndex + token.length;
}
return false;
}
function registerSessionPtyPromptMarker(session, marker) {
if (!session || typeof marker !== "string" || !marker) return () => {};
if (!(session._activePtyPromptMarkers instanceof Map)) {
session._activePtyPromptMarkers = new Map();
}
const markers = session._activePtyPromptMarkers;
markers.set(marker, { ended: false });
return () => {
markers.delete(marker);
if (session._activePtyPromptMarkers === markers && markers.size === 0) {
delete session._activePtyPromptMarkers;
}
};
}
function markSessionPtyPromptMarkerEnded(session, marker) {
const state = session?._activePtyPromptMarkers?.get?.(marker);
if (state) state.ended = true;
}
function markSessionInputActivity(session) {
if (!session) return;
session._terminalInputVersion = getSessionInputVersion(session) + 1;
const markers = session._activePtyPromptMarkers;
if (!(markers instanceof Map)) return;
for (const [marker, state] of markers) {
if (state?.ended || hasCompletedPtyPromptMarker(session._promptTrackTail || "", marker)) {
state.inputAfterEnd = true;
}
}
}
function markSessionInputPending(session, data = "", options = {}) {
if (!session) return;
const inputWasKnownEmpty = isSessionInputLineKnownEmpty(session);
const input = String(data || "");
const isManualInterrupt = options.source === "manual" && input.endsWith("\x03");
const isManualLineClear = options.source === "manual"
&& (input === "\x15" || input === "\x1b");
if (options.source === "manual" && !isManualInterrupt) {
delete session._manualInterruptCanConfirmPrompt;
}
if (options.source === "manual" && !isManualLineClear) {
delete session._manualClearCanConfirmPrompt;
}
markSessionInputActivity(session);
session._inputSinceIdlePrompt = true;
// Enter/newline and Ctrl+C submit or cancel the editable line, so a later
// recognized prompt can confirm that the line is empty again. Other input
// may remain buffered even if the shell redraws its prompt (for example,
// input typed while a foreground process is returning control).
session._submittedInputAwaitingPrompt = /[\r\n\x03]$/.test(input);
if (isManualLineClear) {
// Escape (PSReadLine Windows mode) and Ctrl+U (readline/PSReadLine)
// become trustworthy only if the terminal redraws the recognized prompt
// with no input after it. Modes where the key leaves text on the line do
// not produce that trailing idle-prompt shape.
session._manualClearCanConfirmPrompt = true;
const trustedKind = session._activeShellKindHint;
session._manualPromptKindAtSubmit = ["posix", "fish", "powershell", "cmd"].includes(trustedKind)
? trustedKind
: "";
}
if (options.source === "manual" && session._submittedInputAwaitingPrompt) {
if (isManualInterrupt || (/^[\r\n]+$/.test(input) && inputWasKnownEmpty)) {
delete session._manualInputRequiresClear;
if (isManualInterrupt) session._manualInterruptCanConfirmPrompt = true;
} else {
// Until a recognized prompt matching the shell that accepted this line
// returns, do not let an agent write without a portable clear prefix.
// A different-shell prompt remains untrusted and requires explicit
// recovery; an ordinary same-shell command completion restores the
// normal idle state.
session._manualInputRequiresClear = true;
}
const trustedKind = session._activeShellKindHint;
session._manualPromptKindAtSubmit = ["posix", "fish", "powershell", "cmd"].includes(trustedKind)
? trustedKind
: "";
}
}
function hasReservedSessionInput(session) {
return Number(session?._reservedInputWriteCount || 0) > 0;
}
function reserveSessionInput(session) {
if (!session) return;
markSessionInputActivity(session);
session._reservedInputWriteCount = Number(session._reservedInputWriteCount || 0) + 1;
}
function releaseReservedSessionInput(session) {
if (!session) return;
session._reservedInputWriteCount = Math.max(
0,
Number(session._reservedInputWriteCount || 0) - 1,
);
}
// A cached prompt alone is not proof that the editable line stayed empty:
// input can be buffered locally, echoed late, or configured not to echo.
function isSessionInputLineKnownEmpty(session) {
return Boolean(
session
&& !hasReservedSessionInput(session)
&& session._manualInputRequiresClear !== true
&& session._inputSinceIdlePrompt === false
&& getFreshIdlePrompt(session),
);
}
function hasUnverifiedManualSessionInput(session) {
return session?._manualInputRequiresClear === true;
}
function getSessionActiveShellHint(session) {
// Manual/programmatic terminal input may have entered or exited a nested
// shell. Do not use the prior kind for wrapper selection until a recognized
// prompt confirms the new idle state. Callers can still inspect the last
// observed kind separately to reject ambiguous input without writing.
if (hasReservedSessionInput(session) || session?._inputSinceIdlePrompt !== false) return "";
return getSessionLastObservedShellKind(session);
}
function getSessionLastObservedShellKind(session) {
const untrustedPrompt = session?._untrustedManualPrompt;
const untrustedKind = session?._untrustedManualPromptKind;
if (
untrustedPrompt
&& ["posix", "powershell", "cmd"].includes(untrustedKind)
&& stripAnsi(String(session?._promptTrackTail || "")).replace(/\r/g, "\n")
.endsWith(stripAnsi(untrustedPrompt).replace(/\r/g, "\n"))
) {
return untrustedKind;
}
const hint = session?._activeShellKindHint;
return ["posix", "fish", "powershell", "cmd"].includes(hint) ? hint : "";
}
// Return `session.lastIdlePrompt` only if the PTY's recent rolling tail
// still ends with it. The cached prompt is updated only when
// extractTrailingIdlePrompt recognizes a known shape (PowerShell, cmd.exe,
@@ -966,10 +1211,22 @@ module.exports = {
formatSyntheticEcho,
extractTrailingIdlePrompt,
getFreshIdlePrompt,
getSessionActiveShellHint,
getSessionLastObservedShellKind,
hasReservedSessionInput,
hasUnverifiedManualSessionInput,
markSessionInputPending,
releaseReservedSessionInput,
reserveSessionInput,
isSessionInputLineKnownEmpty,
isDefaultPowerShellPromptLine,
isDefaultCmdPromptLine,
isDefaultPosixPromptLine,
trackSessionIdlePrompt,
confirmSessionIdlePrompt,
getSessionInputVersion,
registerSessionPtyPromptMarker,
markSessionPtyPromptMarkerEnded,
looksLikeIdleAutoLogout,
isLocalhostHostname,
extractFirstNonLocalhostUrl,
+203
View File
@@ -7,6 +7,11 @@ const {
extractTrailingIdlePrompt,
formatSyntheticEcho,
getFreshIdlePrompt,
getSessionActiveShellHint,
getSessionLastObservedShellKind,
hasReservedSessionInput,
hasUnverifiedManualSessionInput,
isSessionInputLineKnownEmpty,
isDefaultPowerShellPromptLine,
isDefaultCmdPromptLine,
isDefaultPosixPromptLine,
@@ -20,6 +25,10 @@ const {
parseRegQueryPath,
expandWindowsEnvRefs,
mergeWindowsPath,
markSessionInputPending,
registerSessionPtyPromptMarker,
releaseReservedSessionInput,
reserveSessionInput,
readWindowsRegistryPath,
trackSessionIdlePrompt,
} = require("./shellUtils.cjs");
@@ -609,6 +618,200 @@ test("tracks PowerShell idle prompt after SSH output", () => {
assert.equal(prompt, "PS C:\\Windows\\System32>");
assert.equal(session.lastIdlePrompt, "PS C:\\Windows\\System32>");
assert.equal(typeof session.lastIdlePromptAt, "number");
assert.equal(isSessionInputLineKnownEmpty(session), true);
assert.equal(getSessionActiveShellHint(session), "powershell");
});
test("a prompt redraw does not clear partial input typed while a process finishes", () => {
const session = {};
trackSessionIdlePrompt(session, "PS C:\\Users\\alice>");
assert.equal(isSessionInputLineKnownEmpty(session), true);
markSessionInputPending(session, "Write-Output 'USER'");
assert.equal(getFreshIdlePrompt(session), "PS C:\\Users\\alice>");
assert.equal(isSessionInputLineKnownEmpty(session), false);
assert.equal(getSessionActiveShellHint(session), "");
assert.equal(getSessionLastObservedShellKind(session), "powershell");
trackSessionIdlePrompt(session, "\r\nPS C:\\Users\\alice>");
assert.equal(getFreshIdlePrompt(session), "PS C:\\Users\\alice>");
assert.equal(isSessionInputLineKnownEmpty(session), false);
markSessionInputPending(session, "\r");
trackSessionIdlePrompt(session, "\r\nPS C:\\Users\\alice>");
assert.equal(isSessionInputLineKnownEmpty(session), true);
assert.equal(getSessionActiveShellHint(session), "powershell");
});
test("the active shell hint follows recognized nested-shell prompts", () => {
const session = {};
trackSessionIdlePrompt(session, "PS C:\\Users\\alice>");
assert.equal(getSessionActiveShellHint(session), "powershell");
trackSessionIdlePrompt(session, "\r\nC:\\Users\\alice>");
assert.equal(getSessionActiveShellHint(session), "cmd");
trackSessionIdlePrompt(session, "\r\nalice@wsl:/mnt/c$");
assert.equal(getSessionActiveShellHint(session), "posix");
});
test("manual command output cannot impersonate a different shell prompt", () => {
const session = {};
trackSessionIdlePrompt(session, "alice@host:~$");
markSessionInputPending(session, "printf 'PS C:\\fake>'\r", { source: "manual" });
assert.equal(trackSessionIdlePrompt(session, "\r\nPS C:\\fake>"), "");
assert.equal(getSessionActiveShellHint(session), "");
assert.equal(getSessionLastObservedShellKind(session), "powershell");
assert.equal(isSessionInputLineKnownEmpty(session), false);
trackSessionIdlePrompt(session, "\r\nalice@host:~$");
assert.equal(getSessionActiveShellHint(session), "posix");
assert.equal(isSessionInputLineKnownEmpty(session), false);
markSessionInputPending(session, "\x03", { source: "manual" });
trackSessionIdlePrompt(session, "\r\nalice@host:~$");
assert.equal(isSessionInputLineKnownEmpty(session), true);
});
test("an ordinary manual command restores a clean line at the same shell prompt", () => {
const session = {};
trackSessionIdlePrompt(session, "PS C:\\Users\\alice>");
markSessionInputPending(session, "Write-Output 'manual'\r", { source: "manual" });
trackSessionIdlePrompt(session, "\r\nmanual\r\nPS C:\\Users\\alice>");
assert.equal(getSessionActiveShellHint(session), "powershell");
assert.equal(getSessionLastObservedShellKind(session), "powershell");
assert.equal(isSessionInputLineKnownEmpty(session), true);
assert.equal(hasUnverifiedManualSessionInput(session), false);
});
test("manual line-clear keys restore a clean line only after an empty prompt redraw", () => {
for (const [initialPrompt, typed, clearKey] of [
["PS C:\\Users\\alice>", "Write-Output 'partial'", "\x1b"],
["PS C:\\Users\\alice>", "Write-Output 'partial'", "\x15"],
["alice@host:~$", "printf partial", "\x15"],
]) {
const session = {};
trackSessionIdlePrompt(session, initialPrompt);
markSessionInputPending(session, typed, { source: "manual" });
markSessionInputPending(session, clearKey, { source: "manual" });
assert.equal(isSessionInputLineKnownEmpty(session), false);
trackSessionIdlePrompt(session, `\r${initialPrompt}`);
assert.equal(isSessionInputLineKnownEmpty(session), true);
assert.equal(hasUnverifiedManualSessionInput(session), false);
}
});
test("a line-clear key cannot claim success while typed text remains after the prompt", () => {
const session = {};
trackSessionIdlePrompt(session, "PS C:\\Users\\alice>");
markSessionInputPending(session, "Write-Output 'partial'", { source: "manual" });
markSessionInputPending(session, "\x1b", { source: "manual" });
trackSessionIdlePrompt(session, "\rPS C:\\Users\\alice>Write-Output 'partial'");
assert.equal(isSessionInputLineKnownEmpty(session), false);
assert.equal(hasUnverifiedManualSessionInput(session), false);
});
test("manual Ctrl+C confirms a legitimate nested-shell transition", () => {
const session = {};
trackSessionIdlePrompt(session, "alice@host:~$");
markSessionInputPending(session, "pwsh\r", { source: "manual" });
assert.equal(trackSessionIdlePrompt(session, "\r\nPS C:\\Users\\alice>"), "");
assert.equal(getSessionActiveShellHint(session), "");
assert.equal(hasUnverifiedManualSessionInput(session), true);
markSessionInputPending(session, "\x03", { source: "manual" });
assert.equal(
trackSessionIdlePrompt(session, "\r\nPS C:\\Users\\alice>"),
"PS C:\\Users\\alice>",
);
assert.equal(getSessionActiveShellHint(session), "powershell");
assert.equal(hasUnverifiedManualSessionInput(session), false);
assert.equal(isSessionInputLineKnownEmpty(session), true);
});
test("a marker-backed command restores trust after manual prompt ambiguity", () => {
const session = {};
trackSessionIdlePrompt(session, "alice@host:~$");
markSessionInputPending(session, "printf 'PS C:\\fake>'\r", { source: "manual" });
trackSessionIdlePrompt(session, "\r\nPS C:\\fake>");
assert.equal(isSessionInputLineKnownEmpty(session), false);
const marker = "__NCMCP_trusted_completion__";
const unregister = registerSessionPtyPromptMarker(session, marker);
markSessionInputPending(session, "agent-wrapper\r", { source: "automated" });
trackSessionIdlePrompt(session, `\r\n${marker}_E:0\r\nalice@host:~$`);
assert.equal(isSessionInputLineKnownEmpty(session), true);
unregister();
});
test("submitting again does not trust an unconfirmed shell prompt", () => {
const session = {};
trackSessionIdlePrompt(session, "alice@host:~$");
markSessionInputPending(session, "printf 'PS C:\\fake>'\r", { source: "manual" });
assert.equal(trackSessionIdlePrompt(session, "\r\nPS C:\\fake>"), "");
assert.equal(getSessionLastObservedShellKind(session), "powershell");
markSessionInputPending(session, "\r", { source: "manual" });
assert.equal(trackSessionIdlePrompt(session, "\r\nPS C:\\fake>"), "");
assert.equal(getSessionActiveShellHint(session), "");
assert.equal(isSessionInputLineKnownEmpty(session), false);
});
test("a fake default prompt expires when the real custom prompt arrives", () => {
const session = {};
markSessionInputPending(session, "printf 'PS C:\\fake>'\r", { source: "manual" });
assert.equal(trackSessionIdlePrompt(session, "PS C:\\fake>"), "");
assert.equal(getSessionLastObservedShellKind(session), "powershell");
trackSessionIdlePrompt(session, "custom-shell ❯ ");
assert.equal(getSessionActiveShellHint(session), "");
assert.equal(getSessionLastObservedShellKind(session), "");
assert.equal(session.lastIdlePrompt, undefined);
});
test("automated startup input can establish a real shell transition", () => {
const session = {};
trackSessionIdlePrompt(session, "C:\\Users\\alice>");
markSessionInputPending(session, "pwsh\r", { source: "automated" });
trackSessionIdlePrompt(session, "\r\nPS C:\\Users\\alice>");
assert.equal(getSessionActiveShellHint(session), "powershell");
assert.equal(isSessionInputLineKnownEmpty(session), true);
});
test("a submitted line followed by partial pasted input stays pending", () => {
const session = {};
trackSessionIdlePrompt(session, "PS C:\\Users\\alice>");
markSessionInputPending(
session,
"Write-Output 'ONE'\rWrite-Output 'USER'",
);
trackSessionIdlePrompt(session, "\r\nONE\r\nPS C:\\Users\\alice>");
assert.equal(getFreshIdlePrompt(session), "PS C:\\Users\\alice>");
assert.equal(isSessionInputLineKnownEmpty(session), false);
assert.equal(getSessionLastObservedShellKind(session), "powershell");
});
test("a prompt cannot release input reserved by an asynchronous interceptor", () => {
const session = {};
trackSessionIdlePrompt(session, "PS C:\\Users\\alice>");
reserveSessionInput(session);
assert.equal(hasReservedSessionInput(session), true);
trackSessionIdlePrompt(session, "\r\nPS C:\\Users\\alice>");
assert.equal(isSessionInputLineKnownEmpty(session), false);
assert.equal(getSessionActiveShellHint(session), "");
markSessionInputPending(session, "Write-Output 'USER'\r");
releaseReservedSessionInput(session);
assert.equal(hasReservedSessionInput(session), false);
trackSessionIdlePrompt(session, "\r\nPS C:\\Users\\alice>");
assert.equal(isSessionInputLineKnownEmpty(session), true);
});
test("getFreshIdlePrompt returns the cached prompt when the live tail still ends with it", () => {
@@ -2,7 +2,13 @@
// 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 {
formatSyntheticEcho,
getSessionActiveShellHint,
getSessionLastObservedShellKind,
hasUnverifiedManualSessionInput,
isSessionInputLineKnownEmpty,
} = require("../ai/shellUtils.cjs");
const { ensureSessionShellKindForExec } = require("../ai/sessionShellKind.cjs");
function getWorkerExecutionMeta(mcpServerBridge, sessionId, chatSessionId) {
@@ -174,8 +180,13 @@ function registerCattyExecHandlers(ctx) {
timeoutMs,
shellKind: session.shellKind,
loginShellHint: session._loginShellKind,
activeShellHint: getSessionActiveShellHint(session),
observedShellHint: getSessionLastObservedShellKind(session),
chatSessionId,
expectedPrompt: getFreshIdlePrompt(session),
inputLineKnownEmpty: isSessionInputLineKnownEmpty(session),
inputLineRequiresManualClear: hasUnverifiedManualSessionInput(session),
promptTrackingSession: session,
typedInput: true,
echoCommand: (rawCommand) => {
const contents = electronModule?.webContents?.fromId?.(session.webContentsId);
@@ -5,6 +5,12 @@ const {
ensureSessionShellKind,
ensureSessionShellKindForExec,
} = require("../ai/sessionShellKind.cjs");
const {
getSessionActiveShellHint,
getSessionLastObservedShellKind,
hasUnverifiedManualSessionInput,
isSessionInputLineKnownEmpty,
} = require("../ai/shellUtils.cjs");
function createExecHandlerApi(ctx) {
with (ctx) {
@@ -149,7 +155,12 @@ function createExecHandlerApi(ctx) {
timeoutMs: commandTimeoutMs,
shellKind: session.shellKind,
loginShellHint: session._loginShellKind,
activeShellHint: getSessionActiveShellHint(session),
observedShellHint: getSessionLastObservedShellKind(session),
expectedPrompt: getFreshIdlePrompt(session),
inputLineKnownEmpty: isSessionInputLineKnownEmpty(session),
inputLineRequiresManualClear: hasUnverifiedManualSessionInput(session),
promptTrackingSession: session,
typedInput: true,
echoCommand: (rawCommand) => echoCommandToSession(session, sessionId, rawCommand),
chatSessionId,
@@ -293,8 +304,13 @@ function createExecHandlerApi(ctx) {
timeoutMs,
shellKind: session.shellKind,
loginShellHint: session._loginShellKind,
activeShellHint: getSessionActiveShellHint(session),
observedShellHint: getSessionLastObservedShellKind(session),
chatSessionId,
expectedPrompt: getFreshIdlePrompt(session),
inputLineKnownEmpty: isSessionInputLineKnownEmpty(session),
inputLineRequiresManualClear: hasUnverifiedManualSessionInput(session),
promptTrackingSession: session,
typedInput: true,
echoCommand: (rawCommand) => echoCommandToSession(session, sessionId, rawCommand),
maxBufferedChars: MAX_BACKGROUND_JOB_OUTPUT_CHARS,
+56 -6
View File
@@ -56,7 +56,13 @@ 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,
markSessionInputPending,
releaseReservedSessionInput,
reserveSessionInput,
} = require("./ai/shellUtils.cjs");
const { createZmodemSentry } = require("./zmodemHelper.cjs");
const { discoverShells } = require("./shellDiscovery.cjs");
const moshHandshake = require("./moshHandshake.cjs");
@@ -1477,7 +1483,27 @@ function shouldBlockSessionInput(session, data) {
return false;
}
function writeToSessionNow(payload, data, logRewrite = payload.logRewrite) {
function hasTerminalInputContent(data) {
if (typeof data === "string") return data.length > 0;
if (Buffer.isBuffer(data) || ArrayBuffer.isView(data)) return data.byteLength > 0;
if (data instanceof ArrayBuffer) return data.byteLength > 0;
return data !== null && data !== undefined;
}
function getSessionInputTrackingData(payload, data) {
if (!payload || !Object.prototype.hasOwnProperty.call(payload, "logicalData")) {
return data;
}
if (payload.logicalData === null) return null;
return typeof payload.logicalData === "string" ? payload.logicalData : data;
}
function writeToSessionNow(
payload,
data,
logRewrite = payload.logRewrite,
releaseInputReservation = false,
) {
const session = sessions.get(payload.sessionId);
const trace = payload.interruptTrace || null;
if (!session) {
@@ -1487,7 +1513,12 @@ function writeToSessionNow(payload, data, logRewrite = payload.logRewrite) {
}, trace);
return;
}
if (!hasTerminalInputContent(data)) {
if (releaseInputReservation) releaseReservedSessionInput(session);
return;
}
if (shouldBlockSessionInput(session, data)) {
if (releaseInputReservation) releaseReservedSessionInput(session);
logTerminalInterruptDebug("write-session-blocked-by-transfer", {
sessionId: payload.sessionId,
dataCode: data === "\x03" ? "ETX" : undefined,
@@ -1495,7 +1526,15 @@ function writeToSessionNow(payload, data, logRewrite = payload.logRewrite) {
}, trace);
return;
}
if (data !== "\x03" && !payload.automated && !isTerminalReportSequence(data)) {
const isReport = isTerminalReportSequence(data);
const inputTrackingData = isReport ? null : getSessionInputTrackingData(payload, data);
if (hasTerminalInputContent(inputTrackingData)) {
markSessionInputPending(session, inputTrackingData, {
source: payload.automated ? "automated" : "manual",
});
}
if (releaseInputReservation) releaseReservedSessionInput(session);
if (inputTrackingData !== "\x03" && !payload.automated && inputTrackingData !== null) {
disarmTerminalInterruptOutputGate(session);
}
@@ -1584,7 +1623,8 @@ function writeToSessionWithInterception(
logRewrite = payload.logRewrite,
expectedSession = sessions.get(payload.sessionId),
) {
const bypass = payload?.sensitive === true || isTerminalReportSequence(data);
const isReport = isTerminalReportSequence(data);
const bypass = payload?.sensitive === true || isReport;
const hasInterceptor = Boolean(
terminalDataPipeline?.interceptInput
&& terminalDataPipeline.has?.(payload.sessionId, "input"),
@@ -1594,10 +1634,16 @@ function writeToSessionWithInterception(
writeToSessionNow(payload, data, logRewrite);
return;
}
// Reserve accepted input before awaiting an asynchronous interceptor. Keep
// the reservation separate from submitted-line tracking: an older queued
// write must not clear the safety state for newer input that is still waiting.
if (!isReport && payload?.logicalData !== null) {
reserveSessionInput(expectedSession);
}
const writeIfCurrent = (nextData) => {
const current = sessions.get(payload.sessionId);
if (!current || current !== expectedSession || current.closed) return;
writeToSessionNow(payload, nextData, logRewrite);
writeToSessionNow(payload, nextData, logRewrite, !isReport && payload?.logicalData !== null);
};
const write = async () => {
if (!hasInterceptor) {
@@ -1633,7 +1679,11 @@ function writeToSession(event, payload) {
// Activity tracking must not interfere with terminal input.
}
if (!payload.automated && !isTerminalReportSequence(payload.data)) {
if (
!payload.automated
&& payload.logicalData !== null
&& !isTerminalReportSequence(payload.data)
) {
clearPendingAutomatedWrites(session);
}
if (shouldBlockSessionInput(session, payload.data)) {
@@ -89,6 +89,7 @@ test("Telnet input is encoded with the session's GB18030 charset", async () => {
assert.deepEqual([...received], [...iconv.encode("你好\r\n", "gb18030")]);
// It must NOT be the UTF-8 serialization that the old code always sent.
assert.notDeepEqual([...received], [...Buffer.from("你好\r", "utf8")]);
assert.equal(sessions.get("telnet-gb18030-input")._inputSinceIdlePrompt, true);
} finally {
terminalBridge.cleanupAllSessions();
for (const socket of sockets) socket.destroy();
@@ -4,14 +4,21 @@ const assert = require("node:assert/strict");
const test = require("node:test");
const terminalBridge = require("./terminalBridge.cjs");
const {
getSessionLastObservedShellKind,
isSessionInputLineKnownEmpty,
trackSessionIdlePrompt,
} = require("./ai/shellUtils.cjs");
function createHarness() {
const writes = [];
const intercepted = [];
const sessions = new Map([["session-1", {
const session = {
type: "local",
proc: { write(data) { writes.push(String(data)); } },
}]]);
_inputSinceIdlePrompt: false,
};
const sessions = new Map([["session-1", session]]);
terminalBridge.init({
sessions,
electronModule: {},
@@ -24,17 +31,82 @@ function createHarness() {
},
},
});
return { writes, intercepted };
return { writes, intercepted, session };
}
test("ordinary terminal input uses the worker-owned interceptor before transport encoding", async () => {
const h = createHarness();
trackSessionIdlePrompt(h.session, "PS C:\\Users\\alice>");
terminalBridge.writeToSession(null, { sessionId: "session-1", data: "hello" });
assert.equal(isSessionInputLineKnownEmpty(h.session), false);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(h.intercepted.map((entry) => entry.data), ["hello"]);
assert.deepEqual(h.writes, ["HELLO"]);
});
test("interceptor-consumed input releases its reservation without becoming pending", async () => {
const writes = [];
const session = {
type: "local",
proc: { write(data) { writes.push(data); } },
};
terminalBridge.init({
sessions: new Map([["session-consumed", session]]),
electronModule: {},
terminalDataPipeline: {
has() { return true; },
async interceptInput() { return new Uint8Array(); },
},
});
trackSessionIdlePrompt(session, "PS C:\\Users\\alice>");
terminalBridge.writeToSession(null, { sessionId: "session-consumed", data: "x" });
assert.equal(isSessionInputLineKnownEmpty(session), false);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(writes, []);
assert.equal(isSessionInputLineKnownEmpty(session), true);
});
test("queued intercepted input stays reserved until every write is delivered", async () => {
const writes = [];
const releases = new Map();
const session = {
type: "local",
proc: { write(data) { writes.push(String(data)); } },
};
terminalBridge.init({
sessions: new Map([["session-queued", session]]),
electronModule: {},
terminalDataPipeline: {
has() { return true; },
interceptInput(_sessionId, data) {
return new Promise((resolve) => { releases.set(data, resolve); });
},
},
});
trackSessionIdlePrompt(session, "PS C:\\Users\\alice>");
terminalBridge.writeToSession(null, { sessionId: "session-queued", data: "ONE\r" });
terminalBridge.writeToSession(null, { sessionId: "session-queued", data: "USER" });
await new Promise((resolve) => setImmediate(resolve));
trackSessionIdlePrompt(session, "\r\nPS C:\\Users\\alice>");
assert.equal(isSessionInputLineKnownEmpty(session), false);
releases.get("ONE\r")("ONE\r");
await new Promise((resolve) => setImmediate(resolve));
trackSessionIdlePrompt(session, "\r\nPS C:\\Users\\alice>");
assert.equal(isSessionInputLineKnownEmpty(session), false);
assert.deepEqual(writes, ["ONE\r"]);
await new Promise((resolve) => setImmediate(resolve));
releases.get("USER")("USER");
await new Promise((resolve) => setImmediate(resolve));
trackSessionIdlePrompt(session, "\r\nPS C:\\Users\\alice>");
assert.equal(isSessionInputLineKnownEmpty(session), false);
assert.deepEqual(writes, ["ONE\r", "USER"]);
});
test("host-classified sensitive input bypasses interceptors and preserves original bytes", async () => {
const h = createHarness();
terminalBridge.writeToSession(null, {
@@ -54,6 +126,10 @@ test("host terminal protocol replies bypass third-party interceptors", async ()
"\x1b[?2004;1$y",
"\x1b[8;24;80t",
"\x1b]10;rgb:ffff/ffff/ffff\x1b\\",
"\x1b]52;c;c2VjcmV0Cg==\x07",
"\x1b[M *%",
"\x1b[<0;10;5M",
"\x1b[<0;10;5m",
];
for (const data of reports) {
terminalBridge.writeToSession(null, { sessionId: "session-1", data });
@@ -61,6 +137,146 @@ test("host terminal protocol replies bypass third-party interceptors", async ()
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(h.intercepted, []);
assert.deepEqual(h.writes, reports);
assert.equal(h.session._inputSinceIdlePrompt, false);
});
test("transport keyboard records use logical Enter semantics for prompt tracking", () => {
const writes = [];
const session = {
type: "local",
proc: { write(data) { writes.push(String(data)); } },
};
terminalBridge.init({
sessions: new Map([["session-keyboard", session]]),
electronModule: {},
});
trackSessionIdlePrompt(session, "PS C:\\Users\\alice>");
const win32Enter = "\x1b[13;28;13;1;0;1_";
terminalBridge.writeToSession(null, {
sessionId: "session-keyboard",
data: win32Enter,
logicalData: "\r",
});
assert.equal(isSessionInputLineKnownEmpty(session), false);
trackSessionIdlePrompt(session, "\r\nPS C:\\Users\\alice>");
assert.equal(isSessionInputLineKnownEmpty(session), true);
const win32KeyUp = "\x1b[13;28;13;0;0;1_";
terminalBridge.writeToSession(null, {
sessionId: "session-keyboard",
data: win32KeyUp,
logicalData: null,
});
assert.equal(isSessionInputLineKnownEmpty(session), true);
assert.deepEqual(writes, [win32Enter, win32KeyUp]);
});
test("modified Enter stays pending when its editing effect is unknown", async () => {
const writes = [];
let releaseInput;
const session = {
type: "local",
proc: { write(data) { writes.push(String(data)); } },
};
terminalBridge.init({
sessions: new Map([["session-modified-enter", session]]),
electronModule: {},
terminalDataPipeline: {
has() { return true; },
interceptInput(_sessionId, data) {
return new Promise((resolve) => { releaseInput = () => resolve(data); });
},
},
});
trackSessionIdlePrompt(session, "PS C:\\Users\\alice>");
const modifiedEnter = "\x1b[13;28;13;1;16;1_";
terminalBridge.writeToSession(null, {
sessionId: "session-modified-enter",
data: modifiedEnter,
logicalData: undefined,
});
await new Promise((resolve) => setImmediate(resolve));
trackSessionIdlePrompt(session, "\r\nPS C:\\Users\\alice>");
assert.equal(isSessionInputLineKnownEmpty(session), false);
releaseInput();
await new Promise((resolve) => setImmediate(resolve));
trackSessionIdlePrompt(session, "\r\nPS C:\\Users\\alice>");
assert.equal(isSessionInputLineKnownEmpty(session), false);
assert.deepEqual(writes, [modifiedEnter]);
terminalBridge.writeToSession(null, {
sessionId: "session-modified-enter",
data: "\x03",
logicalData: "\x03",
});
await new Promise((resolve) => setImmediate(resolve));
releaseInput();
await new Promise((resolve) => setImmediate(resolve));
trackSessionIdlePrompt(session, "\r\nPS C:\\Users\\alice>");
assert.equal(isSessionInputLineKnownEmpty(session), true);
});
test("a manual line-clear key restores clean input after the prompt redraw", () => {
const writes = [];
const session = {
type: "local",
proc: { write(data) { writes.push(String(data)); } },
};
terminalBridge.init({
sessions: new Map([["session-line-clear", session]]),
electronModule: {},
});
trackSessionIdlePrompt(session, "PS C:\\Users\\alice>");
terminalBridge.writeToSession(null, {
sessionId: "session-line-clear",
data: "Write-Output 'partial'",
});
terminalBridge.writeToSession(null, {
sessionId: "session-line-clear",
data: "\x1b",
});
assert.equal(isSessionInputLineKnownEmpty(session), false);
trackSessionIdlePrompt(session, "\rPS C:\\Users\\alice>");
assert.equal(isSessionInputLineKnownEmpty(session), true);
assert.deepEqual(writes, ["Write-Output 'partial'", "\x1b"]);
});
test("manual command output cannot promote a different shell prompt", () => {
const writes = [];
const session = {
type: "local",
proc: { write(data) { writes.push(String(data)); } },
};
terminalBridge.init({
sessions: new Map([["session-manual", session]]),
electronModule: {},
});
trackSessionIdlePrompt(session, "alice@host:~$");
terminalBridge.writeToSession(null, {
sessionId: "session-manual",
data: "printf 'PS C:\\fake>'\r",
});
assert.equal(trackSessionIdlePrompt(session, "\r\nPS C:\\fake>"), "");
assert.equal(getSessionLastObservedShellKind(session), "powershell");
assert.equal(isSessionInputLineKnownEmpty(session), false);
trackSessionIdlePrompt(session, "\r\nalice@host:~$");
assert.equal(getSessionLastObservedShellKind(session), "posix");
assert.equal(isSessionInputLineKnownEmpty(session), false);
terminalBridge.writeToSession(null, {
sessionId: "session-manual",
data: "\x03",
});
trackSessionIdlePrompt(session, "\r\nalice@host:~$");
assert.equal(isSessionInputLineKnownEmpty(session), true);
assert.deepEqual(writes, ["printf 'PS C:\\fake>'\r", "\x03"]);
});
test("input remains ordered when an interceptor is disabled during an in-flight transform", async () => {
+10 -3
View File
@@ -1,8 +1,9 @@
"use strict";
// Terminal-originated automatic replies travel through xterm's public onData
// event alongside user input. Keep the current xterm report families on the
// host-owned bypass path so plugins cannot rewrite terminal negotiation.
// Terminal-originated protocol data travels through xterm's public onData
// event alongside shell-line input. Keep the current xterm report and mouse
// families on the host-owned bypass path so plugins cannot rewrite them and
// shell input tracking does not mistake them for editable text.
function isTerminalReportSequence(data) {
if (typeof data !== "string" || data.length === 0) return false;
// Focus in/out reports: ESC [ I / ESC [ O
@@ -15,8 +16,14 @@ function isTerminalReportSequence(data) {
if (/^\x1b\[\??[0-9]+;[0-4]\$y$/.test(data)) return true;
// Xterm window/cell/character-size reports: ESC [ (4|6|8) ; height ; width t
if (/^\x1b\[(?:4|6|8);[0-9]+;[0-9]+t$/.test(data)) return true;
// Mouse tracking reports: classic X10, SGR (1006), and urxvt (1015).
if (/^\x1b\[M[\s\S]{3}$/.test(data)) return true;
if (/^\x1b\[<[0-9]+;[0-9]+;[0-9]+[Mm]$/.test(data)) return true;
if (/^\x1b\[[0-9]+;[0-9]+;[0-9]+M$/.test(data)) return true;
// Xterm dynamic-color query replies: OSC (4;index|10|11|12) ; rgb:... (BEL|ST)
if (/^\x1b\](?:4;[0-9]{1,3}|1[012]);rgb:[0-9a-f]{1,4}\/[0-9a-f]{1,4}\/[0-9a-f]{1,4}(?:\x07|\x1b\\)$/i.test(data)) return true;
// OSC 52 clipboard read replies: OSC 52 ; selection ; base64 (BEL|ST)
if (/^\x1b\]52;[cpsq0-7]*;(?:[a-z0-9+/]*={0,2}|\?)(?:\x07|\x1b\\)$/i.test(data)) return true;
// DCS replies (XTGETTCAP / DECRQSS, etc.): ESC P ... ESC \
if (/^\x1bP[\s\S]*\x1b\\$/.test(data)) return true;
return false;
@@ -15,15 +15,21 @@ test("recognizes terminal-generated report families without classifying ordinary
"\x1b[?2004;1$y",
"\x1b[8;24;80t",
"\x1b[4;1080;1920t",
"\x1b[M *%",
"\x1b[<0;10;5M",
"\x1b[<0;10;5m",
"\x1b[32;10;5M",
"\x1b]10;rgb:ffff/ffff/ffff\x1b\\",
"\x1b]4;255;rgb:ffff/0000/ffff\x1b\\",
"\x1b]11;rgb:0000/0000/0000\x07",
"\x1b]4;7;rgb:1111/2222/3333\x07",
"\x1b]52;c;c2VjcmV0Cg==\x07",
"\x1b]52;c;\x1b\\",
"\x1bP1$r0m\x1b\\",
]) {
assert.equal(isTerminalReportSequence(report), true, JSON.stringify(report));
}
for (const input of ["hello", "\r", "\x1b[A", "\x1b[200~paste\x1b[201~"]) {
for (const input of ["hello", "\r", "\x1b[A", "\x1b[M", "\x1b[200~paste\x1b[201~"]) {
assert.equal(isTerminalReportSequence(input), false, JSON.stringify(input));
}
});
+5
View File
@@ -346,6 +346,11 @@ function createPreloadApi(ctx) {
data,
automated: Boolean(options?.automated),
sensitive: options?.sensitive === true,
logicalData: options?.logicalData === null
? null
: typeof options?.logicalData === "string"
? options.logicalData
: undefined,
lineDelayMs: Number.isFinite(lineDelayMs) && lineDelayMs > 0 ? lineDelayMs : undefined,
logRewrite: options?.logRewrite && typeof options.logRewrite === "object"
? {
+18 -1
View File
@@ -7,7 +7,14 @@ const {
execViaChannel,
execViaRawPty,
} = require("../bridges/ai/ptyExec.cjs");
const { getFreshIdlePrompt, formatSyntheticEcho } = require("../bridges/ai/shellUtils.cjs");
const {
getFreshIdlePrompt,
getSessionActiveShellHint,
getSessionLastObservedShellKind,
formatSyntheticEcho,
hasUnverifiedManualSessionInput,
isSessionInputLineKnownEmpty,
} = require("../bridges/ai/shellUtils.cjs");
const {
ensureSessionShellKind,
ensureSessionShellKindForExec,
@@ -277,8 +284,13 @@ function createWorkerAiExecHandler({
timeoutMs,
shellKind: session.shellKind,
loginShellHint: session._loginShellKind,
activeShellHint: getSessionActiveShellHint(session),
observedShellHint: getSessionLastObservedShellKind(session),
chatSessionId,
expectedPrompt: getFreshIdlePrompt(session),
inputLineKnownEmpty: isSessionInputLineKnownEmpty(session),
inputLineRequiresManualClear: hasUnverifiedManualSessionInput(session),
promptTrackingSession: session,
typedInput: true,
echoCommand: (rawCommand) => {
event?.sender?.send?.("netcatty:data", {
@@ -444,8 +456,13 @@ function createWorkerAiJobStartHandler({
timeoutMs,
shellKind: session.shellKind,
loginShellHint: session._loginShellKind,
activeShellHint: getSessionActiveShellHint(session),
observedShellHint: getSessionLastObservedShellKind(session),
chatSessionId,
expectedPrompt: getFreshIdlePrompt(session),
inputLineKnownEmpty: isSessionInputLineKnownEmpty(session),
inputLineRequiresManualClear: hasUnverifiedManualSessionInput(session),
promptTrackingSession: session,
typedInput: true,
echoCommand: (rawCommand) => {
event?.sender?.send?.("netcatty:data", {
+2
View File
@@ -329,6 +329,8 @@ declare global {
automated?: boolean;
/** Host-classified secret/no-echo input; always bypasses plugin observers and interceptors. */
sensitive?: boolean;
/** User-facing input represented by a transport-specific keyboard record. */
logicalData?: string | null;
lineDelayMs?: number;
logRewrite?: { sentCommand: string; displayCommand: string };
},