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:
parent
f6d0bde6fb
commit
74e1678d4b
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue