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:
Jinjing 2026-07-11 14:32:55 -07:00 committed by GitHub
parent 800967278e
commit 2802f0419d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 652 additions and 24 deletions

View File

@ -2114,6 +2114,171 @@
],
"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",
"controller-less yields preserve stale-handle errors and confirmation methods retain their controller receiver"
]
},
{
"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/providers/windows-conpty-process-membership.test.ts",
"assertions": [
"exact ConPTY console membership comes from the fixed node-pty helper",
"malformed, incomplete, timed-out, and spawn-error membership reads fail closed"
]
},
{
"file": "src/main/daemon/daemon-foreground-confirmation-protocol.test.ts",
"assertions": [
"daemons from before the fresh-confirmation RPC are rejected by protocol version"
]
},
{
"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"
]
},
{
"file": "src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx",
"assertions": [
"browser annotation send content routes through review-notes send so existing agent sessions remain selectable",
"both browser annotation send surfaces wire the shared menu content"
]
}
],
"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.58,
"summary": "Ten focused test files passed (1183 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",

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(),

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)

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,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({

View File

@ -1205,6 +1205,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
@ -10226,7 +10227,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 =
@ -10250,7 +10252,8 @@ 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))
this.assertTerminalAgentStatusPtyBinding(handle, ptyId)
return {
handle,
isRunningAgent,
@ -10261,14 +10264,42 @@ 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 {
if (this.getTerminalAgentStatusPtyId(handle) === expectedPtyId) {
return
}
// Why: delayed process evidence belongs only to the PTY that started the
// read, while callers still rely on the established stale-handle contract.
throw new Error('terminal_handle_stale')
}
private getTerminalAgentStatusSnapshot(
handle: string,
expectedPtyId: string
): {
waitText: string
waitBlockedAt: number | null
title: string | null
@ -10277,8 +10308,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
@ -10316,6 +10347,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 },
@ -10330,21 +10364,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 confirmationController = this.ptyController
if (!confirmationController?.confirmForegroundProcess) {
return true
}
let confirmedProcess: string | null
try {
confirmedProcess = await confirmationController.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(
@ -20876,7 +20925,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
}

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')
}

View File

@ -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' }),