diff --git a/config/scripts/rebuild-native-deps-node-pty.test.mjs b/config/scripts/rebuild-native-deps-node-pty.test.mjs index 871732dd53d..c3a8f9bbd83 100644 --- a/config/scripts/rebuild-native-deps-node-pty.test.mjs +++ b/config/scripts/rebuild-native-deps-node-pty.test.mjs @@ -173,6 +173,28 @@ describe('rebuild-native-deps patched node-pty rebuild', () => { } }) + it('refuses a Windows rebuild when the process creation-time patch is missing', () => { + const projectDir = mkTempProject() + + try { + writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) + writeFakeElectronRebuild(projectDir) + writeFakeNodePtyConptyPayload(projectDir, 'x64') + writeFakeWindowsProcessTreeWithNodeAddonApi(projectDir, { creationTimePatchApplied: false }) + + const result = runRebuildScript( + projectDir, + { npm_config_platform: 'win32', npm_config_arch: 'x64' }, + ['--platform=win32', '--arch=x64', '--force'] + ) + + expect(result.status).not.toBe(0) + expect(result.stderr).toContain('process creation-time patch') + } finally { + removeTreeSync(projectDir) + } + }) + it('restores the ConPTY runtime payload after a Windows Electron rebuild', () => { const projectDir = mkTempProject() diff --git a/config/scripts/rebuild-native-deps-test-fixtures.mjs b/config/scripts/rebuild-native-deps-test-fixtures.mjs index 2cb7d8ba8b4..db5af45a454 100644 --- a/config/scripts/rebuild-native-deps-test-fixtures.mjs +++ b/config/scripts/rebuild-native-deps-test-fixtures.mjs @@ -374,13 +374,18 @@ export function writeFakeWindowsProcessTree(projectDir) { export function writeFakeWindowsProcessTreeWithNodeAddonApi( projectDir, - { commandLinePatchApplied = true } = {} + { commandLinePatchApplied = true, creationTimePatchApplied = true } = {} ) { const processTreeDir = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree') const nodeAddonApiDir = join(processTreeDir, 'node_modules', 'node-addon-api') mkdirSync(nodeAddonApiDir, { recursive: true }) writeFileSync(join(processTreeDir, 'package.json'), '{"dependencies":{"node-addon-api":"*"}}\n') - writeFileSync(join(processTreeDir, 'index.js'), 'module.exports = {}\n') + writeFileSync( + join(processTreeDir, 'index.js'), + creationTimePatchApplied + ? 'exports.ProcessDataFlag = { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 }\n' + : 'exports.ProcessDataFlag = { None: 0, Memory: 1, CommandLine: 2 }\n' + ) mkdirSync(join(processTreeDir, 'src'), { recursive: true }) writeFileSync( join(processTreeDir, 'src', 'process_commandline.cc'), @@ -388,6 +393,36 @@ export function writeFakeWindowsProcessTreeWithNodeAddonApi( ? '// kProcessCommandLineInformation = 60\n' : unpatchedWindowsProcessTreeCommandLineSource() ) + writeFileSync( + join(processTreeDir, 'src', 'process.h'), + creationTimePatchApplied + ? 'enum ProcessDataFlags { NONE = 0, MEMORY = 1, COMMANDLINE = 2, CREATIONTIME = 4 };\nULONGLONG creationTimeMs;\n' + : 'enum ProcessDataFlags { NONE = 0, MEMORY = 1, COMMANDLINE = 2 };\n' + ) + writeFileSync( + join(processTreeDir, 'src', 'process.cc'), + creationTimePatchApplied + ? 'GetProcessCreationTime(pinfo);\nGetProcessTimes(hProcess, &creationTime, &exitTime, &kernelTime, &userTime);\n' + : 'GetProcessMemoryUsage(pinfo);\n' + ) + writeFileSync( + join(processTreeDir, 'src', 'process_worker.cc'), + creationTimePatchApplied ? 'object.Set("creationTimeMs", process.creationTimeMs);\n' : '\n' + ) + mkdirSync(join(processTreeDir, 'lib'), { recursive: true }) + writeFileSync( + join(processTreeDir, 'lib', 'index.js'), + creationTimePatchApplied ? 'exports.ProcessDataFlag["CreationTime"] = 4;\n' : '\n' + ) + writeFileSync( + join(processTreeDir, 'lib', 'index.ts'), + creationTimePatchApplied ? 'export enum ProcessDataFlag { CreationTime = 4 }\n' : '\n' + ) + mkdirSync(join(processTreeDir, 'typings'), { recursive: true }) + writeFileSync( + join(processTreeDir, 'typings', 'windows-process-tree.d.ts'), + creationTimePatchApplied ? 'creationTimeMs?: number\n' : '\n' + ) writeFileSync(join(nodeAddonApiDir, 'package.json'), '{"name":"node-addon-api"}\n') writeFileSync(join(nodeAddonApiDir, 'napi.h'), '// napi.h\n') writeFileSync(join(nodeAddonApiDir, 'napi-inl.h'), '// napi-inl.h\n') diff --git a/config/scripts/windows-process-tree-gyp-rebuild.mjs b/config/scripts/windows-process-tree-gyp-rebuild.mjs index 20d91e55497..6f21fb2a153 100644 --- a/config/scripts/windows-process-tree-gyp-rebuild.mjs +++ b/config/scripts/windows-process-tree-gyp-rebuild.mjs @@ -33,6 +33,17 @@ export const WINDOWS_PROCESS_TREE_PATCH_PATH = join( /** Only the patched reader defines this; the upstream one walks the PEB. */ const COMMAND_LINE_PATCH_MARKER = 'kProcessCommandLineInformation' +const CREATION_TIME_PATCH_MARKERS = [ + ['src/process.h', 'CREATIONTIME = 4'], + ['src/process.h', 'ULONGLONG creationTimeMs'], + ['src/process.cc', 'GetProcessCreationTime(pinfo)'], + ['src/process.cc', 'GetProcessTimes(hProcess, &creationTime'], + ['src/process_worker.cc', 'object.Set("creationTimeMs"'], + ['lib/index.js', '["CreationTime"] = 4'], + ['lib/index.ts', 'CreationTime = 4'], + ['typings/windows-process-tree.d.ts', 'creationTimeMs?: number'] +] + export const WINDOWS_PROCESS_TREE_NODE_ADDON_API_HEADERS = [ 'napi.h', 'napi-inl.h', @@ -83,6 +94,36 @@ export function inspectWindowsProcessTreeAddon(addonPath) { return readFileSync(addonPath).includes(FLAGGED_IMPORT) ? 'unpatched' : 'clean' } +export function assertWindowsProcessTreeCreationTimePatch( + packageDir = WINDOWS_PROCESS_TREE_PACKAGE_DIR +) { + for (const [relativePath, expected] of CREATION_TIME_PATCH_MARKERS) { + const filePath = join(packageDir, relativePath) + if (!existsSync(filePath)) { + throw new Error( + `${filePath} is missing, so the process creation-time patch cannot be verified. ` + + 'Run pnpm install.' + ) + } + if (!readFileSync(filePath, 'utf8').includes(expected)) { + throw new Error( + `${relativePath} does not contain the process creation-time patch (${expected}). ` + + 'Run pnpm install.' + ) + } + } +} + +export function assertWindowsProcessTreeRuntimeCreationTime(windowsProcessTree) { + if (windowsProcessTree?.ProcessDataFlag?.CreationTime !== 4) { + throw new Error( + '@vscode/windows-process-tree does not expose ProcessDataFlag.CreationTime, so native ' + + 'Windows structured agent-session process ownership cannot be PID-reuse safe. Rebuild it ' + + '(pnpm run rebuild:electron) rather than using the published prebuild.' + ) + } +} + /** * Refuse to compile or load the upstream command-line reader. * @@ -159,6 +200,7 @@ export function ensureWindowsProcessTreeCommandLinePatch( rmSync(windowsProcessTreeAddonPath(packageDir), { force: true }) repaired = true } + assertWindowsProcessTreeCreationTimePatch(packageDir) return repaired } diff --git a/config/scripts/windows-process-tree-gyp-rebuild.test.mjs b/config/scripts/windows-process-tree-gyp-rebuild.test.mjs index f2939b71179..56bd9a385c7 100644 --- a/config/scripts/windows-process-tree-gyp-rebuild.test.mjs +++ b/config/scripts/windows-process-tree-gyp-rebuild.test.mjs @@ -12,12 +12,15 @@ import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { + assertWindowsProcessTreeCreationTimePatch, + assertWindowsProcessTreeRuntimeCreationTime, inspectWindowsProcessTreeAddon, nodeGypRebuildInvocation, stageWindowsProcessTreeNodeAddonApiHeaders, WINDOWS_PROCESS_TREE_NODE_ADDON_API_HEADERS, WINDOWS_PROCESS_TREE_PACKAGE_DIR } from './windows-process-tree-gyp-rebuild.mjs' +import { writeFakeWindowsProcessTreeWithNodeAddonApi } from './rebuild-native-deps-test-fixtures.mjs' describe('windows-process-tree node-gyp rebuild', () => { it("resolves node-addon-api's gyp target from the rebuild cwd", () => { @@ -97,3 +100,47 @@ describe('inspecting a compiled windows-process-tree addon', () => { expect(inspectWindowsProcessTreeAddon(staged)).toBe('unpatched') }) }) + +describe('windows-process-tree CreationTime patch assertion', () => { + let dir + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'orca-windows-process-tree-creation-time-')) + }) + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it('accepts a package whose source and JS surfaces expose process creation time', () => { + writeFakeWindowsProcessTreeWithNodeAddonApi(dir) + + expect(() => + assertWindowsProcessTreeCreationTimePatch( + join(dir, 'node_modules', '@vscode', 'windows-process-tree') + ) + ).not.toThrow() + }) + + it('rejects a package missing the process creation-time patch', () => { + writeFakeWindowsProcessTreeWithNodeAddonApi(dir, { creationTimePatchApplied: false }) + + expect(() => + assertWindowsProcessTreeCreationTimePatch( + join(dir, 'node_modules', '@vscode', 'windows-process-tree') + ) + ).toThrow('process creation-time patch') + }) + + it('requires the runtime ProcessDataFlag.CreationTime enum', () => { + expect(() => + assertWindowsProcessTreeRuntimeCreationTime({ + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 } + }) + ).not.toThrow() + expect(() => + assertWindowsProcessTreeRuntimeCreationTime({ + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 } + }) + ).toThrow('ProcessDataFlag.CreationTime') + }) +}) diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 80cf4a511f2..a23d90e6a1e 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -32,6 +32,7 @@ "../src/main/codex/codex-app-server-capability-cache.ts", "../src/main/codex/codex-app-server-capability-signal.ts", "../src/main/codex/codex-app-server-client.ts", + "../src/main/codex/codex-app-server-process-tree-kill.ts", "../src/main/codex/codex-app-server-record-reader.ts", "../src/main/codex/codex-app-server-session.ts", "../src/main/codex/codex-config-mirror.ts", diff --git a/src/main/codex/codex-app-server-client.test.ts b/src/main/codex/codex-app-server-client.test.ts index b845d3a9694..ce98288bdf0 100644 --- a/src/main/codex/codex-app-server-client.test.ts +++ b/src/main/codex/codex-app-server-client.test.ts @@ -11,7 +11,8 @@ import { runCodexHookTrustGrantSession, type CodexHookTrustGrantRequest } from './codex-app-server-client' -import { killCodexAppServerProcessTree, runCodexAppServerSession } from './codex-app-server-session' +import { killCodexAppServerProcessTree } from './codex-app-server-process-tree-kill' +import { runCodexAppServerSession } from './codex-app-server-session' // Stub codex app-server speaking the same JSONL protocol: initialize → // initialized → hooks/list → config/batchWrite → hooks/list. Scenario-driven diff --git a/src/main/codex/codex-app-server-client.ts b/src/main/codex/codex-app-server-client.ts index 8c95562e66c..efdee5de8bf 100644 --- a/src/main/codex/codex-app-server-client.ts +++ b/src/main/codex/codex-app-server-client.ts @@ -1,4 +1,5 @@ -import { spawn } from 'node:child_process' +import type { ChildProcessHandle, ProcessSpec } from '../../shared/child-process/process-spec' +import { spawnProcess } from '../../shared/child-process/run-process' import { normalizeHookTrustKeyForLookup } from './config-toml-trust' import { runCodexAppServerSession, type CodexAppServerInvocation } from './codex-app-server-session' @@ -105,7 +106,12 @@ function collectHookListings(result: unknown): CodexHookListing[] { */ export async function runCodexHookTrustGrantSession( request: CodexHookTrustGrantRequest, - spawnImpl: typeof spawn = spawn + spawnImpl: ( + program: string, + args: string[], + options: Record + ) => ChildProcessHandle = (program, args, options) => + spawnProcess({ program, args, ...options } as ProcessSpec) ): Promise { return runCodexAppServerSession( request.invocation, diff --git a/src/main/codex/codex-app-server-process-tree-kill.ts b/src/main/codex/codex-app-server-process-tree-kill.ts new file mode 100644 index 00000000000..315246aaa9f --- /dev/null +++ b/src/main/codex/codex-app-server-process-tree-kill.ts @@ -0,0 +1,76 @@ +import { spawnProcess } from '../../shared/child-process/run-process' +import type { ChildProcessHandle, ProcessSpec } from '../../shared/child-process/process-spec' +import { admitProcessTreeKill } from '../../shared/child-process/process-tree-kill-gate' + +/** Spawn seam for tests; production always goes through the hardened spawnProcess wrapper. */ +export type CodexAppServerSpawn = ( + program: string, + args: string[], + options: Record +) => ChildProcessHandle + +export const spawnCodexAppServerProcess: CodexAppServerSpawn = (program, args, options) => + spawnProcess({ program, args, ...options } as ProcessSpec) + +export function killCodexAppServerProcessTree( + child: Pick, + options: { platform?: NodeJS.Platform; spawnImpl?: CodexAppServerSpawn } = {} +): void { + const platform = options.platform ?? process.platform + const spawnImpl = options.spawnImpl ?? spawnCodexAppServerProcess + if (platform === 'win32' && child.pid) { + if ( + !admitProcessTreeKill({ + pid: child.pid, + site: 'codex-app-server-session-deadline', + scope: 'win-taskkill-tree' + }) + ) { + // Refusal blocks the tree walk, not the termination: the root kill is + // handle-addressed, so it cannot reach the recycled pid we refused. + child.kill('SIGKILL') + return + } + try { + // Why: npm-installed Codex runs behind cmd.exe; killing only that wrapper + // leaves the app-server child alive after a timeout or failed shutdown. + const killer = spawnImpl('taskkill', ['/pid', String(child.pid), '/t', '/f'], { + stdio: 'ignore', + windowsHide: true + }) + let fellBack = false + const killDirectChild = (): void => { + if (!fellBack) { + fellBack = true + child.kill('SIGKILL') + } + } + killer.on('error', killDirectChild) + killer.on('exit', (code) => { + if (code !== 0) { + killDirectChild() + } + }) + killer.unref() + return + } catch { + // Fall through to the direct-child best effort when taskkill cannot start. + } + } + if (child.pid) { + try { + // npm/package-manager launchers insert a shim child on POSIX. Reap its + // direct descendants before signalling the wrapper itself. + const descendants = spawnImpl('pkill', ['-KILL', '-P', String(child.pid)], { + stdio: 'ignore' + }) + // A missing pkill surfaces as an async 'error' event, and an unhandled one + // takes down the main process. + descendants.on('error', () => undefined) + descendants.unref() + } catch { + // The direct kill below remains the fallback when pkill is unavailable. + } + } + child.kill('SIGKILL') +} diff --git a/src/main/codex/codex-app-server-session.ts b/src/main/codex/codex-app-server-session.ts index cef7f40c66b..6db33b4c85d 100644 --- a/src/main/codex/codex-app-server-session.ts +++ b/src/main/codex/codex-app-server-session.ts @@ -1,9 +1,13 @@ -import { spawn, type ChildProcess, type ChildProcessWithoutNullStreams } from 'node:child_process' +import type { ChildProcessWithoutNullStreams } from 'node:child_process' import { waitForProcessExitUntil } from './codex-process-exit-deadline' import { stderrIndicatesMissingAppServer } from './codex-app-server-capability-signal' import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' +import { + killCodexAppServerProcessTree, + spawnCodexAppServerProcess, + type CodexAppServerSpawn +} from './codex-app-server-process-tree-kill' import { createCodexAppServerRecordReader } from './codex-app-server-record-reader' -import { admitProcessTreeKill } from '../../shared/child-process/process-tree-kill-gate' // Why: `codex app-server` is Orca's sanctioned RPC surface into Codex-owned // state (hook trust hashes, the sqlite thread index). This module owns the @@ -68,69 +72,6 @@ export type CodexAppServerRpc = { const JSON_RPC_METHOD_NOT_FOUND = -32601 const STDERR_TAIL_MAX_BYTES = 8192 -export function killCodexAppServerProcessTree( - child: Pick, - options: { platform?: NodeJS.Platform; spawnImpl?: typeof spawn } = {} -): void { - const platform = options.platform ?? process.platform - const spawnImpl = options.spawnImpl ?? spawn - if (platform === 'win32' && child.pid) { - if ( - !admitProcessTreeKill({ - pid: child.pid, - site: 'codex-app-server-session-deadline', - scope: 'win-taskkill-tree' - }) - ) { - // Refusal blocks the tree walk, not the termination: the root kill is - // handle-addressed, so it cannot reach the recycled pid we refused. - child.kill('SIGKILL') - return - } - try { - // Why: npm-installed Codex runs behind cmd.exe; killing only that wrapper - // leaves the app-server child alive after a timeout or failed shutdown. - const killer = spawnImpl('taskkill', ['/pid', String(child.pid), '/t', '/f'], { - stdio: 'ignore', - windowsHide: true - }) - let fellBack = false - const killDirectChild = (): void => { - if (!fellBack) { - fellBack = true - child.kill('SIGKILL') - } - } - killer.on('error', killDirectChild) - killer.on('exit', (code) => { - if (code !== 0) { - killDirectChild() - } - }) - killer.unref() - return - } catch { - // Fall through to the direct-child best effort when taskkill cannot start. - } - } - if (child.pid) { - try { - // npm/package-manager launchers insert a shim child on POSIX. Reap its - // direct descendants before signalling the wrapper itself. - const descendants = spawnImpl('pkill', ['-KILL', '-P', String(child.pid)], { - stdio: 'ignore' - }) - // A missing pkill surfaces as an async 'error' event, and an unhandled one - // takes down the main process. - descendants.on('error', () => undefined) - descendants.unref() - } catch { - // The direct kill below remains the fallback when pkill is unavailable. - } - } - child.kill('SIGKILL') -} - /** Codex answering "no such method" is the only response that proves the RPC * surface is absent rather than temporarily failing. */ export function isCodexMethodNotFoundError(error: unknown): boolean { @@ -152,7 +93,7 @@ export function isCodexMethodNotFoundError(error: unknown): boolean { export async function runCodexAppServerSession( invocation: CodexAppServerInvocation, body: (rpc: CodexAppServerRpc) => Promise, - spawnImpl: typeof spawn = spawn + spawnImpl: CodexAppServerSpawn = spawnCodexAppServerProcess ): Promise { // Why: a default-home grant must run against the real ~/.codex, so strip an // inherited CODEX_HOME (envToDelete) after applying the overlay, not before. diff --git a/src/main/codex/codex-structured-launch-resolution.test.ts b/src/main/codex/codex-structured-launch-resolution.test.ts index 484f7c1ee80..de83e74c6c8 100644 --- a/src/main/codex/codex-structured-launch-resolution.test.ts +++ b/src/main/codex/codex-structured-launch-resolution.test.ts @@ -44,7 +44,8 @@ function resolverFor( store: { getRecord: () => value } as unknown as AgentSessionRecordStore, resolveWorkspacePath, resolveCommand: () => '/usr/local/bin/codex', - resolveRollout + resolveRollout, + isWindowsProcessStartTimeAvailable: () => true }) } @@ -68,7 +69,8 @@ describe('codex structured launch resolution', () => { const resolveLaunch = createCodexStructuredLaunchResolver({ store: { getRecord: () => record() } as unknown as AgentSessionRecordStore, resolveWorkspacePath: async () => String.raw`C:\workspaces\orca`, - resolveCommand: () => command + resolveCommand: () => command, + isWindowsProcessStartTimeAvailable: () => true }) await expect(resolveLaunch({ identity: IDENTITY })).resolves.toMatchObject({ @@ -78,6 +80,22 @@ describe('codex structured launch resolution', () => { }) }) + it('fails closed before resolving a Windows launch without creation-time proof', async () => { + await withPlatform('win32', async () => { + const resolveWorkspacePath = vi.fn(async () => String.raw`C:\workspaces\orca`) + const resolveLaunch = createCodexStructuredLaunchResolver({ + store: { getRecord: () => record() } as unknown as AgentSessionRecordStore, + resolveWorkspacePath, + isWindowsProcessStartTimeAvailable: () => false + }) + + await expect(resolveLaunch({ identity: IDENTITY })).rejects.toThrow( + 'Windows process creation-time proof' + ) + expect(resolveWorkspacePath).not.toHaveBeenCalled() + }) + }) + it('resumes the last thread this session actually proved, not one a caller names', async () => { const launch = await resolverFor( record({ diff --git a/src/main/codex/codex-structured-launch-resolution.ts b/src/main/codex/codex-structured-launch-resolution.ts index b1cc7854808..d395ee87c12 100644 --- a/src/main/codex/codex-structured-launch-resolution.ts +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -13,6 +13,7 @@ import { resolveCodexCommand } from '../codex-cli/command' import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' import type { CodexStructuredLaunch } from './codex-structured-session-adapter' import { resolvePinnedCodexRolloutProof } from './codex-tui-rollout-proof' +import { isWindowsProcessStartTimeAvailable } from '../windows/windows-process-table' export type CodexStructuredLaunchResolverDeps = { store: AgentSessionRecordStore @@ -24,6 +25,8 @@ export type CodexStructuredLaunchResolverDeps = { /** Fresh shell/configured environment for this spawn; never written to the session record. */ resolveEnvironment?: () => Promise resolveRollout?: typeof resolvePinnedCodexRolloutProof + /** Test seam for the host capability; production uses the native process table. */ + isWindowsProcessStartTimeAvailable?: () => boolean } export function createCodexStructuredLaunchResolver( @@ -46,6 +49,13 @@ export function createCodexStructuredLaunchResolver( `codex structured sessions run on the local host, not ${location.executionHostId}` ) } + // Refuse before resolving launch data; a PID alone cannot prove Windows ownership. + if ( + process.platform === 'win32' && + !(deps.isWindowsProcessStartTimeAvailable ?? isWindowsProcessStartTimeAvailable)() + ) { + throw new Error('codex structured sessions require Windows process creation-time proof') + } if (accountHome.variable !== 'CODEX_HOME') { throw new Error(`codex sessions pin CODEX_HOME, not ${accountHome.variable}`) } diff --git a/src/main/codex/codex-structured-location-support.test.ts b/src/main/codex/codex-structured-location-support.test.ts new file mode 100644 index 00000000000..324568956d8 --- /dev/null +++ b/src/main/codex/codex-structured-location-support.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' +import { supportsCodexStructuredLocation } from './codex-structured-location-support' + +const LOCAL_WINDOWS_LOCATION: AgentSessionExecutionLocation = { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' +} + +const WSL_WINDOWS_LOCATION: AgentSessionExecutionLocation = { + ...LOCAL_WINDOWS_LOCATION, + wslDistro: 'Ubuntu' +} + +function withPlatform(platform: NodeJS.Platform, run: () => T): T { + const original = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) + try { + return run() + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: original }) + } +} + +describe('Codex structured location support', () => { + it('uses the injected Windows identity capability for location admission', () => { + let proofAvailable = false + withPlatform('win32', () => { + expect(supportsCodexStructuredLocation(LOCAL_WINDOWS_LOCATION, () => proofAvailable)).toBe( + false + ) + proofAvailable = true + expect(supportsCodexStructuredLocation(LOCAL_WINDOWS_LOCATION, () => proofAvailable)).toBe( + true + ) + }) + }) + + it('rejects WSL locations while retaining native folder support on Windows', () => { + withPlatform('win32', () => { + expect(supportsCodexStructuredLocation(WSL_WINDOWS_LOCATION, () => true)).toBe(false) + expect(supportsCodexStructuredLocation(LOCAL_WINDOWS_LOCATION, () => true)).toBe(true) + }) + }) +}) diff --git a/src/main/codex/codex-structured-location-support.ts b/src/main/codex/codex-structured-location-support.ts index 915d9edaa83..ad0bbefa4d3 100644 --- a/src/main/codex/codex-structured-location-support.ts +++ b/src/main/codex/codex-structured-location-support.ts @@ -2,10 +2,14 @@ import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' import { isWindowsProcessStartTimeAvailable } from '../windows/windows-process-table' -export function supportsCodexStructuredLocation(location: AgentSessionExecutionLocation): boolean { +export function supportsCodexStructuredLocation( + location: AgentSessionExecutionLocation, + // Injected by the adapter, which owns this dep for every other Codex gate too. + hasWindowsProcessStartTimeProof: () => boolean = isWindowsProcessStartTimeAvailable +): boolean { return ( location.executionHostId === LOCAL_EXECUTION_HOST_ID && location.wslDistro === null && - (process.platform !== 'win32' || isWindowsProcessStartTimeAvailable()) + (process.platform !== 'win32' || hasWindowsProcessStartTimeProof()) ) } diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index afa881f8254..5b551c8b01e 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -74,7 +74,8 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap }) } - supportsLocation = supportsCodexStructuredLocation + supportsLocation = (location: Parameters[0]): boolean => + supportsCodexStructuredLocation(location, this.deps.isWindowsProcessStartTimeAvailable) acquire = (input: StructuredAgentSessionAcquireInput): Promise => acquireCodexStructuredSession({ diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index 2f805e8570f..5fd82f22ff9 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -41,6 +41,8 @@ export type CodexStructuredSessionAdapterDeps = { resolveLaunch: (input: { identity: AgentSessionJournalIdentity }) => Promise + /** Host capability seam; production uses the native Windows process table. */ + isWindowsProcessStartTimeAvailable?: () => boolean onEvent?: (event: CodexStructuredSessionEvent) => void openConnection?: typeof openCodexAppServerConnection readProcessStartTime?: (pid: number) => Promise diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts new file mode 100644 index 00000000000..cbaafa5ff32 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts @@ -0,0 +1,78 @@ +import { isDeepStrictEqual } from 'node:util' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { + AgentSessionPreSpawnError, + isAgentSessionPreSpawnError, + rethrowAfterAgentSessionAcquisitionCleanup +} from './structured-agent-session-adapter' +import { journalIdentityFor } from './structured-agent-session-attach' +import type { AttachFlowInput } from './structured-agent-session-attach-flow' +import { readNativeSessionOptions } from './structured-agent-session-option-restoration' + +/** A reservation with no process behind it is only a promise to spawn; the + * adapter makes it real and the store then grants the writer. */ +export async function acquireOwner( + input: AttachFlowInput, + record: AgentSessionRecord +): Promise<{ record: AgentSessionRecord; acquisitionGeneration: string | null }> { + const fence = record.lease.runtimeFence + const spawnToken = record.lease.reservedSpawnToken + if (!spawnToken) { + throw new Error('agent_session_ownership_unknown') + } + // Pre-spawn proof is single-use: this retry may create a child after the durable clear. + try { + try { + record = await input.store.setReservationProcesslessProof({ + sessionId: record.sessionId, + fence, + spawnToken, + processlessAt: null, + now: input.now() + }) + await input.onAcquiring?.() + } catch (error) { + throw new AgentSessionPreSpawnError(error) + } + const acquired = await input.adapter.acquire({ + identity: journalIdentityFor(record, input.params), + fence, + // Retries must recover the original reservation, not mint a second child. + spawnToken, + ...(record.options ? { options: record.options } : {}), + ...(input.eventSink ? { events: input.eventSink } : {}) + }) + const options = await readNativeSessionOptions({ + adapter: input.adapter, + sessionId: record.sessionId, + fence, + ...(record.options ? { priorOptions: record.options } : {}) + }) + if (record.lease.ownerProcess === null) { + await input.store.commitProcessIdentity({ + sessionId: record.sessionId, + fence, + process: acquired.process, + now: input.now() + }) + } else if (!isDeepStrictEqual(record.lease.ownerProcess, acquired.process)) { + throw new Error('agent_session_ownership_unknown') + } + const proved = await input.store.proveOwner({ + sessionId: record.sessionId, + fence, + link: acquired.link, + now: input.now(), + ...(options ? { options } : {}) + }) + return { + record: proved, + acquisitionGeneration: acquired.acquisitionGeneration ?? null + } + } catch (error) { + if (isAgentSessionPreSpawnError(error)) { + throw error + } + return rethrowAfterAgentSessionAcquisitionCleanup(input.adapter, record.sessionId, error) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index 8697e76ba3b..4bbdd51cdf9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -5,7 +5,6 @@ // the decisions that must not be client-supplied — the spawn token, the claim // key, the owner probe — and passes them in. -import { isDeepStrictEqual } from 'node:util' import type { AgentSessionAttachResult, AgentSessionMutationResult @@ -16,7 +15,6 @@ import { admitAttachOrRefuse, attachJournal, classifyStoreFailure, - journalIdentityFor, reserveRequestFor, type AgentSessionAttachAuthority, type AgentSessionAttachParams, @@ -24,18 +22,18 @@ import { } from './structured-agent-session-attach' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { adapterSupportsCreateIfDeclared } from './structured-agent-session-provider-support' import { AgentSessionAcquisitionExitUnprovenError, AgentSessionAcquisitionRootExitObservedError, AgentSessionAcquisitionRefusal, - AgentSessionPreSpawnError, isAgentSessionPreSpawnError, rethrowAfterAgentSessionAcquisitionCleanup } from './structured-agent-session-adapter' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' -import { readNativeSessionOptions } from './structured-agent-session-option-restoration' import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' import { readAgentSessionHydrationPage } from './agent-session-history-page' +import { acquireOwner } from './structured-agent-session-acquisition' import { importAdoptedTranscript, prepareAdoptedTranscript @@ -72,15 +70,27 @@ export async function performAttach( input: AttachFlowInput ): Promise> { const { params, store } = input + const unsupported = (): AgentSessionMutationResult => ({ + ok: false, + refusal: { + code: 'structured_agent_session_unsupported', + message: 'This execution host cannot create the requested structured agent session.' + } + }) const sessionId = params.envelope.sessionId const admitted = admitAttachOrRefuse(params) if (!admitted.ok) { return admitted } + // Ensure/recovery bypass create-intent, so recheck before reserving or spawning. + if (!adapterSupportsCreateIfDeclared(input.adapter, params.location, params.agent)) { + return unsupported() + } let record: AgentSessionRecord let acquisitionGeneration: string | null = null let reservedRecord: AgentSessionRecord | null = null + let unsupportedReservationSettlementAttempted = false let replayed = false const preparedTranscript = store.getRecord(sessionId) ? { ok: true as const, items: null } @@ -101,6 +111,21 @@ export async function performAttach( ) record = reserved.record replayed = reserved.disposition === 'replayed' + // Capability can change while the durable reservation is in flight. Recheck + // every reservation at its effect boundary so it cannot bypass the support + // gate, and release a pending reservation that support drift invalidated. + reservedRecord = record + if (!adapterSupportsCreateIfDeclared(input.adapter, params.location, params.agent)) { + if ( + record.lease.claimStatus === 'reserved' && + record.lease.handoffStage === 'new-owner-proving' && + record.lease.reservedSpawnToken + ) { + unsupportedReservationSettlementAttempted = true + await settleUnsupportedReservation(input, record) + } + return unsupported() + } if ( replayed && reserved.operationRow.outcome.status !== 'pending' && @@ -115,7 +140,6 @@ export async function performAttach( return { ok: false, refusal: replay.refusal } } } - reservedRecord = record if (!agentSessionLeaseAdmitsWriter(record.lease)) { const acquired = await acquireOwner(input, record) record = acquired.record @@ -123,7 +147,7 @@ export async function performAttach( } } catch (error) { const spawnToken = reservedRecord?.lease.reservedSpawnToken - if (reservedRecord && spawnToken) { + if (reservedRecord && spawnToken && !unsupportedReservationSettlementAttempted) { // A pre-spawn failure is its own processless proof; the settlement records the // evidence and the failed operation in one durable transaction. const exitProof = isAgentSessionPreSpawnError(error) @@ -217,6 +241,34 @@ export async function performAttach( } } +async function settleUnsupportedReservation( + input: AttachFlowInput, + record: AgentSessionRecord +): Promise { + const spawnToken = record.lease.reservedSpawnToken + if (!spawnToken) { + return + } + try { + await input.store.settleFailedAcquisition({ + sessionId: record.sessionId, + fence: record.lease.runtimeFence, + spawnToken, + callerKey: input.callerKey, + operationId: input.params.envelope.clientOperationId, + outcome: { + status: 'failed', + code: 'structured_agent_session_unsupported', + message: 'Structured session support changed before the provider could start.' + }, + exitProof: 'processless', + now: input.now() + }) + } catch (error) { + throw new AggregateError([error], 'agent session unsupported reservation settlement failed') + } +} + async function settlePostAcquisitionAttachFailure( input: AttachFlowInput, record: AgentSessionRecord, @@ -261,71 +313,3 @@ async function settlePostAcquisitionAttachFailure( } throw cleanupError } - -/** A reservation with no process behind it is only a promise to spawn; the - * adapter makes it real and the store then grants the writer. */ -async function acquireOwner( - input: AttachFlowInput, - record: AgentSessionRecord -): Promise<{ record: AgentSessionRecord; acquisitionGeneration: string | null }> { - const fence = record.lease.runtimeFence - const spawnToken = record.lease.reservedSpawnToken - if (!spawnToken) { - throw new Error('agent_session_ownership_unknown') - } - // Pre-spawn proof is single-use: this retry may create a child after the durable clear. - try { - try { - record = await input.store.setReservationProcesslessProof({ - sessionId: record.sessionId, - fence, - spawnToken, - processlessAt: null, - now: input.now() - }) - await input.onAcquiring?.() - } catch (error) { - throw new AgentSessionPreSpawnError(error) - } - const acquired = await input.adapter.acquire({ - identity: journalIdentityFor(record, input.params), - fence, - // Retries must recover the original reservation, not mint a second child. - spawnToken, - ...(record.options ? { options: record.options } : {}), - ...(input.eventSink ? { events: input.eventSink } : {}) - }) - const options = await readNativeSessionOptions({ - adapter: input.adapter, - sessionId: record.sessionId, - fence, - ...(record.options ? { priorOptions: record.options } : {}) - }) - if (record.lease.ownerProcess === null) { - await input.store.commitProcessIdentity({ - sessionId: record.sessionId, - fence, - process: acquired.process, - now: input.now() - }) - } else if (!isDeepStrictEqual(record.lease.ownerProcess, acquired.process)) { - throw new Error('agent_session_ownership_unknown') - } - const proved = await input.store.proveOwner({ - sessionId: record.sessionId, - fence, - link: acquired.link, - now: input.now(), - ...(options ? { options } : {}) - }) - return { - record: proved, - acquisitionGeneration: acquired.acquisitionGeneration ?? null - } - } catch (error) { - if (isAgentSessionPreSpawnError(error)) { - throw error - } - return rethrowAfterAgentSessionAcquisitionCleanup(input.adapter, record.sessionId, error) - } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.test.ts index 9bf27a11106..bf2a1381b3b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.test.ts @@ -11,6 +11,7 @@ import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' import { createDeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { acquireNativeHandoffOwner, createStructuredAgentSessionHostHandoff, @@ -202,6 +203,191 @@ describe('native handoff acquisition', () => { expect(order).toEqual(['append-entered', 'append-complete', 'unbind', 'acquire']) }) + + it('refuses an unsupported adapter before unbinding the TUI owner', async () => { + const location: AgentSessionExecutionLocation = { + executionHostId: LOCAL_EXECUTION_HOST_ID, + wslDistro: null, + workspaceId: 'workspace-unsupported', + workspaceKind: 'folder' + } + const operationId = `${now}-00000000000000000000000000000011` + const reserved = await store.reserveOwner({ + sessionId: 'session-handoff-unsupported', + location, + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: join(root, 'codex-home') }, + runtimeKind: 'native', + expectedFence: null, + spawnToken: 'unsupported-spawn', + claimKeyId: 'key-1', + handoffOperationId: operationId, + probe: { outcome: 'reservation-unused' }, + operation: { callerKey: 'test', operationId, fingerprint: 'unsupported' }, + now + }) + const journal = await journals.open({ + identity: { + sessionId: 'session-handoff-unsupported', + workspaceId: location.workspaceId, + hostId: location.executionHostId, + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'unsupported-thread' } + }, + journalDir: join(root, 'unsupported-journal') + }) + const eventSink = createDeferredStructuredAgentSessionEventSink() + eventSink.bind({ journal, fence: reserved.record.lease.runtimeFence, publish: () => undefined }) + const unbind = vi.spyOn(eventSink, 'unbind') + const acquire = vi.fn>() + const adapter = { + supportsLocation: vi.fn(() => false), + acquire + } + const session = { + journal, + params: { + envelope: { + sessionId: 'session-handoff-unsupported', + clientOperationId: `${now}-00000000000000000000000000000012`, + expectedRuntimeFence: reserved.record.lease.runtimeFence, + payloadFingerprint: 'unsupported' + }, + location, + provider: 'codex' as const, + agent: 'codex' as const, + accountHome: { variable: 'CODEX_HOME' as const, path: join(root, 'codex-home') }, + runtimeKind: 'native' as const, + providerHandle: { kind: 'codex' as const, threadId: 'unsupported-thread' } + }, + fence: reserved.record.lease.runtimeFence, + hasProviderChild: false, + acquisitionGeneration: null + } + + await expect( + acquireNativeHandoffOwner( + { + store, + adapter: adapter as never, + journalRoot: root, + claimKeyId: 'key-1' + }, + { + session: () => session, + findSession: () => session, + eventSink: () => eventSink, + flush: async () => undefined, + serialize: async (_sessionId, task) => task(), + subscribers: { + publish: vi.fn(), + reset: vi.fn(), + handoff: vi.fn(), + snapshot: vi.fn() + } as never, + now: () => now + }, + { + sessionId: 'session-handoff-unsupported', + fence: reserved.record.lease.runtimeFence, + spawnToken: 'unsupported-spawn' + } + ) + ).rejects.toThrow('structured_agent_session_unsupported') + expect(unbind).not.toHaveBeenCalled() + expect(acquire).not.toHaveBeenCalled() + }) + + it('rechecks adapter support immediately before handoff acquisition', async () => { + const sessionId = 'session-handoff-drift' + const location: AgentSessionExecutionLocation = { + executionHostId: LOCAL_EXECUTION_HOST_ID, + wslDistro: null, + workspaceId: 'workspace-drift', + workspaceKind: 'folder' + } + const operationId = `${now}-00000000000000000000000000000021` + const reserved = await store.reserveOwner({ + sessionId, + location, + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: join(root, 'codex-home') }, + runtimeKind: 'native', + expectedFence: null, + spawnToken: 'drift-spawn', + claimKeyId: 'key-1', + handoffOperationId: operationId, + probe: { outcome: 'reservation-unused' }, + operation: { callerKey: 'test', operationId, fingerprint: 'drift' }, + now + }) + const journal = await journals.open({ + identity: { + sessionId, + workspaceId: location.workspaceId, + hostId: location.executionHostId, + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'drift-thread' } + }, + journalDir: join(root, 'drift-journal') + }) + const eventSink = createDeferredStructuredAgentSessionEventSink() + eventSink.bind({ journal, fence: reserved.record.lease.runtimeFence, publish: () => undefined }) + const unbind = vi.spyOn(eventSink, 'unbind') + const supportsLocation = vi.fn(() => true) + supportsLocation.mockReturnValueOnce(true).mockReturnValueOnce(false) + const acquire = vi.fn>() + const adapter = { supportsLocation, acquire } + const session = { + journal, + params: { + envelope: { + sessionId, + clientOperationId: `${now}-00000000000000000000000000000022`, + expectedRuntimeFence: reserved.record.lease.runtimeFence, + payloadFingerprint: 'drift' + }, + location, + provider: 'codex' as const, + agent: 'codex' as const, + accountHome: { variable: 'CODEX_HOME' as const, path: join(root, 'codex-home') }, + runtimeKind: 'native' as const, + providerHandle: { kind: 'codex' as const, threadId: 'drift-thread' } + }, + fence: reserved.record.lease.runtimeFence, + hasProviderChild: false, + acquisitionGeneration: null + } + + await expect( + acquireNativeHandoffOwner( + { + store, + adapter: adapter as never, + journalRoot: root, + claimKeyId: 'key-1' + }, + { + session: () => session, + findSession: () => session, + eventSink: () => eventSink, + flush: async () => undefined, + serialize: async (_sessionId, task) => task(), + subscribers: { + publish: vi.fn(), + reset: vi.fn(), + handoff: vi.fn(), + snapshot: vi.fn() + } as never, + now: () => now + }, + { sessionId, fence: reserved.record.lease.runtimeFence, spawnToken: 'drift-spawn' } + ) + ).rejects.toThrow('structured_agent_session_unsupported') + expect(supportsLocation).toHaveBeenCalledTimes(2) + expect(unbind).toHaveBeenCalledOnce() + expect(acquire).not.toHaveBeenCalled() + }) }) describe('handoff status published for a session the host no longer holds', () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts index cb316850e5b..7c8c65a5292 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts @@ -14,6 +14,7 @@ import { recoverDeadTuiHandoffStatus } from './structured-agent-session-dead-tui import { readNativeSessionOptions } from './structured-agent-session-option-restoration' import type { AgentSessionSubscribers } from './structured-agent-session-subscribers' import { StructuredTuiTranscriptCatchup } from './structured-tui-transcript-catchup' +import { adapterSupportsCreateIfDeclared } from './structured-agent-session-provider-support' import { retryLoadedStructuredAgentSessionSettlement } from './structured-agent-session-settlement-retry' type HostHandoffAccess = { @@ -195,12 +196,21 @@ export async function acquireNativeHandoffOwner( if (!record) { throw new Error('agent_session_identity_required') } + // Native handoff bypasses attach admission; reject before unbinding TUI ownership. + if (!adapterSupportsCreateIfDeclared(deps.adapter, record.location, record.provider)) { + throw new Error('structured_agent_session_unsupported') + } const eventSink = host.eventSink(input.sessionId) const priorBarrier = await eventSink.drained() if (!priorBarrier.ok) { throw priorBarrier.error } eventSink.unbind() + // Recheck immediately before acquisition; capability probes may drift while + // the old TUI event sink is draining. + if (!adapterSupportsCreateIfDeclared(deps.adapter, record.location, record.provider)) { + throw new Error('structured_agent_session_unsupported') + } const acquired = await deps.adapter.acquire({ identity: journalIdentityFor(record, session.params), fence: input.fence, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts index a1b6b39f5e0..0f115da5ebd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts @@ -64,6 +64,151 @@ function attachParams( } describe('processless structured session reservation', () => { + it('refuses an adapter that declares no create support before reserving a lease', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-unsupported-attach-')) + const store = await AgentSessionRecordStore.open({ + directory: join(root, 'store'), + hostId: 'local' + }) + const reserveOwner = vi.spyOn(store, 'reserveOwner') + const acquire = vi.fn() + const adapter = { + supportsCreate: vi.fn(() => false), + acquire, + dispatch: vi.fn(), + cancelTurn: vi.fn(), + answerPrompt: vi.fn(), + setOption: vi.fn() + } as unknown as StructuredAgentSessionAdapter + + await expect( + performAttach({ + store, + adapter, + journalRoot: root, + authority: { + spawnToken: 'spawn-a', + claimKeyId: 'key-1', + handoffOperationId: OPERATION, + probe: { outcome: 'reservation-unused' } + }, + callerKey: 'client-1', + params: attachParams(), + now: () => NOW, + onAttached: () => {} + }) + ).resolves.toMatchObject({ + ok: false, + refusal: { code: 'structured_agent_session_unsupported' } + }) + expect(reserveOwner).not.toHaveBeenCalled() + expect(acquire).not.toHaveBeenCalled() + }) + + it('refuses a replay when adapter support drifts after durable reservation', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-replay-support-drift-')) + const store = await AgentSessionRecordStore.open({ + directory: join(root, 'store'), + hostId: 'local' + }) + const supportsCreate = vi + .fn>() + .mockReturnValueOnce(true) + .mockReturnValueOnce(true) + .mockReturnValueOnce(false) + const adapter = { + supportsCreate, + acquire: vi.fn(async ({ fence, spawnToken }) => ({ + process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken }, + link: { + linkId: 'link-1', + handle: { provider: 'codex' as const, threadId: 'thread-1' }, + origin: 'created' as const, + mintedAtFence: fence, + observedAt: NOW + } + })) + } as unknown as StructuredAgentSessionAdapter + const input = { + store, + adapter, + journalRoot: root, + authority: { + spawnToken: 'spawn-a', + claimKeyId: 'key-1', + handoffOperationId: OPERATION, + probe: { outcome: 'reservation-unused' as const } + }, + callerKey: 'client-1', + params: attachParams(), + now: () => NOW, + onAttached: () => {} + } + + await expect(performAttach(input)).resolves.toMatchObject({ ok: true }) + await expect(performAttach(input)).resolves.toMatchObject({ + ok: false, + refusal: { code: 'structured_agent_session_unsupported' } + }) + expect(supportsCreate).toHaveBeenCalledTimes(3) + expect(adapter.acquire).toHaveBeenCalledOnce() + }) + + it('releases a new reservation when support drifts before acquisition', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-support-drift-reservation-')) + const store = await AgentSessionRecordStore.open({ + directory: join(root, 'store'), + hostId: 'local' + }) + const supportsCreate = vi + .fn>() + .mockReturnValueOnce(true) + .mockReturnValueOnce(false) + .mockReturnValueOnce(true) + .mockReturnValueOnce(true) + const acquire = vi.fn() + const adapter = { supportsCreate, acquire } as unknown as StructuredAgentSessionAdapter + const input = { + store, + adapter, + journalRoot: root, + authority: { + spawnToken: 'spawn-drift', + claimKeyId: 'key-1', + handoffOperationId: OPERATION, + probe: { outcome: 'reservation-unused' as const } + }, + callerKey: 'client-1', + params: attachParams(), + now: () => NOW, + onAttached: () => {} + } + + await expect(performAttach(input)).resolves.toMatchObject({ + ok: false, + refusal: { code: 'structured_agent_session_unsupported' } + }) + + expect(acquire).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + handoffStage: null, + reservedSpawnToken: null, + processlessAt: null, + runtimeFence: 2, + deathEvidence: { kind: 'pid-absent', detail: 'reservation failed before spawn' } + }) + expect(store.listOperationRows()[0]?.outcome).toMatchObject({ + status: 'failed', + code: 'structured_agent_session_unsupported' + }) + await expect(performAttach(input)).resolves.toMatchObject({ + ok: false, + refusal: { code: 'structured_agent_session_unsupported' } + }) + expect(acquire).not.toHaveBeenCalled() + }) + it('settles a pre-spawn failure and its processless evidence in one durable transaction', async () => { root = await mkdtemp(join(tmpdir(), 'orca-processless-reservation-')) const storeDir = join(root, 'store') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts index 99958a2bcb0..15af150c12f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts @@ -9,17 +9,35 @@ export function adapterSupportsCreate( location: AgentSessionExecutionLocation, agent: string ): boolean { - return ( - adapter.supportsCreate?.(location, agent) ?? - (agent === 'codex' && (adapter.supportsLocation?.(location) ?? false)) - ) + if (adapter.supportsCreate) { + return adapter.supportsCreate(location, agent) + } + if (agent !== 'codex') { + return false + } + // Older Codex adapters exposed only location support; absence still fails closed here. + return adapter.supportsLocation?.(location) ?? false +} + +/** Honors declared gates while retaining legacy adapters whose acquire path is authoritative. */ +export function adapterSupportsCreateIfDeclared( + adapter: StructuredAgentSessionAdapter, + location: AgentSessionExecutionLocation, + agent: string +): boolean { + if (!adapter.supportsCreate && !adapter.supportsLocation) { + return true + } + return adapterSupportsCreate(adapter, location, agent) } export function adapterSupportsRecord( adapter: StructuredAgentSessionAdapter, record: AgentSessionRecord ): boolean { - return adapter.supportsCreate - ? adapter.supportsCreate(record.location, record.provider) - : record.provider === 'codex' + if (adapter.supportsCreate) { + return adapter.supportsCreate(record.location, record.provider) + } + // Old Codex records stay readable unless the adapter explicitly rejects their location. + return record.provider === 'codex' && (adapter.supportsLocation?.(record.location) ?? true) } diff --git a/src/main/own-chromium-tree-kill-guard.test.ts b/src/main/own-chromium-tree-kill-guard.test.ts index 7b9c30687bc..5bfad815631 100644 --- a/src/main/own-chromium-tree-kill-guard.test.ts +++ b/src/main/own-chromium-tree-kill-guard.test.ts @@ -17,7 +17,7 @@ import { admitSelfInitiatedTreeKill, installMainProcessTreeKillGate } from './own-chromium-tree-kill-guard' -import { killCodexAppServerProcessTree } from './codex/codex-app-server-session' +import { killCodexAppServerProcessTree } from './codex/codex-app-server-process-tree-kill' import { setProcessTreeKillGate } from '../shared/child-process/process-tree-kill-gate' import { resetSelfInitiatedTreeKillLogForTest } from './crash-reporting/self-initiated-tree-kill-log' import { diff --git a/src/main/refused-tree-kill-root-termination.test.ts b/src/main/refused-tree-kill-root-termination.test.ts index ada4b5942a9..3912d6e946e 100644 --- a/src/main/refused-tree-kill-root-termination.test.ts +++ b/src/main/refused-tree-kill-root-termination.test.ts @@ -34,7 +34,7 @@ import { terminateNotebookProcessTree } from './ipc/notebook' import { killLocalPrecheckProcessTree } from './automations/precheck-runner' import { killRecipeProcess } from '../shared/ephemeral-vm-recipe-process' import { killSpawnedCommandTree } from './git/command-runner/spawned-command-tree-kill' -import { killCodexAppServerProcessTree } from './codex/codex-app-server-session' +import { killCodexAppServerProcessTree } from './codex/codex-app-server-process-tree-kill' import { signalProcessTree } from '../shared/child-process/process-tree-termination' import { killSourceControlAgentProcess } from './text-generation/source-control-local-process' import { terminateCodexTurnProcesses } from './codex/codex-structured-turn-processes' diff --git a/src/main/runtime/agent-session-process-identity-probe-windows-batch.test.ts b/src/main/runtime/agent-session-process-identity-probe-windows-batch.test.ts new file mode 100644 index 00000000000..5e8ef48ad62 --- /dev/null +++ b/src/main/runtime/agent-session-process-identity-probe-windows-batch.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { isWindowsProcessStartTimeAvailable, readWindowsProcessIdentityTableFresh } = vi.hoisted( + () => ({ + isWindowsProcessStartTimeAvailable: vi.fn(() => true), + readWindowsProcessIdentityTableFresh: vi.fn() + }) +) + +vi.mock('../windows/windows-process-table', async (importOriginal) => ({ + ...(await importOriginal()), + isWindowsProcessStartTimeAvailable, + readWindowsProcessIdentityTableFresh +})) + +const { readProcessStartTimesMs } = await import('./agent-session-process-identity-probe') + +const START_TIME = 1_700_000_000_000 + +afterEach(() => { + isWindowsProcessStartTimeAvailable.mockReset() + isWindowsProcessStartTimeAvailable.mockReturnValue(true) + readWindowsProcessIdentityTableFresh.mockReset() +}) + +describe('Windows owner identity batch probe', () => { + it('reads Windows start times for a batch from one process-table snapshot', async () => { + readWindowsProcessIdentityTableFresh.mockResolvedValue([ + { pid: 4242, ppid: 1, name: 'codex.exe', creationTimeMs: START_TIME }, + { pid: 4243, ppid: 1, name: 'codex.exe', creationTimeMs: START_TIME + 10 } + ]) + + await expect(readProcessStartTimesMs([4242, 4243, 4242], 'win32')).resolves.toEqual( + new Map([ + [4242, START_TIME], + [4243, START_TIME + 10] + ]) + ) + + expect(readWindowsProcessIdentityTableFresh).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/runtime/agent-session-process-identity-probe.ts b/src/main/runtime/agent-session-process-identity-probe.ts index 5d441782ca8..3fa1971b830 100644 --- a/src/main/runtime/agent-session-process-identity-probe.ts +++ b/src/main/runtime/agent-session-process-identity-probe.ts @@ -131,6 +131,25 @@ async function readWindowsProcessStartTimeMs(pid: number): Promise> { + const observed = new Map(pids.map((pid) => [pid, null])) + if (pids.length === 0 || !isWindowsProcessStartTimeAvailable()) { + return observed + } + try { + const table = await readWindowsProcessIdentityTableFresh() + const startTimesByPid = new Map(table.map((row) => [row.pid, row.creationTimeMs ?? null])) + for (const pid of pids) { + observed.set(pid, startTimesByPid.get(pid) ?? null) + } + } catch { + // A missing process table is unknown, never evidence that every owner exited. + } + return observed +} + /** * Process start time is the cross-platform PID-reuse guard when no provider hook can echo the * spawn token back to the owner probe. @@ -160,6 +179,9 @@ export async function readProcessStartTimesMs( const table = await readDarwinProcessStartTimesMs(uniquePids) return new Map(uniquePids.map((pid) => [pid, table.get(pid) ?? null])) } + if (platform === 'win32') { + return readWindowsProcessStartTimesMs(uniquePids) + } return new Map( await Promise.all( uniquePids.map(async (pid) => [pid, await readProcessStartTimeMs(pid, platform)] as const) diff --git a/src/main/runtime/orca-runtime-get-status.ts b/src/main/runtime/orca-runtime-get-status.ts index bd378ea2bf7..d177c8fe64d 100644 --- a/src/main/runtime/orca-runtime-get-status.ts +++ b/src/main/runtime/orca-runtime-get-status.ts @@ -20,6 +20,7 @@ import { browserUnavailableMessage } from '../../shared/runtime-types' import { runtimeTerminalDegradation } from './native-terminal-availability' +import { isWindowsProcessStartTimeAvailable } from '../windows/windows-process-table' import type { RuntimeWorktreeLifecycleEvent } from './orca-runtime-core' import { WORKTREE_CREATE_RESULT_TTL_MS } from './orca-runtime-core' import type { RuntimePtyController } from './runtime-pty-controller-contract' @@ -56,6 +57,10 @@ export class OrcaRuntimeWithGetStatus extends OrcaRuntimeWithGetRuntimeId { const hasOffscreen = !hasRenderer && Boolean(this.offscreenBrowserBackend) const hasHeadlessCommands = runtimeBrowserCommandsFactoryIsHeadless() const canBrowse = hasRenderer || hasOffscreen + // This field reports current Windows process-identity proof. Structured RPC + // support itself stays advertised; agentSession.createSupport owns current eligibility. + const windowsProcessStartTimeAvailable = + process.platform === 'win32' && isWindowsProcessStartTimeAvailable() const capabilities: RuntimeCapability[] = RUNTIME_CAPABILITIES.filter( (capability) => (capability !== 'browser.screencast.v1' || canBrowse) && @@ -110,6 +115,7 @@ export class OrcaRuntimeWithGetStatus extends OrcaRuntimeWithGetRuntimeId { capabilities, ...(degradations.length > 0 ? { degradations } : {}), worktreeCreateIdempotency: { dedupeTtlMs: WORKTREE_CREATE_RESULT_TTL_MS }, + ...(windowsProcessStartTimeAvailable ? { windowsProcessStartTimeAvailable } : {}), hostPlatform: process.platform, terminalWindowsShell: this.store?.getSettings?.().terminalWindowsShell ?? null, floatingWorkspaceEnabled: this.store?.getSettings?.().floatingTerminalEnabled !== false, diff --git a/src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts b/src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts index 37752d207e6..6448cc3911d 100644 --- a/src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts +++ b/src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts @@ -22,6 +22,8 @@ import { hasPersistedStructuredAgentSessionStore as hasPersistedStructuredAgentS import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { homedir } from 'node:os' import { join } from 'node:path' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { parseWorkspaceKey } from '../../shared/workspace-scope' export class OrcaRuntimeWithResolveRecoveredStructuredTuiTranscript extends OrcaRuntimeWithStopStructuredSessionProcess { protected async resolveRecoveredStructuredTuiTranscript(input: { @@ -95,14 +97,23 @@ export class OrcaRuntimeWithResolveRecoveredStructuredTuiTranscript extends Orca protected async resolveStructuredAgentSessionLocation(worktreeSelector: string) { const target = await this.resolveRuntimeFileTarget(worktreeSelector) const repo = this.store?.getRepo(target.worktree.repoId) - // WSL routing describes *this* machine; no remote or runtime host may inherit it. - const wslDistro = - repo && target.executionHostId === LOCAL_EXECUTION_HOST_ID + const folderScope = parseWorkspaceKey(target.worktree.id) + const folderWorkspace = folderScope?.type === 'folder' + // WSL routing describes *this* machine; no remote or runtime host may inherit + // it. Both branches key on executionHostId: the target no longer carries a + // connectionId, which used to spell remote, unresolved and local alike. + const isLocalHost = target.executionHostId === LOCAL_EXECUTION_HOST_ID + const configuredWslDistro = + repo && isLocalHost ? (getLocalProjectWorktreeGitOptions(this.requireStore(), repo).wslDistro ?? null) : null - const folderWorkspace = this.store - ?.getFolderWorkspaces?.() - .some((workspace) => workspace.id === target.worktree.id) + // Folder workspaces have no repo Git options, so a WSL UNC path is the only + // durable signal that native Windows structured Codex cannot safely use it. + const wslDistro = + configuredWslDistro ?? + (folderWorkspace && isLocalHost + ? (parseWslUncPath(target.worktree.path)?.distro ?? null) + : null) return { executionHostId: target.executionHostId, wslDistro, diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-mode.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-mode.test.ts index 7eafe9c86d4..23ed5b5a549 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-mode.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-mode.test.ts @@ -23,13 +23,11 @@ function decide( overrides: { params?: Parameters[0]['params'] settings?: Parameters[0]['settings'] - platform?: NodeJS.Platform } = {} ): WorkerStartModeReceipt { return decideWorkerStartMode({ params: { agent: 'claude', ...overrides.params }, - settings: overrides.settings === undefined ? STRUCTURED_DEFAULT : overrides.settings, - platform: overrides.platform ?? 'darwin' + settings: overrides.settings === undefined ? STRUCTURED_DEFAULT : overrides.settings }) } @@ -90,12 +88,10 @@ describe('a structured default this dispatch cannot honour', () => { ).toMatchObject({ mode: 'terminal', reason: 'tui_launch_customization' }) }) - it('keeps Codex terminal-backed on Windows and leaves Claude to the host', () => { - expect(decide({ params: { agent: 'codex' }, platform: 'win32' })).toMatchObject({ - mode: 'terminal', - reason: 'codex_on_windows' - }) - expect(decide({ params: { agent: 'claude' }, platform: 'win32' }).mode).toBe('structured') + // Neither provider is refused here on the client's platform: only the executing host knows + // whether it can read a provider child's start time, and it answers at create time. + it.each(['claude', 'codex'] as const)('leaves a Windows %s worker to the host', (agent) => { + expect(decide({ params: { agent } }).mode).toBe('structured') }) }) diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-mode.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-mode.ts index 7c02c2a688f..c1f22a2c3f4 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-mode.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-mode.ts @@ -87,7 +87,6 @@ const BLOCKER_REASON: Record< 'floating-workspace': 'structured_unsupported_on_host', 'tui-launch-customization': 'tui_launch_customization', 'remote-execution-host': 'remote_execution_host', - 'codex-on-windows': 'codex_on_windows', 'project-runtime': 'wsl_execution_runtime', 'runtime-capability': 'structured_sessions_unavailable' } @@ -105,7 +104,6 @@ const HOST_SUPPORT_REASON: Record< export function decideWorkerStartMode(args: { params: WorkerStartModePlacement settings: WorkerStartModeSettings | null | undefined - platform: NodeJS.Platform }): WorkerStartModeReceipt { const { params, settings } = args if (!prefersStructuredNativeChatByDefault(settings)) { @@ -125,7 +123,6 @@ export function decideWorkerStartMode(args: { agent, // Set only by --on, which the placement check above already turned into a fallback. executionHostId: 'local', - platform: args.platform, hostCapabilities: RUNTIME_CAPABILITIES, // Orchestration resolves a managed worktree or folder workspace; a floating terminal is never // a worker placement. WSL is left to the executing host's own create-support probe, which diff --git a/src/main/runtime/rpc/methods/orchestration/worker/workers.ts b/src/main/runtime/rpc/methods/orchestration/worker/workers.ts index 6ccac1dea9e..8b14ec044cf 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/workers.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/workers.ts @@ -51,8 +51,7 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [ await assertWorkerStartTaskSpecWithinPromptBudget(params.spec ?? existingTask!.spec) const mode = decideWorkerStartMode({ params, - settings: readWorkerStartModeSettings(runtime), - platform: process.platform + settings: readWorkerStartModeSettings(runtime) }) if (params.on) { // A remote worker is always a terminal agent; the mode receipt rides along so the diff --git a/src/main/runtime/rpc/methods/structured-agent-session-gate.ts b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts index de83820e9c3..60b28425057 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-gate.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts @@ -80,7 +80,10 @@ export function requireStructuredCleanupHost(ctx: RpcContext): StructuredAgentSe export async function ensureStructuredHostInstalled(ctx: RpcContext): Promise { // Gated first: a client that cannot read structured sessions must not be able // to make the host exist, which is an observable side effect of the surface. - if (!supportsStructuredSessions(ctx) || getStructuredAgentSessionHost()) { + if (!supportsStructuredSessions(ctx)) { + return + } + if (getStructuredAgentSessionHost()) { return } await ctx.runtime.ensureStructuredAgentSessionHost() diff --git a/src/main/runtime/structured-agent-session-runtime.test.ts b/src/main/runtime/structured-agent-session-runtime.test.ts index 3b69a0a4be3..2ce51b1c29b 100644 --- a/src/main/runtime/structured-agent-session-runtime.test.ts +++ b/src/main/runtime/structured-agent-session-runtime.test.ts @@ -8,9 +8,11 @@ import { createTrackedJournalOpener } from '../native-chat/agent-session-journal import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' import type { AgentSessionClaimStatus, + AgentSessionExecutionLocation, AgentSessionProcessIdentity, AgentSessionRecord } from '../../shared/agent-session-record' +import { __setWindowsProcessTreeLoaderForTests } from '../windows/windows-process-table' import { createStructuredAgentSessionOwnerProbe, createStructuredAgentSessionOwnerProbes @@ -270,6 +272,35 @@ describe('structured agent-session runtime install', () => { ) ) }) + + it('does not infer Windows process identity support from an injected reader', async () => { + stateDirectory = await mkdtemp(join(tmpdir(), 'orca-structured-runtime-')) + const originalPlatform = process.platform + const location: AgentSessionExecutionLocation = { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + } + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + __setWindowsProcessTreeLoaderForTests(() => null) + try { + const host = await ensureStructuredAgentSessionHost({ + stateDirectory, + hostId: HOST_ID, + claimKeyId: 'key-1', + resolveWorkspacePath: async () => stateDirectory!, + resolveEnvironment: async () => ({}), + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + readProcessStartTime: async () => 1_700_000_000_000 + }) + + expect(host.supportsCreate(location, 'codex')).toBe(false) + } finally { + __setWindowsProcessTreeLoaderForTests() + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) }) // A stop whose teardown fails must not forget the runtime it was tearing down. diff --git a/src/main/runtime/structured-agent-session-support-probe.test.ts b/src/main/runtime/structured-agent-session-support-probe.test.ts index e393e41f3a4..f55a802e979 100644 --- a/src/main/runtime/structured-agent-session-support-probe.test.ts +++ b/src/main/runtime/structured-agent-session-support-probe.test.ts @@ -6,6 +6,21 @@ import { } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate' +const { isWindowsProcessStartTimeAvailable } = vi.hoisted(() => ({ + isWindowsProcessStartTimeAvailable: vi.fn(() => true) +})) + +vi.mock('../windows/windows-process-table', async (importOriginal) => ({ + ...(await importOriginal()), + isWindowsProcessStartTimeAvailable +})) + +const originalPlatform = process.platform + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) +} + type InstallEffects = { storeOpened: boolean writeGateAttached: boolean @@ -94,6 +109,9 @@ async function expectSupportWithoutInstall(input: { describe('structured agent-session create-support probe', () => { afterEach(() => { + setPlatform(originalPlatform) + isWindowsProcessStartTimeAvailable.mockReset() + isWindowsProcessStartTimeAvailable.mockReturnValue(true) setStructuredAgentSessionHost(null) agentSessionPtyWriteGate.detachRecordLookup() vi.restoreAllMocks() @@ -111,6 +129,27 @@ describe('structured agent-session create-support probe', () => { } ) + it.each([ + ['codex', true, { supported: true }], + ['codex', false, { supported: false, reason: 'agent' }], + ['claude', true, { supported: true }], + ['claude', false, { supported: false, reason: 'agent' }] + ] as const)( + 'requires native Windows process identity proof before answering %s support (%s)', + async (agent, proofAvailable, expected) => { + setPlatform('win32') + isWindowsProcessStartTimeAvailable.mockReturnValue(proofAvailable) + + await expectSupportWithoutInstall({ + agent, + location: { executionHostId: 'local', wslDistro: null }, + expected + }) + + expect(isWindowsProcessStartTimeAvailable).toHaveBeenCalled() + } + ) + it.each(['codex', 'claude'] as const)( 'still reports an unsupported remote %s location without installing the host', async (agent) => { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts index adedc94b22a..a1f2a296748 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts @@ -3,7 +3,6 @@ import { type AgentLaunchRoutingInput } from '@/lib/agent-launch-routing' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' -import { CLIENT_PLATFORM } from '@/lib/new-workspace' import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' import { readLocalRuntimeCapabilities } from '@/runtime/local-runtime-capabilities' import { useAppStore } from '@/store' @@ -47,7 +46,6 @@ export function resolveAiVaultSessionResumeInChatForWorkspace(args: { useAppStore.getState(), targetWorkspaceId as string ), - platform: CLIENT_PLATFORM, hostCapabilities: readLocalRuntimeCapabilities(), workspaceKind: (targetWorkspaceId as string).startsWith('folder:') ? 'folder' diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts index ca685dded64..c016538cac9 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts @@ -1,8 +1,4 @@ -import { - CLIENT_PLATFORM, - ensureAgentStartupInTerminal, - type LinkedWorkItemSummary -} from '@/lib/new-workspace' +import { ensureAgentStartupInTerminal, type LinkedWorkItemSummary } from '@/lib/new-workspace' import { seedNativeChatLaunchDraftForAgentTab } from '@/lib/agent-launch-prompt-delivery' import { createBrowserUuid } from '@/lib/browser-uuid' import { buildAgentStartupPlan } from '@/lib/tui-agent-startup' @@ -151,7 +147,6 @@ export async function submitFolderWorkspaceCreate({ executionHostId: runtimeEnvironmentId ? `runtime:${encodeURIComponent(runtimeEnvironmentId)}` : (projectGroup.connectionId ?? 'local'), - platform: CLIENT_PLATFORM, hostCapabilities: readLocalRuntimeCapabilities(), workspaceKind: 'folder', promptDelivery: launchDraftPrompt ? 'draft' : 'auto-submit', diff --git a/src/renderer/src/hooks/composer-state/full-creation-execution.ts b/src/renderer/src/hooks/composer-state/full-creation-execution.ts index f199ca66f0c..0118f6c2236 100644 --- a/src/renderer/src/hooks/composer-state/full-creation-execution.ts +++ b/src/renderer/src/hooks/composer-state/full-creation-execution.ts @@ -33,7 +33,7 @@ import type { PendingSmartGitHubSubmitResolution } from './source-selection-deci import { translate } from '@/i18n/i18n' import { settleComposerSubmit } from '@/lib/composer-submit-cancellation' import { toFolderWorkspaceLinkedTask } from '@/components/sidebar/folder-workspace-composer-helpers' -import { CLIENT_PLATFORM, ensureAgentStartupInTerminal } from '@/lib/new-workspace' +import { ensureAgentStartupInTerminal } from '@/lib/new-workspace' import { createBrowserUuid } from '@/lib/browser-uuid' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache' @@ -140,7 +140,6 @@ export function useFullCreationExecution(input: FullCreationExecutionInput) { agent: tuiAgent, settings, executionHostId: selectedRepoExecutionHostId ?? 'local', - platform: CLIENT_PLATFORM, hostCapabilities: readLocalRuntimeCapabilities(), workspaceKind: selectedRepoIsGit ? 'git-worktree' : 'folder', promptDelivery: startupPlan?.draftPrompt ? 'draft' : 'auto-submit', diff --git a/src/renderer/src/hooks/composer-state/quick-creation-execution.ts b/src/renderer/src/hooks/composer-state/quick-creation-execution.ts index 7160cb48b4c..a25afd9106c 100644 --- a/src/renderer/src/hooks/composer-state/quick-creation-execution.ts +++ b/src/renderer/src/hooks/composer-state/quick-creation-execution.ts @@ -51,7 +51,6 @@ import { resolveAgentLaunchRoute } from '@/lib/agent-launch-routing' import { readLocalRuntimeCapabilities } from '@/runtime/local-runtime-capabilities' -import { CLIENT_PLATFORM } from '@/lib/new-workspace' export function useQuickCreationExecution(input: QuickCreationExecutionInput) { const { @@ -206,7 +205,6 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) { executionHostId: ephemeralVmRecipe ? 'runtime:pending-ephemeral-vm' : (workspaceRunContext?.hostId ?? selectedRepoExecutionHostId ?? 'local'), - platform: CLIENT_PLATFORM, hostCapabilities: readLocalRuntimeCapabilities(), workspaceKind: selectedRepoIsGit ? 'git-worktree' : 'folder', promptDelivery: quickDraftPrompt ? 'draft' : 'auto-submit', diff --git a/src/renderer/src/lib/agent-launch-routing.test.ts b/src/renderer/src/lib/agent-launch-routing.test.ts index af219bab633..cb3a2b70b00 100644 --- a/src/renderer/src/lib/agent-launch-routing.test.ts +++ b/src/renderer/src/lib/agent-launch-routing.test.ts @@ -19,7 +19,6 @@ function route(overrides: Partial[0]> agent: 'codex', settings, executionHostId: 'local', - platform: 'darwin', hostCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], workspaceKind: 'git-worktree', nativeChatTranscriptIsLocalReadable: true, @@ -41,57 +40,16 @@ describe('resolveAgentLaunchRoute', () => { } ) - /** Boundary guard between this lane and the one that owns Windows Codex. Codex's win32 refusal is - * deliberate, so it is asserted against whatever currently lets Claude through rather than - * against one host answer — a future gate swap must not be able to flip Codex on quietly. */ - describe("Codex's Windows refusal", () => { - it('holds in the exact situation that routes Claude to structured', () => { - const onWindows = { platform: 'win32' } as const - expect(route({ ...onWindows, agent: 'claude' })).toBe('structured-native-chat') - expect(route({ ...onWindows, agent: 'codex' })).toBe('legacy-native-chat') - }) - - it('holds for every host capability set, including ones that carry extra gates', () => { - for (const hostCapabilities of [ - [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], - [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, 'agent-session.structured.claude.v1'], - [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, 'agent-session.structured.hold.v1'] - ]) { - expect(route({ agent: 'codex', platform: 'win32', hostCapabilities })).toBe( - 'legacy-native-chat' - ) - } - }) - - it('holds for prompted and folder-workspace launches too', () => { - expect( - route({ - agent: 'codex', - platform: 'win32', - launchText: 'go', - promptDelivery: 'auto-submit' - }) - ).toBe('legacy-native-chat') - expect(route({ agent: 'codex', platform: 'win32', workspaceKind: 'folder' })).toBe( - 'legacy-native-chat' - ) - }) - }) - - /** Pins Codex's whole platform answer, not just win32, so no platform silently changes here. */ - it.each([ - ['darwin', 'structured-native-chat'], - ['linux', 'structured-native-chat'], - ['win32', 'legacy-native-chat'] - ] as const)('leaves Codex routing on %s unchanged', (platform, expected) => { - expect(route({ agent: 'codex', platform })).toBe(expected) - }) - - /** Claude's Windows answer is not a client-side platform guess: the route lets it through and the - * executing host settles it with agentSession.createSupport at create time. */ - it('lets a Windows Claude launch reach the host-measured create support check', () => { - expect(route({ agent: 'claude', platform: 'win32' })).toBe('structured-native-chat') - }) + /** Windows eligibility is no client-side platform guess for either provider: the route lets the + * launch through and the executing host settles it with agentSession.createSupport at create + * time. A stale caller still passing the removed `platform` input must not flip Codex off the + * structured route — the field is gone, not reinterpreted. */ + it.each(['claude', 'codex'] as const)( + 'routes %s to structured even when the caller claims a win32 client platform', + (agent) => { + expect(route({ agent, ...({ platform: 'win32' } as object) })).toBe('structured-native-chat') + } + ) it('routes a supported local Codex launch to structured native chat', () => { expect(route()).toBe('structured-native-chat') @@ -134,15 +92,13 @@ describe('resolveAgentLaunchRoute', () => { it.each(['git-worktree', 'folder'] as const)( 'supports a local %s without widening floating-terminal scope', (workspaceKind) => { - expect(route({ workspaceKind, platform: 'linux' })).toBe('structured-native-chat') + expect(route({ workspaceKind })).toBe('structured-native-chat') } ) it('keeps floating, WSL, and repair-required launches terminal-backed', () => { expect(route({ workspaceKind: 'floating' })).toBe('legacy-native-chat') - expect(route({ agent: 'claude', workspaceKind: 'floating', platform: 'win32' })).toBe( - 'legacy-native-chat' - ) + expect(route({ agent: 'claude', workspaceKind: 'floating' })).toBe('legacy-native-chat') expect( route({ projectRuntime: { diff --git a/src/renderer/src/lib/agent-launch-routing.ts b/src/renderer/src/lib/agent-launch-routing.ts index 2bca72ba3ae..090ef3c9108 100644 --- a/src/renderer/src/lib/agent-launch-routing.ts +++ b/src/renderer/src/lib/agent-launch-routing.ts @@ -30,7 +30,6 @@ export type AgentLaunchRoutingInput = { | null | undefined executionHostId: string - platform: NodeJS.Platform hostCapabilities: readonly string[] workspaceKind?: 'git-worktree' | 'folder' | 'floating' projectRuntime?: ProjectExecutionRuntimeResolution | null @@ -68,7 +67,6 @@ export function structuredAgentLaunchSupported( resolveStructuredNativeChatSupport({ agent: input.agent, executionHostId: input.executionHostId, - platform: input.platform, hostCapabilities: input.hostCapabilities, workspaceKind: input.workspaceKind, projectRuntime: input.projectRuntime, diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index 118fcdbeda8..cb3187878ef 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -213,7 +213,6 @@ function launchAgentInNewTabInternal( agent, settings: store.settings, executionHostId: getExecutionHostIdForWorktree(store, worktreeId), - platform: CLIENT_PLATFORM, hostCapabilities: readLocalRuntimeCapabilities(), workspaceKind, projectRuntime: getLocalProjectExecutionRuntimeContext(store, worktreeId), diff --git a/src/renderer/src/lib/launch-structured-agent-session.test.ts b/src/renderer/src/lib/launch-structured-agent-session.test.ts index d9a75ee2827..1d7dec2cdf3 100644 --- a/src/renderer/src/lib/launch-structured-agent-session.test.ts +++ b/src/renderer/src/lib/launch-structured-agent-session.test.ts @@ -19,37 +19,43 @@ describe('structured agent session launch', () => { }) it('creates a native session with a host-verifiable launch intent', async () => { - vi.mocked(callStructuredAgentSession).mockImplementation(async (_target, _method, params) => ({ - ok: true, - replayed: false, - fence: 1, - cursor: { epoch: 'epoch-1', sequence: 0 }, - value: { - sessionId: (params as { envelope: { sessionId: string } }).envelope.sessionId, - fence: 1, - page: { - sessionId: 'session-1', - epoch: 'epoch-1', - direction: 'tail', - items: [], - removedItemIds: [], - submissions: [], - window: { - oldest: null, - newest: null, - nextCursor: { epoch: 'epoch-1', sequence: 0 } - }, - liveCursor: { epoch: 'epoch-1', sequence: 0 }, - hasOlder: false, - hasNewer: false - }, - unconfirmedClientMessageIds: [] - } - })) + vi.mocked(callStructuredAgentSession).mockImplementation(async (_target, method, params) => + method === 'agentSession.createSupport' + ? { supported: true } + : { + ok: true, + replayed: false, + fence: 1, + cursor: { epoch: 'epoch-1', sequence: 0 }, + value: { + sessionId: (params as { envelope: { sessionId: string } }).envelope.sessionId, + fence: 1, + page: { + sessionId: 'session-1', + epoch: 'epoch-1', + direction: 'tail', + items: [], + removedItemIds: [], + submissions: [], + window: { + oldest: null, + newest: null, + nextCursor: { epoch: 'epoch-1', sequence: 0 } + }, + liveCursor: { epoch: 'epoch-1', sequence: 0 }, + hasOlder: false, + hasNewer: false + }, + unconfirmedClientMessageIds: [] + } + } + ) const intent = createStructuredAgentSessionLaunchIntent('workspace-1', 'codex') const receipt = await launchStructuredAgentSession(intent) - const params = vi.mocked(callStructuredAgentSession).mock.calls[0]?.[2] as { + const params = vi + .mocked(callStructuredAgentSession) + .mock.calls.find(([, method]) => method === 'agentSession.create')?.[2] as { envelope: { sessionId: string; payloadFingerprint: string } worktree: string agent: 'codex' @@ -87,40 +93,46 @@ describe('structured agent session launch', () => { ) }) - it('asks the executing host for create support before creating a Claude session', async () => { - vi.mocked(callStructuredAgentSession).mockImplementation(async (_target, method) => - method === 'agentSession.createSupport' - ? { supported: true } - : { ok: true, replayed: false, value: { sessionId: 'claude_1', fence: 1 } } - ) + it.each(['claude', 'codex'] as const)( + 'asks the executing host for create support before creating a %s session', + async (agent) => { + vi.mocked(callStructuredAgentSession).mockImplementation(async (_target, method) => + method === 'agentSession.createSupport' + ? { supported: true } + : { ok: true, replayed: false, value: { sessionId: `${agent}_1`, fence: 1 } } + ) - const intent = createStructuredAgentSessionLaunchIntent('workspace-1', 'claude') - await launchStructuredAgentSession(intent) + const intent = createStructuredAgentSessionLaunchIntent('workspace-1', agent) + await launchStructuredAgentSession(intent) - expect(vi.mocked(callStructuredAgentSession).mock.calls.map(([, method]) => method)).toEqual([ - 'agentSession.createSupport', - 'agentSession.create' - ]) - expect(callStructuredAgentSession).toHaveBeenNthCalledWith( - 1, - { kind: 'local' }, - 'agentSession.createSupport', - { worktree: 'id:workspace-1', agent: 'claude' } - ) - }) + expect(vi.mocked(callStructuredAgentSession).mock.calls.map(([, method]) => method)).toEqual([ + 'agentSession.createSupport', + 'agentSession.create' + ]) + expect(callStructuredAgentSession).toHaveBeenNthCalledWith( + 1, + { kind: 'local' }, + 'agentSession.createSupport', + { worktree: 'id:workspace-1', agent } + ) + } + ) - it('refuses a Claude launch the host says it cannot support, without creating', async () => { - vi.mocked(callStructuredAgentSession).mockResolvedValue({ supported: false, reason: 'agent' }) + it.each(['claude', 'codex'] as const)( + 'refuses a %s launch the host says it cannot support, without creating', + async (agent) => { + vi.mocked(callStructuredAgentSession).mockResolvedValue({ supported: false, reason: 'agent' }) - const intent = createStructuredAgentSessionLaunchIntent('workspace-1', 'claude') + const intent = createStructuredAgentSessionLaunchIntent('workspace-1', agent) - await expect(launchStructuredAgentSession(intent)).rejects.toBeInstanceOf( - StructuredAgentSessionCreateRefusalError - ) - expect(vi.mocked(callStructuredAgentSession).mock.calls.map(([, method]) => method)).toEqual([ - 'agentSession.createSupport' - ]) - }) + await expect(launchStructuredAgentSession(intent)).rejects.toBeInstanceOf( + StructuredAgentSessionCreateRefusalError + ) + expect(vi.mocked(callStructuredAgentSession).mock.calls.map(([, method]) => method)).toEqual([ + 'agentSession.createSupport' + ]) + } + ) it('fails closed when the create support probe cannot be answered', async () => { vi.mocked(callStructuredAgentSession).mockRejectedValue(new Error('runtime unreachable')) @@ -212,46 +224,40 @@ describe('structured agent session launch', () => { expect(callStructuredAgentSession).toHaveBeenCalledOnce() }) - /** Codex's support answer is settled by the launch route and owned elsewhere; this pins that the - * Claude probe did not change Codex's wire traffic. */ - it('does not probe create support for Codex', async () => { - vi.mocked(callStructuredAgentSession).mockResolvedValue({ - ok: true, - replayed: false, - value: { sessionId: 'codex_1', fence: 1 } - }) - - await launchStructuredAgentSession( - createStructuredAgentSessionLaunchIntent('workspace-1', 'codex') + /** The probe now runs for Codex too, so create-outcome tests script it to say yes. */ + function mockSupportedCreate(create: () => unknown): void { + vi.mocked(callStructuredAgentSession).mockImplementation(async (_target, method) => + method === 'agentSession.createSupport' ? { supported: true } : create() ) - - expect(vi.mocked(callStructuredAgentSession).mock.calls.map(([, method]) => method)).toEqual([ - 'agentSession.create' - ]) - }) + } it('replays the exact create envelope when an unknown outcome is retried', async () => { const intent = createStructuredAgentSessionLaunchIntent('workspace-retry', 'codex') - vi.mocked(callStructuredAgentSession).mockRejectedValue(new Error('response lost')) + mockSupportedCreate(() => { + throw new Error('response lost') + }) await expect(launchStructuredAgentSession(intent)).rejects.toThrow('response lost') await expect(launchStructuredAgentSession(intent)).rejects.toThrow('response lost') - const first = vi.mocked(callStructuredAgentSession).mock.calls[0]?.[2] - const second = vi.mocked(callStructuredAgentSession).mock.calls[1]?.[2] + const createCalls = vi + .mocked(callStructuredAgentSession) + .mock.calls.filter(([, method]) => method === 'agentSession.create') + const first = createCalls[0]?.[2] + const second = createCalls[1]?.[2] expect(first).toBe(intent.params) expect(second).toBe(first) expect(intent.params.envelope.clientOperationId).toMatch(/^\d{13}-[0-9a-f]{32}$/) }) it('preserves an unknown refusal code without classifying it as fallback-safe', async () => { - vi.mocked(callStructuredAgentSession).mockResolvedValue({ + mockSupportedCreate(() => ({ ok: false, refusal: { code: 'agent_session_operation_unknown', message: 'The chat may already exist.' } - }) + })) const error = await launchStructuredAgentSession( createStructuredAgentSessionLaunchIntent('workspace-unknown', 'codex') @@ -266,13 +272,13 @@ describe('structured agent session launch', () => { /** The class is the verdict, so a refusal message that happens to end in a definitive token * must not be re-read into one by the transport-error matcher. */ it('keeps an unknown outcome unknown even when its message ends in a definitive token', async () => { - vi.mocked(callStructuredAgentSession).mockResolvedValue({ + mockSupportedCreate(() => ({ ok: false, refusal: { code: 'agent_session_ownership_unknown', message: 'Owner check failed: method_not_found' } - }) + })) const error = await launchStructuredAgentSession( createStructuredAgentSessionLaunchIntent('workspace-unknown-token', 'codex') @@ -283,13 +289,13 @@ describe('structured agent session launch', () => { }) it('preserves a definitive refusal code for the fallback path', async () => { - vi.mocked(callStructuredAgentSession).mockResolvedValue({ + mockSupportedCreate(() => ({ ok: false, refusal: { code: 'structured_agent_session_unsupported', message: 'Structured chat is unavailable.' } - }) + })) const error = await launchStructuredAgentSession( createStructuredAgentSessionLaunchIntent('workspace-unsupported', 'codex') @@ -303,9 +309,9 @@ describe('structured agent session launch', () => { it.each(['method_not_found', 'structured_agent_session_unsupported'])( 'turns an old-host %s error into a definitive transport refusal', async (code) => { - vi.mocked(callStructuredAgentSession).mockRejectedValueOnce( - Object.assign(new Error(code), { code }) - ) + mockSupportedCreate(() => { + throw Object.assign(new Error(code), { code }) + }) const oldHostError = await launchStructuredAgentSession( createStructuredAgentSessionLaunchIntent(`workspace-old-host-${code}`, 'codex') ).catch((caught: unknown) => caught) @@ -316,9 +322,9 @@ describe('structured agent session launch', () => { ) it('keeps an unclassified transport failure outcome unknown', async () => { - vi.mocked(callStructuredAgentSession).mockRejectedValueOnce( - Object.assign(new Error('Connection lost'), { code: 'runtime_error' }) - ) + mockSupportedCreate(() => { + throw Object.assign(new Error('Connection lost'), { code: 'runtime_error' }) + }) const transportError = await launchStructuredAgentSession( createStructuredAgentSessionLaunchIntent('workspace-offline', 'codex') ).catch((caught: unknown) => caught) diff --git a/src/renderer/src/lib/launch-structured-agent-session.ts b/src/renderer/src/lib/launch-structured-agent-session.ts index 503ae771419..0694090fc5c 100644 --- a/src/renderer/src/lib/launch-structured-agent-session.ts +++ b/src/renderer/src/lib/launch-structured-agent-session.ts @@ -174,17 +174,10 @@ async function hostSupportsCreate(intent: StructuredAgentSessionLaunchIntent): P /** * Only the host that will execute the session can answer whether it supports creating one there — * on Windows that means reading the provider child's process start time, which a client cannot - * observe. - * - * Codex is absent on purpose: its answer is settled by the launch route and owned elsewhere, so - * probing here would change Codex's wire traffic. Note that this early return is also why the - * unresolvable-selector race above has never been able to refuse a Codex launch — the race is - * identical for Codex, nothing asks. Whoever gives Codex a probe inherits it. + * observe. Both providers ask: the host classifies per agent, and Codex inherits the + * unresolvable-selector retry above along with the probe. */ async function requireHostCreateSupport(intent: StructuredAgentSessionLaunchIntent): Promise { - if (intent.agent !== 'claude') { - return - } if (!(await hostSupportsCreate(intent))) { abandonStructuredAgentSessionLaunchIntent(intent) throw new StructuredAgentSessionCreateRefusalError( diff --git a/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts b/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts index 55aa77bddbe..1fdc3b6fa69 100644 --- a/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts +++ b/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts @@ -97,7 +97,6 @@ export async function prepareDirectWorkItemAgentLaunch(args: { agent: effectiveAgent, settings: args.settings, executionHostId: getExecutionHostIdForWorktree(args.latestStore, args.worktreeId), - platform: CLIENT_PLATFORM, hostCapabilities: readLocalRuntimeCapabilities(), workspaceKind: 'git-worktree', projectRuntime: getLocalProjectExecutionRuntimeContext( diff --git a/src/renderer/src/lib/onboarding-folder-agent-startup.ts b/src/renderer/src/lib/onboarding-folder-agent-startup.ts index 958f43eda28..a4341a4dc87 100644 --- a/src/renderer/src/lib/onboarding-folder-agent-startup.ts +++ b/src/renderer/src/lib/onboarding-folder-agent-startup.ts @@ -135,7 +135,6 @@ export function resolveDismissedOnboardingFolderAgentLaunch(args: { agent, settings: args.settings, executionHostId: args.executionHostId, - platform: getClientPlatform(), hostCapabilities: readLocalRuntimeCapabilities(), workspaceKind: 'folder', nativeChatTranscriptIsLocalReadable: args.nativeChatTranscriptIsLocalReadable, diff --git a/src/renderer/src/lib/structured-agent-session-launch-refusal-fallback.test.ts b/src/renderer/src/lib/structured-agent-session-launch-refusal-fallback.test.ts index 45c41bd111e..9139b1c122a 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-refusal-fallback.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-refusal-fallback.test.ts @@ -58,6 +58,9 @@ type CreateReply = { ok: boolean; refusal?: { code: string; message: string } } function replyToCreates(...replies: CreateReply[]): void { let index = 0 mocks.call.mockImplementation(async (_target: unknown, method: string, params: unknown) => { + if (method === 'agentSession.createSupport') { + return { supported: true } + } if (method !== 'agentSession.create') { return { ok: true, page: { fence: 1 } } } diff --git a/src/renderer/src/lib/structured-agent-session-launch-resume-identity.test.ts b/src/renderer/src/lib/structured-agent-session-launch-resume-identity.test.ts index 7e99ff73fe6..f93437bc088 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-resume-identity.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-resume-identity.test.ts @@ -63,11 +63,16 @@ describe('a launch that adopts a conversation is its own identity', () => { vi.clearAllMocks() localStorage.clear() mocks.refresh.mockResolvedValue([]) - mocks.call.mockImplementation(async (_target: unknown, method: string) => - method === 'agentSession.create' - ? new Promise(() => {}) - : { ok: true, value: { submission: { dispatchState: 'accepted' } } } - ) + mocks.call.mockImplementation(async (_target: unknown, method: string) => { + if (method === 'agentSession.create') { + return new Promise(() => {}) + } + // Both providers now ask the executing host before creating. + if (method === 'agentSession.createSupport') { + return { supported: true } + } + return { ok: true, value: { submission: { dispatchState: 'accepted' } } } + }) }) it('does not hand a resume the blank launch already pending for the same worktree', async () => { diff --git a/src/renderer/src/lib/web-client-location.test.ts b/src/renderer/src/lib/web-client-location.test.ts new file mode 100644 index 00000000000..9ea2886e533 --- /dev/null +++ b/src/renderer/src/lib/web-client-location.test.ts @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { isWebClientLocation } from './web-client-location' + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('isWebClientLocation', () => { + it('reports false when there is no window at all', () => { + vi.stubGlobal('window', undefined) + expect(isWebClientLocation()).toBe(false) + }) + + // Why: this runs on the launch-routing path, where a throw is swallowed and + // silently becomes a failed launch. A window without a usable `location` + // must answer the question, not throw. + it('does not throw when window exists without a location', () => { + vi.stubGlobal('window', { api: {} }) + expect(() => isWebClientLocation()).not.toThrow() + expect(isWebClientLocation()).toBe(false) + }) + + it('does not throw when location exists without a pathname', () => { + vi.stubGlobal('window', { location: {} }) + expect(() => isWebClientLocation()).not.toThrow() + expect(isWebClientLocation()).toBe(false) + }) + + it('detects the web client by its entry path', () => { + vi.stubGlobal('window', { location: { pathname: '/web-index.html' } }) + expect(isWebClientLocation()).toBe(true) + }) + + it('detects the web client by its global marker', () => { + vi.stubGlobal('window', { __ORCA_WEB_CLIENT__: true, location: { pathname: '/' } }) + expect(isWebClientLocation()).toBe(true) + }) + + it('reports false for a normal desktop renderer path', () => { + vi.stubGlobal('window', { location: { pathname: '/index.html' } }) + expect(isWebClientLocation()).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/web-client-location.ts b/src/renderer/src/lib/web-client-location.ts index 26c7e70bb21..94d751b8ab1 100644 --- a/src/renderer/src/lib/web-client-location.ts +++ b/src/renderer/src/lib/web-client-location.ts @@ -2,8 +2,13 @@ export function isWebClientLocation(): boolean { if (typeof window === 'undefined') { return false } + // Why the pathname guard: `window` can exist without a usable `location` + // (partial test doubles, and any embedder that stubs the global), and this + // runs on the launch-routing path where a throw is swallowed and silently + // turns into a failed launch rather than a visible error. + const pathname = (window as { location?: { pathname?: unknown } }).location?.pathname return ( Boolean((window as unknown as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__) || - window.location.pathname.endsWith('/web-index.html') + (typeof pathname === 'string' && pathname.endsWith('/web-index.html')) ) } diff --git a/src/renderer/src/lib/windows-terminal-capabilities-race.test.ts b/src/renderer/src/lib/windows-terminal-capabilities-race.test.ts new file mode 100644 index 00000000000..0439e5c76bf --- /dev/null +++ b/src/renderer/src/lib/windows-terminal-capabilities-race.test.ts @@ -0,0 +1,80 @@ +// @vitest-environment happy-dom + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + getCachedWindowsTerminalCapabilities, + loadWindowsTerminalCapabilities, + resetWindowsTerminalCapabilitiesForTests +} from './windows-terminal-capabilities' +import { resetWindowsTerminalCapabilityReprobeForTests } from './windows-terminal-capability-reprobe' + +describe('Windows terminal capability probe ordering', () => { + afterEach(() => { + resetWindowsTerminalCapabilitiesForTests() + resetWindowsTerminalCapabilityReprobeForTests() + vi.unstubAllGlobals() + }) + + it('does not let an older forced probe overwrite a newer identity proof', async () => { + let resolveOlderStatus!: (status: { hostPlatform: NodeJS.Platform }) => void + let resolveNewerStatus!: (status: { + hostPlatform: NodeJS.Platform + windowsProcessStartTimeAvailable: boolean + }) => void + const olderStatus = new Promise<{ hostPlatform: NodeJS.Platform }>((resolve) => { + resolveOlderStatus = resolve + }) + const newerStatus = new Promise<{ + hostPlatform: NodeJS.Platform + windowsProcessStartTimeAvailable: boolean + }>((resolve) => { + resolveNewerStatus = resolve + }) + const runtimeGetStatus = vi + .fn<() => Promise>() + .mockReturnValueOnce(olderStatus) + .mockReturnValueOnce(newerStatus) + vi.stubGlobal('window', { + api: { + wsl: { + isAvailable: vi.fn().mockResolvedValue(false), + listDistros: vi.fn().mockResolvedValue([]) + }, + pwsh: { isAvailable: vi.fn().mockResolvedValue(false) }, + gitBash: { isAvailable: vi.fn().mockResolvedValue(false) }, + runtime: { getStatus: runtimeGetStatus } + } + }) + + const olderProbe = loadWindowsTerminalCapabilities({ + ownerKey: 'local', + force: true, + now: 1_000 + }) + const newerProbe = loadWindowsTerminalCapabilities({ + ownerKey: 'local', + force: true, + now: 2_000 + }) + + resolveNewerStatus({ hostPlatform: 'win32', windowsProcessStartTimeAvailable: true }) + await expect(newerProbe).resolves.toMatchObject({ + hostPlatform: 'win32', + windowsProcessStartTimeAvailable: true + }) + expect(getCachedWindowsTerminalCapabilities('local')).toMatchObject({ + hostPlatform: 'win32', + windowsProcessStartTimeAvailable: true + }) + + resolveOlderStatus({ hostPlatform: 'win32' }) + await expect(olderProbe).resolves.toMatchObject({ + hostPlatform: 'win32', + windowsProcessStartTimeAvailable: true + }) + expect(getCachedWindowsTerminalCapabilities('local')).toMatchObject({ + hostPlatform: 'win32', + windowsProcessStartTimeAvailable: true + }) + }) +}) diff --git a/src/renderer/src/lib/windows-terminal-capabilities.test.ts b/src/renderer/src/lib/windows-terminal-capabilities.test.ts index 1f1a83dec9e..d1ad22b463d 100644 --- a/src/renderer/src/lib/windows-terminal-capabilities.test.ts +++ b/src/renderer/src/lib/windows-terminal-capabilities.test.ts @@ -70,6 +70,7 @@ function stubTerminalCapabilityApi(args: { wslDistros?: string[] gitBashAvailable?: boolean hostPlatform?: NodeJS.Platform | null + windowsProcessStartTimeAvailable?: boolean }): { wslIsAvailable: ReturnType wslListDistros: ReturnType @@ -81,9 +82,12 @@ function stubTerminalCapabilityApi(args: { const wslListDistros = vi.fn().mockResolvedValue(args.wslDistros ?? []) const pwshIsAvailable = vi.fn().mockResolvedValue(args.pwshAvailable) const isGitBashAvailable = vi.fn().mockResolvedValue(args.gitBashAvailable ?? false) - const runtimeGetStatus = vi - .fn() - .mockResolvedValue({ hostPlatform: 'hostPlatform' in args ? args.hostPlatform : 'win32' }) + const runtimeGetStatus = vi.fn().mockResolvedValue({ + hostPlatform: 'hostPlatform' in args ? args.hostPlatform : 'win32', + ...(args.windowsProcessStartTimeAvailable !== undefined + ? { windowsProcessStartTimeAvailable: args.windowsProcessStartTimeAvailable } + : {}) + }) vi.stubGlobal('window', { api: { @@ -583,7 +587,8 @@ describe('windows terminal capabilities', () => { const { wslIsAvailable, wslListDistros } = stubTerminalCapabilityApi({ wslAvailable: false, pwshAvailable: true, - wslDistros: [] + wslDistros: [], + windowsProcessStartTimeAvailable: true }) wslIsAvailable.mockResolvedValueOnce(false).mockResolvedValue(true) wslListDistros.mockResolvedValueOnce([]).mockResolvedValue(['Ubuntu']) diff --git a/src/renderer/src/lib/windows-terminal-capabilities.ts b/src/renderer/src/lib/windows-terminal-capabilities.ts index c759567df15..4bc5d6d7b6e 100644 --- a/src/renderer/src/lib/windows-terminal-capabilities.ts +++ b/src/renderer/src/lib/windows-terminal-capabilities.ts @@ -11,6 +11,8 @@ export type WindowsTerminalCapabilities = { pwshAvailable: boolean gitBashAvailable: boolean hostPlatform: NodeJS.Platform | null + /** Host-owned PID-reuse proof; absent means the host did not advertise it. */ + windowsProcessStartTimeAvailable?: boolean isLoading: boolean } diff --git a/src/renderer/src/lib/windows-terminal-capability-read.ts b/src/renderer/src/lib/windows-terminal-capability-read.ts index 3c9a7edc6bc..9a77538cefc 100644 --- a/src/renderer/src/lib/windows-terminal-capability-read.ts +++ b/src/renderer/src/lib/windows-terminal-capability-read.ts @@ -49,16 +49,13 @@ export async function readWindowsTerminalCapabilities( } if (target.kind === 'local') { - const [wslAvailable, wslDistros, pwshAvailable, gitBashAvailable, hostPlatform] = + const [wslAvailable, wslDistros, pwshAvailable, gitBashAvailable, runtimeStatus] = await Promise.all([ window.api.wsl.isAvailable().catch(() => false), window.api.wsl.listDistros().catch(() => []), window.api.pwsh.isAvailable().catch(() => false), window.api.gitBash.isAvailable().catch(() => false), - window.api.runtime - .getStatus() - .then((status) => status.hostPlatform ?? null) - .catch(() => null) + window.api.runtime.getStatus().catch(() => null) ]) const reconciledWslAvailable = await reconcileWslAvailability(wslAvailable, wslDistros, () => window.api.wsl.isAvailable() @@ -68,7 +65,10 @@ export async function readWindowsTerminalCapabilities( wslDistros, pwshAvailable, gitBashAvailable, - hostPlatform, + hostPlatform: runtimeStatus?.hostPlatform ?? null, + ...(runtimeStatus?.windowsProcessStartTimeAvailable !== undefined + ? { windowsProcessStartTimeAvailable: runtimeStatus.windowsProcessStartTimeAvailable } + : {}), isLoading: false } } diff --git a/src/renderer/src/lib/windows-terminal-capability-reprobe.test.ts b/src/renderer/src/lib/windows-terminal-capability-reprobe.test.ts index ad35839ba3d..3d3d476753e 100644 --- a/src/renderer/src/lib/windows-terminal-capability-reprobe.test.ts +++ b/src/renderer/src/lib/windows-terminal-capability-reprobe.test.ts @@ -40,6 +40,36 @@ afterEach(() => { }) describe('windows terminal capability re-probe', () => { + it('reprobes usable WSL until Windows process identity is proved', async () => { + vi.useFakeTimers() + let current: WindowsTerminalCapabilities = USABLE_WSL + const probe = vi.fn(async () => { + current = { ...current, windowsProcessStartTimeAvailable: true } + return current + }) + const readCached = () => current + startWindowsTerminalCapabilityReprobe({ ownerKey: 'local', probe, readCached }) + + await vi.advanceTimersByTimeAsync(30_000) + expect(probe).toHaveBeenCalledTimes(1) + expect(readCached().windowsProcessStartTimeAvailable).toBe(true) + + await vi.advanceTimersByTimeAsync(30 * 60_000) + expect(probe).toHaveBeenCalledTimes(1) + }) + + it('resets the backoff when only process identity capability changes', async () => { + vi.useFakeTimers() + const identityAvailable = { ...ABSENT_WSL, windowsProcessStartTimeAvailable: true } + const { probe, readCached } = createWatcher([identityAvailable, identityAvailable]) + startWindowsTerminalCapabilityReprobe({ ownerKey: 'local', probe, readCached }) + + await vi.advanceTimersByTimeAsync(30_000) + expect(probe).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(30_000) + expect(probe).toHaveBeenCalledTimes(2) + }) + it('backs off to a five-minute ceiling on a stable answer', async () => { vi.useFakeTimers() const { probe, readCached } = createWatcher() @@ -55,7 +85,9 @@ describe('windows terminal capability re-probe', () => { it('still re-checks a transient absent answer, then stops once WSL answers', async () => { vi.useFakeTimers() - const { probe, readCached } = createWatcher([USABLE_WSL]) + const { probe, readCached } = createWatcher([ + { ...USABLE_WSL, windowsProcessStartTimeAvailable: true } + ]) startWindowsTerminalCapabilityReprobe({ ownerKey: 'local', probe, readCached }) await vi.advanceTimersByTimeAsync(30_000) diff --git a/src/renderer/src/lib/windows-terminal-capability-reprobe.ts b/src/renderer/src/lib/windows-terminal-capability-reprobe.ts index 674adc565d7..f9b9d44b025 100644 --- a/src/renderer/src/lib/windows-terminal-capability-reprobe.ts +++ b/src/renderer/src/lib/windows-terminal-capability-reprobe.ts @@ -31,13 +31,21 @@ function capabilitySignature(capabilities: WindowsTerminalCapabilities): string capabilities.wslDistros.join('\u0000'), capabilities.pwshAvailable, capabilities.gitBashAvailable, - capabilities.hostPlatform ?? '' + capabilities.hostPlatform ?? '', + capabilities.windowsProcessStartTimeAvailable ].join('|') } -/** The answer #11295 waits for: a usable WSL. Nothing further to watch for. */ +/** A usable WSL is settled only after Windows hosts also prove PID identity. */ function isSettled(capabilities: WindowsTerminalCapabilities): boolean { - return capabilities.wslAvailable && capabilities.wslDistros.length > 0 + if (!capabilities.wslAvailable || capabilities.wslDistros.length === 0) { + return false + } + if (capabilities.hostPlatform === 'win32') { + return capabilities.windowsProcessStartTimeAvailable === true + } + // A missing platform means the status probe may have failed; keep checking until it recovers. + return capabilities.hostPlatform !== null } function clearRunnerTimer(runner: CapabilityReprobeRunner): void { diff --git a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt index fd8502d8913..d7a503bbaf2 100644 --- a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt +++ b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt @@ -57,7 +57,6 @@ src/main/codex-accounts/legacy-wsl-runtime-auth-drain-recovery-script-harness.ts src/main/codex-accounts/legacy-wsl-runtime-auth-drain-script-harness.ts src/main/codex-accounts/legacy-wsl-runtime-auth-drain-script-interference-shims.ts src/main/codex-accounts/service.ts -src/main/codex/codex-app-server-client.ts src/main/codex/codex-app-server-posix-supervisor.ts src/main/codex/codex-app-server-session.ts src/main/codex/codex-state-db-backfill-recovery.ts diff --git a/src/shared/child-process/child-process-import-boundary.test.ts b/src/shared/child-process/child-process-import-boundary.test.ts index 3abdf8023c4..ac4a02f6ee5 100644 --- a/src/shared/child-process/child-process-import-boundary.test.ts +++ b/src/shared/child-process/child-process-import-boundary.test.ts @@ -29,7 +29,7 @@ const CHILD_PROCESS_IMPORT_ALLOWLIST: readonly string[] = readFileSync( * May only ever be DECREASED, and only by migrating a file off * `node:child_process`. Raising it is never the fix. */ -const DIRECT_IMPORTER_PIN = 156 +const DIRECT_IMPORTER_PIN = 155 const IMPORT_PATTERN = /(?:from\s+['"]node:child_process['"]|from\s+['"]child_process['"]|require\(\s*['"]node:child_process['"]|require\(\s*['"]child_process['"])/ diff --git a/src/shared/runtime-session-contracts.ts b/src/shared/runtime-session-contracts.ts index 9b7bf2ee0cd..99b4fbe4d6f 100644 --- a/src/shared/runtime-session-contracts.ts +++ b/src/shared/runtime-session-contracts.ts @@ -78,6 +78,8 @@ export type RuntimeStatus = { worktreeCreateIdempotency?: { dedupeTtlMs: number } + /** True only when this Windows host can prove process creation times for PID ownership. */ + windowsProcessStartTimeAvailable?: boolean /** * Optional for mixed-version peers. Absence means the host predates structured * degradation reporting, not that the host proved every optional feature available. diff --git a/src/shared/structured-native-chat-launch-route.test.ts b/src/shared/structured-native-chat-launch-route.test.ts index 48cb117fdf5..ee13a8fb590 100644 --- a/src/shared/structured-native-chat-launch-route.test.ts +++ b/src/shared/structured-native-chat-launch-route.test.ts @@ -22,7 +22,6 @@ function support(overrides: Partial = {}) { return resolveStructuredNativeChatSupport({ agent: 'claude', executionHostId: 'local', - platform: 'darwin', hostCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], workspaceKind: 'git-worktree', ...overrides @@ -64,7 +63,6 @@ describe('per-launch structured feasibility', () => { ['a floating workspace', { workspaceKind: 'floating' }, 'floating-workspace'], ['a custom TUI launch', { requiresTuiLaunchCustomization: true }, 'tui-launch-customization'], ['an SSH host', { executionHostId: 'ssh:host-a' }, 'remote-execution-host'], - ['Codex on Windows', { agent: 'codex', platform: 'win32' }, 'codex-on-windows'], ['a missing capability', { hostCapabilities: [] }, 'runtime-capability'] ] as [string, Partial, string][])( 'names %s as the blocker', @@ -73,9 +71,14 @@ describe('per-launch structured feasibility', () => { } ) - it('leaves a Windows Claude launch to the executing host', () => { - expect(support({ agent: 'claude', platform: 'win32' })).toEqual({ supported: true }) - }) + // The client cannot see whether the host can read a provider child's start time, so neither + // provider is refused here on platform; agentSession.createSupport answers that at create time. + it.each(['claude', 'codex'] as const)( + 'leaves a Windows %s launch to the executing host', + (agent) => { + expect(support({ agent })).toEqual({ supported: true }) + } + ) it('blocks a WSL or repair-required project runtime', () => { expect( diff --git a/src/shared/structured-native-chat-launch-route.ts b/src/shared/structured-native-chat-launch-route.ts index b97ffcc0dac..8498fe674ce 100644 --- a/src/shared/structured-native-chat-launch-route.ts +++ b/src/shared/structured-native-chat-launch-route.ts @@ -26,7 +26,6 @@ export type StructuredNativeChatBlocker = | 'floating-workspace' | 'tui-launch-customization' | 'remote-execution-host' - | 'codex-on-windows' | 'project-runtime' | 'runtime-capability' @@ -37,7 +36,6 @@ export type StructuredNativeChatSupport = export type StructuredNativeChatSupportInput = { agent: TuiAgent executionHostId: string - platform: NodeJS.Platform hostCapabilities: readonly string[] workspaceKind?: 'git-worktree' | 'folder' | 'floating' projectRuntime?: ProjectExecutionRuntimeResolution | null @@ -82,12 +80,6 @@ export function resolveStructuredNativeChatSupport( if (input.executionHostId !== 'local') { return { supported: false, blocker: 'remote-execution-host' } } - // Codex's Windows refusal is deliberate and settled elsewhere, so it stays a client-side answer. - // Claude's is measured by the executing host at create time (agentSession.createSupport) because - // only that host knows whether it can read a provider child's start time. - if (input.agent === 'codex' && input.platform === 'win32') { - return { supported: false, blocker: 'codex-on-windows' } - } const projectRuntime = input.projectRuntime if (projectRuntime?.status === 'repair-required' || projectRuntime?.runtime.kind === 'wsl') { return { supported: false, blocker: 'project-runtime' }