From a899f92402859440cff772e1babde707002f607c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 7 Sep 2026 09:18:38 -0700 Subject: [PATCH] feat(windows): enable structured Codex chat on native Windows (#18519) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(native-chat): enable Windows structured sessions * fix(codex): prove native Windows process identity * style(codex): format Windows session seam * fix Windows structured Codex admission * fix(windows): reprobe missing process identity capability * fix(windows): decide folder-workspace WSL routing before the click Review found pathUsesWslUnc exported but unused, and the folder composer hardcoding worktreeUsesWslPath:false. Together those meant a folder picked under a \\wsl.localhost\ parent routed to structured chat, then got refused by the host and fell back AFTER the click -- which defeats the lane's own design goal that create cannot fail after the click. The group's parentPath is in scope at submit and the workspace is created under it, so the parent decides WSL-ness pre-click. Wires pathUsesWslUnc there and adds tests for the helper, including the unhydrated-store case that previously threw. * fix(windows): collapse the gate derivation to one call, restoring max-lines CI static analysis failed: launch-agent-in-new-tab.ts crossed the 300-line oxlint ceiling. Adding a max-lines disable is forbidden, so the two gate derivations collapse into one readWindowsStructuredGateInputs() call -- a store-backed site now adds one line and one import name instead of two. Better shape anyway: one derivation entry point rather than two reads a call site must remember to pair. * fix(windows): engage the legacy fallback when the host THROWS a refusal Review found a P1 this merge composes: neither parent could reach it. At the lane head the only structured entry was launch-agent-in-new-tab (full store-backed WSL check); on main all win32 was refused. The merge enables win32 in creation flows that pass no projectRuntime, so a WSL folder workspace, a WSL-configured repo, or a repair-required runtime now routes structured -- and the host refuses correctly, but by THROWING rather than returning {ok:false, refusal}. Callers engage their legacy-terminal fallback on the refusal CLASS, so an unmapped throw arrives as a generic RPC rejection: no fallback, empty workspace, error toast, prompt stranded in the launch outbox. Pre-merge the same action opened a legacy terminal agent. Map the host's thrown definitive refusals onto the refusal class at the launch boundary, so every creation flow -- present and future -- degrades to the legacy terminal instead of stranding. Narrow predicate: unrelated failures (ECONNRESET, empty message, non-Error) still propagate untouched. Ablation-proven: removing the mapping reddens the fallback test. * fix(windows): teach the mobile RPC double the status probe the lane added CI's first-ever run on this lane caught a pre-existing lane defect. The lane changed status.get to resolve through runtime.getStatusAfterWindowsProcessStartTimeProbe(), but never taught the mobile-surface runtime double about it, so status.get failed for mobile clients with "not a function". The lane's own test list did not include this file and the lane had zero CI, so nothing ever ran it. The real runtime always implements the method; the double omitted it. * chore: merge current main and regenerate the localization runtime catalog CI static analysis failed on a stale en-runtime-required.json: main added onboarding integration-capability keys, and the generated catalog is checked against the PR MERGE result, not the branch alone -- so it read clean locally while failing in CI. Merging current main (90780acb85) and regenerating. Gates after the merge: pnpm tc 0, oxlint 0, changed-code quality 0/56, 7 gate/lane test files 69 tests green. * fix: route structured launches by execution host platform * fix: recover paired structured session mirror on host swap * Revert "fix: recover paired structured session mirror on host swap" This reverts commit 81bfca0007dbbbc150a9cfcc9a24e3d060c70850. * Revert "fix: route structured launches by execution host platform" This reverts commit 47abbd354acf45fd6acc69589ad55f6341493281. * fix(windows): refuse structured chat in a paired web client Reverts the two review-loop commits (restoring a tree byte-identical to the validated head) and closes the hole they were aiming at, without their cost. A paired web client's `platform` describes the browser's machine, not the host that will run the agent, so the Windows gate cannot be evaluated there. Before this, a browser on macOS driving a Windows runtime read "not win32", skipped the creation-time proof entirely and allowed structured chat — fail-OPEN, the dangerous direction, bypassing the guarantee this lane is built on. `isWebClient` is a required input like the other gate fields, so the compiler enumerated all seven call sites. Refusal is synchronous and fail-closed: no async round-trip, no null window, no cache to invalidate — unlike keying on an asynchronously-fetched host platform, which would have made every desktop launch wait on a round-trip to fix a paired-web-only hole. Paired web therefore gets the legacy chat until the host publishes eligibility itself; that is the proper fix and belongs in its own PR. Ablation-proven: removing the guard reddens both refusal tests; the desktop-unaffected test is a preservation check and passes either way. Gates: tc 0, oxlint 0. Known open: repos-onboarding-folder-startup.test.ts fails on this branch and passes on plain main — under investigation, NOT caused by this commit. * test(onboarding): mock the web-client check the store path now reaches The web-client refusal added `isWebClientLocation()` to the launch-route inputs, which this suite's store path reaches while adding the FIRST folder. The suite stubs `window` as `{ api }` with no `location`, so the function cleared its `typeof window === 'undefined'` guard and then threw on `window.location.pathname`. That threw inside addNonGitFolder's own catch, so folder-1 never activated; folder-2 then returned early (a project already existed) before reaching the call at all, leaving exactly one activation with no startup seed. Test artifact, not a product defect: a real renderer always has `window.location`, so the seeding path is intact for users. Mocking the module is the convention 7 other suites already use, and keeps product code free of defensive branches that only exist to satisfy a stub. Ablation-proven: removing the mock reproduces the original failure exactly. * fix(renderer): make the web-client check total over a partial window isWebClientLocation() guarded `typeof window === 'undefined'` and then assumed `window.location` existed. A window stubbed without a location cleared the guard and threw on `.pathname`. That matters because this branch put the call on the launch-routing path, where the throw is swallowed by the caller's catch and silently becomes a FAILED LAUNCH rather than a visible error. CI caught it as 9 failures in launch-work-item-direct.test.ts. I previously "fixed" this by mocking the module in the one suite I knew about. That was whack-a-mole against an unbounded set, and it missed this one. The defect is the partial-window assumption, so fix it there: the mock is removed from the onboarding suite and both suites now pass on the hardening alone. Ablation-proven: reverting to the unguarded form reddens 11 tests across the new unit suite and launch-work-item-direct. Gates: tc 0, oxlint 0, changed-code quality 0/58. * Move Codex's Windows structured-chat eligibility onto the host createSupport probe The renderer no longer decides Codex win32 eligibility: launchStructuredAgentSession probes agentSession.createSupport for both providers, the host answers via supportsCodexStructuredLocation (process start-time proof + WSL refusal), and the create path re-checks live. Deletes the client-side windows gate module and its routing inputs (windowsProcessStartTime, worktreeUsesWslPath, isWebClient, platform) from six call sites. Splits killCodexAppServerProcessTree out of codex-app-server-session to hold the max-lines ceiling without a disable. * fix(ci): keep pnpm lockfile stable * test(windows): align foreground snapshot flags * Restore main's pane-snapshot flag contract Main asks for CreationTime on both projections; this branch's hot-path isolation went away with the async probe it served. --------- Co-authored-by: Orca Worker Co-authored-by: Merge Sim Co-authored-by: Merge Sim --- .../rebuild-native-deps-node-pty.test.mjs | 22 +++ .../rebuild-native-deps-test-fixtures.mjs | 39 +++- .../windows-process-tree-gyp-rebuild.mjs | 42 ++++ .../windows-process-tree-gyp-rebuild.test.mjs | 47 +++++ config/tsconfig.cli.json | 1 + .../codex/codex-app-server-client.test.ts | 3 +- src/main/codex/codex-app-server-client.ts | 10 +- .../codex-app-server-process-tree-kill.ts | 76 +++++++ src/main/codex/codex-app-server-session.ts | 73 +------ ...codex-structured-launch-resolution.test.ts | 22 ++- .../codex-structured-launch-resolution.ts | 10 + .../codex-structured-location-support.test.ts | 47 +++++ .../codex-structured-location-support.ts | 8 +- .../codex/codex-structured-session-adapter.ts | 3 +- .../codex/codex-structured-session-state.ts | 2 + .../structured-agent-session-acquisition.ts | 78 ++++++++ .../structured-agent-session-attach-flow.ts | 132 ++++++------- ...uctured-agent-session-host-handoff.test.ts | 186 ++++++++++++++++++ .../structured-agent-session-host-handoff.ts | 10 + ...nt-session-processless-reservation.test.ts | 145 ++++++++++++++ ...ructured-agent-session-provider-support.ts | 32 ++- src/main/own-chromium-tree-kill-guard.test.ts | 2 +- ...refused-tree-kill-root-termination.test.ts | 2 +- ...ocess-identity-probe-windows-batch.test.ts | 42 ++++ .../agent-session-process-identity-probe.ts | 22 +++ src/main/runtime/orca-runtime-get-status.ts | 6 + ...lve-recovered-structured-tui-transcript.ts | 23 ++- .../orchestration-worker-start-mode.test.ts | 14 +- .../orchestration-worker-start-mode.ts | 3 - .../methods/orchestration/worker/workers.ts | 3 +- .../methods/structured-agent-session-gate.ts | 5 +- .../structured-agent-session-runtime.test.ts | 31 +++ ...ctured-agent-session-support-probe.test.ts | 39 ++++ ...-vault-session-resume-in-chat-workspace.ts | 2 - .../folder-workspace-composer-submit.ts | 7 +- .../composer-state/full-creation-execution.ts | 3 +- .../quick-creation-execution.ts | 2 - .../src/lib/agent-launch-routing.test.ts | 68 ++----- src/renderer/src/lib/agent-launch-routing.ts | 2 - .../src/lib/launch-agent-in-new-tab.ts | 1 - .../launch-structured-agent-session.test.ts | 182 ++++++++--------- .../lib/launch-structured-agent-session.ts | 11 +- ...unch-work-item-direct-route-preparation.ts | 1 - .../lib/onboarding-folder-agent-startup.ts | 1 - ...nt-session-launch-refusal-fallback.test.ts | 3 + ...ent-session-launch-resume-identity.test.ts | 15 +- .../src/lib/web-client-location.test.ts | 43 ++++ src/renderer/src/lib/web-client-location.ts | 7 +- ...windows-terminal-capabilities-race.test.ts | 80 ++++++++ .../lib/windows-terminal-capabilities.test.ts | 13 +- .../src/lib/windows-terminal-capabilities.ts | 2 + .../lib/windows-terminal-capability-read.ts | 12 +- ...indows-terminal-capability-reprobe.test.ts | 34 +++- .../windows-terminal-capability-reprobe.ts | 14 +- .../child-process-import-allowlist.txt | 1 - .../child-process-import-boundary.test.ts | 2 +- src/shared/runtime-session-contracts.ts | 2 + ...tructured-native-chat-launch-route.test.ts | 13 +- .../structured-native-chat-launch-route.ts | 8 - 59 files changed, 1324 insertions(+), 385 deletions(-) create mode 100644 src/main/codex/codex-app-server-process-tree-kill.ts create mode 100644 src/main/codex/codex-structured-location-support.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts create mode 100644 src/main/runtime/agent-session-process-identity-probe-windows-batch.test.ts create mode 100644 src/renderer/src/lib/web-client-location.test.ts create mode 100644 src/renderer/src/lib/windows-terminal-capabilities-race.test.ts 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' }