test(runtime): pin the fail-closed contract for bare Cursor titles (#12816)

Reverts the classification change and keeps behavior at base.

cursor-agent's native OSC title is the bare literal "Cursor Agent" and never
carries a status word, so it names the agent without proving one is present.
The title tracker drops it live, so main records it only when the stale-working
timer strips the spinner off the synthesized "⠋ Cursor Agent" — and that fires
both when Cursor parks idle and when cursor-agent exited and the shell reclaimed
the pane. The two states are observationally identical: same title, same null
foreground read.

Classifying it as an agent therefore removes a refusal rather than adding
evidence. Guarded sends auto-submit Enter, so the false positive types into the
user's shell. A null foreground is also not "unreadable" on the default local
provider, which returns null when the pty is gone.

hasPty, probePtyLiveness, hasChildProcesses and inspectProcess were each checked
as corroborating signals; none separates alive-with-agent from alive-with-shell
when the foreground read is unavailable.

Tests pin every no-evidence branch fail-closed and document the mechanism, so
both attempted fixes fail loudly if reintroduced.

Real gap tracked in #12946.
This commit is contained in:
BingZ 2026-08-07 04:15:48 +08:00 committed by GitHub
parent f6d0bde6fb
commit 74e1678d4b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 267 additions and 0 deletions

View File

@ -8446,6 +8446,168 @@ describe('OrcaRuntimeService', () => {
})
})
// Why: this pins the mechanism the refusals below exist for. cursor-agent emits only the
// bare native title, and the tracker drops it on sight — so a pane can never hold it
// because Cursor said so *now*. The one route into main's records is the stale-working
// clear stripping the spinner off Orca's synthesized title after 3s of quiet output, and
// that fires whether Cursor parked idle or exited and the shell took the pane back. That
// is exactly why the title cannot tell a live pane from a dead one.
it('only records the bare Cursor native title via the stale-working clear', async () => {
vi.useFakeTimers()
try {
const ptyId = `${TEST_REPO_ID}::/tmp/worktree-a@@pty-bg`
const runtime = createRuntime()
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
listProcesses: async () => [{ id: ptyId, cwd: '/tmp/worktree-a', title: 'shell' }]
})
runtime.attachWindow(1)
runtime.markGraphReady(1)
// Live from cursor-agent: dropped, never recorded.
runtime.onPtyData(ptyId, '\x1b]0;Cursor Agent\x07', 100)
expect((await runtime.listTerminals()).terminals[0].title).not.toBe('Cursor Agent')
// Orca's synthesized spinner, then quiet output: the clear strips it to the bare title.
runtime.onPtyData(ptyId, '\x1b]0;⠋ Cursor Agent\x07', 101)
runtime.onPtyData(ptyId, 'agent finished; shell prompt returns\r\n', 102)
await vi.advanceTimersByTimeAsync(3_000)
expect((await runtime.listTerminals()).terminals[0].title).toBe('Cursor Agent')
} finally {
vi.useRealTimers()
}
})
// Why: this pane reads no foreground and the next reads a live shell, yet both hold the
// same bare title the stale-working clear left behind. Neither read makes that title
// liveness, so both must refuse.
it('refuses a bare Cursor title while the foreground read is unavailable', async () => {
vi.useFakeTimers()
try {
const ptyId = `${TEST_REPO_ID}::/tmp/worktree-a@@pty-bg`
const runtime = createRuntime()
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
listProcesses: async () => [{ id: ptyId, cwd: '/tmp/worktree-a', title: 'shell' }]
})
runtime.attachWindow(1)
runtime.markGraphReady(1)
runtime.onPtyData(ptyId, '\x1b]0;⠋ Cursor Agent\x07', 100)
runtime.onPtyData(ptyId, 'streaming output with no title\r\n', 101)
await vi.advanceTimersByTimeAsync(3_000)
const terminal = (await runtime.listTerminals()).terminals[0]
expect(terminal.title).toBe('Cursor Agent')
await expect(runtime.isTerminalRunningAgent(terminal.handle)).resolves.toBe(false)
await expect(runtime.getTerminalAgentStatus(terminal.handle)).resolves.toEqual({
handle: terminal.handle,
isRunningAgent: false,
status: null
})
} finally {
vi.useRealTimers()
}
})
it('does not treat a bare Cursor title as an agent once the shell owns the foreground', async () => {
vi.useFakeTimers()
try {
const ptyId = `${TEST_REPO_ID}::/tmp/worktree-a@@pty-bg`
const runtime = createRuntime()
let foreground: string | null = null
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => foreground,
listProcesses: async () => [{ id: ptyId, cwd: '/tmp/worktree-a', title: 'shell' }]
})
runtime.attachWindow(1)
runtime.markGraphReady(1)
runtime.onPtyData(ptyId, '\x1b]0;⠋ Cursor Agent\x07', 100)
runtime.onPtyData(ptyId, 'agent exited; back at the shell\r\n', 101)
await vi.advanceTimersByTimeAsync(3_000)
// cursor-agent is gone and the user's shell owns the pane, but the title still reads
// "Cursor Agent". A guarded send here would auto-submit Enter into that shell.
foreground = 'zsh'
const terminal = (await runtime.listTerminals()).terminals[0]
expect(terminal.title).toBe('Cursor Agent')
await expect(runtime.isTerminalRunningAgent(terminal.handle)).resolves.toBe(false)
await expect(runtime.getTerminalAgentStatus(terminal.handle)).resolves.toEqual({
handle: terminal.handle,
isRunningAgent: false,
status: null
})
} finally {
vi.useRealTimers()
}
})
// Why: the refusals here must stay scoped to missing evidence. A working foreground read
// is what unlocks a live Cursor pane — and is the layer to fix if one is ever refused.
it('accepts a bare Cursor title when the foreground read confirms cursor-agent', async () => {
vi.useFakeTimers()
try {
const ptyId = `${TEST_REPO_ID}::/tmp/worktree-a@@pty-bg`
const runtime = createRuntime()
let foreground: string | null = null
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => foreground,
listProcesses: async () => [{ id: ptyId, cwd: '/tmp/worktree-a', title: 'shell' }]
})
runtime.attachWindow(1)
runtime.markGraphReady(1)
runtime.onPtyData(ptyId, '\x1b]0;⠋ Cursor Agent\x07', 100)
runtime.onPtyData(ptyId, 'streaming output with no title\r\n', 101)
await vi.advanceTimersByTimeAsync(3_000)
foreground = 'cursor-agent'
const terminal = (await runtime.listTerminals()).terminals[0]
await expect(runtime.isTerminalRunningAgent(terminal.handle)).resolves.toBe(true)
} finally {
vi.useRealTimers()
}
})
// Why: pins the type-narrowing branch, not a reachable state — no caller detaches the
// controller. It is the runtime-owned pty path, which the window-graph leaf tests below
// never reach, so nothing else would notice it being widened.
it('refuses a bare Cursor title on a runtime pty with no controller attached', async () => {
vi.useFakeTimers()
try {
const ptyId = `${TEST_REPO_ID}::/tmp/worktree-a@@pty-bg`
const runtime = createRuntime()
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => 'cursor-agent',
listProcesses: async () => [{ id: ptyId, cwd: '/tmp/worktree-a', title: 'shell' }]
})
runtime.attachWindow(1)
runtime.markGraphReady(1)
runtime.onPtyData(ptyId, '\x1b]0;⠋ Cursor Agent\x07', 100)
runtime.onPtyData(ptyId, 'streaming output with no title\r\n', 101)
await vi.advanceTimersByTimeAsync(3_000)
const terminal = (await runtime.listTerminals()).terminals[0]
runtime.setPtyController(null)
await expect(runtime.isTerminalRunningAgent(terminal.handle)).resolves.toBe(false)
} finally {
vi.useRealTimers()
}
})
it('clears a stale working title after 3s of title-less output', async () => {
vi.useFakeTimers()
try {
@ -21878,6 +22040,105 @@ describe('OrcaRuntimeService', () => {
await expect(runtime.isTerminalRunningAgent(handle)).resolves.toBe(true)
})
it('does not treat a bare Cursor Agent native title as a running agent session', async () => {
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
// Why: the native title is identity, not liveness — Cursor never decorates it, so it
// reads the same whether cursor-agent is parked or long gone. Sends auto-submit Enter,
// so identity alone must not unlock one.
syncSinglePty(runtime, 'pty-1', { tabTitle: 'bash', paneTitle: 'Cursor Agent' })
const [terminal] = (await runtime.listTerminals()).terminals
await expect(runtime.isTerminalRunningAgent(terminal.handle)).resolves.toBe(false)
await expect(runtime.getTerminalAgentStatus(terminal.handle)).resolves.toEqual({
handle: terminal.handle,
isRunningAgent: false,
status: null
})
})
// Why: a leaf with no PTY is the same no-evidence case as an unreadable foreground —
// nothing was even asked, so the bare title is all that is left. The corroborating
// foreground here is deliberately unreachable: no ptyId means no read.
it('does not treat a bare Cursor title as an agent on a leaf with no pty', async () => {
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => 'cursor-agent'
})
syncSinglePty(runtime, null, { tabTitle: 'bash', paneTitle: 'Cursor Agent' })
const [terminal] = (await runtime.listTerminals()).terminals
await expect(runtime.isTerminalRunningAgent(terminal.handle)).resolves.toBe(false)
})
// Why: a renderer can push the bare title straight onto the pane, skipping the stale
// clear the other tests drive. Arriving that way it lands on top of a `working` status
// the spinner left behind, so the pane looks doubly like an agent — and is still just a
// shell. The tab is left untitled so the bare title is the only evidence in play: the
// refusal is decided at the foreground, and the stale-status gate is held shut behind it.
it('does not let a renderer-pushed bare Cursor title revive stale agent status', async () => {
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => 'zsh'
})
syncSinglePty(runtime, 'pty-1', { tabTitle: '' })
runtime.onPtyData('pty-1', '\x1b]0;⠋ Cursor Agent\x07', 100)
syncSinglePty(runtime, 'pty-1', { tabTitle: '', paneTitle: 'Cursor Agent' })
const [terminal] = (await runtime.listTerminals()).terminals
expect(terminal.title).toBe('Cursor Agent')
await expect(runtime.isTerminalRunningAgent(terminal.handle)).resolves.toBe(false)
})
// Why: pins the outer catch, not a reachable state — the production controller
// (src/main/ipc/pty.ts) already normalizes provider failures, a dropped SSH channel
// included, to null before this sees them. Nothing else here makes the read throw.
it('does not treat a bare Cursor title as an agent when the foreground read throws', async () => {
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => {
throw new Error('ssh channel closed')
}
})
syncSinglePty(runtime, 'pty-1', { tabTitle: '', paneTitle: 'Cursor Agent' })
const [terminal] = (await runtime.listTerminals()).terminals
await expect(runtime.isTerminalRunningAgent(terminal.handle)).resolves.toBe(false)
})
// Why: cursor-agent is a node program, so `node` in the foreground plus a Cursor title
// looks like corroboration. It is not — the wrapper retry has to resolve a real agent
// name, and timing out means it never did.
it('does not treat a bare Cursor title as an agent behind a wrapper foreground', async () => {
vi.useFakeTimers()
try {
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => 'node'
})
syncSinglePty(runtime, 'pty-1', { tabTitle: '', paneTitle: 'Cursor Agent' })
const [terminal] = (await runtime.listTerminals()).terminals
const running = runtime.isTerminalRunningAgent(terminal.handle)
await vi.advanceTimersByTimeAsync(7_000)
await expect(running).resolves.toBe(false)
} finally {
vi.useRealTimers()
}
})
it('does not recognize runtime-created Claude agents management screens as agents', async () => {
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({

View File

@ -30939,6 +30939,10 @@ export class OrcaRuntimeService {
return false
}
const fg = await this.ptyController.getForegroundProcess(leaf.ptyId)
// Why: a bare `Cursor Agent` title is identity, not liveness — it reads the same
// whether cursor-agent is parked or long exited with the shell back. A null
// foreground is untracked, not alive, so no-evidence must stay a refusal. A live
// pane wrongly refused here means the read failed; fix that, not this.
if (!fg) {
return false
}
@ -31000,6 +31004,8 @@ export class OrcaRuntimeService {
return false
}
const fg = await this.ptyController.getForegroundProcess(pty.ptyId)
// Why: mirrors the leaf path — an unreadable foreground is indistinguishable from an
// exited one, so a bare Cursor identity title never substitutes for corroboration.
if (!fg) {
return false
}