From 17a109b2becaaf6cdd13086ccaf3b433ff04556b Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:05:32 -0700 Subject: [PATCH] fix: authorize guarded sends to active ConPTY agents --- config/reliability-gates.jsonc | 144 ++++++++++++ docs/fix-web-annotation-active-agent-send.md | 98 ++++++++ src/main/ipc/pty.test.ts | 34 +++ src/main/ipc/pty.ts | 9 + src/main/runtime/orca-runtime.test.ts | 232 ++++++++++++++++++- src/main/runtime/orca-runtime.ts | 90 +++++-- src/main/runtime/rpc/methods/terminal.ts | 39 +++- src/main/runtime/rpc/terminal-send.test.ts | 78 +++++++ 8 files changed, 700 insertions(+), 24 deletions(-) create mode 100644 docs/fix-web-annotation-active-agent-send.md diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index f7d8172cb5f..01432411788 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -2114,6 +2114,150 @@ ], "demotionRule": "Keep non-blocking or demote to protection none if the Windows byte test silently skips, flakes without an actionable artifact, routes bytes using tab/title/terminal-output heuristics, or adds any ordinary-key lookup, IPC, polling, scan, or lifecycle leak." }, + { + "id": "terminal-input.guarded-agent-send-authorization", + "title": "Guarded agent sends authorize only the confirmed exact PTY", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-input", + "layer": "runtime-provider-contract", + "surfaces": [ + "terminal input", + "guarded note sends", + "agent status", + "PTY identity", + "foreground process confirmation" + ], + "platforms": [ + "macos", + "linux", + "windows" + ], + "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.", + "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.", + "commands": [ + "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/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" + ], + "assertionRefs": [ + { + "file": "src/main/runtime/orca-runtime.test.ts", + "assertions": [ + "a recognized fresh provider confirmation authorizes explicit hook state over an ordinary shell result", + "shell, non-agent, null, thrown, and missing confirmation remain fail-closed", + "title blockers and ordinary recognized agent evidence add no confirmation call", + "PTY exit or handle rebind during either foreground read rejects evidence bound to the original PTY" + ] + }, + { + "file": "src/main/runtime/rpc/terminal-send.test.ts", + "assertions": [ + "a handle rebind during guarded status evaluation returns not writable and writes zero bytes", + "the callback's actual PTY must equal the handle binding before status can authorize a write" + ] + }, + { + "file": "src/main/ipc/pty.test.ts", + "assertions": [ + "runtime confirmation reaches the provider owning the captured PTY", + "unsupported or missing providers return unavailable confirmation" + ] + }, + { + "file": "src/main/providers/agent-foreground-process.test.ts", + "assertions": [ + "recognized Windows candidates require exact ConPTY membership and detached descendants are rejected", + "unavailable process inspection is distinct from authoritative shell evidence" + ] + }, + { + "file": "src/main/providers/local-pty-provider.test.ts", + "assertions": [ + "fresh confirmation is discarded when its owning local PTY exits" + ] + }, + { + "file": "src/main/daemon/pty-subprocess.test.ts", + "assertions": [ + "fresh confirmation bypasses cached PowerShell and waits for a post-request process scan" + ] + }, + { + "file": "src/renderer/src/lib/active-agent-note-send.test.ts", + "assertions": [ + "selected active-agent note sends retain guarded paste and submit routing" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-11", + "runner": "local", + "platform": "macos", + "command": "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", + "result": "passed", + "durationSeconds": 9.54, + "summary": "Ten focused test files passed (1181 tests), covering runtime confirmation and PTY revalidation, guarded RPC zero-write behavior, PTY controller routing, local/daemon fresh scans, exact ConPTY membership, and unchanged renderer note routing." + } + ], + "runtimeBudget": { + "p95Seconds": 45, + "scope": "focused runtime, RPC, PTY provider, and renderer routing units" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "Deterministic units have local coverage only; promotion requires repeated CI and platform history." + }, + "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." + }, + "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." + }, + "promotionCriteria": [ + "Run the browser annotation existing-agent and repeat-send path in Electron on Windows ConPTY without a recognition refusal.", + "Collect stable CI and flake history across local and daemon providers on Windows plus representative macOS/Linux coverage.", + "Attach saved intentional-break evidence for confirmation removal and exact-PTY revalidation removal." + ], + "knownGaps": [ + "Physical Windows validation is unavailable on the current macOS host; exact ConPTY membership is covered deterministically.", + "Electron annotation golden-path, repeat-send, adjacent-menu evidence, and screenshots are left to coordinator validation.", + "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." + }, { "id": "terminal-input.agent-prompt-injection", "title": "Orchestration agent prompts arrive as bracketed paste before submit", diff --git a/docs/fix-web-annotation-active-agent-send.md b/docs/fix-web-annotation-active-agent-send.md new file mode 100644 index 00000000000..5b51b191022 --- /dev/null +++ b/docs/fix-web-annotation-active-agent-send.md @@ -0,0 +1,98 @@ +# Fix Web Annotation Sends to Active Agents + +## Problem + +Issue [#8303](https://github.com/stablyai/orca/issues/8303) reports that sending a browser annotation to an already-running agent on Windows can fail with “The selected terminal is not a recognized agent session.” The browser menu reaches the shared explicit-target path at `src/renderer/src/lib/active-agent-note-send.ts:82`; the message is the `no-agent` result at `src/renderer/src/lib/active-agent-note-send-result.ts:28`. + +## Root cause + +`getTerminalAgentStatus` trusts fresh pane-scoped hook state unless current title/wait evidence blocks it or `terminalHasShellForegroundProcess` reports a shell (`src/main/runtime/orca-runtime.ts:10229`, `src/main/runtime/orca-runtime.ts:10334`). On Windows, the ordinary foreground read can return the ConPTY shell (for example `powershell.exe`) while a recognized agent remains a live descendant, so readiness and guarded writes incorrectly return `no-agent`. + +Local and daemon PTY providers already expose `confirmForegroundProcess` (`src/main/providers/types.ts:212`). Their Windows implementations perform a fresh process scan with exact ConPTY membership (`src/main/providers/local-pty-provider.ts:994`, `src/main/daemon/pty-subprocess.ts:1055`), but the send guard does not use that stronger evidence. + +## Non-goals + +- Change browser annotation UI, copy, formatting, telemetry, or note routing. +- Relax permission/action-required checks or authorize a shell from hook state alone. +- Add a process scanner, polling, retries, provider-specific runtime branching, or a runtime-global confirmation cache. +- Add confirmation support to SSH/WSL/legacy providers or change mixed-version compatibility. + +## Reliability contract + +- **Invariant (`terminal-input.guarded-agent-send-authorization`):** a guarded note send writes only to the exact PTY binding checked by the guard, and only while permission/wait evidence allows input. When ordinary foreground evidence identifies a shell, fresh hook state can authorize that PTY only after strong confirmation identifies an agent. +- **Oracle:** fresh explicit agent state + ordinary `powershell.exe` + confirmed `codex` writes to the same PTY. With the same ordinary-shell conflict, confirmed shell/non-agent, unavailable confirmation, PTY exit, or handle rebind writes zero bytes. +- **Gate:** add an experimental entry to `config/reliability-gates.jsonc` with the focused runtime/RPC/provider tests. The existing modified-Enter gate covers the confirmation primitive, not guarded note sends. +- **Diagnostics:** use guarded refusal results and deterministic zero-write/provider-call test evidence. Do not log annotation content or process command lines. + +## Design + +1. Preserve existing evidence precedence: live permission titles, blocked wait text, and management/shell titles still block before process confirmation. +2. For fresh explicit hook state, keep the ordinary foreground read. Only when it reports a shell and `confirmForegroundProcess` exists, perform one strong confirmation for that status evaluation. Preserve the explicit status only when `recognizeAgentProcess(confirmedProcess)` succeeds; missing, null, thrown, shell, and non-agent confirmation results remain fail-closed for that conflict. +3. Bind the complete status evaluation to one PTY. Capture the handle’s PTY binding before taking the status snapshot or starting either process read, pass that captured `ptyId` to the ordinary read and confirmation, and reject if the handle no longer has that binding after either await. In the guarded RPC callback, compare the callback’s actual write `ptyId` with the handle binding before and after status evaluation; a mismatch is not writable and writes no bytes. +4. Keep the runtime provider-agnostic and reuse the existing provider/daemon contracts. Add optional confirmation to the runtime controller and forward it through the existing PTY controller adapter to `getProviderForPty(ptyId).confirmForegroundProcess`; unsupported providers and routing errors return unavailable evidence. No renderer, RPC schema, daemon protocol, or `IPtyProvider` interface change is needed. + +## Data flow + +- Browser annotation menu selects an eligible agent pane. +- Renderer resolves the explicit terminal and requests agent readiness. +- Runtime sees fresh hook state plus an ordinary shell foreground result. +- Owning PTY provider confirms current agent identity; runtime revalidates the PTY binding. +- Recognized agent + unchanged binding → guarded paste/submit proceeds. +- Missing/unconfirmed agent or changed binding → reject before writing. + +## Edge cases + +- Agent or PTY exits during confirmation: reject. +- Handle rebinds while either foreground read is pending: reject as not writable; never transfer status evidence or authority to the replacement PTY. +- Confirmation finds an unrelated agent outside the ConPTY: existing membership filtering rejects it. +- Confirmation finds a different recognized agent in the same PTY: generic agent identity is sufficient; permission/title/wait evidence still decides sendability. +- Provider lacks confirmation or runs an older daemon/runtime: an ordinary-shell conflict remains fail-closed. +- macOS/Linux shell conflicts use the same provider-owned confirmation path; ordinary non-conflicting sends are unchanged. +- Concurrent sends keep independent PTY bindings while provider snapshot work may be deduplicated. + +## Test plan + +- Runtime unit: fresh explicit state + ordinary shell + confirmed recognized agent is sendable. +- Runtime unit: shell/non-agent/null/throw/missing confirmation remains rejected; confirmation is skipped for permission/wait/title blockers and ordinary recognized foreground evidence. +- Runtime unit: a deferred ordinary read or confirmation followed by PTY exit/rebind cannot authorize the stale or replacement PTY, and both process reads receive the originally captured `ptyId`. +- RPC unit: guarded callback checks the exact write `ptyId`; a rebind returns not writable and invokes no write. +- PTY controller routing unit: confirmation reaches the provider that owns the captured `ptyId`; an unsupported provider or routing failure returns unavailable evidence. +- Provider/daemon regression: existing fresh-scan ordering, ConPTY membership, delayed-exit, mixed-version, and cached-follow-up tests remain green. +- Renderer regression: existing active-agent note-send and browser annotation menu tests remain green. +- Static gates: focused Vitest slices, `pnpm typecheck`, `pnpm lint`, and `pnpm check:max-lines-ratchet`. +- Electron: create an annotation, choose an already-running agent, verify delivery without the recognition error, repeat the send, and smoke-test the adjacent New agent section. + +## Performance and blast radius + +This is not “one scan per send”: readiness and guarded writes evaluate status more than once, and long pastes retain the existing per-16 KiB guard. Each evaluation may add at most one confirmation only for the exceptional `fresh hook + ordinary shell` conflict. Existing provider snapshot/cache dedup remains authoritative. Tests must show no confirmation, polling, retry, session listing, or extra subprocess on non-conflicting checks. + +Local and daemon PTYs on macOS, Linux, and Windows can use confirmation. SSH/older providers without it remain conservative. Renderer, startup, persistence, mobile, and multi-window contracts do not change; PTY-binding revalidation prevents a delayed result from crossing sessions. + +## UI quality bar + +No visual code changes. The annotation send menu must retain its existing STYLEGUIDE-compliant layout, existing-agent rows, New agent section, copy, focus behavior, and real refusal messaging. Successful sends must not show the recognition-error toast/dialog. + +## Review screenshots + +1. Browser annotation send menu open with the existing active agent and New agent section visible. +2. Existing agent terminal after the first annotation is delivered, with no recognition error visible. +3. Repeat-send result showing the same agent accepts another annotation. +4. Adjacent annotation menu state after delivery, proving the send UI remains intact. + +## Rollout + +1. Add runtime confirmation and exact-PTY revalidation with focused regression tests. +2. Add/update the experimental reliability gate and run focused/static checks. +3. Validate the browser annotation golden path, repeat path, and adjacent menu in Electron. +4. Open an unmerged PR with macOS Electron screenshots and deterministic Windows/ConPTY test evidence; note physical Windows as a residual validation gap. + +## Lightweight Eng Review + +- Scope: One runtime authorization fallback plus exact-PTY revalidation; no browser-specific bypass or new process-detection system. +- Architecture/data flow: Runtime remains authorization owner; the owning PTY provider supplies strong process truth; renderer only selects and maps results. +- Failure modes covered: agent/PTY exit, handle rebind, unavailable confirmation, unrelated process, permission state, concurrent sends, mixed-version providers. +- Test coverage required: runtime status/rebind units, guarded RPC zero-write unit, existing provider/daemon confirmation suites, renderer routing suites, Electron golden/repeat/adjacent scenarios. +- Performance/blast radius: Exceptional-path confirmation only; provider dedup is reused; non-conflicting call counts prove no added scans or loops. +- UI quality bar: No visual change; existing dropdown and refusal states stay consistent with `docs/STYLEGUIDE.md`. +- Required review screenshots: existing-agent/New agent menu, first delivery, repeat delivery, post-delivery adjacent state. +- Residual risks: Physical Windows validation is unavailable on this macOS host; deterministic ConPTY membership/confirmation tests cover the reported platform contract, while Electron proves live end-to-end routing on the available host. diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 751245fb62d..94b047d8968 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -3221,6 +3221,40 @@ describe('registerPtyHandlers', () => { }) }) + it('routes runtime foreground confirmation to the provider owning the captured PTY', async () => { + const confirmForegroundProcess = vi.fn(async () => 'codex') + registerSshPtyProvider('ssh-1', { confirmForegroundProcess } as never) + setPtyOwnership('remote-pty', 'ssh-1') + const runtime = { setPtyController: vi.fn() } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as { + confirmForegroundProcess: (ptyId: string) => Promise + } + + await expect(controller.confirmForegroundProcess('remote-pty')).resolves.toBe('codex') + expect(confirmForegroundProcess).toHaveBeenCalledOnce() + expect(confirmForegroundProcess).toHaveBeenCalledWith('remote-pty') + deletePtyOwnership('remote-pty') + }) + + it('returns unavailable runtime confirmation for unsupported or missing providers', async () => { + registerSshPtyProvider('ssh-1', {} as never) + setPtyOwnership('unsupported-pty', 'ssh-1') + setPtyOwnership('missing-pty', 'missing-connection') + const runtime = { setPtyController: vi.fn() } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as { + confirmForegroundProcess: (ptyId: string) => Promise + } + + await expect(controller.confirmForegroundProcess('unsupported-pty')).resolves.toBeNull() + await expect(controller.confirmForegroundProcess('missing-pty')).resolves.toBeNull() + deletePtyOwnership('unsupported-pty') + deletePtyOwnership('missing-pty') + }) + it('rethrows non-not-found local provider shutdown failures', async () => { setLocalPtyProvider({ spawn: vi.fn(), diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 4dcc451324a..493cc494058 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -3397,6 +3397,15 @@ export function registerPtyHandlers( return null } }, + confirmForegroundProcess: async (ptyId) => { + try { + const provider = getProviderForPty(ptyId) + // Why: cached foreground evidence cannot resolve a fresh shell conflict. + return (await provider.confirmForegroundProcess?.(ptyId)) ?? null + } catch { + return null + } + }, getCwd: async (ptyId) => { try { const cwd = await getProviderForPty(ptyId).getCwd(ptyId) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index b8d9eee175f..6a2c597ba60 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -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 + confirmForegroundProcess?: (ptyId: string) => Promise + 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 ): TerminalLayoutSnapshot { @@ -8486,6 +8544,172 @@ 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.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() + 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_not_writable') + expect(confirmForegroundProcess).not.toHaveBeenCalled() + }) + + it('rejects confirmation evidence when the handle rebinds during the fresh read', async () => { + const confirmation = deferred() + 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_not_writable') + }) + + it('rejects confirmation evidence when the owning PTY exits', async () => { + const confirmation = deferred() + 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_not_writable') + }) + it('reports permission from a title-derived action-required agent state', async () => { const runtime = new OrcaRuntimeService(store) runtime.setPtyController({ diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 84f1954d194..d45b352fbb9 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1206,6 +1206,7 @@ type RuntimePtyController = { stopAndWait?(ptyId: string, opts?: { keepHistory?: boolean }): Promise getCwd?(ptyId: string): Promise getForegroundProcess(ptyId: string): Promise + confirmForegroundProcess?(ptyId: string): Promise hasChildProcesses?(ptyId: string): Promise clearBuffer?(ptyId: string): Promise resize?(ptyId: string, cols: number, rows: number): boolean @@ -10227,7 +10228,8 @@ export class OrcaRuntimeService { } async getTerminalAgentStatus(handle: string): Promise { - const terminal = this.getTerminalAgentStatusSnapshot(handle) + const ptyId = this.getTerminalAgentStatusPtyId(handle) + const terminal = this.getTerminalAgentStatusSnapshot(handle, ptyId) const explicitStatus = this.getFreshExplicitAgentStatusForHandle(handle) const blockedByWaitText = detectTerminalWaitBlockedReason(terminal.waitText) const liveTitleClearsBlockedText = @@ -10251,7 +10253,7 @@ export class OrcaRuntimeService { // Fresh hook state is tighter, but current shell/management evidence wins. const isRunningAgent = !terminalTitleBlocksExplicitAgentStatus(terminal.title) && - !(await this.terminalHasShellForegroundProcess(handle)) + !(await this.terminalHasShellForegroundProcess(handle, ptyId)) return { handle, isRunningAgent, @@ -10262,14 +10264,45 @@ export class OrcaRuntimeService { return { handle, isRunningAgent: true, status: terminal.titleStatus } } - return { - handle, - isRunningAgent: await this.isTerminalRunningAgent(handle), - status: null - } + const isRunningAgent = await this.isTerminalRunningAgent(handle) + this.assertTerminalAgentStatusPtyBinding(handle, ptyId) + return { handle, isRunningAgent, status: null } } - private getTerminalAgentStatusSnapshot(handle: string): { + private getTerminalAgentStatusPtyId(handle: string): string { + const pty = this.getLivePtyForHandle(handle) + if (pty) { + if (!pty.pty.connected) { + throw new Error('terminal_gone') + } + return pty.pty.ptyId + } + const { leaf } = this.getLiveLeafForHandle(handle) + if (getTerminalState(leaf) !== 'running') { + throw new Error('terminal_exited') + } + if (!leaf.ptyId) { + throw new Error('terminal_gone') + } + return leaf.ptyId + } + + private assertTerminalAgentStatusPtyBinding(handle: string, expectedPtyId: string): void { + try { + if (this.getTerminalAgentStatusPtyId(handle) === expectedPtyId) { + return + } + } catch { + // Fall through to the stable binding error below. + } + // Why: delayed process evidence belongs only to the PTY that started the read. + throw new Error('terminal_not_writable') + } + + private getTerminalAgentStatusSnapshot( + handle: string, + expectedPtyId: string + ): { waitText: string waitBlockedAt: number | null title: string | null @@ -10278,8 +10311,8 @@ export class OrcaRuntimeService { } { const pty = this.getLivePtyForHandle(handle) if (pty) { - if (!pty.pty.connected) { - throw new Error('terminal_gone') + if (!pty.pty.connected || pty.pty.ptyId !== expectedPtyId) { + throw new Error('terminal_not_writable') } const leaf = this.getPrimaryLeafForPty(pty.pty.ptyId) const leafTitle = leaf @@ -10317,6 +10350,9 @@ export class OrcaRuntimeService { if (!leaf.ptyId) { throw new Error('terminal_gone') } + if (leaf.ptyId !== expectedPtyId) { + throw new Error('terminal_not_writable') + } const title = getLatestAgentCandidateTitleInfo( { title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt }, { title: leaf.lastOscTitle, updatedAt: leaf.lastOscTitleAt }, @@ -10331,21 +10367,36 @@ export class OrcaRuntimeService { } } - private async terminalHasShellForegroundProcess(handle: string): Promise { + private async terminalHasShellForegroundProcess(handle: string, ptyId: string): Promise { if (!this.ptyController) { return false } + let foregroundProcess: string | null try { - const pty = this.getLivePtyForHandle(handle) - const ptyId = pty?.pty.ptyId ?? this.getLiveLeafForHandle(handle).leaf.ptyId - if (!ptyId) { - return false - } - const foregroundProcess = await this.ptyController.getForegroundProcess(ptyId) - return foregroundProcess !== null && isShellProcess(foregroundProcess) + foregroundProcess = await this.ptyController.getForegroundProcess(ptyId) } catch { + this.assertTerminalAgentStatusPtyBinding(handle, ptyId) return false } + this.assertTerminalAgentStatusPtyBinding(handle, ptyId) + if (!foregroundProcess || !isShellProcess(foregroundProcess)) { + return false + } + const confirmForegroundProcess = this.ptyController.confirmForegroundProcess + if (!confirmForegroundProcess) { + return true + } + let confirmedProcess: string | null + try { + confirmedProcess = await confirmForegroundProcess(ptyId) + } catch { + this.assertTerminalAgentStatusPtyBinding(handle, ptyId) + return true + } + this.assertTerminalAgentStatusPtyBinding(handle, ptyId) + // Why: hook identity is generic; strong provider evidence only needs to + // prove that some recognized agent still owns this exact PTY. + return recognizeAgentProcess(confirmedProcess) === null } private shouldDelayPtyBackedMobileSnapshotForForegroundAgent( @@ -20881,7 +20932,8 @@ export class OrcaRuntimeService { // status without throwing on stale handles, so this returns null on any error. getAgentStatusForHandle(handle: string): string | null { try { - return this.getTerminalAgentStatusSnapshot(handle).titleStatus + const ptyId = this.getTerminalAgentStatusPtyId(handle) + return this.getTerminalAgentStatusSnapshot(handle, ptyId).titleStatus } catch { return null } diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 9c39a65f456..7a9b9e448a6 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -348,6 +348,31 @@ function isTerminalSendGuardNotWritable(error: unknown): boolean { return message.includes('terminal_guard_not_writable') } +function isTerminalAgentStatusNotWritable(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + return [ + 'terminal_not_writable', + 'terminal_handle_stale', + 'terminal_gone', + 'terminal_exited' + ].some((code) => message.includes(code)) +} + +function assertTerminalSendExactPtyBinding( + runtime: OrcaRuntimeService, + handle: string, + expectedPtyId: string | undefined +): void { + try { + if (expectedPtyId && runtime.resolveLiveLeafForHandle(handle)?.ptyId === expectedPtyId) { + return + } + } catch { + // Fall through to the stable guarded-send result below. + } + throw new Error('terminal_guard_not_writable') +} + function appendPendingMultiplexOutput( stream: TerminalMultiplexStream, data: string, @@ -1148,10 +1173,22 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ const assertSendPreconditions = params.requireAgentStatus === 'sendable' ? async (ptyId?: string): Promise => { + assertTerminalSendExactPtyBinding(runtime, params.terminal, ptyId) if (ptyId && isTerminalInputLockedForClient(runtime, ptyId, params.client)) { throw new Error('terminal_guard_not_writable') } - const agentStatus = await runtime.getTerminalAgentStatus(params.terminal) + let agentStatus + try { + agentStatus = await runtime.getTerminalAgentStatus(params.terminal) + } catch (error) { + if (isTerminalAgentStatusNotWritable(error)) { + throw new Error('terminal_guard_not_writable') + } + throw error + } + // Why: a send callback can race a pane reconnect; status evidence + // must never authorize the callback's replacement PTY. + assertTerminalSendExactPtyBinding(runtime, params.terminal, ptyId) if (!agentStatus.isRunningAgent) { throw new Error('terminal_guard_no_agent') } diff --git a/src/main/runtime/rpc/terminal-send.test.ts b/src/main/runtime/rpc/terminal-send.test.ts index 12e40c2c977..58b37bcd7fc 100644 --- a/src/main/runtime/rpc/terminal-send.test.ts +++ b/src/main/runtime/rpc/terminal-send.test.ts @@ -525,6 +525,84 @@ describe('terminal send RPC', () => { ) }) + it('writes zero bytes when a guarded callback observes a handle rebind during status', async () => { + let boundPtyId = 'pty-1' + let statusCalls = 0 + const write = vi.fn() + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn(() => ({ ptyId: boundPtyId })), + getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }), + getTerminalAgentStatus: vi.fn().mockImplementation(async () => { + statusCalls += 1 + if (statusCalls === 2) { + boundPtyId = 'pty-2' + } + return { + handle: 'terminal-1', + isRunningAgent: true, + status: 'working' + } + }), + sendTerminal: vi.fn().mockImplementation(async (_handle, _action, options) => { + await options.beforeWrite('pty-1') + write('pty-1', '\r') + return { handle: 'terminal-1', accepted: true, bytesWritten: 1 } + }) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('terminal.send', { + terminal: 'terminal-1', + enter: true, + requireAgentStatus: 'sendable', + client: { id: 'desktop-1', type: 'desktop' } + }) + ) + + expect(response).toMatchObject({ + ok: true, + result: { send: { accepted: false, bytesWritten: 0 } } + }) + expect(runtime.getTerminalAgentStatus).toHaveBeenCalledTimes(2) + expect(write).not.toHaveBeenCalled() + }) + + it('rejects a guarded callback whose actual write PTY differs from the handle binding', async () => { + const write = vi.fn() + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn(() => ({ ptyId: 'pty-1' })), + getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }), + getTerminalAgentStatus: vi.fn().mockResolvedValue({ + handle: 'terminal-1', + isRunningAgent: true, + status: 'working' + }), + sendTerminal: vi.fn().mockImplementation(async (_handle, _action, options) => { + await options.beforeWrite('pty-2') + write('pty-2', '\r') + return { handle: 'terminal-1', accepted: true, bytesWritten: 1 } + }) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('terminal.send', { + terminal: 'terminal-1', + enter: true, + requireAgentStatus: 'sendable', + client: { id: 'desktop-1', type: 'desktop' } + }) + ) + + expect(response).toMatchObject({ + ok: true, + result: { send: { accepted: false, bytesWritten: 0 } } + }) + expect(runtime.getTerminalAgentStatus).toHaveBeenCalledTimes(1) + expect(write).not.toHaveBeenCalled() + }) + it('refuses guarded combined text and submit sends before any PTY write', async () => { const runtime = stubRuntime({ resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),