From 4cf09424c573b59516110b71f740d2336652c7bb Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 11 Sep 2026 02:26:26 -0700 Subject: [PATCH] fix(runtime): stop formatted source code reading as a credential prompt Mining 16,206 distinct credential-vocabulary lines out of this repo's own tracked files and placing each one row above a real agent composer caret produced **100 refusals across 25 distinct lines**, on Codex, Claude Code and OpenCode. The corpus had `rg` hits and diffs, which carry their own chrome, but no plain formatted source -- and that is the category that leaked. 24 of the 25 are one shape. `AUTH_QUESTION_ROW_RE` rests on "prose never starts with a bare `?`", which is true and irrelevant: oxfmt puts a ternary's consequent on its own row as `? someValue`, and **5,666 tracked files have one**. `sawAuthQuestion` then returns unconditionally, with no position requirement, so a composer caret directly below could not clear it. Nine matched only because `\b(?:log[ -]?in)\b` reads `user.login` as the auth verb. The 25th is the other rule: `CREDENTIAL_ASK_PREFIX_RE` was unanchored, so an ask verb anywhere in a row made a row-final credential noun a prompt -- `// Why: a merely missing or expired bundle must not enter the credential` is a real wrapped comment in this repo, and terminal wrapping makes ending on a noun routine. Three fixes, the same position discipline the flow rule already uses: - the ask verb must LEAD its row, modulo decoration and a menu number, so `2. Paste an API key` still reads as a prompt and 60 characters of prose before `enter the` does not; - `.login` is a property access, not an auth verb; - a known composer caret on the bottom row suppresses the question-row rule, because an agent sitting at its own prompt is not asking anyone anything. The caret set deliberately includes a bare `>`. It suppresses only the question-row rule, and the #19749 sign-in dialog -- whose own bottom row is `>` -- matches through `isCredentialPromptLine`, which returns first. Verified: that dialog and the auth-method menu both still refuse. Mined-corpus refusals 100 -> 0, with the mined lines added as a corpus category so the next widening has to refuse them. Sentinel budget unchanged. --- ...rminal-credential-prompt-detection.test.ts | 109 ++++++++++++++++++ .../terminal-credential-prompt-detection.ts | 32 ++++- 2 files changed, 136 insertions(+), 5 deletions(-) diff --git a/src/main/runtime/terminal-credential-prompt-detection.test.ts b/src/main/runtime/terminal-credential-prompt-detection.test.ts index f5c7b5ce245..f1bdd6e803c 100644 --- a/src/main/runtime/terminal-credential-prompt-detection.test.ts +++ b/src/main/runtime/terminal-credential-prompt-detection.test.ts @@ -500,6 +500,115 @@ const LEGITIMATE_AGENT_SCREENS: readonly (readonly [string, string[]])[] = [ [ 'dotenv example printed by the agent', ['$ cat .env.example', 'DATABASE_URL=', 'API_KEY=', 'SESSION_SECRET='] + ], + // Plain source code printed into the pane -- the category this corpus lacked. It had `rg` hits + // and diffs, which carry their own chrome, but not bare formatted source. Every line below was + // mined from this repo's tracked files and refused a prompt above a real composer caret: oxfmt + // renders a ternary consequent as a bare `?` row (5,666 tracked files have one), `.login` reads + // as the auth verb "log in", and prose that merely ENDS on a credential noun read as an ask. + [ + 'ternary consequent with a sign-in string', + [ + " ? 'Update desktop Orca and sign in to connect from anywhere'", + '› Ask Codex to do anything' + ] + ], + [ + 'ternary consequent with an authentication template literal', + [' ? `replacement session authentication timed out (${stage})`', '> '] + ], + [ + 'ternary consequent with a login error', + [' ? `Codex login failed: ${trimmedOutput}`', '❯ '] + ], + [ + 'ternary consequent calling a login spawn builder', + [ + " ? buildWindowsHostInteractiveLoginSpawn(codexCommand, ['login'])", + '› Ask Codex to do anything' + ] + ], + [ + 'ternary consequent with a sign-in status string', + [" ? 'Timed out while checking Codex sign-in status'", '> '] + ], + [ + 'ternary consequent reading an authorization basis', + [' ? this.originPool.controlForBasis(authorization.basisConnId)', '❯ '] + ], + [ + 'ternary consequent building a gh auth command', + [' ? `gh auth login --hostname ${host}`', '› Ask Codex to do anything'] + ], + [ + 'ternary consequent with a translated sign-in-again label', + [" ? translate('settings.signInAgain', 'Sign in again')", '❯ '] + ], + [ + 'ternary consequent with a pat docs url', + [ + " ? 'https://learn.microsoft.com/azure/devops/accounts/use-pat-to-authenticate'", + '› Ask Codex to do anything' + ] + ], + [ + 'ternary filtering assignees by login', + [' ? prevAssignees.filter((l) => l !== login)', '› Ask Codex to do anything'] + ], + [ + 'ternary narrowing a login to a string', + [" ? overrides.filter((login): login is string => typeof login === 'string')", '> '] + ], + [ + 'ternary removing assignees by login', + [' ? { removeAssignees: [user.login] }', '❯ '] + ], + [ + 'ternary mapping project assignees', + [ + ' ? projectRowDetail.assignees.map((login) => ({', + '› Ask Codex to do anything' + ] + ], + [ + 'ternary removing a reviewer by login', + [' ? handleRemoveReviewers([reviewer.login])', '> '] + ], + [ + 'ternary editing assignees by login', + [' ? onEditAssignees?.([], [user.login])', '❯ '] + ], + [ + 'ternary lowercasing an assignee login', + [ + ' ? prevAssignees.filter((user) => user.login.toLowerCase() !== lowerLogin)', + '› Ask Codex to do anything' + ] + ], + [ + 'ternary building an assignee removal patch', + [" ? { family: 'assignees', kind: 'remove', logins: [login] }", '❯ '] + ], + [ + 'wrapped comment ending on a credential noun', + [' // Why: a merely missing or expired bundle must not enter the credential', '❯ '] + ], + [ + 'wrapped comment ending on a password noun', + [' // The caller must provide the current password', '› Ask Codex to do anything'] + ], + [ + 'wrapped jsdoc ending on an api key noun', + [' * Callers are expected to paste their API key', '> '] + ], + // No caret, and the bottom row DOES lead with an action phrase, so the only thing keeping this + // from reading as a live prompt is that `candidate.login` is a property access. + [ + 'source rows where a .login access is the only would-be auth verb', + [ + ' const owner = candidate.login', + ' // Enter the code below to finish linking the account' + ] ] ] diff --git a/src/main/runtime/terminal-credential-prompt-detection.ts b/src/main/runtime/terminal-credential-prompt-detection.ts index 846a5e43d51..bac539c7ff6 100644 --- a/src/main/runtime/terminal-credential-prompt-detection.ts +++ b/src/main/runtime/terminal-credential-prompt-detection.ts @@ -48,8 +48,14 @@ const CREDENTIAL_NOUN_ANYWHERE_RE = new RegExp(CREDENTIAL_NOUN_SOURCE, 'i') // Why a vendor slot: real prompts read "enter your Anthropic API key" and // "paste your personal access token", not just "enter your API key". +// +// Why anchored: unanchored, the ask verb could sit anywhere, so any prose that happened to end on +// a credential noun read as a prompt — `// Why: a merely missing or expired bundle must not enter +// the credential` is a real wrapped comment in this repo, and terminal wrapping makes ending on a +// noun routine. A dialog addresses the user, so its ask verb leads the row, modulo decoration and +// a menu number (`2. Paste an API key`). const CREDENTIAL_ASK_PREFIX_RE = - /(?:^|[^a-z])(?:enter|re-?enter|type|paste|input|provide|confirm)(?:\s+(?:your|the|a|an|my|new|current|old))?(?:\s+[a-z][a-z0-9.'-]{0,20}){0,2}[\s_]+$/i + /^[^a-z0-9]{0,8}(?:\d{1,2}[.)]\s*)?(?:please\s+)?(?:enter|re-?enter|type|paste|input|provide|confirm)(?:\s+(?:your|the|a|an|my|new|current|old))?(?:\s+[a-z][a-z0-9.'-]{0,20}){0,2}[\s_]+$/i // A bare label prompt: decoration, an optional qualifier, an optional env-var vendor segment // (`ANTHROPIC_API_KEY:`), then the noun. @@ -72,8 +78,11 @@ const CLAUSE_TERMINATED_RE = /[:›❯»>_]\s*$/ const SUDO_PASSWORD_RE = /^[^a-z0-9]{0,8}\[sudo\]\s/i const GIT_CREDENTIAL_RE = /^[^a-z0-9]{0,8}(?:username|password) for ['"]?[a-z][a-z0-9+.-]*:\/\//i +// Why the lookbehind: `user.login`, `candidate.login` and `overrides.filter((login) =>` are +// property accesses, not auth wording, and `\b` treats `.` as a boundary. Agents print this +// repo's own source constantly. const AUTH_VERB_RE = - /\b(?:sign[ -]?in|signin|log[ -]?in|authenticate|authorized?|authorization|authentication)\b/i + /(?` is safe to include here: this suppresses only the question-row rule, which is + * the one rule with no position requirement. A sign-in dialog drawn OVER ready chrome — the + * #19749 shape, whose own bottom row is `>` — matches through `isCredentialPromptLine` instead, + * and that returns before this is consulted. + */ +const COMPOSER_CARET_ROW_RE = /^(?:›\s*ask\b.*|✳\s*claude code\b.*|[>❯›◇»$])$/i + // A finished sentence, i.e. narration. A lone `.`/`!`/`?` ends a clause; `...` // and `…` are progress wording ("opening browser...") and are not sentences. // Why CJK punctuation counts: an agent narrating auth work in Chinese or Japanese writes @@ -216,5 +237,6 @@ export function findCredentialPromptIndex(normalized: string): number | null { bottomRowAsks && !NARRATION_ROW_RE.test(bottomRow) && (sawAuthVerb || sawCredentialNoun) - return authFlowOwnsBottom || sawAuthQuestion ? windowStart : null + const bottomRowIsComposerCaret = COMPOSER_CARET_ROW_RE.test(bottomRow) + return authFlowOwnsBottom || (sawAuthQuestion && !bottomRowIsComposerCaret) ? windowStart : null }