fix: authorize guarded sends to active ConPTY agents

This commit is contained in:
Jinjing
2026-07-11 11:05:32 -07:00
parent ff8192a331
commit 17a109b2be
8 changed files with 700 additions and 24 deletions
+144
View File
@@ -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",
@@ -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.
+34
View File
@@ -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<string | null>
}
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<string | null>
}
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(),
+9
View File
@@ -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)
+228 -4
View File
@@ -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,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<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_not_writable')
expect(confirmForegroundProcess).not.toHaveBeenCalled()
})
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_not_writable')
})
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_not_writable')
})
it('reports permission from a title-derived action-required agent state', async () => {
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
+71 -19
View File
@@ -1206,6 +1206,7 @@ type RuntimePtyController = {
stopAndWait?(ptyId: string, opts?: { keepHistory?: boolean }): Promise<boolean>
getCwd?(ptyId: string): Promise<string | null>
getForegroundProcess(ptyId: string): Promise<string | null>
confirmForegroundProcess?(ptyId: string): Promise<string | null>
hasChildProcesses?(ptyId: string): Promise<boolean>
clearBuffer?(ptyId: string): Promise<void>
resize?(ptyId: string, cols: number, rows: number): boolean
@@ -10227,7 +10228,8 @@ export class OrcaRuntimeService {
}
async getTerminalAgentStatus(handle: string): Promise<RuntimeTerminalAgentStatus> {
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<boolean> {
private async terminalHasShellForegroundProcess(handle: string, ptyId: string): Promise<boolean> {
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
}
+38 -1
View File
@@ -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<void> => {
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')
}
@@ -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' }),