mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 08:03:14 +00:00
fix: authorize guarded sends to active ConPTY agents
This commit is contained in:
@@ -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.
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
Reference in New Issue
Block a user