Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 c4eb08c64e fix(terminal): keep colon shell prompts in command history
Program-input classification now matches only identity labels such as
Email and Username. A custom PS1 like dev: stays a shell prompt, so
submitted commands are still recorded.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 05:17:33 +00:00
Cursor Agentand陈大猫 5eb46538c4 fix(terminal): restore true snapshot latches as explicit
Attach and hibernate snapshots only carry the sensitive-latch boolean.
A restored true latch now stays explicit so Email/Username cannot bypass
a plugin-marked field.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 05:17:33 +00:00
Cursor Agentand陈大猫 84e4cd25c3 fix(terminal): keep explicit sensitive prompts closed to snippets
pluginPipelineSensitiveInput shares the password-prompt latch with the
heuristic untrusted-prompt detector. Trusted labels such as Email: may
still bypass a heuristic latch, but an explicit latch stays closed.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 04:50:21 +00:00
Cursor Agentand陈大猫 af55db31f4 fix(terminal): erase snippet words with DEL on POSIX prompts
Canonical read inserts Ctrl-H when the tty erase character is DEL.
Send 0x7f per code point on non-Windows hosts so both readline and
read drop the matched word. Windows line editing still uses backspace.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 04:29:30 +00:00
Cursor Agentand陈大猫 43da1f9f7e test(ssh): keep the Cygwin stall probe from racing descriptor IO
The negotiation timeout is 20ms. A slow descriptor read expired it
before any socket existed, and the close assertion then reported that
the connection stayed open.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 03:44:16 +00:00
Cursor Agentand陈大猫 853265b31a fix(terminal): clear the full line when accepting a script snippet
Mid-line text snippets still delete only the current word. A script
snippet runs automation and sends no replacement text, so accepting
one after another token keeps the Ctrl+U / Windows backspace line clear.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 03:44:16 +00:00
Cursor Agentand陈大猫 a190a6c81e fix(terminal): keep the sensitive latch for unknown input labels
Only plain identity prompts such as Email: or 用户名: may bypass the
untrusted-input latch. Unknown labels and password, token, key, PIN,
or OTP prompts stay closed.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 03:43:11 +00:00
Cursor Agentand陈大猫 9a86f1e414 fix(terminal): count code points when deleting a snippet word
A non-BMP character is two UTF-16 units, so the clear sequence sent an extra
backspace and erased the preceding space. Count code points instead.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 03:17:36 +00:00
Cursor Agentand陈大猫 5a8e2a6ff7 fix(terminal): allow snippets at safe program prompts while latched
Untrusted colon prompts such as Email: set the sensitive-input latch, which
closed autocomplete before snippet filtering. Keep that latch for real
secrets, and let ordinary program prompts through.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 03:17:36 +00:00
Cursor Agentand陈大猫 914c608cbd fix(terminal): insert snippets mid-line and at program prompts
Accepting a snippet after a command like `git pull use` cleared the whole
line. Program prompts such as `Email: ` never offered snippet candidates.
Replace only the typed word in those cases, and keep shell line-start
clearing unchanged.

Fixes #3622

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 02:46:01 +00:00
11 changed files with 576 additions and 17 deletions

No files matched your search

+10 -1
View File
@@ -165,6 +165,7 @@ import { registerTerminalSensitiveInputReader } from "./terminal/runtime/termina
import { registerTerminalCommandInjectionReadyReader } from "./terminal/runtime/terminalCommandInjectionReadyRegistry";
import { isIdleShellReadyForCommandInjection } from "../domain/terminalCommandInjectionReady";
import { detectPrompt } from "./terminal/autocomplete/promptDetector";
import type { SensitiveInputActiveRef } from "./terminal/autocomplete/terminalAutocompletePrompt";
import {
applyUserCursorPreference,
applyUserCursorWidthPreference,
@@ -439,7 +440,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const recorder = useScriptRecorder(sessionId);
const recorderRef = useRef(recorder);
recorderRef.current = recorder;
const passwordPromptActiveRef = useRef(false);
const passwordPromptActiveRef = useRef(false) as SensitiveInputActiveRef;
const terminalTitleRef = useRef<string | undefined>(undefined);
const pluginTerminalLifecycleRef = useRef<ReturnType<typeof usePluginTerminalSessionLifecycle> | null>(null);
useEffect(() => registerTerminalSensitiveInputReader(
@@ -1695,6 +1696,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
if (typeof payload.passwordPromptActive === "boolean") {
passwordPromptActiveRef.current = payload.passwordPromptActive;
// The snapshot boolean has no source. A restored true latch stays
// explicit so Email:/Username: cannot bypass a plugin-marked field.
if (payload.passwordPromptActive) passwordPromptActiveRef.source = "explicit";
}
if (payload.cwd !== undefined) {
const cwd = terminalCwdTracker.setRendererCwd(payload.cwd, "snapshot");
@@ -2013,6 +2017,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
};
if (typeof meta?.pluginPipelineSensitiveInput === "boolean") {
passwordPromptActiveRef.current = meta.pluginPipelineSensitiveInput;
// Authoritative metadata must not be downgraded to the heuristic latch,
// or trusted labels such as Email: would bypass snippet blocking.
passwordPromptActiveRef.source = meta.pluginPipelineSensitiveInput ? "explicit" : "heuristic";
if (meta.pluginPipelineSensitiveInput) {
autocompleteCloseRef.current?.();
} else {
@@ -2024,12 +2031,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
promptSecurityOptions,
)) {
passwordPromptActiveRef.current = true;
passwordPromptActiveRef.source = "heuristic";
autocompleteCloseRef.current?.();
} else if (isConfirmedTerminalShellPrompt(
sensitivePromptOutputTailRef.current,
promptSecurityOptions,
)) {
passwordPromptActiveRef.current = false;
passwordPromptActiveRef.source = "heuristic";
}
}, [isNetworkDevice]);
+61 -1
View File
@@ -1,7 +1,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import { detectPrompt, getAlignedPrompt } from "./autocomplete/promptDetector.ts";
import { detectPrompt, getAlignedPrompt, isProgramInputPromptText } from "./autocomplete/promptDetector.ts";
import { resolveAutocompleteQueryInput } from "./autocomplete/terminalAutocompletePrompt.ts";
import { getSnippetSuggestions } from "./autocomplete/snippetCompleter.ts";
import { stringCellWidth } from "./autocomplete/terminalStringCellWidth.ts";
@@ -817,3 +817,63 @@ test("CMD path prompts with CJK directories do not absorb padding into echoed Ch
"zh",
);
});
test("program input prompts expose snippet text and stay out of shell history (#3622)", () => {
const line = "Email: use";
const term = createFakeTerm(line, line.length);
const snippet = { id: "mail", label: "邮箱", command: "user@example.com", noAutoRun: true };
assert.equal(detectPrompt(term as never).isAtPrompt, false);
const echoed = getAlignedPrompt(term as never, "use", true);
assert.equal(echoed.prompt.isAtPrompt, true);
assert.equal(echoed.prompt.promptText, "Email: ");
assert.equal(echoed.prompt.userInput, "use");
assert.equal(echoed.alignedTyped, null);
assert.equal(resolveAutocompleteQueryInput(echoed.prompt, "use", true), "use");
assert.equal(
getSnippetSuggestions(echoed.prompt.userInput, [snippet as never])[0]?.snippet?.command,
"user@example.com",
);
assert.equal(getCommandToRecordOnEnter(echoed.prompt, echoed.alignedTyped, "use", true), null);
const pending = getAlignedPrompt(createFakeTerm("Email: ", "Email: ".length) as never, "use", true);
assert.equal(pending.prompt.promptText, "Email: ");
assert.equal(resolveAutocompleteQueryInput(pending.prompt, "use", true), "use");
assert.equal(pending.alignedTyped, null);
const shell = "user@host:~$ git pull use";
const shellPrompt = getAlignedPrompt(createFakeTerm(shell, shell.length) as never, "git pull use", true);
assert.equal(shellPrompt.prompt.promptText, "user@host:~$ ");
assert.equal(shellPrompt.prompt.userInput, "git pull use");
assert.equal(shellPrompt.alignedTyped, "git pull use");
});
test("a colon shell prompt is not a program input prompt (#3622)", () => {
for (const prompt of ["dev: ", "development: ", "PS1: "]) {
assert.equal(isProgramInputPromptText(prompt), false, prompt);
const line = `${prompt}ls`;
const term = createFakeTerm(line, line.length);
const aligned = getAlignedPrompt(term as never, "ls", true);
assert.equal(aligned.prompt.isAtPrompt, true, prompt);
assert.equal(aligned.prompt.promptText, prompt, prompt);
assert.equal(aligned.prompt.userInput, "ls", prompt);
assert.equal(
getCommandToRecordOnEnter(aligned.prompt, aligned.alignedTyped, "ls", true),
"ls",
prompt,
);
}
assert.equal(isProgramInputPromptText("Email: "), true);
assert.equal(isProgramInputPromptText("Enter your username: "), true);
assert.equal(isProgramInputPromptText("用户名: "), true);
});
test("password, passphrase, PIN, and secret prompts stay uncompletable (#3622)", () => {
for (const line of ["Password: use", "Passphrase: use", "PIN: use", "Secret: use", "Enter secret: use"]) {
const result = getAlignedPrompt(createFakeTerm(line, line.length) as never, "use", true);
assert.equal(result.prompt.isAtPrompt, false, line);
assert.equal(resolveAutocompleteQueryInput(result.prompt, "use", true), null, line);
}
});
@@ -31,10 +31,55 @@ export function isNonPromptLine(lineText: string): boolean {
return NON_PROMPT_PATTERNS.some((pattern) => pattern.test(lineText));
}
const PROGRAM_INPUT_LABEL = /^(?=[\p{L}\p{N}])[\p{L}\p{N} ._'()-]{1,60}$/u;
/** Identity prompts this feature targets. A custom PS1 like `dev: ` is not one. */
const ORDINARY_INPUT_LABEL =
/^(?:e-?mail(?: address)?|user(?: ?name| ?id)?|login(?: name)?|(?:full )?name|account(?: name)?|\u90ae\u7bb1|\u7535\u5b50\u90ae\u7bb1|\u7535\u5b50\u90ae\u4ef6|\u7528\u6237\u540d|\u7528\u6237|\u59d3\u540d|\u8d26\u53f7|\u5e10\u6237|\u8d26\u6237|\u767b\u5f55\u540d)$/u;
function colonLabelText(promptText: string): string | null {
if (!promptText || promptText.length > 80) return null;
if (isSensitiveTerminalChallenge(promptText)) return null;
if (/\b(?:secret|passphrase|password|pin)\b/iu.test(promptText)) return null;
const trimmedEnd = promptText.trimEnd();
const marker = trimmedEnd.at(-1);
if (marker !== ":" && marker !== "\uFF1A") return null;
const label = trimmedEnd.slice(0, -1).trim();
if (!PROGRAM_INPUT_LABEL.test(label)) return null;
if (startsWithCommonShellCommand(label)) return null;
return label;
}
/** Colon label shape, including a custom shell PS1 (`dev: `). */
function isColonLabelPromptText(promptText: string): boolean {
return colonLabelText(promptText) !== null;
}
/**
* A program waiting for ordinary input (`read -p 'Email: '`).
* Password, passphrase, PIN, and secret prompts stay closed.
* Only the identity-label allowlist matches, so `PS1='dev: '` stays a shell prompt.
*/
export function isProgramInputPromptText(promptText: string): boolean {
const label = colonLabelText(promptText);
if (!label) return false;
const normalized = label
.replace(/\s+/g, " ")
.toLowerCase()
.replace(/^(?:please\s+)?(?:enter|input|type)\s+(?:your\s+)?/u, "")
.replace(/^your\s+/u, "")
.replace(/^(?:\u8bf7\u8f93\u5165|\u8bf7|\u8f93\u5165)/u, "")
.trim();
return ORDINARY_INPUT_LABEL.test(normalized);
}
function isSpecificShellPromptCandidate(
promptText: string,
options: { allowGreaterThanTerminator?: boolean } = {},
): boolean {
if (isProgramInputPromptText(promptText)) return true;
const trimmed = promptText.trim();
if (
!options.allowGreaterThanTerminator &&
@@ -158,6 +203,9 @@ function inferPromptTextBeforeTypedInput(
if (isLikelyNoSpaceShellPromptText(promptText)) return promptText;
}
// `Email: ` with the reply still in the keystroke buffer (echo not painted yet).
if (isProgramInputPromptText(cursorPrefix)) return cursorPrefix;
return null;
}
@@ -388,6 +436,11 @@ function alignTypedInputFromCursorPrefix(
return null;
}
// Program read-prompts are snippet queries only. Do not override a real shell
// prompt, and do not authorize Enter history recording.
const programInput = isProgramInputPromptText(promptText);
if (programInput && raw.isAtPrompt) return null;
return {
prompt: {
isAtPrompt: true,
@@ -395,7 +448,7 @@ function alignTypedInputFromCursorPrefix(
userInput: typedBuffer,
cursorOffset: typedBuffer.length,
},
alignedTyped: typedBuffer,
alignedTyped: programInput ? null : typedBuffer,
};
}
@@ -571,7 +624,9 @@ export function detectPrompt(term: XTerm): PromptDetectionResult {
? lineText
: cursorLinePrefix;
const promptEnd = findPromptBoundary(promptScanText);
if (promptEnd >= 0) {
// Colon read-prompts (`Email: `) stay out of shell prompt detection. Autocomplete
// opts in later via alignTypedInputFromCursorPrefix.
if (promptEnd >= 0 && !isProgramInputPromptText(lineText.substring(0, promptEnd))) {
const promptText = lineText.substring(0, promptEnd);
// Input is whatever sits between the prompt and the cursor on the cell-
// accurate prefix — don't use cursorX as a character index.
@@ -601,7 +656,7 @@ export function detectPrompt(term: XTerm): PromptDetectionResult {
const promptLineText = promptLine.translateToString(false);
if (isSensitiveTerminalChallenge(promptLineText) || isNonPromptLine(promptLineText)) return NO_PROMPT;
const pEnd = findPromptBoundary(promptLineText);
if (pEnd >= 0) {
if (pEnd >= 0 && !isProgramInputPromptText(promptLineText.substring(0, pEnd))) {
const promptText = promptLineText.substring(0, pEnd);
// Concatenate all rows from promptRow to cursorY to get full input
let fullInput = promptLineText.substring(pEnd);
@@ -817,7 +872,24 @@ function findPromptBoundary(lineText: string): number {
}
}
return lastStandardBoundary >= 0 ? lastStandardBoundary : lastPuaBoundary;
if (lastStandardBoundary >= 0) return lastStandardBoundary;
if (lastPuaBoundary >= 0) return lastPuaBoundary;
return findProgramInputPromptBoundary(lineText);
}
function findProgramInputPromptBoundary(lineText: string): number {
const scanLimit = Math.min(lineText.trimEnd().length, 200);
let last = -1;
for (let i = 0; i < scanLimit; i++) {
const ch = lineText[i];
if (ch !== ":" && ch !== "\uFF1A") continue;
const next = i + 1 < lineText.length ? lineText[i + 1] : null;
if (next !== null && next !== " ") continue;
const boundary = next === " " ? i + 2 : i + 1;
if (!isColonLabelPromptText(lineText.slice(0, boundary))) continue;
last = boundary;
}
return last;
}
/**
@@ -1,10 +1,12 @@
import { isSensitiveTerminalChallenge } from "../../../domain/terminalPromptSecurity";
import {
isNonPromptLine,
isProgramInputPromptText,
reconcilePromptWithExternalCommand,
type PromptDetectionResult,
} from "./promptDetector";
import { computeLivePreviewWrite } from "./livePreviewSequence";
import { parseCommandLine, type CompletionSuggestion } from "./completionEngine";
import { computeLivePreviewWrite, isWindowsShellLineInput } from "./livePreviewSequence";
const THEMED_PROMPT_MARKERS = /[❯❮→➜➤⟩»›]/;
@@ -103,10 +105,87 @@ export function isSameAutocompleteQuery(options: {
return options.previewActive && options.previewBaseline === options.queryInput;
}
const TRUSTED_ORDINARY_INPUT_LABELS = new Set([
"email",
"e-mail",
"email address",
"e-mail address",
"username",
"user name",
"user",
"userid",
"user id",
"login",
"login name",
"name",
"full name",
"account",
"account name",
"\u90ae\u7bb1",
"\u7535\u5b50\u90ae\u7bb1",
"\u7535\u5b50\u90ae\u4ef6",
"\u7528\u6237\u540d",
"\u7528\u6237",
"\u59d3\u540d",
"\u8d26\u53f7",
"\u5e10\u6237",
"\u8d26\u6237",
"\u767b\u5f55\u540d",
]);
const UNTRUSTED_INPUT_LABEL = /(?:\b(?:secret|passphrase|password|passcode|token|key|pin|otp)\b|\u5bc6\u7801|\u53e3\u4ee4|\u4ee4\u724c|\u5bc6\u94a5|\u9a8c\u8bc1\u7801)/iu;
function trustedOrdinaryInputLabel(promptText: string): string | null {
const trimmedEnd = promptText.trimEnd();
const marker = trimmedEnd.at(-1);
if (marker !== ":" && marker !== "\uFF1A") return null;
return trimmedEnd
.slice(0, -1)
.trim()
.replace(/\s+/g, " ")
.toLowerCase()
.replace(/^(?:please\s+)?(?:enter|input|type)\s+(?:your\s+)?/u, "")
.replace(/^your\s+/u, "")
.replace(/^(?:\u8bf7\u8f93\u5165|\u8bf7|\u8f93\u5165)/u, "")
.trim();
}
/**
* Positively identified ordinary identity prompts (`Email:`, `Username:`).
* Unknown labels stay fail-closed so they cannot bypass the sensitive latch.
*/
function isTrustedOrdinaryInputPromptText(promptText: string): boolean {
if (UNTRUSTED_INPUT_LABEL.test(promptText)) return false;
const label = trustedOrdinaryInputLabel(promptText);
if (!label || UNTRUSTED_INPUT_LABEL.test(label)) return false;
return TRUSTED_ORDINARY_INPUT_LABELS.has(label);
}
/**
* Why `sensitiveInputActive` is latched.
*
* `heuristic` is the untrusted-prompt detector (`Email:`, unknown `Label:`).
* `explicit` is authoritative metadata such as `pluginPipelineSensitiveInput`.
* Omitted means heuristic, so existing callers keep the trusted-label bypass.
*/
export type SensitiveAutocompleteLatchSource = "heuristic" | "explicit";
export type SensitiveInputActiveRef = {
current: boolean;
source?: SensitiveAutocompleteLatchSource;
};
/**
* Whether fetchSuggestions must refuse to query/render for an already-known
* sensitive line (host latch or auth-challenge prompt text).
*
* The heuristic latch is set for every untrusted input boundary, including
* safe program prompts such as `Email:`. Only a small allowlist of plain
* identity labels may bypass that latch so snippet candidates can render.
* An explicit latch (plugin pipeline or other authoritative sensitive
* metadata) stays closed for those labels too. Unknown labels, and anything
* password/token/key/PIN/OTP-like, stay closed.
*
* This is *not* a substitute for the empty-echo / `allowExternalProviders:
* false` wait in useTerminalAutocomplete: `read -s -p '$ '` still looks like
* a normal shell PS1 until echo validates, so that path stays fail-closed
@@ -115,7 +194,16 @@ export function isSameAutocompleteQuery(options: {
export function shouldBlockAutocompleteForSensitivePrompt(options: {
sensitiveInputActive: boolean;
promptText: string;
sensitiveSource?: SensitiveAutocompleteLatchSource;
}): boolean {
const explicitLatch = options.sensitiveInputActive && options.sensitiveSource === "explicit";
if (
options.sensitiveInputActive
&& !explicitLatch
&& isTrustedOrdinaryInputPromptText(options.promptText)
) {
return false;
}
if (options.sensitiveInputActive) return true;
return isSensitiveTerminalChallenge(options.promptText);
}
@@ -169,6 +257,7 @@ export function getCommandToRecordOnEnter(
typedBufferReliable: boolean,
): string | null {
if (!livePrompt.isAtPrompt) return null;
if (isProgramInputPromptText(livePrompt.promptText)) return null;
const alignedCommand = alignedTyped?.trim();
if (alignedCommand) return alignedCommand;
@@ -209,3 +298,46 @@ export function getCommandToRecordOnEnter(
return liveCommand;
}
/**
* Keystrokes that remove the text a snippet acceptance replaces.
* Line-start keeps the shell kill-line (Ctrl+U, or backspaces on Windows).
* Mid-line and program read-prompts delete only the word being typed.
* POSIX word delete sends DEL, the tty erase character. Ctrl-H is literal
* input to canonical `read` when erase is ^?, while readline still deletes
* on DEL. Windows line editing keeps backspace.
* Script snippets run automation and do not replace the line, so they keep
* the full-line clear.
*/
export function computeSnippetAcceptClearSequence(options: {
line: string | null;
os: string;
promptText: string;
snippetKind?: "snippet" | "script";
}): string {
const line = options.line ?? "";
if (line.length === 0) return "";
const { currentWord, wordIndex } = parseCommandLine(line);
const replaceWord = options.snippetKind !== "script"
&& currentWord.length > 0
&& line.endsWith(currentWord)
&& (wordIndex > 0 || isProgramInputPromptText(options.promptText));
if (replaceWord) {
const eraseChar = isWindowsShellLineInput(options.os, options.promptText) ? "\b" : "\x7f";
return eraseChar.repeat(Array.from(currentWord).length);
}
return isWindowsShellLineInput(options.os, options.promptText)
? "\b".repeat(line.length)
: "\x15";
}
/** Program read-prompts offer snippets only; shell lines keep every source. */
export function limitSuggestionsForPrompt(
promptText: string,
suggestions: CompletionSuggestion[],
): CompletionSuggestion[] {
if (!isProgramInputPromptText(promptText)) return suggestions;
return suggestions.filter((suggestion) => suggestion.source === "snippet");
}
@@ -27,7 +27,7 @@ import {
shouldPreferRemoteShellCwd,
} from "./remotePathCompleter";
import { decideGhostSuggestion } from "./ghostSuggestionPolicy";
import { isWindowsShellLineInput, shouldWriteAutocompleteLivePreview } from "./livePreviewSequence";
import { shouldWriteAutocompleteLivePreview } from "./livePreviewSequence";
import {
areSubDirPanelsEqual,
areSuggestionsEqual,
@@ -41,9 +41,12 @@ import { handleTerminalAutocompleteInput } from "./terminalAutocompleteInput";
import { handleTerminalAutocompleteKeyEvent } from "./terminalAutocompleteKeyEvent";
import {
computeAutocompleteAcceptWrite,
computeSnippetAcceptClearSequence,
isSameAutocompleteQuery,
limitSuggestionsForPrompt,
resolveAutocompleteQueryInput,
shouldBlockAutocompleteForSensitivePrompt,
type SensitiveInputActiveRef,
} from "./terminalAutocompletePrompt";
import { sliceStringByCellColumns } from "./terminalStringCellWidth";
import { isTerminalAlternateScreenActive } from "../terminalHibernateRuntime";
@@ -234,7 +237,7 @@ interface UseTerminalAutocompleteOptions {
* Host-owned password/auth prompt latch. When true, suppress autocomplete
* even if the PS1 still looks like a normal shell (e.g. `read -s -p '$ '`).
*/
sensitiveInputActiveRef?: RefObject<boolean>;
sensitiveInputActiveRef?: SensitiveInputActiveRef;
/** Host-owned completion Provider adapter; defaults to Netcatty's built-in Provider. */
provideCompletions?: (
input: string,
@@ -876,6 +879,7 @@ export function useTerminalAutocomplete(
if (
shouldBlockAutocompleteForSensitivePrompt({
sensitiveInputActive: sensitiveInputActiveRef?.current === true,
sensitiveSource: sensitiveInputActiveRef?.source,
promptText: prompt.promptText,
})
) {
@@ -1004,10 +1008,11 @@ export function useTerminalAutocomplete(
};
const applyCompletions = (
completions: CompletionSuggestion[],
incomingCompletions: CompletionSuggestion[],
currentPrompt: ReturnType<typeof getAlignedPrompt>["prompt"],
options?: { preserveSelection?: boolean },
) => {
const completions = limitSuggestionsForPrompt(currentPrompt.promptText, incomingCompletions);
// Echo-lag-aware logical caret: resolved query `input` can be ahead of
// the live xterm cursor / currentPrompt.userInput. When a late path
// refresh lands while live-preview has already rewritten the shell line,
@@ -1340,8 +1345,8 @@ export function useTerminalAutocomplete(
[handleCommandHistoryCleared],
);
/** Accept a snippet: clear the user's typed input, then run it via the
* host-canonical send path (onAcceptSnippet). */
/** Accept a snippet: clear the matched word (or the whole line for a script
* or a shell prompt), then run it via onAcceptSnippet. */
const acceptSnippet = useCallback((snippet: Snippet): boolean => {
const term = termRef.current;
if (term) {
@@ -1353,10 +1358,13 @@ export function useTerminalAutocomplete(
);
if (line !== null && line.length > 0) {
if (!settingsRef.current.allowLineReplacement) return false;
const clearSequence = isWindowsShellLineInput(hostOsRef.current, prompt.promptText)
? "\b".repeat(line.length)
: "\x15"; // Ctrl+U (readline kill-line)
writeToTerminal(clearSequence);
const clearSequence = computeSnippetAcceptClearSequence({
line,
os: hostOsRef.current,
promptText: prompt.promptText,
snippetKind: snippet.kind,
});
if (clearSequence) writeToTerminal(clearSequence);
}
}
typedInputBufferRef.current = "";
@@ -113,6 +113,32 @@ test('acknowledged pasted commands enter history over output without becoming tr
}
});
test('colon shell prompts still record the submitted command (#3622)', () => {
const executed: string[] = [];
const result = recordTerminalCommandExecution('ls', {
host: { id: 'host-1', label: 'Host' },
sessionId: 'session-1',
onCommandExecuted: (command) => executed.push(command),
commandBufferRef: { current: 'ls' },
}, createFakeTerm('dev: ls') as never);
assert.equal(result, 'ls');
assert.deepEqual(executed, ['ls']);
});
test('program input replies are not stored as shell commands (#3622)', () => {
for (const line of ['Email: use', 'Password: use', 'Secret: use']) {
const executed: string[] = [];
const result = recordTerminalCommandExecution('use', {
host: { id: 'host-1', label: 'Host' },
sessionId: 'session-1',
onCommandExecuted: (command) => executed.push(command),
commandBufferRef: { current: 'use' },
}, createFakeTerm(line) as never);
assert.equal(result, null, line);
assert.deepEqual(executed, [], line);
}
});
test('acknowledged writes retain both live challenge and explicit sensitive checks', () => {
for (const [line, sensitive] of [['Password: ', false], ['OTP> ', false], ['output', true]] as const) {
const history: string[] = [];
@@ -12,6 +12,7 @@ import {
import {
getAlignedPrompt,
isNonPromptLine,
isProgramInputPromptText,
reconcilePromptWithExternalCommand,
reconcilePromptWithTypedInput,
type PromptDetectionResult,
@@ -384,6 +385,7 @@ export const shouldRecordShellHistory = (
const trimmed = command.trim();
const alignedResult = getAlignedPrompt(term, command, true);
if (isProgramInputPromptText(alignedResult.prompt.promptText)) return false;
const prompt = expandPromptUserInputToFullLine(term, alignedResult.prompt, command);
if (!prompt.isAtPrompt) return false;
if (alignedResult.alignedTyped?.trim() === trimmed) return true;
@@ -3,6 +3,10 @@ import { readFileSync } from "node:fs";
import test from "node:test";
const runtimeSource = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
const autocompleteHookSource = readFileSync(
new URL("../autocomplete/useTerminalAutocomplete.ts", import.meta.url),
"utf8",
);
const attachmentSource = readFileSync(new URL("./terminalSessionAttachment.ts", import.meta.url), "utf8");
const terminalSource = readFileSync(new URL("../../Terminal.tsx", import.meta.url), "utf8");
const terminalLayerSource = readFileSync(new URL("../../TerminalLayer.tsx", import.meta.url), "utf8");
@@ -110,6 +114,30 @@ test("renderer flow control acknowledges host ingress rather than transformed di
);
});
test("explicit plugin sensitivity is not treated as the heuristic prompt latch", () => {
const effectsSource = readFileSync(new URL("../useTerminalEffects.ts", import.meta.url), "utf8");
assert.match(
terminalSource,
/passwordPromptActiveRef\.current = meta\.pluginPipelineSensitiveInput;[\s\S]*?passwordPromptActiveRef\.source = meta\.pluginPipelineSensitiveInput \? "explicit" : "heuristic";/,
);
assert.match(
terminalSource,
/passwordPromptActiveRef\.current = payload\.passwordPromptActive;[\s\S]*?if \(payload\.passwordPromptActive\) passwordPromptActiveRef\.source = "explicit";/,
);
assert.match(
effectsSource,
/passwordPromptActiveRef\.current = snap\.passwordPromptActive;[\s\S]*?if \(snap\.passwordPromptActive\) passwordPromptActiveRef\.source = "explicit";/,
);
assert.match(
terminalSource,
/passwordPromptActiveRef\.current = true;[\s\S]*?passwordPromptActiveRef\.source = "heuristic";/,
);
assert.match(
autocompleteHookSource,
/sensitiveSource: sensitiveInputActiveRef\?\.source,/,
);
});
test("active and hibernated output share host-owned sensitive prompt classification", () => {
assert.match(
terminalSource,
@@ -3,10 +3,13 @@ import assert from "node:assert/strict";
import {
computeAutocompleteAcceptWrite,
computeSnippetAcceptClearSequence,
isSameAutocompleteQuery,
limitSuggestionsForPrompt,
resolveAutocompleteQueryInput,
shouldBlockAutocompleteForSensitivePrompt,
} from "./autocomplete/terminalAutocompletePrompt.ts";
import type { CompletionSuggestion } from "./autocomplete/completionEngine.ts";
import type { PromptDetectionResult } from "./autocomplete/promptDetector.ts";
function atPrompt(userInput: string, promptText = "$ "): PromptDetectionResult {
@@ -57,6 +60,97 @@ test("shouldBlockAutocompleteForSensitivePrompt blocks auth-challenge prompts",
);
});
test("shouldBlockAutocompleteForSensitivePrompt lets safe program prompts past the untrusted latch", () => {
// observeTerminalInputPrompt latches every untrusted colon prompt, including
// `Email:`, before limitSuggestionsForPrompt can offer snippets.
for (const promptText of [
"Email: ",
"email:",
"E-mail: ",
"EMAIL ADDRESS: ",
"Enter your username: ",
"user: ",
"User Name: ",
"Login: ",
"Name: ",
"\u90ae\u7bb1: ",
"\u90ae\u7bb1\uFF1A",
"\u7528\u6237\u540d: ",
]) {
assert.equal(
shouldBlockAutocompleteForSensitivePrompt({
sensitiveInputActive: true,
promptText,
}),
false,
promptText,
);
}
assert.equal(
shouldBlockAutocompleteForSensitivePrompt({
sensitiveInputActive: true,
promptText: "Password: ",
}),
true,
);
});
test("shouldBlockAutocompleteForSensitivePrompt keeps explicit plugin sensitivity closed for trusted labels", () => {
// pluginPipelineSensitiveInput (and any other authoritative latch) must not
// use the Email:/Username: bypass. A heuristic-only latch still may.
assert.equal(
shouldBlockAutocompleteForSensitivePrompt({
sensitiveInputActive: true,
sensitiveSource: "explicit",
promptText: "Email:",
}),
true,
);
assert.equal(
shouldBlockAutocompleteForSensitivePrompt({
sensitiveInputActive: true,
sensitiveSource: "explicit",
promptText: "Username:",
}),
true,
);
assert.equal(
shouldBlockAutocompleteForSensitivePrompt({
sensitiveInputActive: true,
sensitiveSource: "heuristic",
promptText: "Email:",
}),
false,
);
});
test("shouldBlockAutocompleteForSensitivePrompt keeps the latch for unknown and secret labels", () => {
for (const promptText of [
"API key:",
"API key: ",
"Recovery key: ",
"Token: ",
"TOKEN:",
"Password: ",
"Passphrase: ",
"Secret: ",
"PIN: ",
"OTP: ",
"One-time password: ",
"Favorite color: ",
"SSH key: ",
]) {
assert.equal(
shouldBlockAutocompleteForSensitivePrompt({
sensitiveInputActive: true,
promptText,
}),
true,
promptText,
);
}
});
test("isSameAutocompleteQuery keeps late paths alive during live preview", () => {
// Highlighting a candidate rewrites typedInputBuffer to the preview text.
// Late path listings for the original query must still apply.
@@ -226,6 +320,126 @@ test("computeAutocompleteAcceptWrite refuses line replacement when disabled", ()
);
});
test("snippet accept deletes only the mid-line word (#3622)", () => {
// POSIX erase is DEL (0x7f). Ctrl-H is literal under canonical `read`.
assert.equal(
computeSnippetAcceptClearSequence({
line: "git pull use",
os: "linux",
promptText: "user@host:~$ ",
}),
"\x7f\x7f\x7f",
);
assert.equal(
computeSnippetAcceptClearSequence({
line: "git pull use",
os: "windows",
promptText: "C:\\Users\\me>",
}),
"\b\b\b",
);
assert.equal(
computeSnippetAcceptClearSequence({
line: "use",
os: "linux",
promptText: "user@host:~$ ",
}),
"\x15",
);
assert.equal(
computeSnippetAcceptClearSequence({
line: "deploy",
os: "windows",
promptText: "C:\\Users\\me>",
}),
"\b".repeat("deploy".length),
);
const emailClear = computeSnippetAcceptClearSequence({
line: "use",
os: "linux",
promptText: "Email: ",
});
assert.equal(emailClear, "\x7f".repeat(3));
assert.deepEqual([...emailClear].map((ch) => ch.charCodeAt(0)), [0x7f, 0x7f, 0x7f]);
assert.equal(
computeSnippetAcceptClearSequence({
line: "foo use",
os: "linux",
promptText: "Email: ",
}),
"\x7f\x7f\x7f",
);
assert.equal(
computeSnippetAcceptClearSequence({
line: "use",
os: "windows",
promptText: "Email: ",
}),
"\b\b\b",
);
assert.equal(
computeSnippetAcceptClearSequence({
line: "git pull \u{1F680}",
os: "linux",
promptText: "user@host:~$ ",
}),
"\x7f",
);
});
test("script snippet accept still clears the whole line (#3622)", () => {
assert.equal(
computeSnippetAcceptClearSequence({
line: "echo sm",
os: "linux",
promptText: "user@host:~$ ",
snippetKind: "script",
}),
"\x15",
);
assert.equal(
computeSnippetAcceptClearSequence({
line: "echo sm",
os: "windows",
promptText: "C:\\Users\\me>",
snippetKind: "script",
}),
"\b".repeat("echo sm".length),
);
assert.equal(
computeSnippetAcceptClearSequence({
line: "hello use",
os: "linux",
promptText: "Email: ",
snippetKind: "script",
}),
"\x15",
);
assert.equal(
computeSnippetAcceptClearSequence({
line: "echo sm",
os: "linux",
promptText: "user@host:~$ ",
snippetKind: "snippet",
}),
"\x7f\x7f",
);
});
test("program input prompts keep snippet suggestions only (#3622)", () => {
const suggestions = [
{ text: "useradd", displayText: "useradd", source: "history", score: 10 },
{ text: "邮箱", displayText: "邮箱", source: "snippet", score: 20 },
] as CompletionSuggestion[];
assert.deepEqual(
limitSuggestionsForPrompt("Email: ", suggestions).map((suggestion) => suggestion.source),
["snippet"],
);
assert.equal(limitSuggestionsForPrompt("user@host:~$ ", suggestions).length, 2);
assert.equal(limitSuggestionsForPrompt("Password: ", suggestions).length, 2);
});
test("computeAutocompleteAcceptWrite clears with backspaces on a mislabeled Windows shell (#3184)", () => {
// Windows PowerShell host saved with the os:"linux" default: the detected
// drive-letter prompt must route the clear to backspaces, not Ctrl-U.
@@ -705,6 +705,8 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
}
if (typeof snap.passwordPromptActive === "boolean") {
passwordPromptActiveRef.current = snap.passwordPromptActive;
// Same conservative restore as the home apply-snapshot path.
if (snap.passwordPromptActive) passwordPromptActiveRef.source = "explicit";
}
if (snap.cwd !== undefined) {
const cwd = terminalCwdTracker.setRendererCwd(snap.cwd, "snapshot");
@@ -271,7 +271,13 @@ test("Cygwin agent validation closes a stalled negotiation socket", async (t) =>
fs.rmSync(dir, { recursive: true, force: true });
});
assert.equal(await cygwinAgentConnectable(descriptorPath, { timeoutMs: 20 }), false);
// The timeout covers a stalled negotiation. Reading the descriptor from
// memory keeps a slow filesystem from expiring that budget before the
// socket exists, which otherwise looks like a connection that stayed open.
assert.equal(await cygwinAgentConnectable(descriptorPath, {
timeoutMs: 20,
readFileImpl: async () => fs.readFileSync(descriptorPath, "utf8"),
}), false);
await Promise.race([
closed,
new Promise((_, reject) => setTimeout(() => reject(new Error("Cygwin probe connection stayed open")), 2_000)),