mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 00:03:59 +00:00
Fix browser annotation sends to active Windows agents (#8309)
* fix: authorize guarded sends to active ConPTY agents * Fix stale-handle rejection using controller-less status checks and contr - Distinguish `terminal_handle_stale` (PTY rebind mid-check) from `terminal_exited` so callers get an accurate reason instead of a generic `terminal_not_writable`. - Move the PTY binding assertion to always run after the fresh foreground read, even when no controller is present, closing a gap where a rebind during a controller-less check went undetected. - Call `confirmForegroundProcess` through its owning controller instance so the method keeps its `this` receiver instead of being invoked unbound. - Update reliability gates and design doc to reflect the new test coverage and corrected error semantics. * rm design doc
This commit is contained in:
@@ -30,10 +30,7 @@ import {
|
||||
removeWorktree
|
||||
} from '../git/worktree'
|
||||
import * as gitRunner from '../git/runner'
|
||||
import {
|
||||
clearSubmodulePathsCacheForTests,
|
||||
listSubmodulePaths
|
||||
} from '../git/status'
|
||||
import { clearSubmodulePathsCacheForTests, listSubmodulePaths } from '../git/status'
|
||||
import {
|
||||
createSetupRunnerScript,
|
||||
getEffectiveHooks,
|
||||
@@ -1179,6 +1176,67 @@ const store = {
|
||||
getProjects: () => []
|
||||
}
|
||||
|
||||
async function createExplicitAgentStatusHarness(options: {
|
||||
getForegroundProcess: (ptyId: string) => Promise<string | null>
|
||||
confirmForegroundProcess?: (ptyId: string) => Promise<string | null>
|
||||
title?: string
|
||||
}): Promise<{
|
||||
runtime: OrcaRuntimeService
|
||||
handle: string
|
||||
syncPty: (ptyId: string | null) => void
|
||||
}> {
|
||||
const leafId = '11111111-1111-4111-8111-111111111111'
|
||||
const paneKey = makePaneKey('tab-1', leafId)
|
||||
const runtime = new OrcaRuntimeService(store, undefined, {
|
||||
getAgentStatusSnapshot: () => [
|
||||
{
|
||||
paneKey,
|
||||
state: 'working',
|
||||
prompt: '',
|
||||
agentType: 'codex',
|
||||
connectionId: null,
|
||||
receivedAt: Date.now(),
|
||||
stateStartedAt: Date.now(),
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID
|
||||
}
|
||||
]
|
||||
})
|
||||
runtime.setPtyController({
|
||||
spawn: vi.fn().mockResolvedValue({ id: 'pty-1' }),
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: options.getForegroundProcess,
|
||||
confirmForegroundProcess: options.confirmForegroundProcess
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
const syncPty = (ptyId: string | null): void => {
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: options.title ?? 'repo terminal',
|
||||
activeLeafId: leafId,
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId,
|
||||
paneRuntimeId: 1,
|
||||
ptyId
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
syncPty('pty-1')
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
return { runtime, handle: terminal.handle, syncPty }
|
||||
}
|
||||
|
||||
function makeHeadlessTerminalLayout(
|
||||
ptyIdsByLeafId: Record<string, string | undefined>
|
||||
): TerminalLayoutSnapshot {
|
||||
@@ -8486,6 +8544,203 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('uses strong provider confirmation to authorize fresh hook state over a shell foreground', async () => {
|
||||
const getForegroundProcess = vi.fn(async () => 'powershell.exe')
|
||||
const confirmForegroundProcess = vi.fn(async () => 'claude')
|
||||
const { runtime, handle } = await createExplicitAgentStatusHarness({
|
||||
getForegroundProcess,
|
||||
confirmForegroundProcess
|
||||
})
|
||||
|
||||
await expect(runtime.getTerminalAgentStatus(handle)).resolves.toEqual({
|
||||
handle,
|
||||
isRunningAgent: true,
|
||||
status: 'working'
|
||||
})
|
||||
expect(getForegroundProcess).toHaveBeenCalledOnce()
|
||||
expect(getForegroundProcess).toHaveBeenCalledWith('pty-1')
|
||||
expect(confirmForegroundProcess).toHaveBeenCalledOnce()
|
||||
expect(confirmForegroundProcess).toHaveBeenCalledWith('pty-1')
|
||||
})
|
||||
|
||||
it('calls foreground confirmation with its controller receiver', async () => {
|
||||
const getForegroundProcess = vi.fn(async () => 'powershell.exe')
|
||||
const confirmForegroundProcess = vi.fn(
|
||||
async function (this: { getForegroundProcess: typeof getForegroundProcess }) {
|
||||
return this.getForegroundProcess === getForegroundProcess ? 'codex' : null
|
||||
}
|
||||
)
|
||||
const { runtime, handle } = await createExplicitAgentStatusHarness({
|
||||
getForegroundProcess,
|
||||
confirmForegroundProcess
|
||||
})
|
||||
|
||||
await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({
|
||||
isRunningAgent: true,
|
||||
status: 'working'
|
||||
})
|
||||
expect(confirmForegroundProcess).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['shell', async () => 'pwsh.exe'],
|
||||
['non-agent', async () => 'vim'],
|
||||
['unavailable', async () => null],
|
||||
[
|
||||
'failure',
|
||||
async () => {
|
||||
throw new Error('provider unavailable')
|
||||
}
|
||||
]
|
||||
])('fails closed when shell-conflict confirmation returns %s', async (_case, confirm) => {
|
||||
const confirmForegroundProcess = vi.fn(confirm)
|
||||
const { runtime, handle } = await createExplicitAgentStatusHarness({
|
||||
getForegroundProcess: async () => 'zsh',
|
||||
confirmForegroundProcess
|
||||
})
|
||||
|
||||
await expect(runtime.getTerminalAgentStatus(handle)).resolves.toEqual({
|
||||
handle,
|
||||
isRunningAgent: false,
|
||||
status: null
|
||||
})
|
||||
expect(confirmForegroundProcess).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('fails closed on a shell conflict when the controller cannot confirm it', async () => {
|
||||
const { runtime, handle } = await createExplicitAgentStatusHarness({
|
||||
getForegroundProcess: async () => 'zsh'
|
||||
})
|
||||
|
||||
await expect(runtime.getTerminalAgentStatus(handle)).resolves.toEqual({
|
||||
handle,
|
||||
isRunningAgent: false,
|
||||
status: null
|
||||
})
|
||||
})
|
||||
|
||||
it('skips strong confirmation when ordinary foreground evidence recognizes an agent', async () => {
|
||||
const getForegroundProcess = vi.fn(async () => 'codex')
|
||||
const confirmForegroundProcess = vi.fn(async () => 'codex')
|
||||
const { runtime, handle } = await createExplicitAgentStatusHarness({
|
||||
getForegroundProcess,
|
||||
confirmForegroundProcess
|
||||
})
|
||||
|
||||
await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({
|
||||
isRunningAgent: true,
|
||||
status: 'working'
|
||||
})
|
||||
expect(getForegroundProcess).toHaveBeenCalledOnce()
|
||||
expect(confirmForegroundProcess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips both foreground reads when current title evidence blocks explicit hook state', async () => {
|
||||
const getForegroundProcess = vi.fn(async () => 'zsh')
|
||||
const confirmForegroundProcess = vi.fn(async () => 'codex')
|
||||
const { runtime, handle } = await createExplicitAgentStatusHarness({
|
||||
getForegroundProcess,
|
||||
confirmForegroundProcess,
|
||||
title: 'zsh'
|
||||
})
|
||||
|
||||
await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({
|
||||
isRunningAgent: false,
|
||||
status: null
|
||||
})
|
||||
expect(getForegroundProcess).not.toHaveBeenCalled()
|
||||
expect(confirmForegroundProcess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips foreground reads for permission title and blocked wait evidence', async () => {
|
||||
for (const blocked of ['title', 'wait'] as const) {
|
||||
const getForegroundProcess = vi.fn(async () => 'zsh')
|
||||
const confirmForegroundProcess = vi.fn(async () => 'codex')
|
||||
const { runtime, handle } = await createExplicitAgentStatusHarness({
|
||||
getForegroundProcess,
|
||||
confirmForegroundProcess
|
||||
})
|
||||
runtime.onPtyData(
|
||||
'pty-1',
|
||||
blocked === 'title'
|
||||
? '\x1b]0;Codex waiting for permission\x07'
|
||||
: 'Hooks need review. Press enter to confirm\n',
|
||||
Date.now() + 1000
|
||||
)
|
||||
getForegroundProcess.mockClear()
|
||||
confirmForegroundProcess.mockClear()
|
||||
|
||||
await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({
|
||||
isRunningAgent: true,
|
||||
status: 'permission'
|
||||
})
|
||||
expect(getForegroundProcess).not.toHaveBeenCalled()
|
||||
expect(confirmForegroundProcess).not.toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects foreground evidence when the handle rebinds during the ordinary read', async () => {
|
||||
const foreground = deferred<string | null>()
|
||||
const getForegroundProcess = vi.fn(() => foreground.promise)
|
||||
const confirmForegroundProcess = vi.fn(async () => 'codex')
|
||||
const { runtime, handle, syncPty } = await createExplicitAgentStatusHarness({
|
||||
getForegroundProcess,
|
||||
confirmForegroundProcess
|
||||
})
|
||||
|
||||
const status = runtime.getTerminalAgentStatus(handle)
|
||||
await vi.waitFor(() => expect(getForegroundProcess).toHaveBeenCalledWith('pty-1'))
|
||||
syncPty('pty-2')
|
||||
foreground.resolve('zsh')
|
||||
|
||||
await expect(status).rejects.toThrow('terminal_handle_stale')
|
||||
expect(confirmForegroundProcess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a handle rebind while a controller-less status check yields', async () => {
|
||||
const { runtime, handle, syncPty } = await createExplicitAgentStatusHarness({
|
||||
getForegroundProcess: async () => 'zsh'
|
||||
})
|
||||
runtime.setPtyController(null)
|
||||
|
||||
const status = runtime.getTerminalAgentStatus(handle)
|
||||
syncPty('pty-2')
|
||||
|
||||
await expect(status).rejects.toThrow('terminal_handle_stale')
|
||||
})
|
||||
|
||||
it('rejects confirmation evidence when the handle rebinds during the fresh read', async () => {
|
||||
const confirmation = deferred<string | null>()
|
||||
const confirmForegroundProcess = vi.fn(() => confirmation.promise)
|
||||
const { runtime, handle, syncPty } = await createExplicitAgentStatusHarness({
|
||||
getForegroundProcess: async () => 'powershell.exe',
|
||||
confirmForegroundProcess
|
||||
})
|
||||
|
||||
const status = runtime.getTerminalAgentStatus(handle)
|
||||
await vi.waitFor(() => expect(confirmForegroundProcess).toHaveBeenCalledWith('pty-1'))
|
||||
syncPty('pty-2')
|
||||
confirmation.resolve('codex')
|
||||
|
||||
await expect(status).rejects.toThrow('terminal_handle_stale')
|
||||
})
|
||||
|
||||
it('rejects confirmation evidence when the owning PTY exits', async () => {
|
||||
const confirmation = deferred<string | null>()
|
||||
const confirmForegroundProcess = vi.fn(() => confirmation.promise)
|
||||
const { runtime, handle } = await createExplicitAgentStatusHarness({
|
||||
getForegroundProcess: async () => 'powershell.exe',
|
||||
confirmForegroundProcess
|
||||
})
|
||||
|
||||
const status = runtime.getTerminalAgentStatus(handle)
|
||||
await vi.waitFor(() => expect(confirmForegroundProcess).toHaveBeenCalledWith('pty-1'))
|
||||
runtime.onPtyExit('pty-1', 0)
|
||||
confirmation.resolve('codex')
|
||||
|
||||
await expect(status).rejects.toThrow('terminal_exited')
|
||||
})
|
||||
|
||||
it('reports permission from a title-derived action-required agent state', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
|
||||
Reference in New Issue
Block a user