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:
Neil
2026-09-10 23:51:07 -07:00
parent b3e0a33fa4
commit 3d4aeef4ec
15 changed files with 792 additions and 86 deletions
@@ -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)
)
}
+61 -65
View File
@@ -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))
)
}
+3 -5
View File
@@ -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
+1
View File
@@ -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()
})
})