fix(terminal): require agent identity for guarded sends (STA-4028) (#14092)

* fix(terminal): require agent identity for guarded sends (STA-4028)

Quarter-circle spinner glyphs (U+25D0-U+25D3) started classifying a title
as "working" in #13925, and that status alone authorized guarded agent
sends — which auto-submit with Enter — into any pane whose TUI animates
those generic progress frames.

Keep the glyphs as an activity signal, but stop treating a title whose
only agent evidence is a quarter circle as proof an agent owns the pane:
send authorization now falls through to recognized-agent identity in the
title or a recognized foreground process. Braille-spinner behavior is
unchanged.

* fix(terminal): preserve verified managed busy identity

* fix(terminal): bind busy identity to process incarnation

* chore(test): avoid duplicate terminal gate suite

(cherry picked from commit cb41f3c5f4)
This commit is contained in:
Brennan Benson
2026-08-12 17:13:39 -07:00
committed by Jinjing
parent 735d1a17d9
commit ffe2f0b2a9
5 changed files with 313 additions and 10 deletions
+62 -4
View File
@@ -6,6 +6,8 @@ import {
isClaudeManagementTitle,
isCursorAgentTitle,
isCursorNativeAgentTitle,
isOpenCodeNativeTitle,
isQuarterCircleSpinnerOnlyAgentTitle,
isShellProcess,
normalizeTerminalTitle
} from '../../shared/agent-detection'
@@ -1306,6 +1308,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
@@ -9373,6 +9377,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)
@@ -12372,6 +12377,7 @@ export class OrcaRuntimeService {
}
this.restoredOrchestrationAuthorityByPtyId.delete(ptyId)
pty.launchToken = null
pty.launchIncarnationId = null
const paneKeys = new Set<string>()
if (pty.paneKey && parsePaneKey(pty.paneKey)) {
paneKeys.add(pty.paneKey)
@@ -16746,6 +16752,20 @@ export class OrcaRuntimeService {
}
}
if (terminal.titleStatus) {
// 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 {
handle,
isRunningAgent,
status: isRunningAgent ? terminal.titleStatus : null
}
}
return { handle, isRunningAgent: true, status: terminal.titleStatus }
}
@@ -25688,6 +25708,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
@@ -29185,6 +29206,7 @@ export class OrcaRuntimeService {
paneKey: state.paneKey ?? null,
launchConfig: null,
launchToken: null,
launchIncarnationId: null,
launchAgent: null,
foregroundAgent: null,
connected: state.connected ?? true,
@@ -31326,15 +31348,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') {
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') {
if (
trackedPty
? ptyTitleProvesAgentPresence(trackedPty, tabTitle, tabTitleClassification)
: agentTitleProvesAgentPresence(tabTitle, tabTitleClassification)
) {
return true
}
const waitText = buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview)
@@ -31382,7 +31413,7 @@ export class OrcaRuntimeService {
)
: null
const leafTitleClassification = classifyAgentTitle(leafTitle)
if (leafTitleClassification === 'agent') {
if (ptyTitleProvesAgentPresence(pty, leafTitle, leafTitleClassification)) {
return true
}
const ptyTitle = getLatestAgentCandidateTitle(
@@ -31390,7 +31421,7 @@ export class OrcaRuntimeService {
{ title: pty.lastOscTitle, updatedAt: pty.lastOscTitleAt }
)
const ptyTitleClassification = classifyAgentTitle(ptyTitle)
if (leafTitle === null && ptyTitleClassification === 'agent') {
if (leafTitle === null && ptyTitleProvesAgentPresence(pty, ptyTitle, ptyTitleClassification)) {
return true
}
const managementTitleClassification = classifyLatestAgentTitle({
@@ -37330,6 +37361,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'
@@ -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<typeof vi.fn>
}> {
const runtime = new OrcaRuntimeService(null)
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
})
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<string> {
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<string, { launchAgent: string | null; launchToken: string | null }>
}
).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')
})
})
+1
View File
@@ -25,6 +25,7 @@ export {
clearWorkingIndicators,
createAgentStatusTracker,
detectAgentStatusFromTitle,
isQuarterCircleSpinnerOnlyAgentTitle,
normalizeTerminalTitle
} from './agent-title-status'
+13
View File
@@ -16,6 +16,7 @@ import {
containsAgentName,
containsAgentSpinnerGlyph,
containsAny,
containsQuarterCircleSpinner,
containsLegacyAgentName,
isClaudeManagementTitle,
isGeminiTerminalTitle,
@@ -219,3 +220,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
}