mirror of
https://github.com/stablyai/orca.git
synced 2026-09-28 00:02:41 +00:00
fix(runtime): refuse a task prompt into a live credential prompt
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.
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<unknown>
|
||||
}
|
||||
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<string | null> => 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()
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user