mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 08:03:37 +00:00
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:
@@ -7238,14 +7238,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",
|
||||
@@ -7258,6 +7260,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": [
|
||||
@@ -7325,6 +7335,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",
|
||||
@@ -7345,11 +7364,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.",
|
||||
@@ -7362,7 +7381,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",
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -25,6 +25,7 @@ export {
|
||||
clearWorkingIndicators,
|
||||
createAgentStatusTracker,
|
||||
detectAgentStatusFromTitle,
|
||||
isQuarterCircleSpinnerOnlyAgentTitle,
|
||||
normalizeTerminalTitle
|
||||
} from './agent-title-status'
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user