mirror of
https://github.com/stablyai/orca.git
synced 2026-09-28 08:02:43 +00:00
fix(renderer): report every credential refusal, and prove the cross-emulator projection
`submitPromptToAgentPty` and `sendBracketedPasteToRunningAgent` collapsed a credential refusal to `false` and never called `onUndelivered`, so the automation dispatch path abandoned a session reuse with no record at all. Both now route through `deliverBracketedPaste`; the dispatch site tracks the refusal without a toast, because it recovers by launching a fresh background session. Quick launch — the highest-volume entry point — showed the readiness-timeout wording and `paste_readiness_timeout` telemetry for a credential refusal. The notice now takes the failure and routes it, which is what the `onUndelivered` rename was for; renamed to match what it reports. The equivalence proof rendered only through `@xterm/headless`, while the pane that feeds the guard is `@xterm/xterm` on a neighbouring beta. Added a parity suite driving the corpus through the real renderer emulator: both project the same bytes, so the skew is immaterial and a future divergence now fails a test. Split the corpus by side to stay under max-lines.
This commit is contained in:
@@ -2,10 +2,8 @@
|
||||
// blocks must also reach `agent-credential-prompt` through the wait-blocked vocabulary, which
|
||||
// is what makes `writeTerminalAgentPrompt` refuse the PTY write.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
LEGITIMATE_AGENT_SCREENS,
|
||||
LIVE_CREDENTIAL_SURFACES
|
||||
} from '../../shared/terminal-credential-prompt-corpus'
|
||||
import { LEGITIMATE_AGENT_SCREENS } from '../../shared/terminal-legitimate-agent-screens-corpus'
|
||||
import { LIVE_CREDENTIAL_SURFACES } from '../../shared/terminal-live-credential-surfaces-corpus'
|
||||
import {
|
||||
detectTerminalWaitBlockedReason,
|
||||
isKnownReadyPromptPreview
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
// The equivalence proof in src/shared renders through @xterm/headless, but the pane that actually
|
||||
// feeds the renderer guard is @xterm/xterm — a different package on a neighbouring beta. Byte
|
||||
// identity proven against one emulator says nothing about the other, so this drives the corpus
|
||||
// through the real renderer emulator and pins the two projections to each other.
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { Unicode11Addon } from '@xterm/addon-unicode11'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createRendererParityTerminal,
|
||||
writeToTerminal
|
||||
} from '../../../../shared/terminal-restore-parity-fixture'
|
||||
import { buildTerminalVisibleScreenText } from '../../../../shared/terminal-visible-screen-projection'
|
||||
import { activateOrcaTerminalUnicodeProvider } from '../../../../shared/terminal-unicode-provider'
|
||||
import { DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT } from '../../../../shared/terminal-scrollback-policy'
|
||||
import { findCredentialPromptIndex } from '../../../../shared/terminal-credential-prompt-detection'
|
||||
import { LEGITIMATE_AGENT_SCREENS } from '../../../../shared/terminal-legitimate-agent-screens-corpus'
|
||||
import { LIVE_CREDENTIAL_SURFACES } from '../../../../shared/terminal-live-credential-surfaces-corpus'
|
||||
|
||||
const VIEWPORT = { cols: 120, rows: 24 }
|
||||
|
||||
function renderThroughRendererEmulator(lines: string[]): Promise<string> {
|
||||
const terminal = new Terminal({
|
||||
cols: VIEWPORT.cols,
|
||||
rows: VIEWPORT.rows,
|
||||
scrollback: DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT,
|
||||
allowProposedApi: true,
|
||||
vtExtensions: { kittyKeyboard: true }
|
||||
})
|
||||
terminal.loadAddon(new Unicode11Addon())
|
||||
activateOrcaTerminalUnicodeProvider(terminal)
|
||||
return new Promise((resolve) => {
|
||||
terminal.write(`\x1b[H\x1b[2J${lines.join('\r\n')}`, () => {
|
||||
resolve(buildTerminalVisibleScreenText(terminal))
|
||||
terminal.dispose()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function renderThroughHeadlessEmulator(lines: string[]): Promise<string> {
|
||||
const { terminal } = createRendererParityTerminal(VIEWPORT)
|
||||
await writeToTerminal(terminal, `\x1b[H\x1b[2J${lines.join('\r\n')}`)
|
||||
return buildTerminalVisibleScreenText(terminal)
|
||||
}
|
||||
|
||||
describe('@xterm/xterm and @xterm/headless project the same visible screen', () => {
|
||||
it.each([...LIVE_CREDENTIAL_SURFACES, ...LEGITIMATE_AGENT_SCREENS])(
|
||||
'agrees byte-for-byte on %s',
|
||||
async (_name, lines) => {
|
||||
expect(await renderThroughRendererEmulator(lines)).toBe(
|
||||
await renderThroughHeadlessEmulator(lines)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(LIVE_CREDENTIAL_SURFACES)(
|
||||
'keeps %s detectable through the renderer emulator',
|
||||
async (_name, lines) => {
|
||||
const screen = await renderThroughRendererEmulator(lines)
|
||||
expect(findCredentialPromptIndex(screen.toLowerCase())).not.toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
it.each(LEGITIMATE_AGENT_SCREENS)(
|
||||
'keeps %s passable through the renderer emulator',
|
||||
async (_name, lines) => {
|
||||
const screen = await renderThroughRendererEmulator(lines)
|
||||
expect(findCredentialPromptIndex(screen.toLowerCase())).toBeNull()
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { listAutomationRunsForTarget } from '@/components/automations/automation-host-client'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { submitPromptToAgentPty } from '@/lib/agent-paste-draft'
|
||||
import { trackAgentPasteCredentialPromptRefusal } from '@/lib/agent-paste-credential-prompt-notice'
|
||||
import { launchAgentBackgroundSession } from '@/lib/launch-agent-background-session'
|
||||
import { observeExistingAutomationSession } from '@/lib/automation-session-observer'
|
||||
import { findReusableAutomationSession } from '@/lib/automation-session-reuse'
|
||||
@@ -111,7 +112,13 @@ export async function handleAutomationDispatchRequest({
|
||||
const submitted = await submitPromptToAgentPty({
|
||||
tabId: reusableSession.tabId,
|
||||
ptyId: reusableSession.ptyId,
|
||||
content: automation.prompt
|
||||
content: automation.prompt,
|
||||
// Why no toast: a refused reuse falls through to a fresh background session below,
|
||||
// so the prompt still lands — but the refusal must not vanish from telemetry.
|
||||
onUndelivered: (failure) =>
|
||||
failure === 'credential-prompt'
|
||||
? trackAgentPasteCredentialPromptRefusal(automation.agentId)
|
||||
: undefined
|
||||
})
|
||||
if (!submitted) {
|
||||
completion.cleanupRunObservers()
|
||||
|
||||
@@ -6,10 +6,8 @@ import {
|
||||
createRendererParityTerminal,
|
||||
writeToTerminal
|
||||
} from '../../../shared/terminal-restore-parity-fixture'
|
||||
import {
|
||||
LEGITIMATE_AGENT_SCREENS,
|
||||
LIVE_CREDENTIAL_SURFACES
|
||||
} from '../../../shared/terminal-credential-prompt-corpus'
|
||||
import { LEGITIMATE_AGENT_SCREENS } from '../../../shared/terminal-legitimate-agent-screens-corpus'
|
||||
import { LIVE_CREDENTIAL_SURFACES } from '../../../shared/terminal-live-credential-surfaces-corpus'
|
||||
import { registerPtyVisibleScreen } from '@/components/terminal-pane/pty-visible-screen-registry'
|
||||
import { isAgentPasteBlockedByCredentialPrompt } from './agent-paste-credential-prompt-guard'
|
||||
|
||||
|
||||
@@ -18,6 +18,11 @@ export function showAgentPasteCredentialPromptToast(agent: TuiAgent, submitted:
|
||||
{ value0: submitted ? 'prompt' : 'notes' }
|
||||
)
|
||||
)
|
||||
trackAgentPasteCredentialPromptRefusal(agent)
|
||||
}
|
||||
|
||||
/** For callers that recover on their own and so must not toast, but still owe the refusal a record. */
|
||||
export function trackAgentPasteCredentialPromptRefusal(agent: TuiAgent): void {
|
||||
// Why 'unknown': errorClassSchema has no credential-refusal slot, and the dashboard's unknown
|
||||
// slice is this repo's established trigger to add one.
|
||||
track('agent_error', { error_class: 'unknown', agent_kind: tuiAgentToAgentKind(agent) })
|
||||
|
||||
@@ -10,6 +10,7 @@ import { registerPtyVisibleScreen } from '@/components/terminal-pane/pty-visible
|
||||
import {
|
||||
pasteDraftToAgentPtyWhenReady,
|
||||
pasteDraftWhenAgentReady,
|
||||
sendBracketedPasteToRunningAgent,
|
||||
submitPromptToAgentPty
|
||||
} from './agent-paste-draft'
|
||||
|
||||
@@ -227,10 +228,24 @@ describe('the agent paste lane refuses a live credential prompt', () => {
|
||||
|
||||
it('guards the automation reuse entry point, which has no readiness wait at all', async () => {
|
||||
await showOnPane('pty-1', SIGN_IN_DIALOG)
|
||||
const onUndelivered = vi.fn()
|
||||
|
||||
await expect(
|
||||
submitPromptToAgentPty({ tabId: 'tab-1', ptyId: 'pty-1', content: PROMPT })
|
||||
submitPromptToAgentPty({ tabId: 'tab-1', ptyId: 'pty-1', content: PROMPT, onUndelivered })
|
||||
).resolves.toBe(false)
|
||||
expect(testState.sendRuntimePtyInputVerified).not.toHaveBeenCalled()
|
||||
// A refusal the caller never hears about is the silent drop the guard exists to prevent.
|
||||
expect(onUndelivered).toHaveBeenCalledExactlyOnceWith('credential-prompt')
|
||||
})
|
||||
|
||||
it('reports a refused paste into an already-running agent', async () => {
|
||||
await showOnPane('pty-1', SIGN_IN_DIALOG)
|
||||
const onUndelivered = vi.fn()
|
||||
|
||||
await expect(
|
||||
sendBracketedPasteToRunningAgent({ ptyId: 'pty-1', content: PROMPT, onUndelivered })
|
||||
).resolves.toBe(false)
|
||||
expect(testState.sendRuntimePtyInputVerified).not.toHaveBeenCalled()
|
||||
expect(onUndelivered).toHaveBeenCalledExactlyOnceWith('credential-prompt')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -189,27 +189,27 @@ export async function submitPromptToAgentPty(args: {
|
||||
tabId: string
|
||||
ptyId: string
|
||||
content: string
|
||||
onUndelivered?: (failure: AgentDraftDeliveryFailure) => void
|
||||
}): Promise<boolean> {
|
||||
return (
|
||||
(await sendBracketedPasteToAgent({
|
||||
return await deliverBracketedPaste(
|
||||
{
|
||||
settings: getSettingsForAgentTabRuntimeOwner(args.tabId),
|
||||
ptyId: args.ptyId,
|
||||
content: args.content,
|
||||
submit: true
|
||||
})) === 'delivered'
|
||||
},
|
||||
args.onUndelivered
|
||||
)
|
||||
}
|
||||
|
||||
export async function sendBracketedPasteToRunningAgent(args: {
|
||||
ptyId: string
|
||||
content: string
|
||||
onUndelivered?: (failure: AgentDraftDeliveryFailure) => void
|
||||
}): Promise<boolean> {
|
||||
return (
|
||||
(await sendBracketedPasteToAgent({
|
||||
ptyId: args.ptyId,
|
||||
content: args.content,
|
||||
submit: true
|
||||
})) === 'delivered'
|
||||
return await deliverBracketedPaste(
|
||||
{ ptyId: args.ptyId, content: args.content, submit: true },
|
||||
args.onUndelivered
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { CLIENT_PLATFORM } from '@/lib/new-workspace'
|
||||
import { getAgentLaunchPlatformForRepo } from '@/lib/agent-launch-platform'
|
||||
import { persistAgentLaunchTabOrder } from '@/lib/launch-agent-tab-order'
|
||||
import { tuiAgentToAgentKind } from '@/lib/telemetry'
|
||||
import { createPasteReadinessTimeoutNotice } from '@/lib/launch-agent-paste-timeout-notice'
|
||||
import { createPasteUndeliveredNotice } from '@/lib/launch-agent-paste-undelivered-notice'
|
||||
import {
|
||||
deliverLaunchPromptToAgentTab,
|
||||
seedNativeChatLaunchDraftForAgentTab
|
||||
@@ -264,7 +264,7 @@ function launchAgentInNewTabInternal(
|
||||
seedNativeChatLaunchDraftForAgentTab({ tabId: tab.id, agent, text: trimmedPrompt })
|
||||
}
|
||||
if (pasteDraftAfterLaunch !== null) {
|
||||
const timeoutNotice = createPasteReadinessTimeoutNotice({
|
||||
const undeliveredNotice = createPasteUndeliveredNotice({
|
||||
worktreeId,
|
||||
tabId: tab.id,
|
||||
agent,
|
||||
@@ -276,7 +276,7 @@ function launchAgentInNewTabInternal(
|
||||
agent,
|
||||
submit: submitPastedPrompt,
|
||||
forcePaste: promptDelivery === 'submit-after-ready',
|
||||
onUndelivered: timeoutNotice.onTimeout
|
||||
onUndelivered: undeliveredNotice.onUndelivered
|
||||
}).then((delivered) => {
|
||||
if (delivered) {
|
||||
if (agent === 'command-code' && submitPastedPrompt) {
|
||||
@@ -286,7 +286,7 @@ function launchAgentInNewTabInternal(
|
||||
}
|
||||
onPromptDelivered?.()
|
||||
}
|
||||
return { delivered, failureNotified: !delivered && timeoutNotice.wasNotified() }
|
||||
return { delivered, failureNotified: !delivered && undeliveredNotice.wasNotified() }
|
||||
})
|
||||
if (promptDelivery === 'submit-after-ready') {
|
||||
promptDeliveryResult = deliveryPromise
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// Quick launch is the highest-volume paste entry point, and it used to report every undelivered
|
||||
// prompt as a readiness timeout — including a credential refusal, which is a different event with
|
||||
// different advice and different telemetry.
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { createPasteUndeliveredNotice } from './launch-agent-paste-undelivered-notice'
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
appState: {
|
||||
activeWorktreeId: 'wt-1',
|
||||
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'pty-1' }] } as Record<
|
||||
string,
|
||||
{ id: string; ptyId: string | null }[]
|
||||
>
|
||||
},
|
||||
toastMessage: vi.fn(),
|
||||
track: vi.fn(),
|
||||
showCredentialToast: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({ useAppStore: { getState: () => testState.appState } }))
|
||||
vi.mock('sonner', () => ({ toast: { message: testState.toastMessage } }))
|
||||
vi.mock('@/lib/telemetry', () => ({
|
||||
track: testState.track,
|
||||
tuiAgentToAgentKind: (agent: string) => agent
|
||||
}))
|
||||
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
|
||||
vi.mock('@/lib/agent-paste-credential-prompt-notice', () => ({
|
||||
showAgentPasteCredentialPromptToast: testState.showCredentialToast
|
||||
}))
|
||||
|
||||
function notice(): ReturnType<typeof createPasteUndeliveredNotice> {
|
||||
return createPasteUndeliveredNotice({
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-1',
|
||||
agent: 'codex',
|
||||
submitted: true
|
||||
})
|
||||
}
|
||||
|
||||
describe('createPasteUndeliveredNotice', () => {
|
||||
beforeEach(() => {
|
||||
testState.appState.activeWorktreeId = 'wt-1'
|
||||
testState.appState.tabsByWorktree = { 'wt-1': [{ id: 'tab-1', ptyId: 'pty-1' }] }
|
||||
testState.toastMessage.mockReset()
|
||||
testState.track.mockReset()
|
||||
testState.showCredentialToast.mockReset()
|
||||
})
|
||||
|
||||
it('routes a credential refusal to the credential notice, not the timeout one', () => {
|
||||
const undelivered = notice()
|
||||
undelivered.onUndelivered('credential-prompt')
|
||||
|
||||
expect(testState.showCredentialToast).toHaveBeenCalledExactlyOnceWith('codex', true)
|
||||
expect(testState.track).not.toHaveBeenCalled()
|
||||
expect(undelivered.wasNotified()).toBe(true)
|
||||
})
|
||||
|
||||
it('still reports a readiness timeout as a readiness timeout', () => {
|
||||
const undelivered = notice()
|
||||
undelivered.onUndelivered('readiness-timeout')
|
||||
|
||||
expect(testState.showCredentialToast).not.toHaveBeenCalled()
|
||||
expect(testState.track).toHaveBeenCalledExactlyOnceWith('agent_error', {
|
||||
error_class: 'paste_readiness_timeout',
|
||||
agent_kind: 'codex'
|
||||
})
|
||||
expect(undelivered.wasNotified()).toBe(true)
|
||||
})
|
||||
|
||||
it('stays silent when the PTY never spawned, so the caller owns the sole notice', () => {
|
||||
testState.appState.tabsByWorktree = { 'wt-1': [{ id: 'tab-1', ptyId: null }] }
|
||||
const undelivered = notice()
|
||||
undelivered.onUndelivered('credential-prompt')
|
||||
|
||||
expect(testState.showCredentialToast).not.toHaveBeenCalled()
|
||||
expect(undelivered.wasNotified()).toBe(false)
|
||||
})
|
||||
|
||||
it('suppresses the toast but marks notified once the user has moved on', () => {
|
||||
testState.appState.activeWorktreeId = 'wt-2'
|
||||
const undelivered = notice()
|
||||
undelivered.onUndelivered('credential-prompt')
|
||||
|
||||
expect(testState.showCredentialToast).not.toHaveBeenCalled()
|
||||
expect(testState.toastMessage).not.toHaveBeenCalled()
|
||||
expect(undelivered.wasNotified()).toBe(true)
|
||||
})
|
||||
})
|
||||
+14
-4
@@ -2,25 +2,31 @@ import { toast } from 'sonner'
|
||||
import { useAppStore } from '@/store'
|
||||
import { track, tuiAgentToAgentKind } from '@/lib/telemetry'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { showAgentPasteCredentialPromptToast } from '@/lib/agent-paste-credential-prompt-notice'
|
||||
import type { AgentDraftDeliveryFailure } from '@/lib/agent-paste-credential-prompt-guard'
|
||||
import type { TuiAgent } from '../../../shared/tui-agent'
|
||||
|
||||
/**
|
||||
* Notice for a post-launch paste that never landed — a stalled readiness wait
|
||||
* would otherwise drop the user's text silently.
|
||||
*
|
||||
* Why it takes the failure: the two reasons want different telemetry. Reporting a
|
||||
* credential refusal as `paste_readiness_timeout` is the same lie the reason argument
|
||||
* exists to prevent.
|
||||
*
|
||||
* `wasNotified()` reports whether the user already heard about it, so a
|
||||
* deferred caller can suppress a duplicate toast.
|
||||
*/
|
||||
export function createPasteReadinessTimeoutNotice(args: {
|
||||
export function createPasteUndeliveredNotice(args: {
|
||||
worktreeId: string
|
||||
tabId: string
|
||||
agent: TuiAgent
|
||||
submitted: boolean
|
||||
}): { onTimeout: () => void; wasNotified: () => boolean } {
|
||||
}): { onUndelivered: (failure: AgentDraftDeliveryFailure) => void; wasNotified: () => boolean } {
|
||||
let notified = false
|
||||
return {
|
||||
wasNotified: () => notified,
|
||||
onTimeout: () => {
|
||||
onUndelivered: (failure) => {
|
||||
const state = useAppStore.getState()
|
||||
const currentTab = (state.tabsByWorktree[args.worktreeId] ?? []).find(
|
||||
(tab) => tab.id === args.tabId
|
||||
@@ -34,6 +40,11 @@ export function createPasteReadinessTimeoutNotice(args: {
|
||||
notified = true
|
||||
return
|
||||
}
|
||||
notified = true
|
||||
if (failure === 'credential-prompt') {
|
||||
showAgentPasteCredentialPromptToast(args.agent, args.submitted)
|
||||
return
|
||||
}
|
||||
toast.message(
|
||||
translate(
|
||||
'auto.lib.launch.agent.in.new.tab.a5a1f7033f',
|
||||
@@ -41,7 +52,6 @@ export function createPasteReadinessTimeoutNotice(args: {
|
||||
{ value0: args.submitted ? 'prompt' : 'notes' }
|
||||
)
|
||||
)
|
||||
notified = true
|
||||
track('agent_error', {
|
||||
error_class: 'paste_readiness_timeout',
|
||||
agent_kind: tuiAgentToAgentKind(args.agent)
|
||||
@@ -0,0 +1,133 @@
|
||||
// Agent output ABOUT auth work, which reads exactly like a credential dialog to any rule that
|
||||
// only looks for auth wording. Every one of these blocked before the pair rule gained a prompt-shape
|
||||
// requirement, and every one leaves the agent's own composer caret on the last row.
|
||||
import type { TerminalCredentialPromptCase } from './terminal-live-credential-surfaces-corpus'
|
||||
|
||||
export const AGENT_AUTH_SUMMARY_SCREENS: readonly TerminalCredentialPromptCase[] = [
|
||||
// An agent SUMMARISING auth work it just finished, with its own composer on the last row.
|
||||
// These pair an auth verb with an auth-flow phrase, which is the shape that used to match
|
||||
// with no prompt terminator and no position requirement at all.
|
||||
[
|
||||
'codex summarising two-factor work',
|
||||
[
|
||||
'• I implemented two-factor authentication for the login flow.',
|
||||
' The authenticator app now generates a 6-digit code.',
|
||||
'› Ask Codex to do anything'
|
||||
]
|
||||
],
|
||||
[
|
||||
'claude summarising two-factor work',
|
||||
[
|
||||
'· Added two-factor authentication. Tests for the authenticator app pass.',
|
||||
'✳ Claude Code',
|
||||
'> '
|
||||
]
|
||||
],
|
||||
[
|
||||
'codex summarising a sign-in button',
|
||||
[
|
||||
'• Added a Sign in with Google button; it logs the authorization result.',
|
||||
'› Ask Codex to do anything'
|
||||
]
|
||||
],
|
||||
[
|
||||
'codex reporting an auth error it hit',
|
||||
[
|
||||
' └ ERROR: authentication required. Please sign in with the CLI.',
|
||||
'› Ask Codex to do anything'
|
||||
]
|
||||
],
|
||||
[
|
||||
'agent asking whether MFA is wanted',
|
||||
['· Should the app require MFA, or is authentication via password enough?', '> ']
|
||||
],
|
||||
[
|
||||
'codex summarising a device-code flow it built',
|
||||
[
|
||||
'• The OAuth flow now shows a device code and waits for authentication.',
|
||||
'› Ask Codex to do anything'
|
||||
]
|
||||
],
|
||||
[
|
||||
'rg hit on auth documentation',
|
||||
[' └ docs/auth.md:12: Users authenticate with the authenticator app.', '> ']
|
||||
],
|
||||
[
|
||||
'rg hits on a login component',
|
||||
[
|
||||
' └ src/Login.tsx:31: <button>Sign in with GitHub</button>',
|
||||
' └ src/Login.tsx:44: // authorization code exchange',
|
||||
'› Ask Codex to do anything'
|
||||
]
|
||||
],
|
||||
[
|
||||
'codex reporting MFA tests passing',
|
||||
['• All MFA tests pass; authentication is wired end to end.', '› Ask Codex to do anything']
|
||||
],
|
||||
[
|
||||
'codex quoting a build failure',
|
||||
[
|
||||
"• The build failed: 'authorization required'. You need to log in with `vercel login`.",
|
||||
'› Ask Codex to do anything'
|
||||
]
|
||||
],
|
||||
[
|
||||
'rg hit on a readme auth section',
|
||||
[
|
||||
' └ docs/auth.md:3: ## Authentication',
|
||||
' Users sign in with GitHub or an authenticator app.',
|
||||
'> '
|
||||
]
|
||||
],
|
||||
[
|
||||
'claude summarising an oauth change',
|
||||
[
|
||||
'· Done — the OAuth login now requires authentication via the device code flow.',
|
||||
'✳ Claude Code',
|
||||
'> '
|
||||
]
|
||||
],
|
||||
[
|
||||
'gemini summarising SSO work',
|
||||
['✦ Added SSO. Users authenticate with Okta; the sign in with SAML path is tested.', '◇ ']
|
||||
],
|
||||
[
|
||||
'opencode wrapping an auth summary',
|
||||
[
|
||||
'Added requireAuth middleware. Unauthenticated requests get 401; sign in with the',
|
||||
'token endpoint returns a JWT.',
|
||||
'❯ '
|
||||
]
|
||||
],
|
||||
[
|
||||
'stack trace over a codex composer',
|
||||
[
|
||||
'Error: authentication required',
|
||||
' at signInWithToken (auth.ts:22)',
|
||||
'› Ask Codex to do anything'
|
||||
]
|
||||
],
|
||||
[
|
||||
'shell deploy failure',
|
||||
[
|
||||
'Running deploy...',
|
||||
'ERROR: authentication required',
|
||||
'Please sign in with the CLI and retry.',
|
||||
'exit code 1'
|
||||
]
|
||||
],
|
||||
// Printed config whose bare `password:` label is not the screen's bottom row.
|
||||
[
|
||||
'printed kubernetes secret manifest',
|
||||
['kind: Secret', 'stringData:', ' password:', '› Ask Codex to do anything']
|
||||
],
|
||||
[
|
||||
'printed signup form template',
|
||||
[
|
||||
'• The signup form now has these fields:',
|
||||
' email:',
|
||||
' password:',
|
||||
'› Ask Codex to do anything'
|
||||
]
|
||||
]
|
||||
]
|
||||
@@ -7,10 +7,8 @@ import {
|
||||
findCredentialPromptIndex,
|
||||
TERMINAL_CREDENTIAL_PROMPT_SENTINEL_RE
|
||||
} from './terminal-credential-prompt-detection'
|
||||
import {
|
||||
LEGITIMATE_AGENT_SCREENS,
|
||||
LIVE_CREDENTIAL_SURFACES
|
||||
} from './terminal-credential-prompt-corpus'
|
||||
import { LEGITIMATE_AGENT_SCREENS } from './terminal-legitimate-agent-screens-corpus'
|
||||
import { LIVE_CREDENTIAL_SURFACES } from './terminal-live-credential-surfaces-corpus'
|
||||
import { TERMINAL_TITLE_CLASSIFICATION_CORPUS } from './terminal-title-classification-corpus'
|
||||
|
||||
function screen(lines: string[]): string {
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
// The other side of the credential-prompt corpus: screens the guard MUST let through. A refusal
|
||||
// here is worse than a missed prompt — the reason is unconditional, so it also pins an idle agent
|
||||
// to `permission` in the agent-status store.
|
||||
import type { TerminalCredentialPromptCase } from './terminal-live-credential-surfaces-corpus'
|
||||
import { AGENT_AUTH_SUMMARY_SCREENS } from './terminal-agent-auth-summary-screens-corpus'
|
||||
|
||||
export const LEGITIMATE_AGENT_SCREENS: readonly TerminalCredentialPromptCase[] = [
|
||||
// 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'
|
||||
]
|
||||
],
|
||||
...AGENT_AUTH_SUMMARY_SCREENS
|
||||
]
|
||||
@@ -0,0 +1,91 @@
|
||||
// Screens a live credential or sign-in surface owns, i.e. every screen the guard MUST refuse.
|
||||
// Shared by the shared-detector suite, the main-process wait-vocabulary suite and the renderer
|
||||
// paste-lane suite: one corpus keeps the three lanes provably in agreement.
|
||||
|
||||
export type TerminalCredentialPromptCase = readonly [name: string, lines: string[]]
|
||||
|
||||
export const LIVE_CREDENTIAL_SURFACES: readonly TerminalCredentialPromptCase[] = [
|
||||
[
|
||||
'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:']],
|
||||
[
|
||||
'gh auth login device code',
|
||||
[
|
||||
'! First copy your one-time code: 1A2B-3C4D',
|
||||
'Press Enter to open github.com in your browser...'
|
||||
]
|
||||
],
|
||||
[
|
||||
'claude /login paste-code screen',
|
||||
[
|
||||
"Browser didn't open? Use the url below to sign in:",
|
||||
'https://claude.ai/oauth/authorize?code=true',
|
||||
'Paste code here if prompted >'
|
||||
]
|
||||
]
|
||||
]
|
||||
@@ -10,10 +10,8 @@ import {
|
||||
visibleNonBlankTerminalLines
|
||||
} from './terminal-visible-screen-projection'
|
||||
import { findCredentialPromptIndex } from './terminal-credential-prompt-detection'
|
||||
import {
|
||||
LEGITIMATE_AGENT_SCREENS,
|
||||
LIVE_CREDENTIAL_SURFACES
|
||||
} from './terminal-credential-prompt-corpus'
|
||||
import { LEGITIMATE_AGENT_SCREENS } from './terminal-legitimate-agent-screens-corpus'
|
||||
import { LIVE_CREDENTIAL_SURFACES } from './terminal-live-credential-surfaces-corpus'
|
||||
|
||||
const VIEWPORT = { cols: 120, rows: 24 }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user