From f7f2ecd83719cea5fc19786795570fcb694f75cf Mon Sep 17 00:00:00 2001 From: Orca Worker Date: Tue, 1 Sep 2026 21:26:58 -0700 Subject: [PATCH] fix(codex): prove native Windows process identity --- .../@vscode__windows-process-tree@0.8.0.patch | 179 +++++++++++++++++- ...build-windows-process-tree-relay-addon.mjs | 149 ++++++++++++++- docs/reference/windows-process-enumeration.md | 37 ++-- pnpm-lock.yaml | 6 +- ...codex-structured-launch-resolution.test.ts | 22 ++- .../codex-structured-launch-resolution.ts | 10 + .../codex-structured-location-support.test.ts | 35 ++++ .../codex-structured-location-support.ts | 7 +- .../codex/codex-structured-session-adapter.ts | 3 +- .../codex/codex-structured-session-state.ts | 2 + src/main/ipc/runtime.test.ts | 31 ++- src/main/ipc/runtime.ts | 4 +- .../structured-agent-session-attach-flow.ts | 11 ++ ...uctured-agent-session-host-handoff.test.ts | 94 +++++++++ .../structured-agent-session-host-handoff.ts | 5 + ...nt-session-processless-reservation.test.ts | 41 ++++ ...ructured-agent-session-provider-support.ts | 32 +++- ...tured-agent-session-recovery-exits.test.ts | 15 +- src/main/runtime/orca-runtime-get-status.ts | 22 ++- ...-status-windows-process-start-time.test.ts | 80 ++++++++ ...ime-request-connection.integration.test.ts | 20 +- src/main/runtime/rpc/methods/status.ts | 4 +- src/main/runtime/rpc/methods/updater.test.ts | 5 +- ...time-rpc-mobile-terminal-streaming.test.ts | 1 + ...d-agent-session-integration-replay.test.ts | 6 +- ...ructured-agent-session-integration.test.ts | 5 +- ...uctured-agent-session-runtime-exit.test.ts | 9 +- .../structured-agent-session-runtime.test.ts | 30 +++ .../structured-agent-session-runtime.ts | 19 +- .../windows/windows-process-table.test.ts | 53 +++++- src/main/windows/windows-process-table.ts | 108 +++++++++-- .../settings/ExperimentalPane.test.tsx | 2 +- .../NativeChatExperimentalSetting.tsx | 2 +- src/renderer/src/i18n/locales/en.json | 2 +- ...tructured-native-chat-availability.test.ts | 69 ++++++- .../structured-native-chat-availability.ts | 11 ++ ...windows-terminal-capabilities-race.test.ts | 80 ++++++++ .../src/lib/windows-terminal-capabilities.ts | 2 + .../lib/windows-terminal-capability-read.ts | 12 +- src/shared/runtime-session-contracts.ts | 2 + 40 files changed, 1132 insertions(+), 95 deletions(-) create mode 100644 src/main/codex/codex-structured-location-support.test.ts create mode 100644 src/main/runtime/orca-runtime-status-windows-process-start-time.test.ts create mode 100644 src/renderer/src/lib/windows-terminal-capabilities-race.test.ts diff --git a/config/patches/@vscode__windows-process-tree@0.8.0.patch b/config/patches/@vscode__windows-process-tree@0.8.0.patch index 10780f5288a..2c51ac56834 100644 --- a/config/patches/@vscode__windows-process-tree@0.8.0.patch +++ b/config/patches/@vscode__windows-process-tree@0.8.0.patch @@ -26,11 +26,56 @@ index 855bd4b86f0a3c18c7594212c0e42b6e35bc4001..33774e7ae296f0de39dd94156673c9e7 "AdditionalOptions": [ "/guard:cf", "/sdl", +diff --git a/src/process.h b/src/process.h +index 3c9b852e3b..72e164825a 100644 +--- a/src/process.h ++++ b/src/process.h +@@ -22,18 +22,22 @@ struct ProcessInfo { + DWORD ppid; + DWORD memory; // Reported in bytes + std::string commandLine; ++ ULONGLONG creationTimeMs; + }; + + enum ProcessDataFlags { + NONE = 0, + MEMORY = 1, +- COMMANDLINE = 2 ++ COMMANDLINE = 2, ++ CREATIONTIME = 4 + }; + + uint32_t GetRawProcessList(std::vector& process_info, DWORD flags); + + void GetProcessMemoryUsage(ProcessInfo& process_info); + ++void GetProcessCreationTime(ProcessInfo& process_info); ++ + void GetCpuUsage(Cpu& cpu_info, bool first_run); + + #endif // SRC_PROCESS_H_ diff --git a/src/process.cc b/src/process.cc -index 3eea92077c4d1d433119361d5c432881859131e9..1998f4addd4d7e9aba946ea6f7f7a4a5d13291bc 100644 +index ad63727362..6f1600570b 100644 --- a/src/process.cc +++ b/src/process.cc -@@ -37,7 +37,7 @@ uint32_t GetRawProcessList(std::vector& process_info, +@@ -21,7 +21,7 @@ uint32_t GetRawProcessList(std::vector& process_info, + if (Process32First(snapshot_handle, &process_entry)) { + do { + if (process_entry.th32ProcessID != 0) { +- ProcessInfo pinfo; ++ ProcessInfo pinfo{}; + pinfo.pid = process_entry.th32ProcessID; + pinfo.ppid = process_entry.th32ParentProcessID; + +@@ -33,17 +33,43 @@ uint32_t GetRawProcessList(std::vector& process_info, + GetProcessCommandLine(pinfo); + } + ++ if (CREATIONTIME & process_data_flags) { ++ GetProcessCreationTime(pinfo); ++ } ++ + strcpy(pinfo.name, process_entry.szExeFile); process_info.push_back(std::move(pinfo)); process_count++; } @@ -39,3 +84,133 @@ index 3eea92077c4d1d433119361d5c432881859131e9..1998f4addd4d7e9aba946ea6f7f7a4a5 } CloseHandle(snapshot_handle); + return process_count; + } + ++void GetProcessCreationTime(ProcessInfo& process_info) { ++ HANDLE hProcess = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, process_info.pid); ++ if (hProcess == NULL) { ++ return; ++ } ++ ++ FILETIME creationTime, exitTime, kernelTime, userTime; ++ if (GetProcessTimes(hProcess, &creationTime, &exitTime, &kernelTime, &userTime)) { ++ ULARGE_INTEGER timestamp; ++ timestamp.LowPart = creationTime.dwLowDateTime; ++ timestamp.HighPart = creationTime.dwHighDateTime; ++ constexpr ULONGLONG WINDOWS_EPOCH_OFFSET_100NS = 116444736000000000ULL; ++ constexpr ULONGLONG HUNDRED_NS_PER_MILLISECOND = 10000ULL; ++ if (timestamp.QuadPart >= WINDOWS_EPOCH_OFFSET_100NS) { ++ process_info.creationTimeMs = ++ (timestamp.QuadPart - WINDOWS_EPOCH_OFFSET_100NS) / HUNDRED_NS_PER_MILLISECOND; ++ } ++ } ++ ++ CloseHandle(hProcess); ++} ++ + void GetProcessMemoryUsage(ProcessInfo& process_info) { + DWORD pid = process_info.pid; + HANDLE hProcess; +diff --git a/src/process_worker.cc b/src/process_worker.cc +index f59559d31c..1d61c87a56 100644 +--- a/src/process_worker.cc ++++ b/src/process_worker.cc +@@ -43,6 +43,11 @@ void GetProcessesWorker::OnOK() { + Napi::String::New(env, pinfo.commandLine)); + } + ++ if ((CREATIONTIME & process_data_flags_) && pinfo.creationTimeMs != 0) { ++ object.Set("creationTimeMs", ++ Napi::Number::New(env, static_cast(pinfo.creationTimeMs))); ++ } ++ + result.Set(i, object); + } + +diff --git a/lib/index.ts b/lib/index.ts +index 7b53aad05c..e982c8c0be 100644 +--- a/lib/index.ts ++++ b/lib/index.ts +@@ -11,7 +11,8 @@ import { IProcessInfo, IProcessTreeNode, IProcessCpuInfo } from '@vscode/windows + export enum ProcessDataFlag { + None = 0, + Memory = 1, +- CommandLine = 2 ++ CommandLine = 2, ++ CreationTime = 4 + } + + type RequestCallback = (processList: IProcessInfo[]) => void; +@@ -81,11 +82,12 @@ export function buildProcessTree(rootPid: number, processList: Iterable ({ ++ const buildNode = ({ info: { pid, name, memory, commandLine, creationTimeMs }, children }: IProcessInfoNode, depth: number): IProcessTreeNode => ({ + pid, + name, + memory, + commandLine, ++ creationTimeMs, + children: depth > 0 ? children.map(c => buildNode(c, depth - 1)) : [], + }); + +diff --git a/lib/index.js b/lib/index.js +index e586cd6ead..07ad1b914a 100644 +--- a/lib/index.js ++++ b/lib/index.js +@@ -12,6 +12,7 @@ var ProcessDataFlag; + ProcessDataFlag[ProcessDataFlag["None"] = 0] = "None"; + ProcessDataFlag[ProcessDataFlag["Memory"] = 1] = "Memory"; + ProcessDataFlag[ProcessDataFlag["CommandLine"] = 2] = "CommandLine"; ++ ProcessDataFlag[ProcessDataFlag["CreationTime"] = 4] = "CreationTime"; + })(ProcessDataFlag = exports.ProcessDataFlag || (exports.ProcessDataFlag = {})); + // requestInProgress is used for any function that uses CreateToolhelp32Snapshot, as multiple calls + // to this cannot be done at the same time. +@@ -66,11 +67,12 @@ function buildProcessTree(rootPid, processList, maxDepth = MAX_FILTER_DEPTH) { + // • the properties are inlined/splatted + // • the 'ppid' field is omitted + // • the depth of the tree is limited by `maxDepth` +- const buildNode = ({ info: { pid, name, memory, commandLine }, children }, depth) => ({ ++ const buildNode = ({ info: { pid, name, memory, commandLine, creationTimeMs }, children }, depth) => ({ + pid, + name, + memory, + commandLine, ++ creationTimeMs, + children: depth > 0 ? children.map(c => buildNode(c, depth - 1)) : [], + }); + return buildNode(root, maxDepth); +diff --git a/typings/windows-process-tree.d.ts b/typings/windows-process-tree.d.ts +index 70e242b123..fb1b810707 100644 +--- a/typings/windows-process-tree.d.ts ++++ b/typings/windows-process-tree.d.ts +@@ -7,7 +7,8 @@ declare module '@vscode/windows-process-tree' { + export enum ProcessDataFlag { + None = 0, + Memory = 1, +- CommandLine = 2 ++ CommandLine = 2, ++ CreationTime = 4 + } + + export interface IProcessInfo { +@@ -24,6 +25,9 @@ declare module '@vscode/windows-process-tree' { + * The string returned is at most 512 chars, strings exceeding this length are truncated. + */ + commandLine?: string; ++ ++ /** Process creation time in Unix milliseconds. */ ++ creationTimeMs?: number; + } + + export interface IProcessCpuInfo extends IProcessInfo { +@@ -35,6 +39,7 @@ declare module '@vscode/windows-process-tree' { + name: string; + memory?: number; + commandLine?: string; ++ creationTimeMs?: number; + children: IProcessTreeNode[]; + } + diff --git a/config/scripts/build-windows-process-tree-relay-addon.mjs b/config/scripts/build-windows-process-tree-relay-addon.mjs index d3b9db939cd..720ca8bb056 100644 --- a/config/scripts/build-windows-process-tree-relay-addon.mjs +++ b/config/scripts/build-windows-process-tree-relay-addon.mjs @@ -89,6 +89,152 @@ function assertPatchApplied() { 'config/patches/@vscode__windows-process-tree@0.8.0.patch; run pnpm install.' ) } + const requiredCreationTimeSources = [ + ['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'] + ] + for (const [relativePath, expected] of requiredCreationTimeSources) { + if (!readFileSync(join(PACKAGE_DIR, relativePath), 'utf8').includes(expected)) { + throw new Error( + `${relativePath} does not contain the process creation-time patch (${expected}). ` + + 'Run pnpm install before building the relay addon.' + ) + } + } +} + +function repairCreationTimeSources() { + let repaired = false + const rewrite = (relativePath, transform) => { + const filePath = join(PACKAGE_DIR, relativePath) + const source = readFileSync(filePath, 'utf8') + const next = transform(source, source.includes('\r\n') ? '\r\n' : '\n') + if (next !== source) { + writeFileSync(filePath, next) + repaired = true + } + } + + rewrite('src/process.h', (source, eol) => { + let next = source + if (!next.includes('ULONGLONG creationTimeMs')) { + next = next.replace( + / std::string commandLine;\r?\n/, + ` std::string commandLine;${eol} ULONGLONG creationTimeMs;${eol}` + ) + } + if (!next.includes('CREATIONTIME = 4')) { + next = next.replace( + / COMMANDLINE = 2\r?\n/, + ` COMMANDLINE = 2,${eol} CREATIONTIME = 4${eol}` + ) + } + if (!next.includes('void GetProcessCreationTime')) { + next = next.replace( + /void GetProcessMemoryUsage\(ProcessInfo& process_info\);\r?\n/, + `void GetProcessMemoryUsage(ProcessInfo& process_info);${eol}${eol}` + + `void GetProcessCreationTime(ProcessInfo& process_info);${eol}` + ) + } + return next + }) + + rewrite('src/process.cc', (source, eol) => { + let next = source.replace('ProcessInfo pinfo;', 'ProcessInfo pinfo{};') + if (!next.includes('GetProcessCreationTime(pinfo)')) { + next = next.replace( + /( if \(COMMANDLINE & process_data_flags\) \{\r?\n GetProcessCommandLine\(pinfo\);\r?\n \})/, + `$1${eol}${eol} if (CREATIONTIME & process_data_flags) {${eol}` + + ` GetProcessCreationTime(pinfo);${eol} }` + ) + } + if (!next.includes('void GetProcessCreationTime(ProcessInfo& process_info) {')) { + const producer = [ + 'void GetProcessCreationTime(ProcessInfo& process_info) {', + ' HANDLE hProcess = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, process_info.pid);', + ' if (hProcess == NULL) {', + ' return;', + ' }', + '', + ' FILETIME creationTime, exitTime, kernelTime, userTime;', + ' if (GetProcessTimes(hProcess, &creationTime, &exitTime, &kernelTime, &userTime)) {', + ' ULARGE_INTEGER timestamp;', + ' timestamp.LowPart = creationTime.dwLowDateTime;', + ' timestamp.HighPart = creationTime.dwHighDateTime;', + ' constexpr ULONGLONG WINDOWS_EPOCH_OFFSET_100NS = 116444736000000000ULL;', + ' constexpr ULONGLONG HUNDRED_NS_PER_MILLISECOND = 10000ULL;', + ' if (timestamp.QuadPart >= WINDOWS_EPOCH_OFFSET_100NS) {', + ' process_info.creationTimeMs =', + ' (timestamp.QuadPart - WINDOWS_EPOCH_OFFSET_100NS) / HUNDRED_NS_PER_MILLISECOND;', + ' }', + ' }', + '', + ' CloseHandle(hProcess);', + '}', + '' + ].join(eol) + next = next.replace( + 'void GetProcessMemoryUsage', + `${producer}${eol}void GetProcessMemoryUsage` + ) + } + return next + }) + + rewrite('src/process_worker.cc', (source, eol) => { + if (source.includes('object.Set("creationTimeMs"')) { + return source + } + const emission = [ + ' if ((CREATIONTIME & process_data_flags_) && pinfo.creationTimeMs != 0) {', + ' object.Set("creationTimeMs",', + ' Napi::Number::New(env, static_cast(pinfo.creationTimeMs)));', + ' }', + '' + ].join(eol) + return source.replace( + ' result.Set(i, object);', + `${emission}${eol} result.Set(i, object);` + ) + }) + + for (const relativePath of ['lib/index.ts', 'lib/index.js']) { + rewrite(relativePath, (source, eol) => { + if (source.includes('CreationTime')) { + return source + } + return relativePath.endsWith('.ts') + ? source.replace(' CommandLine = 2', ` CommandLine = 2,${eol} CreationTime = 4`) + : source.replace( + ' ProcessDataFlag[ProcessDataFlag["CommandLine"] = 2] = "CommandLine";', + ' ProcessDataFlag[ProcessDataFlag["CommandLine"] = 2] = "CommandLine";' + + `${eol} ProcessDataFlag[ProcessDataFlag["CreationTime"] = 4] = "CreationTime";` + ) + }) + } + + rewrite('typings/windows-process-tree.d.ts', (source, eol) => { + let next = source + if (!next.includes('CreationTime = 4')) { + next = next.replace(' CommandLine = 2', ` CommandLine = 2,${eol} CreationTime = 4`) + } + if (!next.includes('creationTimeMs?: number')) { + next = next.replace( + / commandLine\?: string;\r?\n/, + ` commandLine?: string;${eol}${eol}` + + ` /** Process creation time in Unix milliseconds. */${eol}` + + ` creationTimeMs?: number;${eol}` + ) + } + return next + }) + return repaired } // pnpm can materialize this CRLF package without applying its patch. Repair the @@ -130,8 +276,9 @@ function applyWindowsProcessTreeBuildFixes() { if (processCc !== originalProcess) { writeFileSync(processPath, processCc) } + const repairedCreationTime = repairCreationTimeSources() stageWindowsProcessTreeNodeAddonApiHeaders(PACKAGE_DIR) - if (bindingGyp !== originalBinding || processCc !== originalProcess) { + if (bindingGyp !== originalBinding || processCc !== originalProcess || repairedCreationTime) { console.warn('[windows-process-tree] Repaired un-applied pnpm patch hunks before build.') } } diff --git a/docs/reference/windows-process-enumeration.md b/docs/reference/windows-process-enumeration.md index ef1faf5237c..caa8bed8600 100644 --- a/docs/reference/windows-process-enumeration.md +++ b/docs/reference/windows-process-enumeration.md @@ -1,8 +1,9 @@ # Reading the Windows process table -Orca needs three things from the Windows process table: who a PID's parent is +Orca needs four things from the Windows process table: who a PID's parent is (descendant walks and teardown identity), what a process is running (agent -recognition), and how much memory/CPU it uses (Resource Manager). +recognition), when it started (PID-reuse-safe ownership), and how much +memory/CPU it uses (Resource Manager). Node cannot answer the first one without native code. That is why seven independent readers existed, each forking `powershell.exe` to run @@ -180,7 +181,7 @@ on any other OS keeps using the scan. ## Why the package is patched -`config/patches/@vscode__windows-process-tree@0.8.0.patch` carries three hunks. +`config/patches/@vscode__windows-process-tree@0.8.0.patch` carries four changes. 1. **Spectre mitigation.** The upstream `binding.gyp` requires Spectre-mitigated libraries, which Orca's Windows build agents do not install. `node-pty` is @@ -195,6 +196,12 @@ on any other OS keeps using the scan. realpath, then loads the relative path from the `node_modules` symlink, so `node_addon_api.gyp` resolves outside the repo and hourly Windows builds die at configure. `node-pty` is patched the same way for the same reason. +4. **Process creation time.** The addon requests `GetProcessTimes` under + `PROCESS_QUERY_LIMITED_INFORMATION` and publishes Unix milliseconds only + when Windows returned them. Orca requests that field with the shared + snapshot and requires a valid value for its own PID before advertising the + capability; a patched JavaScript enum over a stale binary therefore fails + closed. The typings claim `commandLine` is truncated at 512 characters. Measured, it is not: the longest observed on a real host was 26,059. @@ -212,12 +219,21 @@ The addon is Windows-only, so it follows the same contract as `ensure-native-runtime.mjs`; - copied into the packaged `node_modules` for win32 only. -## What the snapshot does not provide +## Creation time and identity -`CreationDate` (process start time) has no equivalent. Anything using a start -time to prove a PID has not been recycled — daemon identity, managed-hook -ownership, and CPU accounting in the memory collector — still reads it through -its own query. Those callers are not migrated. +The patched snapshot exposes `creationTimeMs`, and +`agent-session-process-identity-probe.ts` uses it for Windows process identity. +The field is omitted when a process denies the query handle or the native +binary predates the patch. Structured Codex ownership therefore probes the +querying process first and stays unavailable unless the native binary proves +the field end to end. + +Start time is a PID-reuse guard, not ownership by itself. Structured sessions +also retain host, provider, account, workspace, spawn-token, and lease-fence +evidence. For PTY trees Orca creates, an inherited job-object handle remains +stronger than reconstructing ownership from process-table fields. + +## What the snapshot does not provide Committed private bytes have no equivalent either, and the one memory value the snapshot does carry is unusable for the sizes Orca now sees: `process.cc` stores @@ -226,11 +242,6 @@ second reason `windows-process-resource-collector.ts` still runs its own `Get-CimInstance` sweep — it needs `PageFileUsage` (commit) and the CPU-time counters in the same pass. Migrating it to the native table would cost both. -Start time is a proxy for identity, not identity. The durable answer for the -process trees Orca itself spawns is an inherited handle: a job object names the -tree Orca created, so no start-time comparison is needed. Those readers should -be resolved that way rather than by adding a start time to this module. - Do not adopt `getProcessCpuUsage()` from the package. It takes both CPU samples inside one call with a blocking `Sleep(1000)` in the middle, which would hold a libuv threadpool slot for a full second out of the Resource Manager's two-second diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b5b4c054a0..8a2036d6596 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -109,7 +109,7 @@ overrides: monaco-editor>dompurify: 3.4.13 patchedDependencies: - '@vscode/windows-process-tree@0.8.0': 9217ef36c01ed74127fef5512b0c92089cdbf820fd6c109dd671137eebdc7585 + '@vscode/windows-process-tree@0.8.0': c8de5dc333734ff23891a849ffb4cbbac724357d9ea385b8606d2f924d27ba6e '@xterm/addon-ligatures@0.11.0-beta.300': 47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920 '@xterm/addon-serialize@0.15.0-beta.300': 851eac3d75e6d8c013b9f4c053e61d824b23965cb19ecc28e335e05059f3a294 '@xterm/addon-webgl@0.20.0-beta.299': 94687e89a0115e6e6aa102837f986debdc029c091527ee5eb4a4e17ceaf9473e @@ -506,7 +506,7 @@ importers: optionalDependencies: '@vscode/windows-process-tree': specifier: 0.8.0 - version: 0.8.0(patch_hash=9217ef36c01ed74127fef5512b0c92089cdbf820fd6c109dd671137eebdc7585) + version: 0.8.0(patch_hash=c8de5dc333734ff23891a849ffb4cbbac724357d9ea385b8606d2f924d27ba6e) sherpa-onnx-darwin-arm64: specifier: 1.12.37 version: 1.12.37 @@ -9737,7 +9737,7 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 - '@vscode/windows-process-tree@0.8.0(patch_hash=9217ef36c01ed74127fef5512b0c92089cdbf820fd6c109dd671137eebdc7585)': + '@vscode/windows-process-tree@0.8.0(patch_hash=c8de5dc333734ff23891a849ffb4cbbac724357d9ea385b8606d2f924d27ba6e)': dependencies: node-addon-api: 7.1.0 optional: true 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..8a23cdfca1a --- /dev/null +++ b/src/main/codex/codex-structured-location-support.test.ts @@ -0,0 +1,35 @@ +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' +} + +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 + ) + }) + }) +}) diff --git a/src/main/codex/codex-structured-location-support.ts b/src/main/codex/codex-structured-location-support.ts index 915d9edaa83..0630e853bb1 100644 --- a/src/main/codex/codex-structured-location-support.ts +++ b/src/main/codex/codex-structured-location-support.ts @@ -2,10 +2,13 @@ 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, + 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 17cf12331ef..4d3c1bc6726 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -71,7 +71,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/ipc/runtime.test.ts b/src/main/ipc/runtime.test.ts index dc7f8a01cd7..967b86889ba 100644 --- a/src/main/ipc/runtime.test.ts +++ b/src/main/ipc/runtime.test.ts @@ -108,16 +108,18 @@ describe('registerRuntimeHandlers', () => { }) it('routes generic local runtime RPC calls through the dispatcher', async () => { + const status = { + runtimeId: 'runtime-1', + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0 + } const runtime = { syncWindowGraph: vi.fn(), - getStatus: vi.fn().mockReturnValue({ - runtimeId: 'runtime-1', - rendererGraphEpoch: 0, - graphStatus: 'ready', - authoritativeWindowId: null, - liveTabCount: 0, - liveLeafCount: 0 - }), + getStatus: vi.fn().mockReturnValue(status), + getStatusAfterWindowsProcessStartTimeProbe: vi.fn().mockResolvedValue(status), getRuntimeId: vi.fn().mockReturnValue('runtime-1') } @@ -136,6 +138,19 @@ describe('registerRuntimeHandlers', () => { }) }) + it('waits for the host process identity probe before returning local runtime status', async () => { + const status = { runtimeId: 'runtime-1', windowsProcessStartTimeAvailable: true } + const getStatusAfterWindowsProcessStartTimeProbe = vi.fn().mockResolvedValue(status) + registerRuntimeHandlers({ + syncWindowGraph: vi.fn(), + getStatusAfterWindowsProcessStartTimeProbe + } as never) + const handler = handleMock.mock.calls.find(([channel]) => channel === 'runtime:getStatus')![1] + + await expect(handler()).resolves.toBe(status) + expect(getStatusAfterWindowsProcessStartTimeProbe).toHaveBeenCalledOnce() + }) + it('registers project group runtime RPC methods for local desktop callers', async () => { const runtime = { syncWindowGraph: vi.fn(), diff --git a/src/main/ipc/runtime.ts b/src/main/ipc/runtime.ts index 901d14bfce6..e48653ea995 100644 --- a/src/main/ipc/runtime.ts +++ b/src/main/ipc/runtime.ts @@ -52,8 +52,8 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void { } ) - ipcMain.handle('runtime:getStatus', (): RuntimeStatus => { - return runtime.getStatus() + ipcMain.handle('runtime:getStatus', (): Promise => { + return runtime.getStatusAfterWindowsProcessStartTimeProbe() }) ipcMain.handle( 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 5be2d8ed09e..329462a484d 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 @@ -24,6 +24,7 @@ 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, AgentSessionAcquisitionRefusal, @@ -71,6 +72,16 @@ export async function performAttach( 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 { + ok: false, + refusal: { + code: 'structured_agent_session_unsupported', + message: 'This execution host cannot create the requested structured agent session.' + } + } + } let record: AgentSessionRecord let acquisitionGeneration: string | null = null 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 1828807f887..c529d8f492b 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 @@ -10,6 +10,7 @@ import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' import { createDeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { acquireNativeHandoffOwner, structuredTuiTranscriptImportOptions @@ -196,4 +197,97 @@ 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 openAgentSessionJournal({ + 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, + 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() + }) }) 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 6fb12f9bd13..9aefd014229 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' type HostHandoffAccess = { session: (sessionId: string) => StructuredAgentSessionHostSession @@ -179,6 +180,10 @@ 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) { 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..b391911bd3c 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,47 @@ 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('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/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts index a5927dc0c14..5193cc12987 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts @@ -9,6 +9,7 @@ import { CODEX_SPAWN_TOKEN_ENV } from '../../codex/codex-structured-owner-identi import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { readProcessStartTimeMs } from '../../runtime/agent-session-process-identity-probe' import { createStructuredAgentSessionOwnerProbe } from '../../runtime/structured-agent-session-runtime' +import { probeWindowsProcessStartTimeAvailability } from '../../windows/windows-process-table' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' @@ -273,7 +274,14 @@ describe('recovery exits', () => { }) }) - it('recovers when the outgoing runtime writes after the replacement probes its dying owner', async () => { + // This scenario deliberately captures a real child identity. An unpatched + // Windows process-tree addon cannot provide creation time, and fabricating a + // timestamp would test the opposite of the ownership contract. + it('recovers when the outgoing runtime writes after the replacement probes its dying owner', async (ctx) => { + // Probe asynchronously so a healthy Windows addon is not skipped before its first read. + if (process.platform === 'win32' && !(await probeWindowsProcessStartTimeAvailability())) { + return ctx.skip() + } const outgoing = await spawnOwner('spawn-a') acquire.mockResolvedValueOnce({ process: outgoing.process, @@ -290,7 +298,10 @@ describe('recovery exits', () => { const outgoingHost = host const outgoingStore = store supersededHosts.add(outgoingHost) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await AgentSessionRecordStore.open({ + directory: join(root, 'store'), + hostId: 'local' + }) const realProbe = createStructuredAgentSessionOwnerProbe('local') let overlapDriven = false openHost({ diff --git a/src/main/runtime/orca-runtime-get-status.ts b/src/main/runtime/orca-runtime-get-status.ts index bd378ea2bf7..3e2f9090bad 100644 --- a/src/main/runtime/orca-runtime-get-status.ts +++ b/src/main/runtime/orca-runtime-get-status.ts @@ -13,6 +13,7 @@ import { RUNTIME_CAPABILITIES, RUNTIME_PROTOCOL_VERSION, SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY } from '../../shared/protocol-version' import { @@ -20,6 +21,10 @@ import { browserUnavailableMessage } from '../../shared/runtime-types' import { runtimeTerminalDegradation } from './native-terminal-availability' +import { + isWindowsProcessStartTimeAvailable, + probeWindowsProcessStartTimeAvailability +} 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 +61,10 @@ export class OrcaRuntimeWithGetStatus extends OrcaRuntimeWithGetRuntimeId { const hasOffscreen = !hasRenderer && Boolean(this.offscreenBrowserBackend) const hasHeadlessCommands = runtimeBrowserCommandsFactoryIsHeadless() const canBrowse = hasRenderer || hasOffscreen + // Structured ownership on Windows requires a native creation-time field; + // an older host must advertise the legacy terminal path instead. + const windowsProcessStartTimeAvailable = + process.platform === 'win32' && isWindowsProcessStartTimeAvailable() const capabilities: RuntimeCapability[] = RUNTIME_CAPABILITIES.filter( (capability) => (capability !== 'browser.screencast.v1' || canBrowse) && @@ -65,7 +74,10 @@ export class OrcaRuntimeWithGetStatus extends OrcaRuntimeWithGetRuntimeId { (process.env.ORCA_E2E_DISABLE_PAIRED_TERMINAL_PARKING !== '1' || capability !== TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY) && (process.env.ORCA_E2E_DISABLE_AUTHORITATIVE_SESSION_TABS_INVENTORY !== '1' || - capability !== SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY) + capability !== SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY) && + (capability !== STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY || + process.platform !== 'win32' || + windowsProcessStartTimeAvailable) ) if (hasOffscreen || hasHeadlessCommands) { capabilities.push(BROWSER_HEADLESS_RUNTIME_CAPABILITY) @@ -110,6 +122,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, @@ -118,6 +131,13 @@ export class OrcaRuntimeWithGetStatus extends OrcaRuntimeWithGetRuntimeId { } } + async getStatusAfterWindowsProcessStartTimeProbe(): Promise { + if (process.platform === 'win32') { + await probeWindowsProcessStartTimeAvailability() + } + return this.getStatus() + } + setPtyController(controller: RuntimePtyController | null): void { // Why: CLI terminal writes must go through the main-owned PTY registry // instead of tunneling back through renderer IPC, or live handles could diff --git a/src/main/runtime/orca-runtime-status-windows-process-start-time.test.ts b/src/main/runtime/orca-runtime-status-windows-process-start-time.test.ts new file mode 100644 index 00000000000..3d85fb50bfe --- /dev/null +++ b/src/main/runtime/orca-runtime-status-windows-process-start-time.test.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../shared/protocol-version' + +const { isWindowsProcessStartTimeAvailable, probeWindowsProcessStartTimeAvailability } = vi.hoisted( + () => ({ + isWindowsProcessStartTimeAvailable: vi.fn(() => true), + probeWindowsProcessStartTimeAvailability: vi.fn(async () => true) + }) +) + +vi.mock('../windows/windows-process-table', async (importOriginal) => ({ + ...(await importOriginal()), + isWindowsProcessStartTimeAvailable, + probeWindowsProcessStartTimeAvailability +})) + +const originalPlatform = process.platform + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) +} + +afterEach(() => { + setPlatform(originalPlatform) + isWindowsProcessStartTimeAvailable.mockReturnValue(true) + probeWindowsProcessStartTimeAvailability.mockReset() + probeWindowsProcessStartTimeAvailability.mockResolvedValue(true) +}) + +beforeEach(() => { + isWindowsProcessStartTimeAvailable.mockClear() +}) + +describe('runtime status Windows process start-time proof', () => { + it('fails closed when a Windows host cannot read process creation time', () => { + setPlatform('win32') + isWindowsProcessStartTimeAvailable.mockReturnValue(false) + + const status = new OrcaRuntimeService().getStatus() + + expect(status.capabilities).not.toContain(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) + expect(status).not.toHaveProperty('windowsProcessStartTimeAvailable') + }) + + it('publishes the proof and structured capability when Windows creation time is available', () => { + setPlatform('win32') + isWindowsProcessStartTimeAvailable.mockReturnValue(true) + + const status = new OrcaRuntimeService().getStatus() + + expect(status.capabilities).toContain(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) + expect(status.windowsProcessStartTimeAvailable).toBe(true) + }) + + it('does not publish a Windows-only proof off Windows', () => { + setPlatform('darwin') + + const status = new OrcaRuntimeService().getStatus() + + expect(status).not.toHaveProperty('windowsProcessStartTimeAvailable') + expect(status.capabilities).toContain(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) + expect(isWindowsProcessStartTimeAvailable).not.toHaveBeenCalled() + }) + + it('waits for the Windows proof before publishing runtime capabilities', async () => { + setPlatform('win32') + isWindowsProcessStartTimeAvailable.mockReturnValue(false) + probeWindowsProcessStartTimeAvailability.mockImplementation(async () => { + isWindowsProcessStartTimeAvailable.mockReturnValue(true) + return true + }) + + const status = await new OrcaRuntimeService().getStatusAfterWindowsProcessStartTimeProbe() + + expect(probeWindowsProcessStartTimeAvailability).toHaveBeenCalledOnce() + expect(status.windowsProcessStartTimeAvailable).toBe(true) + expect(status.capabilities).toContain(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) + }) +}) diff --git a/src/main/runtime/remote-runtime-request-connection.integration.test.ts b/src/main/runtime/remote-runtime-request-connection.integration.test.ts index 7429b2aaa88..ce060b9eb80 100644 --- a/src/main/runtime/remote-runtime-request-connection.integration.test.ts +++ b/src/main/runtime/remote-runtime-request-connection.integration.test.ts @@ -505,18 +505,20 @@ describe('remote runtime request connection integration', () => { activeTabType: null, tabs: [] } + const runtimeStatus = { + runtimeId: 'shared-runtime-test', + startedAt: 1, + version: '1.0.0', + protocolVersion: 1, + minCompatibleDesktopVersion: '1.0.0', + minCompatibleMobileVersion: '1.0.0', + capabilities: [REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY] + } const runtime = { getRuntimeId: () => 'shared-runtime-test', getStartedAt: () => 1, - getStatus: () => ({ - runtimeId: 'shared-runtime-test', - startedAt: 1, - version: '1.0.0', - protocolVersion: 1, - minCompatibleDesktopVersion: '1.0.0', - minCompatibleMobileVersion: '1.0.0', - capabilities: [REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY] - }), + getStatus: () => runtimeStatus, + getStatusAfterWindowsProcessStartTimeProbe: async () => runtimeStatus, cleanupSubscriptionsForConnection: (connectionId: string) => { for (const [id, cleanup] of Array.from(subscriptionCleanups)) { if (id.includes(connectionId)) { diff --git a/src/main/runtime/rpc/methods/status.ts b/src/main/runtime/rpc/methods/status.ts index 03d66f84fb1..db034c6768c 100644 --- a/src/main/runtime/rpc/methods/status.ts +++ b/src/main/runtime/rpc/methods/status.ts @@ -5,10 +5,10 @@ export const STATUS_METHODS: RpcMethod[] = [ defineMethod({ name: 'status.get', params: null, - handler: (_params, { runtime, pairedDeviceId }) => { + handler: async (_params, { runtime, pairedDeviceId }) => { const snapshot = getRemoteServerUpdaterSnapshot(runtime.getRuntimeId()) return { - ...runtime.getStatus(), + ...(await runtime.getStatusAfterWindowsProcessStartTimeProbe()), ...(pairedDeviceId ? { pairedDeviceId } : {}), appVersion: snapshot.appVersion, remoteUpdateSupport: snapshot.support diff --git a/src/main/runtime/rpc/methods/updater.test.ts b/src/main/runtime/rpc/methods/updater.test.ts index 9c3ce0ef810..6b51fda04f6 100644 --- a/src/main/runtime/rpc/methods/updater.test.ts +++ b/src/main/runtime/rpc/methods/updater.test.ts @@ -28,9 +28,11 @@ describe('runtime updater RPC methods', () => { targetVersion: '1.5.1', runtimeId: 'runtime-rpc' })) + const runtimeStatus = { runtimeId: 'runtime-rpc', liveTabCount: 2, liveLeafCount: 3 } const runtime = { getRuntimeId: () => 'runtime-rpc', - getStatus: () => ({ runtimeId: 'runtime-rpc', liveTabCount: 2, liveLeafCount: 3 }) + getStatus: () => runtimeStatus, + getStatusAfterWindowsProcessStartTimeProbe: vi.fn(async () => runtimeStatus) } beforeEach(() => { @@ -67,5 +69,6 @@ describe('runtime updater RPC methods', () => { appVersion: '1.5.0', remoteUpdateSupport: snapshot.support }) + expect(runtime.getStatusAfterWindowsProcessStartTimeProbe).toHaveBeenCalledOnce() }) }) diff --git a/src/main/runtime/runtime-rpc-mobile-terminal-streaming.test.ts b/src/main/runtime/runtime-rpc-mobile-terminal-streaming.test.ts index e558d19b54b..ebb759fd09f 100644 --- a/src/main/runtime/runtime-rpc-mobile-terminal-streaming.test.ts +++ b/src/main/runtime/runtime-rpc-mobile-terminal-streaming.test.ts @@ -407,6 +407,7 @@ describe('OrcaRuntimeRpcServer', () => { getRuntimeId: () => 'proxy-runtime-test', getStartedAt: () => 1, getStatus: () => ({ graphStatus: 'unavailable' }), + getStatusAfterWindowsProcessStartTimeProbe: async () => ({ graphStatus: 'unavailable' }), cleanupSubscriptionsForConnection: () => {}, cancelMobileDictationForConnection: () => {}, onClientDisconnected: () => {} diff --git a/src/main/runtime/structured-agent-session-integration-replay.test.ts b/src/main/runtime/structured-agent-session-integration-replay.test.ts index 7bc5e66dc45..ce9454e8227 100644 --- a/src/main/runtime/structured-agent-session-integration-replay.test.ts +++ b/src/main/runtime/structured-agent-session-integration-replay.test.ts @@ -270,7 +270,8 @@ beforeEach(async () => { return { CODEX_PROFILE: configuredCodexProfile } }, openCodexConnection: codex.openConnection, - readProcessStartTime: async () => 1_700_000_000_000 + readProcessStartTime: async () => 1_700_000_000_000, + isWindowsProcessStartTimeAvailable: () => true }).then(() => undefined), registerOwnedSubscriptionCleanup: vi.fn((_id: string, dispose: () => void) => { return { @@ -318,7 +319,8 @@ describe('a structured codex session over agentSession.*', () => { resolveWorkspacePath: async (workspaceId) => `/repos/${workspaceId}`, resolveCodexCommand: () => '/usr/local/bin/codex', openCodexConnection: codex.openConnection, - readProcessStartTime: async () => 1_700_000_000_000 + readProcessStartTime: async () => 1_700_000_000_000, + isWindowsProcessStartTimeAvailable: () => true }) const adapter = (host as unknown as { deps: { adapter: CodexStructuredSessionAdapter } }).deps .adapter diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index 582bd225b40..6ecb05c9174 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -319,7 +319,10 @@ beforeEach(async () => { return { CODEX_PROFILE: configuredCodexProfile } }, openCodexConnection: codex.openConnection, - readProcessStartTime: async () => 1_700_000_000_000 + readProcessStartTime: async () => 1_700_000_000_000, + isWindowsProcessStartTimeAvailable: () => true, + captureTurnProcesses: async () => null, + terminateTurnProcesses: async () => true }).then(() => undefined), registerOwnedSubscriptionCleanup: vi.fn((_id: string, dispose: () => void) => { return { diff --git a/src/main/runtime/structured-agent-session-runtime-exit.test.ts b/src/main/runtime/structured-agent-session-runtime-exit.test.ts index a8419176357..becdaabd15c 100644 --- a/src/main/runtime/structured-agent-session-runtime-exit.test.ts +++ b/src/main/runtime/structured-agent-session-runtime-exit.test.ts @@ -87,7 +87,8 @@ describe('structured session runtime provider-exit wiring', () => { resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), openCodexConnection: openConnection, - readProcessStartTime: async () => 1_700_000_000_000 + readProcessStartTime: async () => 1_700_000_000_000, + isWindowsProcessStartTimeAvailable: () => true }) const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) attachParams.envelope.clientOperationId = operationId() @@ -183,7 +184,8 @@ describe('structured session runtime provider-exit wiring', () => { resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), openCodexConnection: openConnection, - readProcessStartTime: async () => 1_700_000_000_000 + readProcessStartTime: async () => 1_700_000_000_000, + isWindowsProcessStartTimeAvailable: () => true }) const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) attachParams.envelope.clientOperationId = operationId() @@ -263,7 +265,8 @@ describe('structured session runtime provider-exit wiring', () => { resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), openCodexConnection: openConnection, - readProcessStartTime: async () => 1_700_000_000_000 + readProcessStartTime: async () => 1_700_000_000_000, + isWindowsProcessStartTimeAvailable: () => true }) const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) attachParams.envelope.clientOperationId = operationId() diff --git a/src/main/runtime/structured-agent-session-runtime.test.ts b/src/main/runtime/structured-agent-session-runtime.test.ts index 6adf5d368fd..bf6573f1c7d 100644 --- a/src/main/runtime/structured-agent-session-runtime.test.ts +++ b/src/main/runtime/structured-agent-session-runtime.test.ts @@ -4,9 +4,11 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionClaimStatus, + AgentSessionExecutionLocation, AgentSessionProcessIdentity, AgentSessionRecord } from '../../shared/agent-session-record' +import { __setWindowsProcessTreeLoaderForTests } from '../windows/windows-process-table' import { createStructuredAgentSessionOwnerProbe, createStructuredAgentSessionOwnerProbes, @@ -262,4 +264,32 @@ 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 () => ({}), + readProcessStartTime: async () => 1_700_000_000_000 + }) + + expect(host.supportsCreate(location, 'codex')).toBe(false) + } finally { + __setWindowsProcessTreeLoaderForTests() + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) }) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index ce916bc6769..ea8b2e1de11 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -32,6 +32,7 @@ import { readEchoedAgentSessionSpawnToken } from './agent-session-spawn-token-re import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate' import { resolveLoginShellEnvironment } from '../startup/login-shell-environment' import { recordAgentSessionProviderHandle } from './agent-session-provider-handle-transition' +import { probeWindowsProcessStartTimeAvailability } from '../windows/windows-process-table' /** Sibling of the journal tree rather than inside it: one file adjudicates every * session's lease, while a journal is per session. */ @@ -59,6 +60,11 @@ export type StructuredAgentSessionRuntimeDeps = { openCodexConnection?: CodexStructuredSessionAdapterDeps['openConnection'] /** Scripted app-servers carry fake pids the real start-time read cannot answer for. */ readProcessStartTime?: CodexStructuredSessionAdapterDeps['readProcessStartTime'] + /** Capability paired with an injected process identity reader in tests. */ + isWindowsProcessStartTimeAvailable?: () => boolean + /** Scripted app-server pids need scripted turn-process cancellation. */ + captureTurnProcesses?: CodexStructuredSessionAdapterDeps['captureTurnProcesses'] + terminateTurnProcesses?: CodexStructuredSessionAdapterDeps['terminateTurnProcesses'] resolveLaunchArgs?: (provider: AgentSessionRecord['provider']) => Promise | string[] resolveLaunchEnv?: () => Promise resolveLaunchEnvOverlay?: () => Promise> | Record @@ -145,6 +151,11 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise { if (event.type !== 'ended' || !('cause' in event) || event.cause !== 'unexpected-exit') { return diff --git a/src/main/windows/windows-process-table.test.ts b/src/main/windows/windows-process-table.test.ts index 609de009820..6a565bcfc63 100644 --- a/src/main/windows/windows-process-table.test.ts +++ b/src/main/windows/windows-process-table.test.ts @@ -5,6 +5,7 @@ import { __setWindowsProcessTreeRequireForTests, isWindowsProcessTableAvailable, isWindowsProcessStartTimeAvailable, + probeWindowsProcessStartTimeAvailability, readWindowsProcessTable, readWindowsProcessTableFresh, resetWindowsProcessTableForTests @@ -64,13 +65,20 @@ describe('windows process table', () => { ]) }) - it('requests memory and command line together', async () => { + it('requests memory, command line, and creation time together', async () => { await readWindowsProcessTableFresh() expect(getAllProcesses.mock.calls[0]?.[1]).toBe(7) }) - it('only advertises PID-safe ownership when the native creation-time field exists', () => { + it('only advertises PID-safe ownership after measuring the querying process row', async () => { + expect(isWindowsProcessStartTimeAvailable()).toBe(false) + getAllProcesses.mockImplementation((cb: (rows: unknown) => void) => + cb([{ ...SELF, creationTimeMs: 1_700_000_000_001 }, NATIVE[1]]) + ) + resetWindowsProcessTableForTests() + await expect(probeWindowsProcessStartTimeAvailability()).resolves.toBe(true) expect(isWindowsProcessStartTimeAvailable()).toBe(true) + __setWindowsProcessTreeLoaderForTests(() => ({ ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 }, getAllProcesses @@ -78,6 +86,39 @@ describe('windows process table', () => { expect(isWindowsProcessStartTimeAvailable()).toBe(false) }) + it('rejects a patched JS enum backed by a binary that omits creation time', async () => { + await expect(probeWindowsProcessStartTimeAvailability()).resolves.toBe(false) + expect(isWindowsProcessStartTimeAvailable()).toBe(false) + expect(getAllProcesses).toHaveBeenCalledWith(expect.any(Function), 7) + }) + + it.each([0, -1, 1.5, Number.POSITIVE_INFINITY, Number.NaN, Number.MAX_SAFE_INTEGER + 1])( + 'drops an invalid native creation time (%s)', + async (creationTimeMs) => { + getAllProcesses.mockImplementation((cb: (rows: unknown) => void) => + cb([ + { ...SELF, creationTimeMs: 1_700_000_000_001 }, + { ...NATIVE[1], creationTimeMs } + ]) + ) + resetWindowsProcessTableForTests() + + const rows = await readWindowsProcessTableFresh() + expect(rows[1]).not.toHaveProperty('creationTimeMs') + } + ) + + it('rejects a malformed creation-time enum value', async () => { + __setWindowsProcessTreeLoaderForTests(() => ({ + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 8 }, + getAllProcesses + })) + + await expect(probeWindowsProcessStartTimeAvailability()).resolves.toBe(false) + expect(isWindowsProcessStartTimeAvailable()).toBe(false) + expect(getAllProcesses).not.toHaveBeenCalled() + }) + it('serves repeat reads from the shared snapshot', async () => { await readWindowsProcessTable() await readWindowsProcessTable() @@ -413,7 +454,7 @@ describe('resolving the native reader', () => { expect(isWindowsProcessTableAvailable()).toBe(true) }) - it('asks the addon for memory and command line, as the package path does', async () => { + it('asks the addon for every field, including creation time', async () => { const addon = addonReturning(NATIVE) __setWindowsProcessTreeRequireForTests((specifier: string) => { if (specifier === ADDON_SPECIFIER) { @@ -422,9 +463,9 @@ describe('resolving the native reader', () => { throw new Error('MODULE_NOT_FOUND') }) await readWindowsProcessTableFresh() - // Memory | CommandLine. A bare snapshot would silently drop the command - // line every agent-recognition caller matches on first. - expect(addon.getProcessList).toHaveBeenCalledWith(expect.any(Function), 3) + // Memory | CommandLine | CreationTime. The bare addon does not have the + // package enum wrapper, so this mirror is its only source of the new bit. + expect(addon.getProcessList).toHaveBeenCalledWith(expect.any(Function), 7) }) it('reaches the CIM scan when neither the package nor the addon is present', async () => { diff --git a/src/main/windows/windows-process-table.ts b/src/main/windows/windows-process-table.ts index 9308c64a9f0..261289db615 100644 --- a/src/main/windows/windows-process-table.ts +++ b/src/main/windows/windows-process-table.ts @@ -83,7 +83,7 @@ type WindowsProcessTreeAddon = { } /** Mirrors the package's enum; the addon takes the raw bit field. */ -const PROCESS_DATA_FLAG = { None: 0, Memory: 1, CommandLine: 2 } as const +const PROCESS_DATA_FLAG = { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 } as const /** Staged beside the relay bundle by build-relay; see RELAY_ARTIFACTS. */ const RELAY_ADDON_FILENAME = './windows-process-tree.node' @@ -161,10 +161,28 @@ const WINDOWS_PROCESS_QUERY_TIMEOUT_MS = 3_000 const unreturnedReads = new Set() let readSequence = 0 let nativeReaderEpoch = 0 +let nativeProcessStartTimeCapability: boolean | undefined +let nativeProcessStartTimeProbe: Promise | null = null function resetNativeReaderState(): void { nativeReaderEpoch += 1 unreturnedReads.clear() + nativeProcessStartTimeCapability = undefined + nativeProcessStartTimeProbe = null +} + +function normalizeCreationTimeMs(value: unknown): number | undefined { + return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : undefined +} + +function hasNativeCreationTimeFlag(native: WindowsProcessTreeModule): boolean { + return native.ProcessDataFlag.CreationTime === PROCESS_DATA_FLAG.CreationTime +} + +function hasOwnProcessStartTime(processes: readonly NativeProcessInfo[]): boolean { + return processes.some( + (row) => row.pid === process.pid && normalizeCreationTimeMs(row.creationTimeMs) !== undefined + ) } function readNativeRows(): Promise { @@ -199,7 +217,7 @@ function readNativeRows(): Promise { const flags = native.ProcessDataFlag.Memory | native.ProcessDataFlag.CommandLine | - (native.ProcessDataFlag.CreationTime ?? 0) + (hasNativeCreationTimeFlag(native) ? PROCESS_DATA_FLAG.CreationTime : 0) return new Promise((resolve, reject) => { // Hoisted so a synchronous throw from getAllProcesses can clear it. An // orphaned timer would otherwise fire later and wedge a reader that had @@ -221,6 +239,9 @@ function readNativeRows(): Promise { // that actually wedged can be holding the gate shut. unreturnedReads.delete(readId) if (!processes) { + if (readerEpoch === nativeReaderEpoch) { + nativeProcessStartTimeCapability = false + } reject(new Error('windows process table returned no snapshot')) return } @@ -232,20 +253,31 @@ function readNativeRows(): Promise { // unfalsifiably present in any honest snapshot, so this one predicate // catches empty, truncated and permission-filtered tables alike. if (!processes.some((row) => row.pid === process.pid)) { + if (readerEpoch === nativeReaderEpoch) { + nativeProcessStartTimeCapability = false + } reject(new Error('windows process table is unreadable')) return } + if (readerEpoch === nativeReaderEpoch) { + // The enum is copied into JS, so a stale package can claim the new bit + // while its old binary silently drops it. A real own-PID row is the + // only capability evidence we trust. + nativeProcessStartTimeCapability = + hasNativeCreationTimeFlag(native) && hasOwnProcessStartTime(processes) + } resolve( - processes.map((row) => ({ - pid: row.pid, - ppid: row.ppid, - name: row.name, - command: row.commandLine ?? '', - memoryBytes: row.memory, - ...(typeof row.creationTimeMs === 'number' - ? { creationTimeMs: row.creationTimeMs } - : {}) - })) + processes.map((row) => { + const creationTimeMs = normalizeCreationTimeMs(row.creationTimeMs) + return { + pid: row.pid, + ppid: row.ppid, + name: row.name, + command: row.commandLine ?? '', + memoryBytes: row.memory, + ...(creationTimeMs === undefined ? {} : { creationTimeMs }) + } + }) ) }, flags) } catch (error) { @@ -263,6 +295,7 @@ function readNativeRows(): Promise { * so nothing downstream reads it as proof a process died. */ async function readCimRows(): Promise { + nativeProcessStartTimeCapability = false const rows = await cimScan() if (!rows.some((row) => row.pid === process.pid)) { throw new Error('windows process table is unreadable') @@ -306,7 +339,56 @@ export function isWindowsProcessTableAvailable(): boolean { */ export function isWindowsProcessStartTimeAvailable(): boolean { const native = moduleLoader() - return native !== null && typeof native.ProcessDataFlag.CreationTime === 'number' + if (native === null || !hasNativeCreationTimeFlag(native)) { + nativeProcessStartTimeCapability = false + return false + } + if (nativeProcessStartTimeCapability === undefined) { + void probeWindowsProcessStartTimeAvailability() + } + return nativeProcessStartTimeCapability === true +} + +/** Probe the native binary, rather than trusting its JS enum, before advertising ownership. */ +export function probeWindowsProcessStartTimeAvailability(): Promise { + if (process.platform !== 'win32') { + return Promise.resolve(false) + } + const native = moduleLoader() + if (native === null || !hasNativeCreationTimeFlag(native)) { + nativeProcessStartTimeCapability = false + return Promise.resolve(false) + } + if (nativeProcessStartTimeCapability !== undefined) { + return Promise.resolve(nativeProcessStartTimeCapability) + } + if (nativeProcessStartTimeProbe) { + return nativeProcessStartTimeProbe + } + const probeEpoch = nativeReaderEpoch + nativeProcessStartTimeProbe = readWindowsProcessTableFresh() + .then((rows) => { + const supported = rows.some( + (row) => + row.pid === process.pid && normalizeCreationTimeMs(row.creationTimeMs) !== undefined + ) + if (probeEpoch === nativeReaderEpoch) { + nativeProcessStartTimeCapability = supported + } + return probeEpoch === nativeReaderEpoch ? supported : false + }) + .catch(() => { + if (probeEpoch === nativeReaderEpoch) { + nativeProcessStartTimeCapability = false + } + return false + }) + .finally(() => { + if (probeEpoch === nativeReaderEpoch) { + nativeProcessStartTimeProbe = null + } + }) + return nativeProcessStartTimeProbe } /** diff --git a/src/renderer/src/components/settings/ExperimentalPane.test.tsx b/src/renderer/src/components/settings/ExperimentalPane.test.tsx index b421b77bfc2..71a14c543ba 100644 --- a/src/renderer/src/components/settings/ExperimentalPane.test.tsx +++ b/src/renderer/src/components/settings/ExperimentalPane.test.tsx @@ -259,7 +259,7 @@ describe('ExperimentalPane', () => { expect(container.textContent).toContain('Use updated structured native chat') expect(container.textContent).toContain( - 'Local macOS and Linux sessions only for now. Windows, WSL, and remote execution hosts (including SSH) continue to use terminal chat.' + "Uses the local execution host's structured Codex runtime when it advertises support. Windows hosts without process identity proof, WSL, SSH, and other remote hosts keep the recoverable terminal chat path." ) expect(container.textContent).toContain('Default view') root.unmount() diff --git a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx index 93c4b1c899d..b16a312557b 100644 --- a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx +++ b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx @@ -132,7 +132,7 @@ export function NativeChatExperimentalSetting({

{translate( 'auto.components.settings.ExperimentalPane.nativeChat.structuredScope', - 'Local macOS and Linux sessions only for now. Windows, WSL, and remote execution hosts (including SSH) continue to use terminal chat.' + "Uses the local execution host's structured Codex runtime when it advertises support. Windows hosts without process identity proof, WSL, SSH, and other remote hosts keep the recoverable terminal chat path." )}

diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 8ab61e5ce21..b1e823be2da 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -6936,7 +6936,7 @@ "defaultViewNative": "Chat UI", "structuredTitle": "Use updated structured native chat", "structuredCopy": "Opt in to the host-owned structured Codex runtime. Off keeps the existing terminal-backed chat path.", - "structuredScope": "Local macOS and Linux sessions only for now. Windows, WSL, and remote execution hosts (including SSH) continue to use terminal chat.", + "structuredScope": "Uses the local execution host's structured Codex runtime when it advertises support. Windows hosts without process identity proof, WSL, SSH, and other remote hosts keep the recoverable terminal chat path.", "structuredToggleLabel": "Toggle updated structured native chat" }, "agentDashboard": { diff --git a/src/renderer/src/lib/structured-native-chat-availability.test.ts b/src/renderer/src/lib/structured-native-chat-availability.test.ts index cb6105b3357..c246e427de5 100644 --- a/src/renderer/src/lib/structured-native-chat-availability.test.ts +++ b/src/renderer/src/lib/structured-native-chat-availability.test.ts @@ -1,7 +1,11 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AppState } from '@/store/types' import type * as localPreflightContext from '@/lib/local-preflight-context' import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime' +import { + loadWindowsTerminalCapabilities, + resetWindowsTerminalCapabilitiesForTests +} from './windows-terminal-capabilities' import { canUseStructuredNativeChat } from './structured-native-chat-availability' const { mockGetRendererAppPlatform } = vi.hoisted(() => ({ @@ -82,6 +86,26 @@ function stateFor(input: { } as unknown as AppState } +async function cacheWindowsProcessStartTimeCapability(): Promise { + 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: vi.fn().mockResolvedValue({ + hostPlatform: 'win32', + windowsProcessStartTimeAvailable: true + }) + } + } + }) + await loadWindowsTerminalCapabilities() +} + describe('canUseStructuredNativeChat', () => { beforeEach(() => { mockGetRendererAppPlatform.mockReturnValue('darwin') @@ -94,6 +118,11 @@ describe('canUseStructuredNativeChat', () => { }) }) + afterEach(() => { + resetWindowsTerminalCapabilitiesForTests() + vi.unstubAllGlobals() + }) + it('allows the structured stack on a local worktree', () => { expect(canUseStructuredNativeChat(stateFor({}), 'wt-1')).toBe(true) }) @@ -152,21 +181,53 @@ describe('canUseStructuredNativeChat', () => { expect(canUseStructuredNativeChat(stateFor({ windowsRuntime: 'wsl' }), 'wt-1')).toBe(false) }) - it('allows Windows-host projects when structured chat is enabled', () => { + it('refuses Windows-host projects when process identity proof is unavailable', () => { mockGetRendererAppPlatform.mockReturnValue('win32') - expect(canUseStructuredNativeChat(stateFor({ windowsRuntime: 'windows-host' }), 'wt-1')).toBe(true) + expect(canUseStructuredNativeChat(stateFor({ windowsRuntime: 'windows-host' }), 'wt-1')).toBe( + false + ) }) - it('refuses a Windows folder workspace even though its key resolves no project runtime', () => { + it('allows Windows-host projects once native start-time proof is cached', async () => { + await cacheWindowsProcessStartTimeCapability() + mockGetRendererAppPlatform.mockReturnValue('win32') + expect(canUseStructuredNativeChat(stateFor({ windowsRuntime: 'windows-host' }), 'wt-1')).toBe( + true + ) + }) + + it('refuses a Windows folder workspace without process identity proof', () => { mockGetRendererAppPlatform.mockReturnValue('win32') const state = { ...stateFor({}), activeRepoId: null, activeWorktreeId: null } as unknown as AppState + expect(canUseStructuredNativeChat(state, 'folder:folder-1')).toBe(false) + }) + + it('allows a local Windows folder workspace once process identity is proved', async () => { + await cacheWindowsProcessStartTimeCapability() + mockGetRendererAppPlatform.mockReturnValue('win32') + const state = { + ...stateFor({}), + activeRepoId: null, + activeWorktreeId: null + } as unknown as AppState + expect(canUseStructuredNativeChat(state, 'folder:folder-1')).toBe(true) }) + it.each(['ssh-a', 'runtime-ssh-a'])( + 'keeps the Windows terminal path for remote owner %s even with local proof', + async (connectionId) => { + await cacheWindowsProcessStartTimeCapability() + mockGetRendererAppPlatform.mockReturnValue('win32') + + expect(canUseStructuredNativeChat(stateFor({ connectionId }), 'wt-1')).toBe(false) + } + ) + it('allows a folder workspace on a non-Windows platform', () => { const state = { ...stateFor({}), diff --git a/src/renderer/src/lib/structured-native-chat-availability.ts b/src/renderer/src/lib/structured-native-chat-availability.ts index ff5e5df0177..8863c142fce 100644 --- a/src/renderer/src/lib/structured-native-chat-availability.ts +++ b/src/renderer/src/lib/structured-native-chat-availability.ts @@ -1,6 +1,8 @@ import type { AppState } from '@/store/types' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' +import { getRendererAppPlatform } from '@/lib/renderer-app-platform' +import { getCachedWindowsTerminalCapabilities } from './windows-terminal-capabilities' export function canUseStructuredNativeChat(state: AppState, worktreeId: string): boolean { if (state.settings?.experimentalStructuredNativeChat !== true) { @@ -15,6 +17,15 @@ export function canUseStructuredNativeChat(state: AppState, worktreeId: string): if (getExecutionHostIdForWorktree(state, worktreeId) !== 'local') { return false } + // Structured ownership on Windows is safe only when the local runtime has + // already proved that its process table exposes creation times. Unknown is + // deliberately treated as unavailable so a stale PID can never be adopted. + if ( + getRendererAppPlatform() === 'win32' && + getCachedWindowsTerminalCapabilities('local').windowsProcessStartTimeAvailable !== true + ) { + return false + } // Refuse WSL and repair-required runtimes; Windows native execution is // supported when the host advertises the process identity capability. const projectRuntime = getLocalProjectExecutionRuntimeContext(state, worktreeId) 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.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/shared/runtime-session-contracts.ts b/src/shared/runtime-session-contracts.ts index cd0dd7a5cc7..d366b319721 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.