diff --git a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts index 142d1706f9c..f09e6d3ff02 100644 --- a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts +++ b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts @@ -1,9 +1,14 @@ import { describe, expect, it } from 'vitest' -import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types' +import { + CONFIRM_FOREGROUND_PROCESS_PROTOCOL_VERSION, + PREVIOUS_DAEMON_PROTOCOL_VERSIONS, + PROTOCOL_VERSION +} from './types' describe('foreground-confirmation daemon protocol', () => { it('rejects daemons from before the fresh-confirmation RPC', () => { - expect(PROTOCOL_VERSION).toBe(36) + expect(PROTOCOL_VERSION).toBe(37) + expect(CONFIRM_FOREGROUND_PROCESS_PROTOCOL_VERSION).toBe(37) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23) diff --git a/src/main/daemon/daemon-protocol-version.test.ts b/src/main/daemon/daemon-protocol-version.test.ts index dfe84947ff5..caf43b1fbd9 100644 --- a/src/main/daemon/daemon-protocol-version.test.ts +++ b/src/main/daemon/daemon-protocol-version.test.ts @@ -6,6 +6,7 @@ import { CODEX_SHELL_LAUNCH_PREFLIGHT_DAEMON_PROTOCOL_VERSION, COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION, CONTENT_ADDRESSED_SHELL_WRAPPER_DAEMON_PROTOCOL_VERSION, + CONFIRM_FOREGROUND_PROCESS_PROTOCOL_VERSION, GET_FOREGROUND_PROCESS_PROTOCOL_VERSION, HISTORY_SEED_TRANSFER_PROTOCOL_VERSION, MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION, @@ -19,7 +20,8 @@ import { describe('daemon protocol version', () => { it('ships bounded history transfer after the 2031-unsubscribe fact', () => { - expect(PROTOCOL_VERSION).toBe(36) + expect(PROTOCOL_VERSION).toBe(37) + expect(CONFIRM_FOREGROUND_PROCESS_PROTOCOL_VERSION).toBe(37) expect(CONTENT_ADDRESSED_SHELL_WRAPPER_DAEMON_PROTOCOL_VERSION).toBe(36) expect(ASYNC_CWD_VALIDATION_DAEMON_PROTOCOL_VERSION).toBe(35) expect(CODEX_SHELL_LAUNCH_PREFLIGHT_DAEMON_PROTOCOL_VERSION).toBe(34) @@ -33,7 +35,7 @@ describe('daemon protocol version', () => { expect(AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION).toBe(26) expect(AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION).toBe(26) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toEqual( - Array.from({ length: 35 }, (_, index) => index + 1) + Array.from({ length: 36 }, (_, index) => index + 1) ) }) diff --git a/src/main/daemon/daemon-protocol-version.ts b/src/main/daemon/daemon-protocol-version.ts index bae2ae972dc..700c65fa64b 100644 --- a/src/main/daemon/daemon-protocol-version.ts +++ b/src/main/daemon/daemon-protocol-version.ts @@ -1,6 +1,7 @@ // Why: daemons survive app updates, so wire behavior must be version-gated. -// v36 launches shells from content-addressed wrapper trees; older owners stay attachable. -export const PROTOCOL_VERSION = 36 +// v37 adds fresh foreground confirmation; older owners stay attachable without that RPC. +export const PROTOCOL_VERSION = 37 +export const CONFIRM_FOREGROUND_PROCESS_PROTOCOL_VERSION = 37 export const CONTENT_ADDRESSED_SHELL_WRAPPER_DAEMON_PROTOCOL_VERSION = 36 export const ASYNC_CWD_VALIDATION_DAEMON_PROTOCOL_VERSION = 35 export const CODEX_SHELL_LAUNCH_PREFLIGHT_DAEMON_PROTOCOL_VERSION = 34 @@ -30,7 +31,7 @@ export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24 export const MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION = 29 export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, - 28, 29, 30, 31, 32, 33, 34, 35 + 28, 29, 30, 31, 32, 33, 34, 35, 36 ] as const export function supportsPtyStartupIngress(protocolVersion: number): boolean { diff --git a/src/main/daemon/daemon-pty-adapter-protocol-compatibility.test.ts b/src/main/daemon/daemon-pty-adapter-protocol-compatibility.test.ts index 0ee95478743..c9c5f809e7a 100644 --- a/src/main/daemon/daemon-pty-adapter-protocol-compatibility.test.ts +++ b/src/main/daemon/daemon-pty-adapter-protocol-compatibility.test.ts @@ -5,6 +5,7 @@ import { DaemonClient } from './client' import { DaemonPtyAdapter, LIVENESS_PROBE_TIMEOUT_MS } from './daemon-pty-adapter' import { COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION, + CONFIRM_FOREGROUND_PROCESS_PROTOCOL_VERSION, GET_FOREGROUND_PROCESS_PROTOCOL_VERSION, GET_SIZE_PROTOCOL_VERSION, PROTOCOL_VERSION @@ -613,6 +614,19 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { legacy.dispose() }) + it('does not send fresh confirmation to a stale daemon', async () => { + const request = vi.fn(async () => ({ foregroundProcess: 'zsh' })) + const legacy = createInspectionAdapter( + CONFIRM_FOREGROUND_PROCESS_PROTOCOL_VERSION - 1, + request + ) + + await expect(legacy.confirmForegroundProcess('sess-a')).resolves.toBeNull() + expect(request).not.toHaveBeenCalled() + + legacy.dispose() + }) + it.each([ GET_FOREGROUND_PROCESS_PROTOCOL_VERSION, COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION - 1 diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 56bad83e39e..b1c0008e618 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -21,6 +21,7 @@ import { CODEX_SHELL_READY_TIMEOUT_MS } from './session-shell-ready-barrier' import { CLEAN_DISCONNECT_PROTOCOL_VERSION, COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION, + CONFIRM_FOREGROUND_PROCESS_PROTOCOL_VERSION, GET_FOREGROUND_PROCESS_PROTOCOL_VERSION, AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, @@ -1622,6 +1623,9 @@ export class DaemonPtyAdapter implements IPtyProvider { } async confirmForegroundProcess(id: string): Promise { + if (this.protocolVersion < CONFIRM_FOREGROUND_PROCESS_PROTOCOL_VERSION) { + return null + } try { const result = await this.client.request<{ foregroundProcess: string | null }>( 'confirmForegroundProcess', diff --git a/src/main/daemon/session.test.ts b/src/main/daemon/session.test.ts index ea37c9237e5..ff06365232f 100644 --- a/src/main/daemon/session.test.ts +++ b/src/main/daemon/session.test.ts @@ -41,6 +41,7 @@ function createMockSubprocess() { getForegroundProcess(): string | null { return this.foregroundProcess }, + confirmForegroundProcess: undefined as (() => Promise) | undefined, write(data: string) { written.push(data) }, @@ -309,6 +310,17 @@ describe('Session', () => { }) }) + describe('foreground confirmation', () => { + it('does not fall back to cached foreground evidence', async () => { + createSession({ shellReadySupported: false }) + subprocess.foregroundProcess = 'codex' + subprocess.confirmForegroundProcess = vi.fn(async () => null) + + await expect(session.confirmForegroundProcess()).resolves.toBeNull() + expect(subprocess.confirmForegroundProcess).toHaveBeenCalledOnce() + }) + }) + describe('emulator does not reply to terminal queries', () => { // Why: daemon emulator parses in-process synchronously — before // handleSubprocessData forwards bytes onward — so any auto-reply it diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 2862a01e6f1..e74042a20ca 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -250,7 +250,7 @@ export class Session { } async confirmForegroundProcess(): Promise { - return this.subprocess.confirmForegroundProcess?.() ?? this.subprocess.getForegroundProcess() + return (await this.subprocess.confirmForegroundProcess?.()) ?? null } clearScrollback(): void { diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 2f1817d14c7..8ee257d4cac 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -29,6 +29,7 @@ export { ASYNC_CWD_VALIDATION_DAEMON_PROTOCOL_VERSION, CLEAN_DISCONNECT_PROTOCOL_VERSION, COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION, + CONFIRM_FOREGROUND_PROCESS_PROTOCOL_VERSION, GET_FOREGROUND_PROCESS_PROTOCOL_VERSION, GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION, PREVIOUS_DAEMON_PROTOCOL_VERSIONS, diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index cfb6fdbb4d8..77f34a93042 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -13314,6 +13314,32 @@ describe('OrcaRuntimeService', () => { ).toBeUndefined() }) + it('retires runtime authority in a dependency-free host without hook state', async () => { + const harness = await createCommandFinishedRuntimeHarness({ + ptyId: 'pty-headless-authority', + confirmForegroundProcess: async () => 'zsh', + authorityDeps: { + captureAgentHookPaneAuthorityState: undefined, + retireAgentHookPaneAuthorityIfCurrent: undefined + } + }) + + vi.useFakeTimers() + try { + harness.runtime.onPtyData('pty-headless-authority', '\x1b]133;D;0\x07', 100) + await vi.advanceTimersByTimeAsync(COMMAND_FINISHED_LAUNCH_AUTHORITY_SETTLE_MS) + } finally { + vi.useRealTimers() + } + + expect(harness.confirmForegroundProcess).toHaveBeenCalledOnce() + expect( + harness.runtime.getAgentStatusLaunchConfigForPaneKey(harness.paneKey, { + launchToken: harness.launchToken + }) + ).toBeUndefined() + }) + it('conditionally retires canonical AgentHookServer authority before clearing runtime launch authority', async () => { const spawn = vi .fn() diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 27e938c38ed..c85d1e45c66 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -2328,7 +2328,7 @@ type CommandFinishedLaunchAuthorityAttempt = Readonly<{ launchIncarnationId: PtyIncarnationId | null incarnationId: PtyIncarnationId | null restoredReceipt: RestoredOrchestrationAuthorityReceipt | null - hookAuthorityState: AgentHookPaneAuthorityStateReceipt + hookAuthorityState: AgentHookPaneAuthorityStateReceipt | null }> type OrchestrationCompatibilitySshAttachmentAuthority = Extract< @@ -14073,8 +14073,14 @@ export class OrcaRuntimeService { } const paneKey = (pty.paneKey && parsePaneKey(pty.paneKey) ? pty.paneKey : null) ?? restoredReceipt?.paneKey - const hookAuthorityState = paneKey ? this.captureAgentHookPaneAuthorityStateFn?.(paneKey) : null - if (!hookAuthorityState) { + const hookAuthoritySeamAvailable = + this.captureAgentHookPaneAuthorityStateFn !== null && + this.retireAgentHookPaneAuthorityIfCurrentFn !== null + const hookAuthorityState = + hookAuthoritySeamAvailable && paneKey + ? (this.captureAgentHookPaneAuthorityStateFn?.(paneKey) ?? null) + : null + if (hookAuthoritySeamAvailable && !hookAuthorityState) { return } const attempt: CommandFinishedLaunchAuthorityAttempt = Object.freeze({ @@ -14095,7 +14101,9 @@ export class OrcaRuntimeService { }) if ( !this.isCommandFinishedLaunchAuthorityAttemptCurrent(ptyId, attempt) || - !this.isAgentHookPaneAuthorityStateCurrent(attempt.hookAuthorityState) + (hookAuthoritySeamAvailable && + (!attempt.hookAuthorityState || + !this.isAgentHookPaneAuthorityStateCurrent(attempt.hookAuthorityState))) ) { return } @@ -14111,13 +14119,19 @@ export class OrcaRuntimeService { if (!shouldRetireLaunchAuthorityOnCommandFinished(foregroundProcess)) { return } - if (!this.retireAgentHookPaneAuthorityIfCurrentFn?.(attempt.hookAuthorityState)) { + if ( + hookAuthoritySeamAvailable && + (!attempt.hookAuthorityState || + !this.retireAgentHookPaneAuthorityIfCurrentFn?.(attempt.hookAuthorityState)) + ) { return } if (!this.isCommandFinishedLaunchAuthorityAttemptCurrent(ptyId, attempt)) { return } - this.retirePtyAgentLaunchAuthority(ptyId, { hookAuthorityAlreadyRetired: true }) + this.retirePtyAgentLaunchAuthority(ptyId, { + hookAuthorityAlreadyRetired: hookAuthoritySeamAvailable + }) } async resolveTerminalCwd(handle: string): Promise { diff --git a/src/shared/shell-process-detection.test.ts b/src/shared/shell-process-detection.test.ts index 6d3ffc41c2e..fe6e81ae1c5 100644 --- a/src/shared/shell-process-detection.test.ts +++ b/src/shared/shell-process-detection.test.ts @@ -15,4 +15,8 @@ describe('isShellProcess', () => { it('does not classify an unknown foreground process as a shell', () => { expect(isShellProcess('node')).toBe(false) }) + + it.each(['', '/'])('does not classify %j as a shell', (processName) => { + expect(isShellProcess(processName)).toBe(false) + }) }) diff --git a/src/shared/shell-process-detection.ts b/src/shared/shell-process-detection.ts index 9a69fa98369..73b0b4ae0f8 100644 --- a/src/shared/shell-process-detection.ts +++ b/src/shared/shell-process-detection.ts @@ -2,7 +2,7 @@ // renderer (agent-ready-wait, new-workspace). A bare shell is the negative // signal for "is an agent running" because it garbles injected preambles. const SHELL_NAMES = new Set( - '|bash|zsh|sh|fish|cmd|cmd.exe|powershell|powershell.exe|pwsh|pwsh.exe|nu|ksh|mksh|dash|ash|tcsh|csh|elvish|xonsh'.split( + 'bash|zsh|sh|fish|cmd|cmd.exe|powershell|powershell.exe|pwsh|pwsh.exe|nu|ksh|mksh|dash|ash|tcsh|csh|elvish|xonsh'.split( '|' ) )