diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 3e0a1f1bda1..8829e52feea 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -7663,14 +7663,16 @@ "providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"], "coveredPlatforms": ["macos"], "coveredProviders": ["local", "daemon"], - "coverageNotes": "Runtime and provider-contract tests on macOS cover exact-PTY authorization, local/daemon fresh confirmation, exact ConPTY membership, and unsupported-provider fail-closed behavior. Physical Windows, live Linux, SSH, WSL, and remote-runtime validation remain explicit gaps; providers without confirmation preserve conservative refusal on a shell conflict.", + "coverageNotes": "Runtime and provider-contract tests on macOS cover exact-PTY authorization, verified managed-Claude launch identity, local/daemon fresh confirmation, exact ConPTY membership, and unsupported-provider fail-closed behavior. Physical Windows, live Linux, SSH, WSL, and remote-runtime validation remain explicit gaps; providers without confirmation preserve conservative refusal when no verified launch identity exists.", "motivatingLinks": ["https://github.com/stablyai/orca/issues/8303"], - "invariant": "A guarded note send writes only to the exact PTY binding checked by the guard and only while permission/wait evidence allows input; fresh hook state conflicting with an ordinary shell foreground requires fresh provider confirmation of a recognized agent in that PTY.", - "oracle": "Fresh explicit state plus ordinary PowerShell plus confirmed recognized agent is sendable on the same PTY. Confirmed shell/non-agent, unavailable confirmation, PTY exit, handle rebind, or a callback PTY mismatch returns a refusal or not-writable result and writes zero bytes.", + "invariant": "A guarded note send writes only to the exact PTY binding checked by the guard and only while permission/wait evidence allows input; a generic busy title requires independent agent identity from the title, a verified managed launch bound to the current PTY incarnation, or the foreground process; fresh hook state conflicting with an ordinary shell foreground requires fresh provider confirmation of a recognized agent in that PTY.", + "oracle": "A quarter-circle Claude task title authorizes without a provider lookup only on the exact PTY incarnation carrying verified managed-Claude launch identity; the same title in a bare pane, with an unverified launch hint, or after PTY incarnation replacement is refused while current Claude activity remains a working signal. Fresh explicit state plus ordinary PowerShell plus confirmed recognized agent is sendable on the same PTY. Confirmed shell/non-agent, unavailable confirmation, PTY exit, handle rebind, or a callback PTY mismatch returns a refusal or not-writable result and writes zero bytes.", "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/quarter-circle-title-send-authorization.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/ipc/pty.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider.test.ts src/main/providers/windows-conpty-process-membership.test.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/pty-subprocess.test.ts src/renderer/src/lib/active-agent-note-send.test.ts src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx" ], "testFiles": [ + "src/main/runtime/quarter-circle-title-send-authorization.test.ts", "src/main/runtime/orca-runtime.test.ts", "src/main/runtime/rpc/terminal-send.test.ts", "src/main/ipc/pty.test.ts", @@ -7683,6 +7685,14 @@ "src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx" ], "assertionRefs": [ + { + "file": "src/main/runtime/quarter-circle-title-send-authorization.test.ts", + "assertions": [ + "a verified managed-Claude launch authorizes a task-text quarter-circle title without foreground-process work", + "a bare pane, an unverified launch hint, and a replacement PTY incarnation remain refused", + "quarter circles remain working signals and braille authorization is unchanged" + ] + }, { "file": "src/main/runtime/orca-runtime.test.ts", "assertions": [ @@ -7750,6 +7760,15 @@ } ], "evidenceRuns": [ + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/quarter-circle-title-send-authorization.test.ts", + "result": "passed", + "durationSeconds": 20.9, + "summary": "The focused file passed 9 tests, including current-incarnation managed-Claude authorization with zero foreground calls and fail-closed bare, unverified, and replacement-incarnation quarter-circle titles." + }, { "date": "2026-07-11", "runner": "local", @@ -7770,11 +7789,11 @@ }, "redGreenEvidence": { "status": "partial", - "evidence": "Removing strong confirmation fails the shell-conflict success oracle, while removing either exact-binding comparison fails zero-write rebind coverage; saved intentional-break and physical Windows evidence remain missing." + "evidence": "Before the verified-launch fallback, the managed-Claude unavailable-foreground case failed with isRunningAgent false; the focused file passed 7 tests after the fix. Removing the launch-incarnation comparison makes the replacement-incarnation refusal fail. Removing strong confirmation fails the shell-conflict success oracle, while removing either exact-binding comparison fails zero-write rebind coverage; physical Windows evidence remains missing." }, "performanceBudget": { "required": true, - "evidence": "Confirmation is invoked at most once per status evaluation and only for fresh explicit hook state whose ordinary foreground result is a shell. Count tests prove permission/title blockers and ordinary recognized-agent evidence add zero confirmations; no polling, retry, session listing, provider fanout, or runtime-global cache is added, and existing provider snapshot dedup remains authoritative." + "evidence": "Confirmation is invoked at most once per status evaluation and only for fresh explicit hook state whose ordinary foreground result is a shell. Count tests prove verified managed-Claude launch identity, permission/title blockers, and ordinary recognized-agent evidence add zero provider calls; no polling, retry, session listing, provider fanout, or runtime-global cache is added, and existing provider snapshot dedup remains authoritative." }, "promotionCriteria": [ "Run the browser annotation existing-agent and repeat-send path in Electron on Windows ConPTY without a recognition refusal.", @@ -7787,7 +7806,7 @@ "SSH, WSL, legacy daemon, and remote-runtime providers without confirmation remain intentionally fail-closed on an ordinary-shell conflict; no live artifacts cover those degraded paths.", "No live Linux PTY, paired-web, mobile/relay, restore/replay, or multi-window artifact is attached; those surfaces receive no renderer, persistence, or protocol change." ], - "demotionRule": "Keep non-blocking or demote to protection none if provider confirmation becomes unconditional, exact-PTY mismatch can write bytes, unsupported providers fail open, or the focused gate flakes without an actionable product or harness defect." + "demotionRule": "Keep non-blocking or demote to protection none if a generic busy title authorizes without exact title, verified-launch, or foreground identity; provider confirmation becomes unconditional; exact-PTY mismatch can write bytes; unsupported providers fail open; or the focused gate flakes without an actionable product or harness defect." }, { "id": "terminal-input.agent-prompt-injection", diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 6e81da239ce..cb0de7350c8 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -7,6 +7,7 @@ import { isCursorAgentTitle, isCursorNativeAgentTitle, isOpenCodeNativeTitle, + isQuarterCircleSpinnerOnlyAgentTitle, isShellProcess, normalizeTerminalTitle } from '../../shared/agent-detection' @@ -1317,6 +1318,8 @@ type RuntimePtyWorktreeRecord = { paneKey: string | null launchConfig: SleepingAgentLaunchConfig | null launchToken: string | null + // Why: provider PTY IDs can be reused; launch identity belongs only to the process that received the token. + launchIncarnationId: PtyIncarnationId | null launchAgent: TuiAgent | null foregroundAgent: TuiAgent | null connected: boolean @@ -9393,6 +9396,7 @@ export class OrcaRuntimeService { isTuiAgent(agentLaunchAuthority.launchAgent) ) { pty.launchToken = agentLaunchAuthority.launchToken + pty.launchIncarnationId = binding.incarnationId pty.launchAgent = agentLaunchAuthority.launchAgent } const pendingIncarnation = this.pendingPtyRegistrationIncarnations.get(ptyId) @@ -12402,6 +12406,7 @@ export class OrcaRuntimeService { } this.restoredOrchestrationAuthorityByPtyId.delete(ptyId) pty.launchToken = null + pty.launchIncarnationId = null const paneKeys = new Set() if (pty.paneKey && parsePaneKey(pty.paneKey)) { paneKeys.add(pty.paneKey) @@ -16795,7 +16800,12 @@ export class OrcaRuntimeService { } } if (terminal.titleStatus) { - if (isOpenCodeNativeTitle(terminal.title)) { + // Why: an OpenCode marker and a lone quarter-circle spinner (STA-4028) are activity, + // not identity, so resolve both through the identity/foreground evidence path. + if ( + isOpenCodeNativeTitle(terminal.title) || + isQuarterCircleSpinnerOnlyAgentTitle(terminal.title) + ) { const isRunningAgent = await this.isTerminalRunningAgent(handle) this.assertTerminalAgentStatusPtyBinding(handle, ptyId) return { @@ -25775,6 +25785,7 @@ export class OrcaRuntimeService { ? copySleepingAgentLaunchConfig(effectiveLaunchConfig) : null pty.launchToken = launchToken ?? null + pty.launchIncarnationId = launchToken ? pty.incarnationId : null pty.launchAgent = launchOpts.launchAgent ?? null } pty.tabId = tabId @@ -29399,6 +29410,7 @@ export class OrcaRuntimeService { paneKey: state.paneKey ?? null, launchConfig: null, launchToken: null, + launchIncarnationId: null, launchAgent: null, foregroundAgent: null, connected: state.connected ?? true, @@ -31540,15 +31552,24 @@ export class OrcaRuntimeService { return await this.isPtyRunningAgent(pty.pty, leaf) } const { leaf } = this.getLiveLeafForHandle(handle) + const trackedPty = leaf.ptyId ? this.ptysById.get(leaf.ptyId) : null // Why: check the leaf pane title and the tab title, which already carries OSC-enriched agent indicators (e.g. ✳ prefix). const paneTitle = getLatestLeafTitle(leaf, null) const paneTitleClassification = classifyAgentTitle(paneTitle) - if (paneTitleClassification === 'agent' && !isOpenCodeNativeTitle(paneTitle)) { + if ( + trackedPty + ? ptyTitleProvesAgentPresence(trackedPty, paneTitle, paneTitleClassification) + : agentTitleProvesAgentPresence(paneTitle, paneTitleClassification) + ) { return true } const tabTitle = this.tabs.get(leaf.tabId)?.title?.trim() || null const tabTitleClassification = paneTitle === null ? classifyAgentTitle(tabTitle) : 'neutral' - if (tabTitleClassification === 'agent' && !isOpenCodeNativeTitle(tabTitle)) { + if ( + trackedPty + ? ptyTitleProvesAgentPresence(trackedPty, tabTitle, tabTitleClassification) + : agentTitleProvesAgentPresence(tabTitle, tabTitleClassification) + ) { return true } const openCodeMarkerTitle = paneTitle ?? tabTitle @@ -31597,7 +31618,7 @@ export class OrcaRuntimeService { ) : null const leafTitleClassification = classifyAgentTitle(leafTitle) - if (leafTitleClassification === 'agent' && !isOpenCodeNativeTitle(leafTitle)) { + if (ptyTitleProvesAgentPresence(pty, leafTitle, leafTitleClassification)) { return true } const ptyTitle = getLatestAgentCandidateTitle( @@ -31605,11 +31626,7 @@ export class OrcaRuntimeService { { title: pty.lastOscTitle, updatedAt: pty.lastOscTitleAt } ) const ptyTitleClassification = classifyAgentTitle(ptyTitle) - if ( - leafTitle === null && - ptyTitleClassification === 'agent' && - !isOpenCodeNativeTitle(ptyTitle) - ) { + if (leafTitle === null && ptyTitleProvesAgentPresence(pty, ptyTitle, ptyTitleClassification)) { return true } const managementTitleClassification = classifyLatestAgentTitle({ @@ -37569,6 +37586,33 @@ function getLatestLeafTitle(leaf: RuntimeLeafRecord, tabTitle: string | null): s ) } +// Why: an 'agent' title only proves an agent owns the pane when something other than a +// quarter-circle spinner carries it — those glyphs are generic progress frames (STA-4028). +function agentTitleProvesAgentPresence( + title: string | null, + classification: 'agent' | 'management' | 'neutral' +): boolean { + return ( + classification === 'agent' && + !isOpenCodeNativeTitle(title) && + !isQuarterCircleSpinnerOnlyAgentTitle(title) + ) +} + +function ptyTitleProvesAgentPresence( + pty: RuntimePtyWorktreeRecord, + title: string | null, + classification: 'agent' | 'management' | 'neutral' +): boolean { + return ( + agentTitleProvesAgentPresence(title, classification) || + (isQuarterCircleSpinnerOnlyAgentTitle(title) && + pty.launchAgent === 'claude' && + pty.launchToken !== null && + pty.launchIncarnationId === pty.incarnationId) + ) +} + function classifyAgentTitle(title: string | null): 'agent' | 'management' | 'neutral' { if (!title) { return 'neutral' diff --git a/src/main/runtime/quarter-circle-title-send-authorization.test.ts b/src/main/runtime/quarter-circle-title-send-authorization.test.ts new file mode 100644 index 00000000000..d8e58073cb0 --- /dev/null +++ b/src/main/runtime/quarter-circle-title-send-authorization.test.ts @@ -0,0 +1,212 @@ +// STA-4028 (regression from #13925): quarter circles are ordinary progress glyphs — +// ora, installers, any TUI animates them — so a title carrying nothing else must not +// authorize a guarded send, which auto-submits with Enter into whatever owns the pane. +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { assertTerminalAgentSendable } from './rpc/terminal-agent-send-guard' +import { detectAgentStatusFromTitle } from '../../shared/agent-detection' + +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 = '11111111-1111-4111-8111-111111111111' +const TAB_ID = 'tab-1' +const WORKTREE_ID = 'wt-1' +const PTY_ID = 'pty-1' + +// Captured from Claude Code 2.1.228 while it read this repository's package.json. +const SPINNER_ONLY_TITLE = '◑ Check package version in package.json' +const SPINNER_WITH_IDENTITY_TITLE = '◐ Claude Code' +const BRAILLE_SPINNER_ONLY_TITLE = '⠂ Deploying release 4.2' + +async function createRuntimeWithTitle( + paneTitle: string, + foregroundProcess: string | null, + launchAgent?: 'claude', + verifiedLaunch = true +): Promise<{ + runtime: OrcaRuntimeService + handle: string + getForegroundProcess: ReturnType +}> { + const runtime = new OrcaRuntimeService(null) + const internals = runtime as unknown as { + resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise + } + vi.spyOn(internals, 'resolveTerminalWorkspaceLaunchScope').mockResolvedValue({ + id: WORKTREE_ID, + path: '/repo/app', + connectionId: null, + repo: null, + folderWorkspace: null + }) + const getForegroundProcess = vi.fn(async () => foregroundProcess) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: PTY_ID, incarnationId: 'initial-incarnation' }), + write: () => true, + kill: () => true, + getForegroundProcess + }) + const terminal = await runtime.createTerminal(`id:${WORKTREE_ID}`, { + tabId: TAB_ID, + leafId: LEAF_ID, + title: 'Terminal', + ...(launchAgent + ? { + launchAgent, + ...(verifiedLaunch + ? { launchConfig: { agentCommand: 'claude', agentArgs: '', agentEnv: {} } } + : {}) + } + : {}) + }) + 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 + } + ] + }) + return { runtime, handle: terminal.handle, getForegroundProcess } +} + +const AUTHORIZED = 'authorized' + +async function guardedSendResult(runtime: OrcaRuntimeService, handle: string): Promise { + try { + await assertTerminalAgentSendable({ runtime, handle, assertWritable: () => {} }) + return AUTHORIZED + } catch (error) { + return error instanceof Error ? error.message : String(error) + } +} + +describe('quarter-circle title send authorization (STA-4028)', () => { + it('refuses a guarded send when a quarter-circle spinner is the only agent evidence', async () => { + const { runtime, handle } = await createRuntimeWithTitle(SPINNER_ONLY_TITLE, 'node') + + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: false + }) + await expect(guardedSendResult(runtime, handle)).resolves.toBe('terminal_guard_no_agent') + }) + + it('refuses a guarded send when the foreground process cannot be read at all', async () => { + // Why: SSH and folder-workspace panes can fail the foreground read; no evidence + // stays a refusal rather than falling back to the glyph. + const { runtime, handle } = await createRuntimeWithTitle(SPINNER_ONLY_TITLE, null) + + await expect(guardedSendResult(runtime, handle)).resolves.toBe('terminal_guard_no_agent') + }) + + it('authorizes a guarded send when the foreground process is a recognized agent', async () => { + const { runtime, handle } = await createRuntimeWithTitle(SPINNER_ONLY_TITLE, 'claude') + + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: true, + status: 'working' + }) + await expect(guardedSendResult(runtime, handle)).resolves.toBe(AUTHORIZED) + }) + + it('authorizes a managed Claude launch when the foreground process is unavailable', async () => { + const { runtime, handle, getForegroundProcess } = await createRuntimeWithTitle( + SPINNER_ONLY_TITLE, + null, + 'claude' + ) + expect( + ( + runtime as unknown as { + ptysById: Map + } + ).ptysById.get(PTY_ID) + ).toMatchObject({ launchAgent: 'claude', launchToken: expect.any(String) }) + + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: true, + status: 'working' + }) + await expect(guardedSendResult(runtime, handle)).resolves.toBe(AUTHORIZED) + expect(getForegroundProcess).not.toHaveBeenCalled() + }) + + it('does not trust an unverified Claude launch hint', async () => { + const { runtime, handle } = await createRuntimeWithTitle( + SPINNER_ONLY_TITLE, + null, + 'claude', + false + ) + + await expect(guardedSendResult(runtime, handle)).resolves.toBe('terminal_guard_no_agent') + }) + + it('does not carry managed Claude identity into a replacement PTY incarnation', async () => { + const { runtime, handle } = await createRuntimeWithTitle(SPINNER_ONLY_TITLE, null, 'claude') + const pty = ( + runtime as unknown as { + ptysById: Map< + string, + { incarnationId: string | null; launchIncarnationId: string | null; launchToken: string } + > + } + ).ptysById.get(PTY_ID) + expect(pty).toMatchObject({ + incarnationId: 'initial-incarnation', + launchIncarnationId: 'initial-incarnation' + }) + + runtime.onPtySpawned(PTY_ID, 'replacement-incarnation', { awaitsRegistration: false }) + + expect(pty).toMatchObject({ + incarnationId: 'replacement-incarnation', + launchIncarnationId: 'initial-incarnation', + launchToken: expect.any(String) + }) + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: false + }) + await expect(guardedSendResult(runtime, handle)).resolves.toBe('terminal_guard_no_agent') + }) + + it('authorizes a guarded send when the busy title itself names the agent', async () => { + const { runtime, handle } = await createRuntimeWithTitle(SPINNER_WITH_IDENTITY_TITLE, null) + + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: true, + status: 'working' + }) + await expect(guardedSendResult(runtime, handle)).resolves.toBe(AUTHORIZED) + }) + + it('leaves braille-spinner authorization unchanged', async () => { + const { runtime, handle } = await createRuntimeWithTitle(BRAILLE_SPINNER_ONLY_TITLE, null) + + await expect(guardedSendResult(runtime, handle)).resolves.toBe(AUTHORIZED) + }) + + it('keeps the quarter-circle glyph a working activity signal (#13889)', async () => { + expect(detectAgentStatusFromTitle(SPINNER_ONLY_TITLE)).toBe('working') + expect(detectAgentStatusFromTitle(SPINNER_WITH_IDENTITY_TITLE)).toBe('working') + }) +}) diff --git a/src/shared/agent-detection.ts b/src/shared/agent-detection.ts index a2e419c4ff3..c603914b450 100644 --- a/src/shared/agent-detection.ts +++ b/src/shared/agent-detection.ts @@ -25,6 +25,7 @@ export { clearWorkingIndicators, createAgentStatusTracker, detectAgentStatusFromTitle, + isQuarterCircleSpinnerOnlyAgentTitle, normalizeTerminalTitle } from './agent-title-status' diff --git a/src/shared/agent-title-status.ts b/src/shared/agent-title-status.ts index 1a0785aa368..5c13bb56e95 100644 --- a/src/shared/agent-title-status.ts +++ b/src/shared/agent-title-status.ts @@ -16,6 +16,7 @@ import { containsAgentName, containsAgentSpinnerGlyph, containsAny, + containsQuarterCircleSpinner, containsLegacyAgentName, isClaudeManagementTitle, isGeminiTerminalTitle, @@ -223,3 +224,15 @@ export function detectAgentStatusFromTitle(title: string): AgentStatus | null { return 'idle' } + +/** + * True when a quarter-circle spinner frame is the only agent evidence a title carries. + * Any TUI animates those glyphs, so they prove activity, not identity — callers that + * authorize writes into the pane need independent evidence (STA-4028, regression #13925). + */ +export function isQuarterCircleSpinnerOnlyAgentTitle(title: string | null | undefined): boolean { + if (!title || !containsQuarterCircleSpinner(title)) { + return false + } + return detectAgentStatusFromTitle(title.replace(QUARTER_CIRCLE_SPINNER_RE, '').trim()) === null +}