mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-07 16:41:30 +00:00
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd0d8a9e18 | ||
|
|
394d7e9df7 | ||
|
|
6f9d46b079 | ||
|
|
47b4f4227e | ||
|
|
7c608bd626 | ||
|
|
7355dad458 | ||
|
|
03d9ff8543 | ||
|
|
597f60b11c | ||
|
|
4040149821 | ||
|
|
8f74b156d0 | ||
|
|
10bbd70dd5 | ||
|
|
b6fd7b7255 | ||
|
|
cec8b49a43 | ||
|
|
1605861cb4 | ||
|
|
b59cafea88 | ||
|
|
2ce77e12aa | ||
|
|
27a68fd36b | ||
|
|
45ebd70deb | ||
|
|
4c7643f3b2 | ||
|
|
ac0c1dd0f1 | ||
|
|
0ee573f2f7 | ||
|
|
42e6d67cdf | ||
|
|
3523b87a80 | ||
|
|
4d39c7573e | ||
|
|
aa0f8d3c79 | ||
|
|
1f65c5e1a6 |
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
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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 () => {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
@@ -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"
|
||||
? {
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user