diff --git a/src/main/agent-hooks/installer-utils.ts b/src/main/agent-hooks/installer-utils.ts index 0bab6e22c2a..f6e52ced5b7 100644 --- a/src/main/agent-hooks/installer-utils.ts +++ b/src/main/agent-hooks/installer-utils.ts @@ -177,7 +177,10 @@ export function buildWindowsAgentHookPostCommand( } // Why: PowerShell per-post costs ~300ms startup and mangles UTF-8 via code-page translation; curl.exe (Win10 1803+) avoids both. -export function buildWindowsAgentHookCurlPostCommand(source: AgentHookSource): string { +export function buildWindowsAgentHookCurlPostCommand( + source: AgentHookSource, + extraFormLines: readonly string[] = [] +): string { return [ '"%SystemRoot%\\System32\\curl.exe" -sS -X POST', `"http://127.0.0.1:%ORCA_AGENT_HOOK_PORT%/hook/${source}"`, @@ -190,6 +193,7 @@ export function buildWindowsAgentHookCurlPostCommand(source: AgentHookSource): s '--data-urlencode "worktreeId=%ORCA_WORKTREE_ID%"', '--data-urlencode "env=%ORCA_AGENT_HOOK_ENV%"', '--data-urlencode "version=%ORCA_AGENT_HOOK_VERSION%"', + ...extraFormLines, '--data-urlencode "payload@-"', '>nul 2>&1' ].join(' ') diff --git a/src/main/agent-hooks/server.test-fixtures.ts b/src/main/agent-hooks/server.test-fixtures.ts index 12ef7e9c6e9..e059a1874c3 100644 --- a/src/main/agent-hooks/server.test-fixtures.ts +++ b/src/main/agent-hooks/server.test-fixtures.ts @@ -16,6 +16,7 @@ export const RUNNING_SHELL = { id: 'shell-1', type: 'shell', status: 'running' } export type Body = { paneKey: string launchToken?: string + sessionNonce?: string tabId?: string worktreeId?: string env?: string diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 48ff387fad4..6d3b4385368 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -103,7 +103,11 @@ type NormalizedLocalHook = { type PersistedAgentHookEventPayload = Omit< EnrichedAgentHookEventPayload, - 'claudeRunningNonAgentTask' | 'launchToken' | 'promptInteractionKey' | 'restoredUnconfirmed' + | 'claudeRunningNonAgentTask' + | 'launchToken' + | 'promptInteractionKey' + | 'restoredUnconfirmed' + | 'sessionNonce' > & { launchTokenHash?: string } @@ -2824,6 +2828,8 @@ export class AgentHookServer { promptInteractionKey: _promptInteractionKey, // Why: never persisted — hydrate re-stamps it, so a stored copy could only drift. restoredUnconfirmed: _restoredUnconfirmed, + // Why: a one-shot resume nonce is meaningless after the process it named exits. + sessionNonce: _sessionNonce, launchToken, ...persistedPayload } = payload as EnrichedAgentHookEventPayload diff --git a/src/main/codex/adopted-codex-tui-readiness.ts b/src/main/codex/adopted-codex-tui-readiness.ts new file mode 100644 index 00000000000..60a642aca88 --- /dev/null +++ b/src/main/codex/adopted-codex-tui-readiness.ts @@ -0,0 +1,115 @@ +// Off-screen readiness proof for a Codex TUI resumed inside a pane Orca already +// owns. Adoption stops the pane's Codex and types a `codex resume` line into the +// bare shell that is left; nothing on screen can tell that shell apart from a +// Codex that has finished starting. The CLI's own SessionStart hook can: it is +// emitted by the resumed process itself, carries the conversation it reopened, +// and reaches Orca over the hook endpoint instead of the terminal buffer. +import type { AgentHookEventPayload } from '../../shared/agent-hook-listener' + +export type AdoptedCodexReadinessEvent = Pick< + AgentHookEventPayload, + 'paneKey' | 'source' | 'hookEventName' | 'isReplay' | 'sessionNonce' | 'providerSession' +> + +export type AdoptedCodexReadinessVerdict = 'ignore' | 'ready' | 'wrong-session' + +export type AdoptedCodexReadinessTarget = { + paneKey: string + threadId: string + sessionNonce: string +} + +export const ADOPTED_CODEX_READINESS_TIMEOUT_MS = 30_000 + +export function classifyAdoptedCodexReadinessEvent( + event: AdoptedCodexReadinessEvent, + target: AdoptedCodexReadinessTarget +): AdoptedCodexReadinessVerdict { + if ( + event.source !== 'codex' || + event.hookEventName !== 'SessionStart' || + event.isReplay === true || + event.paneKey !== target.paneKey + ) { + return 'ignore' + } + // A hook script installed by an older Orca omits the nonce entirely. Pane + thread + // still bind it, so degrade rather than strand the return path — but a nonce that + // IS present and disagrees names a different invocation, which is the stale + // SessionStart this gate exists to reject. + if (event.sessionNonce !== undefined && event.sessionNonce !== target.sessionNonce) { + return 'ignore' + } + const observedThreadId = event.providerSession?.id + if (!observedThreadId) { + return 'ignore' + } + if (observedThreadId === target.threadId) { + return 'ready' + } + // Only our own nonce makes a mismatch conclusive; without one this could be any + // Codex sharing the pane's env. + return event.sessionNonce === target.sessionNonce ? 'wrong-session' : 'ignore' +} + +export type AdoptedCodexReadinessWatch = { + /** Resolves when the resumed Codex proves itself; rejects on timeout or a wrong thread. */ + readonly settled: Promise + dispose(): void +} + +/** + * Starts listening BEFORE the resume command is written — a fast Codex can emit + * SessionStart before the write call even returns. + */ +export function beginAdoptedCodexReadinessWatch(input: { + subscribe: (listener: (event: AdoptedCodexReadinessEvent) => void) => () => void + target: AdoptedCodexReadinessTarget + timeoutMs?: number + setTimer?: (callback: () => void, ms: number) => unknown + clearTimer?: (timer: unknown) => void +}): AdoptedCodexReadinessWatch { + const setTimer = input.setTimer ?? ((callback, ms) => setTimeout(callback, ms)) + const clearTimer = input.clearTimer ?? ((timer) => clearTimeout(timer as never)) + let finish: ((error: Error | null) => void) | null = null + const settled = new Promise((resolve, reject) => { + finish = (error) => { + if (error) { + reject(error) + } else { + resolve() + } + } + }) + let unsubscribe: (() => void) | null = null + let timer: unknown = null + const settle = (error: Error | null): void => { + if (!finish) { + return + } + const complete = finish + finish = null + if (timer !== null) { + clearTimer(timer) + timer = null + } + unsubscribe?.() + unsubscribe = null + complete(error) + } + unsubscribe = input.subscribe((event) => { + const verdict = classifyAdoptedCodexReadinessEvent(event, input.target) + if (verdict === 'ready') { + settle(null) + } else if (verdict === 'wrong-session') { + settle(new Error('The agent terminal resumed a different Codex session.')) + } + }) + timer = setTimer(() => { + settle(new Error('The agent terminal did not report the resumed Codex session.')) + }, input.timeoutMs ?? ADOPTED_CODEX_READINESS_TIMEOUT_MS) + return { + settled, + dispose: () => settle(new Error('The agent terminal stopped resuming Codex.')) + } +} diff --git a/src/main/codex/codex-session-nonce-wire.test.ts b/src/main/codex/codex-session-nonce-wire.test.ts new file mode 100644 index 00000000000..5583f0eca11 --- /dev/null +++ b/src/main/codex/codex-session-nonce-wire.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import { AGENT_HOOK_SESSION_NONCE_ENV_VAR } from '../../shared/agent-hook-session-nonce' +import { AgentHookServer, _internals } from '../agent-hooks/server' +import { buildBody, PANE } from '../agent-hooks/server.test-fixtures' +import { classifyAdoptedCodexReadinessEvent } from './adopted-codex-tui-readiness' +import { getCodexManagedHookInstallMaterial } from './hook-service' + +describe('Codex session-nonce hook wire', () => { + it('posts the nonce env var back as a form field', () => { + const { script } = getCodexManagedHookInstallMaterial() + expect(script).toContain(`sessionNonce=`) + expect(script).toContain(AGENT_HOOK_SESSION_NONCE_ENV_VAR) + }) + + it('keeps the nonce post fire-and-forget: no extra request, no extra wait', () => { + const { script } = getCodexManagedHookInstallMaterial() + // Why this is asserted at all: Codex blocks synchronously on hooks, so a second + // POST or a longer budget for the nonce would stall every resume by seconds. + expect(script.match(/-X POST/g)).toHaveLength(1) + expect(script).not.toMatch(/--max-time "?[3-9]/) + }) + + it('carries the nonce from the form body onto the normalized event', () => { + const event = _internals.normalizeHookPayload( + 'codex', + buildBody( + { hook_event_name: 'SessionStart', source: 'resume', session_id: 'thread-1' }, + { sessionNonce: 'nonce-1' } + ), + 'production' + ) + expect(event?.sessionNonce).toBe('nonce-1') + expect(event?.hookEventName).toBe('SessionStart') + expect(event?.providerSession?.id).toBe('thread-1') + }) + + it('delivers the nonce over the real hook endpoint to enriched-status subscribers', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + const seen: unknown[] = [] + const unsubscribe = server.subscribeEnrichedStatus((event) => seen.push(event)) + try { + const env = server.buildPtyEnv() + const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/codex`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify( + buildBody( + { hook_event_name: 'SessionStart', source: 'resume', session_id: 'thread-1' }, + { sessionNonce: 'nonce-1' } + ) + ) + }) + expect(response.status).toBe(204) + } finally { + unsubscribe() + server.stop() + } + // The seam the runtime's readiness watch actually sits on: a SessionStart that + // travelled the wire end to end must classify as proof. + expect(seen).toHaveLength(1) + expect( + classifyAdoptedCodexReadinessEvent(seen[0] as never, { + paneKey: PANE, + threadId: 'thread-1', + sessionNonce: 'nonce-1' + }) + ).toBe('ready') + }) + + it('leaves the nonce undefined when an older installed script omits it', () => { + const event = _internals.normalizeHookPayload( + 'codex', + buildBody({ hook_event_name: 'SessionStart', source: 'resume', session_id: 'thread-1' }), + 'production' + ) + expect(event?.sessionNonce).toBeUndefined() + }) +}) diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index 3bf087b14d9..18b698c3101 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -73,6 +73,7 @@ import { snapshotCodexRuntimeHookTrustProvenance } from './hook-trust-promotion' import { grantManagedCodexHookTrust } from './codex-hook-trust-grant' +import { AGENT_HOOK_SESSION_NONCE_ENV_VAR } from '../../shared/agent-hook-session-nonce' import { readCurrentCodexTrustGrantLedgerHome } from './codex-trust-grant-host' import { getCodexLedgerTrustedHash, @@ -140,6 +141,8 @@ type MirroredRuntimeUserHookTrustEntry = { enabled: boolean } +const CODEX_SESSION_NONCE_FORM_LINE_WINDOWS = `--data-urlencode "sessionNonce=%${AGENT_HOOK_SESSION_NONCE_ENV_VAR}%"` + function getManagedScriptPath(): string { return getSharedManagedScriptPath(getCodexManagedScriptFileName()) } @@ -772,7 +775,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { // Why: the endpoint file holds this install's live port/token; sourcing it lets a surviving PTY reach the current server (see claude/hook-service.ts). 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', ...buildWindowsHookEnvironmentGuardLines(), - buildWindowsAgentHookCurlPostCommand('codex'), + buildWindowsAgentHookCurlPostCommand('codex', [CODEX_SESSION_NONCE_FORM_LINE_WINDOWS]), 'exit /b 0', ...buildWindowsHookStdinDrainEpilogue(), '' @@ -824,6 +827,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', ' --data-urlencode "tabId=${ORCA_TAB_ID}" \\', ' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\', + ` --data-urlencode "sessionNonce=\${${AGENT_HOOK_SESSION_NONCE_ENV_VAR}}" \\`, ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', diff --git a/src/main/index.ts b/src/main/index.ts index b26ea85d435..14e7602f94d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2590,6 +2590,9 @@ void app.whenReady().then(async () => { agentHookServer.attestCompatibilityAuthority(candidate), retireAgentHookCompatibilityAuthority: (paneKey) => agentHookServer.retirePaneAuthority(paneKey), + // Why: the adopted-Codex return path proves readiness from the resumed CLI's own + // SessionStart hook rather than the pane's screen, so it needs a live event tap. + subscribeAgentHookEvents: (listener) => agentHookServer.subscribeEnrichedStatus(listener), canRecoverPersistentLocalPtys: () => getDaemonProvider() !== null, // Why: source codex-home here (runs in window AND serve) so aiVault.listSessions includes managed-Codex sessions; registerCoreHandlers is window-only. getAdditionalAiVaultCodexHomePaths: () => diff --git a/src/main/runtime/orca-runtime-adopted-codex-tui-readiness.test.ts b/src/main/runtime/orca-runtime-adopted-codex-tui-readiness.test.ts new file mode 100644 index 00000000000..5e1f07a7d33 --- /dev/null +++ b/src/main/runtime/orca-runtime-adopted-codex-tui-readiness.test.ts @@ -0,0 +1,266 @@ +import { describe, expect, it, vi } from 'vitest' +import { AGENT_HOOK_SESSION_NONCE_ENV_VAR } from '../../shared/agent-hook-session-nonce' +import type { StructuredAgentSessionHandoffTransport } from '../native-chat/agent-session-wire/structured-agent-session-handoff-types' +import type { AdoptedCodexReadinessEvent } from '../codex/adopted-codex-tui-readiness' +import { OrcaRuntimeService } from './orca-runtime' + +const { proveCodexTuiRollout, readStructuredTuiProcessIdentity, resolvePinnedCodexRolloutProof } = + vi.hoisted(() => ({ + proveCodexTuiRollout: vi.fn(), + readStructuredTuiProcessIdentity: vi.fn(), + resolvePinnedCodexRolloutProof: vi.fn() + })) + +vi.mock('./structured-tui-process-identity', () => ({ readStructuredTuiProcessIdentity })) +vi.mock('../codex/codex-tui-rollout-proof', () => ({ + proveCodexTuiRollout, + resolveLiveCodexTuiRollout: vi.fn(), + resolvePinnedCodexRolloutProof +})) + +const WORKTREE_ID = 'repo-1::/tmp/adopted-readiness' +const PANE_KEY = 'tab-adopt:leaf-adopt' +const THREAD_ID = 'thread-adopt' +const SESSION_ID = 'session-adopt' +const TRANSCRIPT = '/tmp/codex-home/rollout.jsonl' + +/** The tail a bare shell leaves behind once adoption has stopped the pane's Codex. */ +const READY_SHELL_TAIL = ['dev@host ~/repo %'] + +function returnToTerminalHarness(settingsOverrides: Record = {}) { + const listeners = new Set<(event: AdoptedCodexReadinessEvent) => void>() + const runtime = new OrcaRuntimeService( + { + getSettings: () => ({ + disabledTuiAgents: [], + agentCmdOverrides: {}, + agentDefaultArgs: {}, + agentDefaultEnv: {}, + ...settingsOverrides + }) + } as never, + undefined, + { + subscribeAgentHookEvents: (listener) => { + listeners.add(listener) + return () => listeners.delete(listener) + } + } + ) + const writeAgentSessionProof = vi.fn(() => true) + runtime.setPtyController({ + writeAgentSessionProof, + listProcesses: async () => [ + { id: 'pty-adopt', incarnationId: 'inc-adopt', rootProcessId: 31337 } + ] + } as never) + + const internal = runtime as unknown as { + ptysById: Map + adoptedStructuredTuiOwners: Map + resolveTerminalWorkspaceLaunchScope(): Promise + createStructuredAgentSessionHandoffTransport(): StructuredAgentSessionHandoffTransport + } + internal.ptysById.set('pty-adopt', { + ptyId: 'pty-adopt', + connected: true, + connectionId: null, + wslDistro: null, + tabId: 'tab-adopt', + paneKey: PANE_KEY, + worktreeId: WORKTREE_ID, + incarnationId: 'inc-adopt', + // Decoys: exactly the two signals a `tui-idle` wait would settle on. + lastAgentStatus: 'idle', + tailBuffer: READY_SHELL_TAIL, + tailPartialLine: '', + preview: READY_SHELL_TAIL.join('\n'), + lastOutputAt: 1_700_000_000_000 + }) + internal.adoptedStructuredTuiOwners.set(SESSION_ID, { + terminal: { + handle: 'term-adopt', + tabId: 'tab-adopt', + paneKey: PANE_KEY, + ptyId: 'pty-adopt' + }, + process: { hostId: 'local', pid: 4242, processStartTimeMs: 10, spawnToken: 'spawn-adopt' }, + link: { + linkId: 'link-adopt', + handle: { provider: 'codex', threadId: THREAD_ID }, + origin: 'adopted', + mintedAtFence: 1, + observedAt: 1 + }, + historySource: 'provider-resume', + adoptedTerminal: true + }) + internal.resolveTerminalWorkspaceLaunchScope = vi.fn(async () => ({ + id: WORKTREE_ID, + path: '/tmp/adopted-readiness', + connectionId: null, + repo: null, + folderWorkspace: null + })) + + readStructuredTuiProcessIdentity.mockResolvedValue({ + hostId: 'local', + pid: 5252, + processStartTimeMs: 20, + spawnToken: 'spawn-adopt' + }) + resolvePinnedCodexRolloutProof.mockResolvedValue(TRANSCRIPT) + + const launch = (): Promise<{ transcriptPath?: string }> => + internal.createStructuredAgentSessionHandoffTransport().launchTui({ + record: { + sessionId: SESSION_ID, + location: { workspaceId: WORKTREE_ID, executionHostId: 'local' }, + accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex-home' }, + options: null, + providerHandleChain: [{ handle: { provider: 'codex', threadId: THREAD_ID }, observedAt: 1 }] + } as never, + fence: 3, + spawnToken: 'spawn-adopt' + }) as Promise<{ transcriptPath?: string }> + + const writtenNonce = (): string => { + const written = writeAgentSessionProof.mock.calls + .map((call) => (call as unknown as [string, string])[1]) + .join('') + const match = new RegExp(`${AGENT_HOOK_SESSION_NONCE_ENV_VAR}[=:']*([0-9a-f-]{36})`).exec( + written + ) + if (!match?.[1]) { + throw new Error(`no session nonce in written command: ${written}`) + } + return match[1] + } + + const emit = (event: Partial): void => { + const full = { + paneKey: PANE_KEY, + source: 'codex', + hookEventName: 'SessionStart', + providerSession: { key: 'session_id', id: THREAD_ID }, + ...event + } as AdoptedCodexReadinessEvent + for (const listener of Array.from(listeners)) { + listener(full) + } + } + + return { runtime, launch, emit, writtenNonce, writeAgentSessionProof } +} + +/** Resolves to `'pending'` unless `promise` settles first. */ +async function settlesBefore(promise: Promise): Promise { + return Promise.race([ + promise, + new Promise<'pending'>((resolve) => { + setTimeout(() => resolve('pending'), 0) + }) + ]) +} + +describe('returning an adopted Codex session to its terminal', () => { + it('does not accept a ready shell as the resumed Codex, only its SessionStart hook', async () => { + const rig = returnToTerminalHarness() + const owner = rig.launch() + const stillWaiting = owner.then( + () => 'resolved' as const, + () => 'rejected' as const + ) + + // The pane now looks exactly like a settled agent terminal: an idle status + // left over from the Codex adoption stopped, and a shell prompt on screen. + expect(await settlesBefore(stillWaiting)).toBe('pending') + expect(proveCodexTuiRollout).not.toHaveBeenCalled() + + rig.emit({ sessionNonce: rig.writtenNonce() }) + await expect(owner).resolves.toMatchObject({ transcriptPath: TRANSCRIPT }) + }) + + it('stamps a one-shot nonce into the resume command and ignores a SessionStart without it', async () => { + const rig = returnToTerminalHarness() + const owner = rig.launch() + const stillWaiting = owner.then( + () => 'resolved' as const, + () => 'rejected' as const + ) + await settlesBefore(stillWaiting) + const nonce = rig.writtenNonce() + + // A SessionStart from an earlier Codex in this pane: same pane, same thread, + // a nonce Orca minted for some other invocation. + rig.emit({ sessionNonce: `stale-${nonce}` }) + expect(await settlesBefore(stillWaiting)).toBe('pending') + + rig.emit({ sessionNonce: nonce }) + await expect(owner).resolves.toMatchObject({ transcriptPath: TRANSCRIPT }) + }) + + it('ignores non-SessionStart codex hooks and replayed ones', async () => { + const rig = returnToTerminalHarness() + const owner = rig.launch() + const stillWaiting = owner.then( + () => 'resolved' as const, + () => 'rejected' as const + ) + await settlesBefore(stillWaiting) + const sessionNonce = rig.writtenNonce() + + rig.emit({ sessionNonce, hookEventName: 'Stop' }) + rig.emit({ sessionNonce, hookEventName: 'UserPromptSubmit' }) + rig.emit({ sessionNonce, isReplay: true }) + rig.emit({ sessionNonce, source: 'claude' }) + rig.emit({ sessionNonce, paneKey: 'tab-other:leaf-other' }) + expect(await settlesBefore(stillWaiting)).toBe('pending') + + rig.emit({ sessionNonce }) + await expect(owner).resolves.toMatchObject({ transcriptPath: TRANSCRIPT }) + }) + + it('rejects when the nonce Orca minted comes back bound to another conversation', async () => { + const rig = returnToTerminalHarness() + const owner = rig.launch() + const settled = owner.catch((error: Error) => error.message) + await settlesBefore(settled) + + rig.emit({ + sessionNonce: rig.writtenNonce(), + providerSession: { key: 'session_id', id: 'thread-other' } + }) + await expect(settled).resolves.toContain('resumed a different Codex session') + }) + + it('says why it cannot confirm the session when agent status hooks are off', async () => { + const rig = returnToTerminalHarness({ agentStatusHooksEnabled: false }) + await expect(rig.launch()).rejects.toThrow('Agent status hooks are off') + // Nothing may be typed at the pane when the proof can never arrive. + expect(rig.writeAgentSessionProof).not.toHaveBeenCalled() + }) + + it('never types a screen probe at the pane', async () => { + const rig = returnToTerminalHarness() + const owner = rig.launch() + const stillWaiting = owner.then( + () => 'resolved' as const, + () => 'rejected' as const + ) + await settlesBefore(stillWaiting) + rig.emit({ sessionNonce: rig.writtenNonce() }) + await owner + + const written = rig.writeAgentSessionProof.mock.calls.map( + (call) => (call as unknown as [string, string])[1] + ) + expect(written).toHaveLength(1) + expect(written[0]).toContain('codex') + expect(written[0]).not.toContain('/status') + // Bracketed paste and the bare/kitty submit keys the old proof sent. + expect(written[0]).not.toContain('[200~') + expect(written[0]).not.toContain('') + expect(proveCodexTuiRollout).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 671f266d2eb..237c9c2b14a 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -99,6 +99,13 @@ import { scopedAgentSessionClaimsEqual } from '../../shared/claimed-agent-pty-owner-snapshot' import { codexProviderHandleLink } from '../codex/codex-structured-owner-identity' +import { + beginAdoptedCodexReadinessWatch, + type AdoptedCodexReadinessEvent, + type AdoptedCodexReadinessWatch +} from '../codex/adopted-codex-tui-readiness' +import { AGENT_HOOK_SESSION_NONCE_ENV_VAR } from '../../shared/agent-hook-session-nonce' +import { withInlineEnvAssignments } from '../../shared/inline-env-command-prefix' import { proveCodexTuiRollout, resolveLiveCodexTuiRollout, @@ -3523,6 +3530,9 @@ export class OrcaRuntimeService { }) => AgentHookAuthorityAttestation | null) | null private readonly retireAgentHookCompatibilityAuthorityFn: ((paneKey: string) => void) | null + private readonly subscribeAgentHookEventsFn: + | ((listener: (event: AdoptedCodexReadinessEvent) => void) => () => void) + | null private readonly canRecoverPersistentLocalPtysFn: () => boolean private readonly buildAgentHookPtyEnv: (() => Record) | null private readonly getDesktopWindowStatusFn: () => RuntimeDesktopWindowStatus @@ -3606,6 +3616,12 @@ export class OrcaRuntimeService { terminalProvenance: 'current_runtime' | 'restored' }) => AgentHookAuthorityAttestation | null retireAgentHookCompatibilityAuthority?: (paneKey: string) => void + /** Live tap on accepted agent hook events. The adopted-Codex return path uses + * it to take the resumed CLI's own SessionStart as its readiness proof + * instead of scraping the pane. */ + subscribeAgentHookEvents?: ( + listener: (event: AdoptedCodexReadinessEvent) => void + ) => () => void canRecoverPersistentLocalPtys?: () => boolean // Why: codex-home paths for the Agent Session History scan must be sourced // here, not via the window-only registerCoreHandlers path — that path never @@ -3649,6 +3665,7 @@ export class OrcaRuntimeService { deps?.attestAgentHookCompatibilityAuthority ?? null this.retireAgentHookCompatibilityAuthorityFn = deps?.retireAgentHookCompatibilityAuthority ?? null + this.subscribeAgentHookEventsFn = deps?.subscribeAgentHookEvents ?? null this.canRecoverPersistentLocalPtysFn = deps?.canRecoverPersistentLocalPtys ?? (() => true) // Why: configure the shared AiVault scan cache from a serve-mode-reachable // seam so the aiVault.listSessions RPC includes managed-Codex + WSL sessions @@ -10771,84 +10788,110 @@ export class OrcaRuntimeService { shell, isRemote: false }) - if ( - !startup || - !this.ptyController?.writeAgentSessionProof?.(pty.ptyId, `${startup.launchCommand}\r`, { - sessionId: input.record.sessionId, - spawnToken: input.spawnToken - }) - ) { + if (!startup || !pty.paneKey) { throw new Error('The adopted terminal could not resume Codex.') } - const listing = (await this.ptyController.listProcesses?.())?.find( - (candidate) => - candidate.id === pty.ptyId && - (!pty.incarnationId || candidate.incarnationId === pty.incarnationId) - ) - if (!listing?.rootProcessId) { - throw new Error('The adopted terminal did not publish a process identity.') + // Why this is checked up front: the resumed Codex proves itself through its own + // SessionStart hook, so without the hooks there is nothing to wait for — fail + // now with the reason instead of after a 30s silence. + const subscribeAgentHookEvents = this.subscribeAgentHookEventsFn + if ( + !subscribeAgentHookEvents || + settings.agentStatusHooksEnabled === false || + settings.disabledTuiAgents?.includes('codex') === true + ) { + throw new Error('Agent status hooks are off, so Codex cannot confirm the resumed session.') } - const process = await readStructuredTuiProcessIdentity({ - hostId: input.record.location.executionHostId, - rootPid: listing.rootProcessId, - spawnToken: input.spawnToken, - agent: 'codex' + // Why the watch opens before the write: a warm Codex can post SessionStart + // before `writeAgentSessionProof` even returns, and a proof that arrives + // before its listener is a proof Orca never sees. + const sessionNonce = randomUUID() + const readiness = beginAdoptedCodexReadinessWatch({ + subscribe: subscribeAgentHookEvents, + target: { paneKey: pty.paneKey, threadId: head.handle.threadId, sessionNonce } }) - const owner: StructuredTuiOwner = { - ...input.owner, - process, - link: codexProviderHandleLink({ - threadId: head.handle.threadId, - resumed: true, - fence: input.fence, - observedAt: Date.now() + // Why: a throw between here and the await would otherwise leave the watch's + // timeout rejection unhandled. + void readiness.settled.catch(() => {}) + let proved = false + try { + const resumeCommand = withInlineEnvAssignments({ + command: startup.launchCommand, + env: { [AGENT_HOOK_SESSION_NONCE_ENV_VAR]: sessionNonce }, + platform, + shell }) + if ( + !this.ptyController?.writeAgentSessionProof?.(pty.ptyId, `${resumeCommand}\r`, { + sessionId: input.record.sessionId, + spawnToken: input.spawnToken + }) + ) { + throw new Error('The adopted terminal could not resume Codex.') + } + const listing = (await this.ptyController.listProcesses?.())?.find( + (candidate) => + candidate.id === pty.ptyId && + (!pty.incarnationId || candidate.incarnationId === pty.incarnationId) + ) + if (!listing?.rootProcessId) { + throw new Error('The adopted terminal did not publish a process identity.') + } + const process = await readStructuredTuiProcessIdentity({ + hostId: input.record.location.executionHostId, + rootPid: listing.rootProcessId, + spawnToken: input.spawnToken, + agent: 'codex' + }) + const owner: StructuredTuiOwner = { + ...input.owner, + process, + link: codexProviderHandleLink({ + threadId: head.handle.threadId, + resumed: true, + fence: input.fence, + observedAt: Date.now() + }) + } + await input.onSpawned?.(owner) + const transcriptPath = await this.waitForAdoptedStructuredTuiProof({ + owner, + readiness, + threadId: head.handle.threadId, + codexHome: input.record.accountHome.path + }) + proved = true + const provenOwner = { ...owner, transcriptPath } + this.adoptedStructuredTuiOwners.set(input.record.sessionId, provenOwner) + return provenOwner + } finally { + if (!proved) { + readiness.dispose() + } } - await input.onSpawned?.(owner) - const proof = await this.waitForAdoptedStructuredTuiProof({ - owner, - threadId: head.handle.threadId, - codexHome: input.record.accountHome.path, - sessionId: input.record.sessionId, - spawnToken: input.spawnToken - }) - const proved = { ...owner, transcriptPath: proof.transcriptPath } - this.adoptedStructuredTuiOwners.set(input.record.sessionId, proved) - return proved } + // Why nothing is typed at the pane here: the old proof pasted `/status` and + // scraped the tail for a session id, which cannot run until Codex is already up + // and cannot tell a ready shell from a ready Codex. The CLI's own SessionStart + // hook answers both questions off-screen, so this only has to wait for it and + // then name the rollout the thread already owns on disk. private async waitForAdoptedStructuredTuiProof(input: { owner: StructuredTuiOwner + readiness: AdoptedCodexReadinessWatch threadId: string codexHome: string - sessionId: string - spawnToken: string - }): Promise<{ transcriptPath: string }> { - const readPty = (): RuntimePtyWorktreeRecord => { - const pty = this.ptysById.get(input.owner.terminal.ptyId) - if (!pty?.connected || pty.paneKey !== input.owner.terminal.paneKey) { - throw new Error('The adopted terminal lost its pane identity.') - } - return pty + }): Promise { + await input.readiness.settled + const pty = this.ptysById.get(input.owner.terminal.ptyId) + if (!pty?.connected || pty.paneKey !== input.owner.terminal.paneKey) { + throw new Error('The adopted terminal lost its pane identity.') } - const pty = readPty() - return proveCodexTuiRollout({ - codexHome: input.codexHome, - threadId: input.threadId, - kittyKeyboardFlags: this.providerModeTrackersByPtyId.get(pty.ptyId)?.flags ?? 0, - readOutput: () => { - const current = readPty() - return { - text: buildTerminalWaitText(current.tailBuffer, current.tailPartialLine, current.preview), - lastOutputAt: current.lastOutputAt - } - }, - write: (data) => - this.ptyController?.writeAgentSessionProof?.(readPty().ptyId, data, { - sessionId: input.sessionId, - spawnToken: input.spawnToken - }) ?? false - }) + const transcriptPath = await resolvePinnedCodexRolloutProof(input.codexHome, input.threadId) + if (!transcriptPath) { + throw new Error('The agent terminal did not prove the expected Codex rollout.') + } + return transcriptPath } private async closeAdoptedStructuredTuiOwner(owner: StructuredTuiOwner): Promise { diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index ec9a947b94c..5eca70030d3 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -331,6 +331,11 @@ export type AgentHookEventPayload = { source?: AgentHookSource /** Ephemeral Orca launch identity stamped into the PTY env for this process. */ launchToken?: string + /** One-shot nonce Orca minted for a single agent invocation, stamped into that + * process's env only. Unlike `launchToken` (pane-lifetime identity that + * authority attestation matches against) this is per-launch and disposable, so + * it can bind a specific spawn without rewriting the pane's launch identity. */ + sessionNonce?: string tabId?: string worktreeId?: string /** SSH connection the event arrived on, or null for local. Only `ingestRemote` can stamp it — the loopback HTTP path has no mux identity — and receivers key off it to drop @@ -4304,6 +4309,7 @@ export function normalizeHookPayload( } const worktreeId = readStringField(record, 'worktreeId') const launchToken = readStringField(record, 'launchToken') + const sessionNonce = readStringField(record, 'sessionNonce') const hookPayloadRecord = hookPayload as Record if (source === 'claude') { @@ -4507,6 +4513,7 @@ export function normalizeHookPayload( paneKey, source, launchToken, + ...(sessionNonce ? { sessionNonce } : {}), tabId, worktreeId, // Why: normalization is transport-agnostic — only ingestRemote knows the mux identity to stamp here. diff --git a/src/shared/agent-hook-session-nonce.ts b/src/shared/agent-hook-session-nonce.ts new file mode 100644 index 00000000000..a87dcf0b9d4 --- /dev/null +++ b/src/shared/agent-hook-session-nonce.ts @@ -0,0 +1,7 @@ +// Why: a resumed Codex TUI must prove itself off-screen. Orca stamps this one-shot +// nonce into the single `codex resume` invocation it types, and the managed hook +// echoes it back as the `sessionNonce` form field, so a stale SessionStart from an +// earlier process in the same pane cannot pass for the new one. Deliberately NOT +// `launchToken`: that is pane-lifetime identity and authority attestation matches +// against it, so rotating it per resume would break orchestration dispatch. +export const AGENT_HOOK_SESSION_NONCE_ENV_VAR = 'ORCA_AGENT_SESSION_NONCE' diff --git a/src/shared/inline-env-command-prefix.test.ts b/src/shared/inline-env-command-prefix.test.ts new file mode 100644 index 00000000000..0eb636168c2 --- /dev/null +++ b/src/shared/inline-env-command-prefix.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import { withInlineEnvAssignments } from './inline-env-command-prefix' + +const ENV = { ORCA_AGENT_SESSION_NONCE: 'nonce-1' } + +describe('inline env assignments for a typed command', () => { + it('leaves the command untouched when there is nothing to assign', () => { + expect( + withInlineEnvAssignments({ command: 'codex resume x', env: {}, platform: 'darwin' }) + ).toBe('codex resume x') + }) + + it('uses env(1) on POSIX so fish parses it too', () => { + for (const shell of ['posix', 'fish'] as const) { + expect( + withInlineEnvAssignments({ + command: "codex resume 'thread-1'", + env: ENV, + platform: 'linux', + shell + }) + // Why not `NAME=value cmd`: fish rejects that form outright. + ).toBe("env ORCA_AGENT_SESSION_NONCE='nonce-1' codex resume 'thread-1'") + } + }) + + it('uses PowerShell and cmd assignment syntax on Windows', () => { + expect( + withInlineEnvAssignments({ + command: 'codex resume thread-1', + env: ENV, + platform: 'win32', + shell: 'powershell' + }) + ).toBe("$env:ORCA_AGENT_SESSION_NONCE='nonce-1'; codex resume thread-1") + expect( + withInlineEnvAssignments({ + command: 'codex resume thread-1', + env: ENV, + platform: 'win32', + shell: 'cmd' + }) + ).toBe('set "ORCA_AGENT_SESSION_NONCE=nonce-1" && codex resume thread-1') + }) + + it('quotes values for the target dialect', () => { + expect( + withInlineEnvAssignments({ + command: 'codex', + env: { N: "it's" }, + platform: 'darwin', + shell: 'posix' + }) + ).toContain(`N='it'\\''s'`) + expect( + withInlineEnvAssignments({ + command: 'codex', + env: { N: "it's" }, + platform: 'win32', + shell: 'powershell' + }) + ).toContain(`$env:N='it''s'`) + }) +}) diff --git a/src/shared/inline-env-command-prefix.ts b/src/shared/inline-env-command-prefix.ts new file mode 100644 index 00000000000..e471d77a1cb --- /dev/null +++ b/src/shared/inline-env-command-prefix.ts @@ -0,0 +1,46 @@ +// Env assignments for a command Orca TYPES into a shell it already owns, rather +// than one it spawns with an env map. The adopted-Codex return path has no spawn +// to attach env to: the pane is a live shell and the resume command is keystrokes. +import { + quoteStartupArg, + resolveStartupShell, + type AgentStartupShell +} from './tui-agent-startup-shell' + +export function buildInlineEnvCommandPrefix(args: { + env: Record + platform: NodeJS.Platform + shell?: AgentStartupShell +}): string { + const entries = Object.entries(args.env) + if (entries.length === 0) { + return '' + } + const shell = resolveStartupShell(args.platform, args.shell) + const quote = (value: string): string => quoteStartupArg(value, shell) + if (shell === 'powershell') { + return `${entries.map(([name, value]) => `$env:${name}=${quote(value)}`).join('; ')}; ` + } + if (shell === 'cmd') { + // Why: `set` here outlives the command, unlike the POSIX one-shot form. Safe + // because every caller overwrites the same names on its next invocation, so a + // leftover value can only ever fail a match — never satisfy a fresh one. + return `${entries.map(([name, value]) => `set ${quote(`${name}=${value}`)}`).join(' && ')} && ` + } + // Why `env` rather than the bare `NAME=value cmd` prefix: fish rejects that form + // outright ("Unsupported use of '='"), and the pane's POSIX dialect is not + // recorded anywhere Orca can read at write time. `env` parses in sh, bash, zsh + // and fish alike, and matches how every other Orca launch path reaches the agent + // binary — through the executable, not a shell alias. + return `env ${entries.map(([name, value]) => `${name}=${quote(value)}`).join(' ')} ` +} + +/** Prefixes a typed command so the assignments apply to that invocation. */ +export function withInlineEnvAssignments(args: { + command: string + env: Record + platform: NodeJS.Platform + shell?: AgentStartupShell +}): string { + return `${buildInlineEnvCommandPrefix(args)}${args.command}` +}