From 3d4aeef4ece05c0f3ed73bc09b6f7ac083392d5a Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 10 Sep 2026 17:39:59 -0700 Subject: [PATCH] fix(runtime): refuse a task prompt into a live credential prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A readiness verdict is what decides whether Orca types the user's task prompt into a pane, and any readiness detector can be wrong. The Antigravity one has been rewritten five times and has repeatedly read ready with a sign-in dialog on screen, at which point the prompt is typed into that dialog: submitted to the auth provider as a credential attempt, and — because the daemon records raw PTY output with no redaction (terminal-history-session-writer.ts) — written into the session transcript and published over terminal.read / worktree.ps if the surface echoes. The write site already has the guard machinery: writeTerminalAgentPrompt throws agent_prompt_blocked whenever getAgentPromptActivity reads `permission`, and that comes from detectTerminalWaitBlockedReason over the tail. What was missing is vocabulary — the blocked-signal set knew about trust, update, hooks and approval dialogs, and nothing about passwords, API keys, OTPs, 2FA or OAuth device codes. So this adds an agent-neutral `agent-credential-prompt` reason and a shape-based detector for it, and makes it unconditional: unlike every other reason, a live idle title and a ready caret must not clear it, because a wrong readiness verdict is exactly the failure being guarded. isKnownReadyPromptPreview stops calling such a screen ready, so terminal.wait reports the reason rather than resolving. The refusal is a defer, not a drop: the wait poll re-reads the tail, so it clears once the dialog is answered. Bias is deliberately toward refusing. Measured on a corpus of 47,581 string literals from the repo's own runtime/shared/cli fixtures, the detector fires on 7 (0.015%), all of which are genuine credential-prompt shapes when read as a screen-bottom line. On a two-sided corpus in the new suite it blocks 23/23 live credential surfaces and fires on 0/31 legitimate agent screens, including agents narrating credential work, agents asking the user credential-adjacent questions, rg output quoting prompt wording, and diffs adding prompt strings. Verified by mutation: disabling the candidate turns 29 tests red, and the two write-site regressions then let the prompt through. Also fixes a latent main-process hang found while building the corpus: startOfLastNonBlankLines spun forever on any tail whose first character is a newline, because lastIndexOf clamps a negative position up to 0. Every producer filters blank lines today, so it is unreachable rather than live, but it sits on the PTY data path. Wire compatibility: `agent-credential-prompt` is an additive union member, per docs/reference/remote-wire-compatibility.md rule 1. No zod schema validates the value and the only equality comparisons are on locally-computed reasons; the CLI interpolates it as a string. --- ...-authoritative-terminal-wait-permission.ts | 9 +- .../runtime-terminal-agent-status-query.ts | 9 +- ...rminal-credential-prompt-detection.test.ts | 326 ++++++++++++++++++ .../terminal-credential-prompt-detection.ts | 127 +++++++ ...inal-credential-prompt-write-guard.test.ts | 154 +++++++++ .../terminal-cursor-approval-detection.ts | 47 +++ .../terminal-tail-sentinel-index.test.ts | 2 +- .../runtime/terminal-tail-sentinel-index.ts | 4 +- .../runtime/terminal-wait-blocked-sentinel.ts | 13 + src/main/runtime/terminal-wait-detection.ts | 126 ++++--- src/main/runtime/terminal-wait-tail-state.ts | 8 +- .../runtime/terminal-wait-tail-window.test.ts | 28 ++ src/main/runtime/terminal-wait-tail-window.ts | 5 +- src/shared/runtime-terminal-contracts.ts | 1 + ...l-wait-blocked-reason-legacy-alias.test.ts | 19 +- 15 files changed, 792 insertions(+), 86 deletions(-) create mode 100644 src/main/runtime/terminal-credential-prompt-detection.test.ts create mode 100644 src/main/runtime/terminal-credential-prompt-detection.ts create mode 100644 src/main/runtime/terminal-credential-prompt-write-guard.test.ts create mode 100644 src/main/runtime/terminal-cursor-approval-detection.ts create mode 100644 src/main/runtime/terminal-wait-blocked-sentinel.ts create mode 100644 src/main/runtime/terminal-wait-tail-window.test.ts diff --git a/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts b/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts index efd0f5cb1c9..73fa0a86ce9 100644 --- a/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts +++ b/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts @@ -3,7 +3,10 @@ import { OrcaRuntimeWithAgentPromptRequestCorrelation } from './orca-runtime-age import type { RuntimeTerminalAgentStatusSnapshot } from './runtime-terminal-agent-status-query' import type { AgentStatus } from '../../shared/agent-detection' import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-types' -import { detectTerminalWaitBlockedReason } from './terminal-wait-detection' +import { + detectTerminalWaitBlockedReason, + isUnconditionalTerminalWaitBlockedReason +} from './terminal-wait-detection' import { isOpenCodeNativeTitle } from '../../shared/agent-detection' import type { AgentStatusEntry } from '../../shared/agent-status-types' import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' @@ -31,11 +34,11 @@ export class OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission extends O terminal.titleStatus !== null && terminal.titleStatus !== 'permission' && !isOpenCodeNativeTitle(terminal.title) && - blockedByWaitText !== 'agent-approval-prompt' + !isUnconditionalTerminalWaitBlockedReason(blockedByWaitText) if (liveTitleClearsBlockedText && lifecycle?.status !== terminal.titleStatus) { return null } - if (blockedByWaitText === 'agent-approval-prompt') { + if (isUnconditionalTerminalWaitBlockedReason(blockedByWaitText)) { return blockedByWaitText } const newestPermissionAt = Math.max( diff --git a/src/main/runtime/runtime-terminal-agent-status-query.ts b/src/main/runtime/runtime-terminal-agent-status-query.ts index 5a06ea3f688..df0bc96f9b1 100644 --- a/src/main/runtime/runtime-terminal-agent-status-query.ts +++ b/src/main/runtime/runtime-terminal-agent-status-query.ts @@ -13,7 +13,10 @@ import { terminalTitleBlocksExplicitAgentStatus, getLatestAgentCandidateTitleInfo } from './runtime-worktree-status-projection' -import { detectTerminalWaitBlockedReason } from './terminal-wait-detection' +import { + detectTerminalWaitBlockedReason, + isUnconditionalTerminalWaitBlockedReason +} from './terminal-wait-detection' import { getTerminalState } from './terminal-wait-results' import { buildTerminalWaitText } from './terminal-wait-tail-state' @@ -72,7 +75,7 @@ export class RuntimeTerminalAgentStatusQuery { terminal.titleStatus !== null && terminal.titleStatus !== 'permission' && !isOpenCodeNativeTitle(terminal.title) && - blockedByWaitText !== 'agent-approval-prompt' + !isUnconditionalTerminalWaitBlockedReason(blockedByWaitText) const newestPermissionAt = Math.max( explicitStatus?.status === 'permission' ? explicitStatus.updatedAt : -1, lifecycle?.status === 'permission' ? lifecycle.updatedAt : -1, @@ -88,7 +91,7 @@ export class RuntimeTerminalAgentStatusQuery { if ( blockedByWaitText && (!liveTitleClearsBlockedText || lifecycle?.status === terminal.titleStatus) && - (blockedByWaitText === 'agent-approval-prompt' || + (isUnconditionalTerminalWaitBlockedReason(blockedByWaitText) || (newestPermissionAt >= 0 && newestPermissionAt >= newestClearAt)) ) { return { handle, isRunningAgent: true, status: 'permission' } diff --git a/src/main/runtime/terminal-credential-prompt-detection.test.ts b/src/main/runtime/terminal-credential-prompt-detection.test.ts new file mode 100644 index 00000000000..9dd0cd6454d --- /dev/null +++ b/src/main/runtime/terminal-credential-prompt-detection.test.ts @@ -0,0 +1,326 @@ +// The guard exists because any readiness detector can be wrong, so this suite is a +// two-sided corpus rather than a handful of examples: every LIVE_CREDENTIAL_SURFACE must +// block, and every LEGITIMATE_AGENT_SCREEN must not. A false negative types the user's task +// prompt into a credential field; a false positive only defers until the dialog is answered. +import { describe, expect, it } from 'vitest' +import { + findCredentialPromptIndex, + TERMINAL_CREDENTIAL_PROMPT_SENTINEL_RE +} from './terminal-credential-prompt-detection' +import { + detectTerminalWaitBlockedReason, + isKnownReadyPromptPreview +} from './terminal-wait-detection' +import { TERMINAL_TITLE_CLASSIFICATION_CORPUS } from '../../shared/terminal-title-classification-corpus' + +function screen(lines: string[]): string { + return lines.join('\n') +} + +const LIVE_CREDENTIAL_SURFACES: readonly (readonly [string, string[]])[] = [ + [ + 'antigravity device-code sign-in drawn over ready chrome (#19749)', + [ + 'Antigravity CLI', + 'gemini 3 pro (high)', + '~/orca/workspaces/orca/crash-closer', + '>', + '', + ' Sign in to Antigravity', + ' Open https://antigravity.google/device and enter the code: KXTD-9PQR', + ' Waiting for authentication…' + ] + ], + [ + 'antigravity auth-method menu over ready chrome', + [ + 'Antigravity CLI', + 'gemini 3 pro (high)', + '>', + '', + '? How would you like to authenticate?', + '❯ Sign in with Google', + ' Use an API key' + ] + ], + [ + 'api-key prompt under a complete Codex ready header', + [ + 'OpenAI Codex', + 'model: gpt-6', + 'directory: ~/repo', + '', + '› Ask Codex to do anything', + '', + 'Enter your API key:' + ] + ], + ['bare api-key ask', ['Enter your API key: ']], + ['vendor-qualified api-key ask with a caret', ['? Enter your Anthropic API key ›']], + ['bare password label', ['Password:']], + ['lowercase password label', ['password: ']], + ['sudo password', ['[sudo] password for neil:']], + ['ssh key passphrase', ["Enter passphrase for key '/Users/neil/.ssh/id_ed25519':"]], + ['git username', ["Username for 'https://github.com': "]], + ['git password', ["Password for 'https://neil@github.com': "]], + ['sms verification code', ['Enter the verification code we sent to your phone:']], + ['one-time code', ['Enter your one-time code:']], + [ + 'two-factor dialog', + ['Two-factor authentication', 'Enter the 6-digit code from your authenticator app:'] + ], + ['personal access token paste', ['Paste your personal access token here:']], + ['sign-in wall', ['Authentication required', 'Sign in with GitHub to continue']], + [ + 'oauth device-code flow', + [ + 'Please open the following url in your browser:', + ' https://github.com/login/device', + '', + 'and enter the code: ABCD-1234' + ] + ], + ['client secret', ['Enter client secret:']], + ['password confirmation', ['Re-enter password:']], + ['access token ask', ['Provide your access token:']], + ['otp ask', ['Type your OTP:']], + ['bare credentials label', ['credentials:']], + ['device code ask', ['Enter device code:']] +] + +const LEGITIMATE_AGENT_SCREENS: readonly (readonly [string, string[]])[] = [ + // An agent narrating credential work and returning to its composer. + [ + 'codex narrating password hashing', + [ + '• I added bcrypt password hashing to src/auth/user.ts.', + '', + '› Ask Codex to do anything', + '', + ' gpt-6 medium · ~/repo' + ] + ], + [ + 'codex narrating api-key wiring', + ['• Wired the API key into .env.example and documented it.', '', '› Ask Codex to do anything'] + ], + [ + 'claude narrating login work', + ['· Updated the login form to use the new session cookie.', '', '✳ Claude Code', '', '> '] + ], + [ + 'codex narrating an oauth refresh', + [ + '• Done. The OAuth device-code flow now refreshes the access token.', + '', + '› Ask Codex to do anything' + ] + ], + [ + 'claude narrating a secret rotation', + ['· I rotated the client secret and pushed the change.', '', '> '] + ], + // An agent legitimately ASKING the user something credential-adjacent. + [ + 'agent asks where to store a password', + [ + '· Should I store the password in the .env file or in the keychain?', + '', + '✳ Claude Code', + '', + '> ' + ] + ], + [ + 'agent asks about reading an api key', + ['· Do you want me to read your api key from process.env.OPENAI_API_KEY?', '', '> '] + ], + [ + 'agent asks which auth provider', + ['· Which auth provider should the sign in page use?', '', '> '] + ], + [ + 'agent asks about a token in CI', + ['· I need to know: does your CI already have a personal access token?', '', '> '] + ], + [ + 'agent asks where a template goes', + ['· Where should I put the verification code template?', '', '> '] + ], + ['agent asks about OTP expiry', ['· Should the OTP expire after 5 minutes or 10?', '', '> ']], + [ + 'agent asks about 2FA delivery', + ['· Do you want 2FA codes emailed or via authenticator app?', '', '> '] + ], + [ + 'agent narrates an upcoming passphrase edit', + ['· Ready. Next I will enter the passphrase handling into the key loader.', '', '> '] + ], + [ + 'agent narrates an api-key edit mid-sentence', + ['· I will enter the API key into the vault once you confirm the vault name', '', '> '] + ], + [ + 'agent asks which secret key to rotate', + ['· Please tell me which secret key you want rotated first', '', '> '] + ], + // The user's own task prompt echoed above the composer. + ['echoed login task prompt', ['> Implement the login form', '', '· Working…']], + [ + 'echoed password-reset task prompt', + ['> add password reset via one-time code', '', '· Working…'] + ], + [ + 'echoed credentials task prompt', + ['> Refactor the credentials module', '', '✳ Claude Code', '', '> '] + ], + // Search output quoting credential-shaped source, the shape that broke earlier detectors. + [ + 'rg hit on a password call', + [ + ' └ src/auth.ts:42: const password = await promptPassword()', + '', + '› Ask Codex to do anything' + ] + ], + [ + 'rg hit on an api-key label literal', + [" └ 118: label: 'Enter your API key'", '', '› Ask Codex to do anything'] + ], + [ + 'rg hit on a password test name', + [" └ tests/auth.test.ts:9: it('prompts for password', () => {", '', '> '] + ], + [ + 'search narration quoting a prompt', + [' └ Search "enter your password" in src/', '', '› Ask Codex to do anything'] + ], + // A diff that ADDS a credential prompt string. + [ + 'diff adding an api-key log', + ['+ console.log("Enter your API key:")', '', '› Ask Codex to do anything'] + ], + ['diff adding a password prompt field', ['+ prompt: "Password:"', '', '> ']], + // Other terminal traffic. + [ + 'cursor approval menu', + [ + 'Run this command?', + ' cat ~/.ssh/id_rsa', + ' Run (once) (enter)', + ' Skip & tell the agent (esc)' + ] + ], + [ + 'vitest auth suite output', + [ + ' ✓ auth > rejects an expired access token (4 ms)', + ' ✓ auth > hashes the password with argon2 (9 ms)', + '', + 'Test Files 1 passed' + ] + ], + [ + 'jest login suite output', + ['PASS src/login.test.ts', '', ' ● login form › submits credentials', '', '> '] + ], + [ + 'git push rejection', + [ + 'remote: Support for password authentication was removed.', + 'fatal: Authentication failed', + '$ ' + ] + ], + ['clean git push', ['Everything up-to-date', '$ ']], + [ + 'rendered readme auth section', + ['## Authentication', '', 'Set `ORCA_API_KEY` in your environment before running.', '', '$ '] + ], + [ + 'agent narrating a failed gh auth', + [ + '• The gh CLI says authentication failed; I skipped the PR step.', + '', + '› Ask Codex to do anything' + ] + ] +] + +describe('findCredentialPromptIndex', () => { + it.each(LIVE_CREDENTIAL_SURFACES)('blocks %s', (_name, lines) => { + expect(findCredentialPromptIndex(screen(lines).toLowerCase())).not.toBeNull() + }) + + it.each(LEGITIMATE_AGENT_SCREENS)('does not fire on %s', (_name, lines) => { + expect(findCredentialPromptIndex(screen(lines).toLowerCase())).toBeNull() + }) + + it('ignores an answered credential prompt that scrolled out of the live window', () => { + const tail = screen([ + 'Enter your API key:', + '', + 'Signed in as neil@example.com.', + '', + 'OpenAI Codex', + 'model: gpt-6', + 'directory: ~/repo', + '', + '› Ask Codex to do anything' + ]) + expect(findCredentialPromptIndex(tail.toLowerCase())).toBeNull() + expect(detectTerminalWaitBlockedReason(tail)).toBeNull() + }) + + it('keeps the sentinel a superset of everything the detector matches', () => { + // The retained-tail index skips any tail the sentinel rejects, so a detector match the + // sentinel misses would never be parsed at all. + for (const [name, lines] of LIVE_CREDENTIAL_SURFACES) { + const matched = lines.some((line) => TERMINAL_CREDENTIAL_PROMPT_SENTINEL_RE.test(line)) + expect(matched, name).toBe(true) + } + }) + + it('does not fire on any realistic terminal title', () => { + for (const title of TERMINAL_TITLE_CLASSIFICATION_CORPUS) { + expect(findCredentialPromptIndex(title.toLowerCase()), title).toBeNull() + } + }) +}) + +describe('credential prompts reach the wait-blocked vocabulary', () => { + it.each(LIVE_CREDENTIAL_SURFACES)('reports agent-credential-prompt for %s', (_name, lines) => { + expect(detectTerminalWaitBlockedReason(screen(lines))).toBe('agent-credential-prompt') + }) + + it('refuses to call the #19749 screen a ready prompt', () => { + // HEAD's Antigravity readiness rule (header, a gemini model row, a lone `>` caret) is all + // present here, which is exactly why the detector reported ready while the dialog was live. + const tail = screen([ + 'Antigravity CLI', + 'gemini 3 pro (high)', + '>', + '', + ' Sign in to Antigravity', + ' Open https://antigravity.google/device and enter the code: KXTD-9PQR', + ' Waiting for authentication…' + ]) + expect(isKnownReadyPromptPreview(tail)).toBe(false) + expect(detectTerminalWaitBlockedReason(tail)).toBe('agent-credential-prompt') + }) + + it('is not cleared by a ready caret drawn elsewhere on the screen', () => { + // Every other blocked reason is dismissible by a live prompt; this one must not be, or the + // agent's own input box would vouch for the dialog covering it. + const tail = screen([ + 'OpenAI Codex', + 'model: gpt-6', + 'directory: ~/repo', + '› Ask Codex to do anything', + '', + 'Authentication required', + 'Sign in with GitHub to continue' + ]) + expect(detectTerminalWaitBlockedReason(tail)).toBe('agent-credential-prompt') + }) +}) diff --git a/src/main/runtime/terminal-credential-prompt-detection.ts b/src/main/runtime/terminal-credential-prompt-detection.ts new file mode 100644 index 00000000000..08bca1962eb --- /dev/null +++ b/src/main/runtime/terminal-credential-prompt-detection.ts @@ -0,0 +1,127 @@ +import { startOfLastNonBlankLines } from './terminal-wait-tail-window' + +/** + * Recognizes a live credential / authentication prompt owning the bottom of a + * terminal screen. + * + * Why this exists separately from the agent-specific blocked signals: every + * readiness detector Orca has can be wrong, and when one is, the caller types + * the user's task prompt into whatever surface is actually on screen. If that + * surface is a credential prompt the prompt is submitted to an auth provider as + * a credential attempt, and — because the daemon records raw PTY output + * unredacted — an echoing prompt also writes it into the session transcript. So + * this is deliberately agent-agnostic: it keys on the shape of a credential + * prompt, not on which agent drew it. + * + * The bias is asymmetric on purpose. A false negative types secrets-adjacent + * text into a credential field; a false positive only delays a prompt until the + * dialog is answered, and the wait poll re-evaluates the tail continuously, so + * a refusal clears on its own. + */ + +// Why bounded: an answered credential prompt stays in scrollback, and agents +// print credential wording constantly while working on auth code. Only a prompt +// owning the screen bottom is live. +const CREDENTIAL_TAIL_LINES = 4 + +// Why capped: a wrapped narration line is not a prompt, and an unbounded line +// makes the per-line scan the hot path for streaming output. +const MAX_CREDENTIAL_LINE_LENGTH = 512 + +const CREDENTIAL_NOUN_SOURCE = + 'password|passphrase|api[ -]?keys?|access[ -]tokens?|auth(?:orization)?[ -]tokens?|bearer tokens?|personal access tokens?|secret keys?|client secrets?|one[- ]time (?:code|password)|otp|verification codes?|authentication codes?|security codes?|2fa codes?|device codes?|credentials?' + +const CREDENTIAL_NOUN_RE = new RegExp(CREDENTIAL_NOUN_SOURCE, 'gi') + +// Why a vendor slot: real prompts read "enter your Anthropic API key" and +// "paste your personal access token", not just "enter your 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 bare label prompt: decoration, an optional qualifier, then the noun. +const CREDENTIAL_LABEL_PREFIX_RE = /^[^a-z0-9]{0,8}(?:(?:new|current|old|your)\s+)?$/i + +const PURE_TERMINATOR_RE = /^[\s:?>›❯»*_|.…-]{0,16}$/ +const FOR_TARGET_TERMINATED_RE = /[:?>›❯»_]\s*$/ +const FOR_TARGET_RE = /^\s+(?:for|to)\s/i + +// Why `?` is excluded here: a credential prompt ends in a colon or an input +// caret. An agent legitimately asking the user a credential-adjacent question +// ("Should I store the password in .env?") ends in a question mark, and that +// must not read as a prompt. +const CLAUSE_TERMINATED_RE = /[:›❯»>_]\s*$/ + +const SUDO_PASSWORD_RE = /^[^a-z0-9]{0,8}\[sudo\]\s+password for\b/i +const GIT_CREDENTIAL_RE = /^[^a-z0-9]{0,8}(?:username|password) for ['"]?[a-z][a-z0-9+.-]*:\/\//i + +// Why two markers: a lone auth verb is narration. A device-code or sign-in +// dialog always pairs the verb with a flow marker in the same live window. +const AUTH_VERB_RE = + /\b(?:sign[ -]?in|signin|log[ -]?in|authenticate|authorized?|authorization|authentication)\b/i +const AUTH_FLOW_RE = + /\b(?:sign[ -]?in with|log[ -]?in with|authenticate with|authentication required|authorization required|sign[ -]?in required|login required|enter (?:the )?code|device code|verification code|waiting for (?:authentication|authorization|you to)|open (?:this|the following) url|press enter to (?:open|sign)|paste (?:it|the code) (?:here|below)|two[ -]factor|2fa|authenticator app|mfa|\d-digit code)\b/i + +/** + * Cheap superset of everything `findCredentialPromptIndex` can match, tested + * per retained tail line at streaming rate. Must stay a superset: a tail the + * index rejects is never parsed in full. + */ +export const TERMINAL_CREDENTIAL_PROMPT_SENTINEL_RE = new RegExp( + `(?:(?:${CREDENTIAL_NOUN_SOURCE}|username for)[^\\n]{0,64}[:?>›❯»_]\\s*$)|` + + `(?:${AUTH_FLOW_RE.source})`, + 'im' +) + +function isCredentialPromptLine(line: string): boolean { + if (line.length > MAX_CREDENTIAL_LINE_LENGTH) { + return false + } + if (SUDO_PASSWORD_RE.test(line) || GIT_CREDENTIAL_RE.test(line)) { + return true + } + CREDENTIAL_NOUN_RE.lastIndex = 0 + let match: RegExpExecArray | null + while ((match = CREDENTIAL_NOUN_RE.exec(line)) !== null) { + const prefix = line.slice(0, match.index) + const suffix = line.slice(match.index + match[0].length) + const terminated = + PURE_TERMINATOR_RE.test(suffix) || + (suffix.length <= 100 && + FOR_TARGET_RE.test(suffix) && + FOR_TARGET_TERMINATED_RE.test(suffix)) || + (suffix.length <= 64 && CLAUSE_TERMINATED_RE.test(suffix)) + if (!terminated) { + continue + } + if (CREDENTIAL_ASK_PREFIX_RE.test(prefix) || CREDENTIAL_LABEL_PREFIX_RE.test(prefix)) { + return true + } + } + return false +} + +/** Offset of the live credential prompt in `normalized`, or null. */ +export function findCredentialPromptIndex(normalized: string): number | null { + const windowStart = startOfLastNonBlankLines(normalized, CREDENTIAL_TAIL_LINES) + const tail = normalized.slice(windowStart) + let offset = 0 + let promptIndex: number | null = null + let sawAuthVerb = false + let sawAuthFlow = false + for (const raw of tail.split('\n')) { + // Why: dialogs are drawn inside box rules, which otherwise glue the frame to the wording. + const line = raw.replace(/[\u2500-\u257f]+/g, ' ').trim() + if (isCredentialPromptLine(line)) { + promptIndex = windowStart + offset + } + if (line.length <= MAX_CREDENTIAL_LINE_LENGTH) { + sawAuthVerb = sawAuthVerb || AUTH_VERB_RE.test(line) + sawAuthFlow = sawAuthFlow || AUTH_FLOW_RE.test(line) + } + offset += raw.length + 1 + } + if (promptIndex !== null) { + return promptIndex + } + return sawAuthVerb && sawAuthFlow ? windowStart : null +} diff --git a/src/main/runtime/terminal-credential-prompt-write-guard.test.ts b/src/main/runtime/terminal-credential-prompt-write-guard.test.ts new file mode 100644 index 00000000000..2703c95bf65 --- /dev/null +++ b/src/main/runtime/terminal-credential-prompt-write-guard.test.ts @@ -0,0 +1,154 @@ +// Regression for the #19749 harm at the WRITE site rather than at the detector. +// +// The readiness detector for Antigravity has been rewritten five times and has repeatedly +// reported ready with a live sign-in dialog on screen, at which point the orchestrator typed +// the user's task prompt into it. This suite fixes the pane in exactly that state — the full +// Antigravity ready chrome that HEAD's detector accepts, an idle OSC title, and a device-code +// sign-in dialog drawn over it — and asserts the send is refused anyway. +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { assertTerminalAgentSendable } from './rpc/terminal-agent-send-guard' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +const LEAF_ID = '22222222-2222-4222-8222-222222222222' +const TAB_ID = 'tab-1' +const WORKTREE_ID = 'wt-1' +const PTY_ID = 'pty-1' + +// Antigravity's ready chrome: header, a gemini model row, and a lone `>` caret. All three are +// what `findAntigravityReadyPromptIndex` keys on, so this prefix reads ready on its own. +const ANTIGRAVITY_READY = [ + 'Antigravity CLI', + 'gemini 3 pro (high)', + '~/orca/workspaces/orca/crash-closer', + '>', + '' +].join('\n') + +const ANTIGRAVITY_SIGN_IN = [ + ' Sign in to Antigravity', + ' Open https://antigravity.google/device and enter the code: KXTD-9PQR', + ' Waiting for authentication…', + '' +].join('\n') + +const API_KEY_PROMPT = [' Enter your Antigravity API key:', ''].join('\n') + +// A title Orca reads as explicitly idle, which is what let the wait resolve as ready. +const IDLE_TITLE = '◇ Gemini CLI ready' + +async function createPane(data: string): Promise<{ + runtime: OrcaRuntimeService + handle: string + writes: string[] +}> { + const runtime = new OrcaRuntimeService(null) + const writes: string[] = [] + const internals = runtime as unknown as { + resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise + } + vi.spyOn(internals, 'resolveTerminalWorkspaceLaunchScope').mockResolvedValue({ + id: WORKTREE_ID, + path: '/repo/app', + connectionId: null, + repo: null, + folderWorkspace: null + }) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: PTY_ID, incarnationId: 'inc-1' }), + write: (_id: string, payload: string): boolean => { + writes.push(payload) + return true + }, + kill: () => true, + getForegroundProcess: (): Promise => Promise.resolve('agy') + }) + const terminal = await runtime.createTerminal(`id:${WORKTREE_ID}`, { + tabId: TAB_ID, + leafId: LEAF_ID, + title: 'Terminal' + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + title: 'Terminal', + activeLeafId: LEAF_ID, + layout: null + } + ], + leaves: [ + { + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + leafId: LEAF_ID, + paneRuntimeId: 1, + ptyId: PTY_ID, + paneTitle: IDLE_TITLE + } + ] + }) + runtime.onPtyData(PTY_ID, data, Date.now()) + return { runtime, handle: terminal.handle, writes } +} + +describe('a task prompt is never typed into a live credential surface (#19749)', () => { + it('refuses the send while a device-code sign-in dialog covers the ready chrome', async () => { + const { runtime, handle, writes } = await createPane( + `${ANTIGRAVITY_READY}${ANTIGRAVITY_SIGN_IN}` + ) + + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: true, + status: 'permission' + }) + await expect( + assertTerminalAgentSendable({ runtime, handle, assertWritable: () => {} }) + ).rejects.toThrow('terminal_guard_permission') + await expect( + runtime.sendTerminalAgentPrompt(handle, 'Fix the crash in the worktree list and push') + ).rejects.toThrow('agent_prompt_blocked') + expect(writes).toEqual([]) + }) + + it('refuses the send while an API-key prompt covers the ready chrome', async () => { + const { runtime, handle, writes } = await createPane(`${ANTIGRAVITY_READY}${API_KEY_PROMPT}`) + + await expect( + runtime.sendTerminalAgentPrompt(handle, 'Fix the crash in the worktree list and push') + ).rejects.toThrow('agent_prompt_blocked') + expect(writes).toEqual([]) + }) + + it('names the credential prompt instead of reporting the lane idle', async () => { + // Defer, do not drop: the caller is told why, and the wait poll re-reads the tail, so the + // refusal clears on its own once the dialog is answered. + const { runtime, handle } = await createPane(`${ANTIGRAVITY_READY}${ANTIGRAVITY_SIGN_IN}`) + + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 400 }) + ).resolves.toMatchObject({ satisfied: false, blockedReason: 'agent-credential-prompt' }) + }) + + it('admits the send once the sign-in is answered and the agent returns to its prompt', async () => { + // The control. Same pane, same title, dialog replaced by live ready chrome. + const { runtime, handle } = await createPane(`${ANTIGRAVITY_READY}${ANTIGRAVITY_SIGN_IN}`) + await expect(runtime.sendTerminalAgentPrompt(handle, 'first attempt')).rejects.toThrow( + 'agent_prompt_blocked' + ) + + runtime.onPtyData(PTY_ID, `\nSigned in as neil@example.com.\n${ANTIGRAVITY_READY}`, Date.now()) + + await expect( + assertTerminalAgentSendable({ runtime, handle, assertWritable: () => {} }) + ).resolves.toBeUndefined() + }) +}) diff --git a/src/main/runtime/terminal-cursor-approval-detection.ts b/src/main/runtime/terminal-cursor-approval-detection.ts new file mode 100644 index 00000000000..a25475162e7 --- /dev/null +++ b/src/main/runtime/terminal-cursor-approval-detection.ts @@ -0,0 +1,47 @@ +import { startOfLastLines } from './terminal-wait-tail-window' + +/** cursor-agent's key-bound exec-approval menu, which no hook reports. */ +// Why text at all: cursor-agent has no approval hook, so the key-bound menu is the only authority. +const CURSOR_APPROVAL_CHOICE_MARKERS = [ + 'run (once)', + 'to allowlist?', + 'run everything', + 'skip & tell the agent' +] +// Why bounded: an answered menu remains in scrollback; only a dialog owning the screen bottom is live. +const CURSOR_APPROVAL_TAIL_LINES = 8 + +export function findCursorApprovalPromptIndex(normalized: string): number | null { + const windowStart = startOfLastLines(normalized, CURSOR_APPROVAL_TAIL_LINES) + const tail = normalized.slice(windowStart) + if (!tail.includes('run this command?')) { + return null + } + const lines = tail.split('\n') + while (lines.length > 0 && lines.at(-1)?.trim() === '') { + lines.pop() + } + let matchedLines = 0 + let lastChoiceLine = -1 + for (let index = 0; index < lines.length; index += 1) { + if (!isCursorApprovalChoiceLine(lines[index])) { + continue + } + matchedLines += 1 + lastChoiceLine = index + } + return matchedLines >= 2 && lastChoiceLine === lines.length - 1 + ? windowStart + tail.lastIndexOf('run this command?') + : null +} + +// Why the trailing key: narration can repeat the menu wording, but it does not end in a selectable key. +const CURSOR_APPROVAL_CHOICE_KEY_RE = + /\((?:shift\+tab|ctrl\+[a-z]|esc(?: or [a-z])*|tab|enter|return|space|[a-z]|[\u21b5\u21e7\u21b9\u238b\u23ce]{1,3})\)\s*$/ + +function isCursorApprovalChoiceLine(line: string): boolean { + return ( + CURSOR_APPROVAL_CHOICE_KEY_RE.test(line) && + CURSOR_APPROVAL_CHOICE_MARKERS.some((marker) => line.includes(marker)) + ) +} diff --git a/src/main/runtime/terminal-tail-sentinel-index.test.ts b/src/main/runtime/terminal-tail-sentinel-index.test.ts index cd8bf2e68fb..466626ba3c7 100644 --- a/src/main/runtime/terminal-tail-sentinel-index.test.ts +++ b/src/main/runtime/terminal-tail-sentinel-index.test.ts @@ -8,7 +8,7 @@ import { tailMayContainBlockedSignal } from './terminal-tail-sentinel-index' import { computeTerminalTailWaitState } from './terminal-wait-tail-state' -import { TERMINAL_WAIT_BLOCKED_SENTINEL_RE } from './terminal-wait-detection' +import { TERMINAL_WAIT_BLOCKED_SENTINEL_RE } from './terminal-wait-blocked-sentinel' import type { RetainedTailRedrawCursor } from './terminal-tail-redraw-buffer' // The definition the incremental index must reproduce: does ANY retained line (or the diff --git a/src/main/runtime/terminal-tail-sentinel-index.ts b/src/main/runtime/terminal-tail-sentinel-index.ts index c99fd31bac7..0a89cac8518 100644 --- a/src/main/runtime/terminal-tail-sentinel-index.ts +++ b/src/main/runtime/terminal-tail-sentinel-index.ts @@ -1,4 +1,4 @@ -import { TERMINAL_WAIT_BLOCKED_SENTINEL_RE } from './terminal-wait-detection' +import { mayContainTerminalWaitBlockedSentinel } from './terminal-wait-blocked-sentinel' /** * Which retained tail lines match the wait-blocked sentinel, memoized per @@ -19,7 +19,7 @@ function collectSentinelMatches( into: number[] ): void { for (let index = startIndex; index < lines.length; index += 1) { - if (TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(lines[index]!)) { + if (mayContainTerminalWaitBlockedSentinel(lines[index]!)) { into.push(index) } } diff --git a/src/main/runtime/terminal-wait-blocked-sentinel.ts b/src/main/runtime/terminal-wait-blocked-sentinel.ts new file mode 100644 index 00000000000..f556ee35111 --- /dev/null +++ b/src/main/runtime/terminal-wait-blocked-sentinel.ts @@ -0,0 +1,13 @@ +import { TERMINAL_CREDENTIAL_PROMPT_SENTINEL_RE } from './terminal-credential-prompt-detection' + +/** Cheap negative scan run per retained tail line, so only candidate-bearing tails parse in full. */ +export const TERMINAL_WAIT_BLOCKED_SENTINEL_RE = + /update available|choose working directory to|codex just got an upgrade|hooks need review|do you trust|trust this|trusted workspace|press enter to (?:confirm|continue|view|insert)|press t to trust|permission required|requires permission|allow once|allow always|run this command\?/i + +/** Whether `text` can carry any blocked signal, credential prompts included. */ +export function mayContainTerminalWaitBlockedSentinel(text: string): boolean { + return ( + TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(text) || + TERMINAL_CREDENTIAL_PROMPT_SENTINEL_RE.test(text) + ) +} diff --git a/src/main/runtime/terminal-wait-detection.ts b/src/main/runtime/terminal-wait-detection.ts index cae25e8bfa6..8772c84cee1 100644 --- a/src/main/runtime/terminal-wait-detection.ts +++ b/src/main/runtime/terminal-wait-detection.ts @@ -4,11 +4,10 @@ import { type AgentStatus } from '../../shared/agent-detection' import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-types' -import { - isTerminalWaitWhitespace, - startOfLastLines, - startOfLastNonBlankLines -} from './terminal-wait-tail-window' +import { isTerminalWaitWhitespace, startOfLastNonBlankLines } from './terminal-wait-tail-window' +import { findCursorApprovalPromptIndex } from './terminal-cursor-approval-detection' +import { findCredentialPromptIndex } from './terminal-credential-prompt-detection' +import { mayContainTerminalWaitBlockedSentinel } from './terminal-wait-blocked-sentinel' const EXPLICIT_IDLE_TITLE_RE = /(^|\s)(ready|idle|done)(\s|$|[.!?])/i const CLAUDE_IDLE_PREFIX = '\u2733' @@ -42,10 +41,28 @@ export function isKnownReadyPromptPreview(preview: string): boolean { return false } const blockedSignal = findTerminalWaitBlockedSignal(normalized) - if (blockedSignal !== null && blockedSignal.index > readyIndex) { - return false + if (blockedSignal === null) { + return true } - return true + // Why index-independent for these two: an unconditional reason is one a ready caret must not + // vouch for, and a dialog drawn over ready chrome can share the caret's offset (#19749). + return ( + !isUnconditionalTerminalWaitBlockedReason(blockedSignal.reason) && + blockedSignal.index <= readyIndex + ) +} + +/** + * Reasons a live non-permission title must not clear. + * + * `agent-approval-prompt`: cursor-agent never reports idle via OSC title. + * `agent-credential-prompt`: a readiness verdict is exactly what is wrong when a + * sign-in dialog is on screen, so it cannot be the thing that overrides this. + */ +export function isUnconditionalTerminalWaitBlockedReason( + reason: RuntimeTerminalWaitBlockedReason | null +): boolean { + return reason === 'agent-approval-prompt' || reason === 'agent-credential-prompt' } export function detectTerminalWaitBlockedReason( @@ -62,6 +79,12 @@ export function findActionableTerminalWaitBlockedSignal( if (blockedSignal === null) { return null } + // Why never dismissible: a ready caret elsewhere on the screen is not evidence + // that a live credential prompt was answered, and mistaking one for the other + // submits the task prompt as a credential. + if (blockedSignal.reason === 'agent-credential-prompt') { + return blockedSignal + } const dismissedModalIndex = findDismissedStartupModalIndex(normalized) // Why: a live prompt after the modal means it was dismissed → signal no longer actionable, even mid-run (Cursor never reports idle via OSC title). return dismissedModalIndex !== null && dismissedModalIndex > blockedSignal.index @@ -158,54 +181,6 @@ function findAntigravityReadyPromptIndex(normalized: string): number | null { return modelIndex !== null && promptIndex !== null ? Math.max(modelIndex, promptIndex) : null } -export const TERMINAL_WAIT_BLOCKED_SENTINEL_RE = - /update available|choose working directory to|codex just got an upgrade|hooks need review|do you trust|trust this|trusted workspace|press enter to (?:confirm|continue|view|insert)|press t to trust|permission required|requires permission|allow once|allow always|run this command\?/i - -// Why text at all: cursor-agent has no approval hook, so the key-bound menu is the only authority. -const CURSOR_APPROVAL_CHOICE_MARKERS = [ - 'run (once)', - 'to allowlist?', - 'run everything', - 'skip & tell the agent' -] -// Why bounded: an answered menu remains in scrollback; only a dialog owning the screen bottom is live. -const CURSOR_APPROVAL_TAIL_LINES = 8 - -function findCursorApprovalPromptIndex(normalized: string): number | null { - const windowStart = startOfLastLines(normalized, CURSOR_APPROVAL_TAIL_LINES) - const tail = normalized.slice(windowStart) - if (!tail.includes('run this command?')) { - return null - } - const lines = tail.split('\n') - while (lines.length > 0 && lines.at(-1)?.trim() === '') { - lines.pop() - } - let matchedLines = 0 - let lastChoiceLine = -1 - for (let index = 0; index < lines.length; index += 1) { - if (!isCursorApprovalChoiceLine(lines[index])) { - continue - } - matchedLines += 1 - lastChoiceLine = index - } - return matchedLines >= 2 && lastChoiceLine === lines.length - 1 - ? windowStart + tail.lastIndexOf('run this command?') - : null -} - -// Why the trailing key: narration can repeat the menu wording, but it does not end in a selectable key. -const CURSOR_APPROVAL_CHOICE_KEY_RE = - /\((?:shift\+tab|ctrl\+[a-z]|esc(?: or [a-z])*|tab|enter|return|space|[a-z]|[\u21b5\u21e7\u21b9\u238b\u23ce]{1,3})\)\s*$/ - -function isCursorApprovalChoiceLine(line: string): boolean { - return ( - CURSOR_APPROVAL_CHOICE_KEY_RE.test(line) && - CURSOR_APPROVAL_CHOICE_MARKERS.some((marker) => line.includes(marker)) - ) -} - // Why bounded: answered dialogs and quoted prompt wording (agents grep this file and its specs) stay in the // retained tail; only a dialog owning the screen bottom is live. Real Codex dialogs (trust, hooks review, // update, exec approval) are 4-8 lines; the slack covers a wrapped command or a longer hook list. @@ -217,7 +192,7 @@ function findTerminalWaitBlockedSignal( const windowStart = startOfLastNonBlankLines(fullTail, LIVE_PROMPT_TAIL_LINES) const normalized = windowStart === 0 ? fullTail : fullTail.slice(windowStart) // Why: one combined negative scan avoids a dozen searches when no prompt can match. - if (!TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(normalized)) { + if (!mayContainTerminalWaitBlockedSentinel(normalized)) { return null } const signal = findBlockedSignalInLiveWindow(normalized) @@ -228,7 +203,10 @@ function findTerminalWaitBlockedSignal( function findBlockedSignalInLiveWindow( normalized: string ): { reason: RuntimeTerminalWaitBlockedReason; index: number } | null { - const candidates: { reason: RuntimeTerminalWaitBlockedReason; index: number }[] = [] + const candidates: { + reason: RuntimeTerminalWaitBlockedReason + index: number + }[] = [] const updateIndex = normalized.lastIndexOf('update available') if (updateIndex !== -1 && normalized.includes('press enter to continue', updateIndex)) { candidates.push({ reason: 'agent-update-prompt', index: updateIndex }) @@ -242,7 +220,10 @@ function findBlockedSignalInLiveWindow( modelMigrationIndex !== -1 && normalized.includes('press enter to continue', modelMigrationIndex) ) { - candidates.push({ reason: 'codex-model-migration-prompt', index: modelMigrationIndex }) + candidates.push({ + reason: 'codex-model-migration-prompt', + index: modelMigrationIndex + }) } const hooksIndex = normalized.lastIndexOf('hooks need review') if (hooksIndex !== -1 && normalized.includes('press enter to confirm', hooksIndex)) { @@ -294,9 +275,19 @@ function findBlockedSignalInLiveWindow( candidates.push({ reason: 'agent-interactive-prompt', index: interactivePromptIndex }) } } + const credentialPromptIndex = findCredentialPromptIndex(normalized) + if (credentialPromptIndex !== null) { + candidates.push({ + reason: 'agent-credential-prompt', + index: credentialPromptIndex + }) + } const cursorApprovalIndex = findCursorApprovalPromptIndex(normalized) if (cursorApprovalIndex !== null) { - candidates.push({ reason: 'agent-approval-prompt', index: cursorApprovalIndex }) + candidates.push({ + reason: 'agent-approval-prompt', + index: cursorApprovalIndex + }) } const permissionPromptIndex = Math.max( normalized.lastIndexOf('permission required'), @@ -317,9 +308,14 @@ function findBlockedSignalInLiveWindow( candidates.push({ reason: 'agent-interactive-prompt', index: permissionPromptIndex }) } } - return candidates.length > 0 - ? candidates.reduce((latest, candidate) => - candidate.index > latest.index ? candidate : latest - ) - : null + if (candidates.length === 0) { + return null + } + // Why it outranks a later signal: every other reason can be cleared by a ready + // caret, so a credential prompt losing the index race would lose the block too. + const credential = candidates.find((candidate) => candidate.reason === 'agent-credential-prompt') + return ( + credential ?? + candidates.reduce((latest, candidate) => (candidate.index > latest.index ? candidate : latest)) + ) } diff --git a/src/main/runtime/terminal-wait-tail-state.ts b/src/main/runtime/terminal-wait-tail-state.ts index 712b301a961..08111c24be9 100644 --- a/src/main/runtime/terminal-wait-tail-state.ts +++ b/src/main/runtime/terminal-wait-tail-state.ts @@ -1,10 +1,8 @@ import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-types' import { buildTailLines } from './terminal-tail-state' import { tailMayContainBlockedSignal } from './terminal-tail-sentinel-index' -import { - findActionableTerminalWaitBlockedSignal, - TERMINAL_WAIT_BLOCKED_SENTINEL_RE -} from './terminal-wait-detection' +import { findActionableTerminalWaitBlockedSignal } from './terminal-wait-detection' +import { mayContainTerminalWaitBlockedSentinel } from './terminal-wait-blocked-sentinel' export function buildTerminalWaitText( lines: string[], @@ -66,7 +64,7 @@ function inspectTerminalWaitTail( // Why the index: proving a signal is ABSENT can't early-exit, so a full re-test of the // 2000-line tail ran per scan; the index tests only the lines each append produced. mayContainBlockedSignal: - tailMayContainBlockedSignal(lines) || TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(partialLine) + tailMayContainBlockedSignal(lines) || mayContainTerminalWaitBlockedSentinel(partialLine) } } diff --git a/src/main/runtime/terminal-wait-tail-window.test.ts b/src/main/runtime/terminal-wait-tail-window.test.ts new file mode 100644 index 00000000000..e7cf071f4a6 --- /dev/null +++ b/src/main/runtime/terminal-wait-tail-window.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { startOfLastLines, startOfLastNonBlankLines } from './terminal-wait-tail-window' + +describe('startOfLastNonBlankLines', () => { + it('returns 0 for a tail that begins with a newline', () => { + // Regression: `lastIndexOf('\n', -1)` clamps its position argument up to 0, so a leading + // newline made the walk answer lineStart=1 with lineEnd=0 forever. Every caller runs on + // the main process's PTY data path, so the spin was a hard hang, not a slow scan. Reached + // only when the tail also holds fewer than `count` non-blank lines, which is the shape of + // a small startup dialog on an otherwise empty screen. + expect(startOfLastNonBlankLines('\ndo you trust this folder?', 12)).toBe(0) + expect(startOfLastNonBlankLines('\n', 12)).toBe(0) + expect(startOfLastNonBlankLines('\n\n\n \n', 4)).toBe(0) + }) + + it('still windows a tail with interior blank rows', () => { + const tail = 'one\n\ntwo\n\nthree' + expect(startOfLastNonBlankLines(tail, 1)).toBe(tail.indexOf('three')) + expect(startOfLastNonBlankLines(tail, 2)).toBe(tail.indexOf('two')) + expect(startOfLastNonBlankLines(tail, 9)).toBe(0) + }) + + it('counts blank rows in the raw line window', () => { + const tail = 'one\n\ntwo' + expect(startOfLastLines(tail, 2)).toBe(tail.indexOf('\ntwo') - 0) + expect(startOfLastLines(tail, 9)).toBe(0) + }) +}) diff --git a/src/main/runtime/terminal-wait-tail-window.ts b/src/main/runtime/terminal-wait-tail-window.ts index 788000328f1..f366d39d418 100644 --- a/src/main/runtime/terminal-wait-tail-window.ts +++ b/src/main/runtime/terminal-wait-tail-window.ts @@ -31,7 +31,10 @@ export function startOfLastNonBlankLines(value: string, count: number): number { return lineStart } } - if (lineStart === 0) { + // Why `lineEnd === 0`: a tail whose first character is a newline makes + // `lastIndexOf('\n', -1)` answer 0 (the position argument clamps up), so + // `lineStart` stays 1 while `lineEnd` stays 0 and the walk never advances. + if (lineStart === 0 || lineEnd === 0) { return 0 } lineEnd = lineStart - 1 diff --git a/src/shared/runtime-terminal-contracts.ts b/src/shared/runtime-terminal-contracts.ts index ad392a2b9a0..5940dd13e27 100644 --- a/src/shared/runtime-terminal-contracts.ts +++ b/src/shared/runtime-terminal-contracts.ts @@ -347,6 +347,7 @@ export type RuntimeTerminalWaitBlockedReason = | 'agent-hooks-review-prompt' | 'agent-interactive-prompt' | 'agent-approval-prompt' + | 'agent-credential-prompt' export type RuntimeTerminalWait = { handle: string diff --git a/src/shared/terminal-wait-blocked-reason-legacy-alias.test.ts b/src/shared/terminal-wait-blocked-reason-legacy-alias.test.ts index 2e3e7e4687c..19b6759b135 100644 --- a/src/shared/terminal-wait-blocked-reason-legacy-alias.test.ts +++ b/src/shared/terminal-wait-blocked-reason-legacy-alias.test.ts @@ -50,10 +50,17 @@ describe('describeTerminalWaitBlockedReason', () => { ) }) - it.each(['agent-trust-workspace', 'codex-model-migration-prompt'] as const)( - 'renders %s unannotated', - (reason) => { - expect(describeTerminalWaitBlockedReason(reason)).toBe(reason) - } - ) + it.each([ + 'agent-trust-workspace', + 'codex-model-migration-prompt', + // Why pinned: this reason was born neutral, so it has no older spelling to annotate. An alias + // entry for it would invent one and print it at all four render sites. + 'agent-credential-prompt' + ] as const)('renders %s unannotated', (reason) => { + expect(describeTerminalWaitBlockedReason(reason)).toBe(reason) + }) + + it('has no legacy alias for the credential prompt', () => { + expect(agentNeutralTerminalWaitBlockedReason('agent-credential-prompt')).toBeNull() + }) })