fix(codex): prove native Windows process identity

This commit is contained in:
Orca Worker
2026-09-01 21:26:58 -07:00
parent 33c388753c
commit f7f2ecd837
40 changed files with 1132 additions and 95 deletions
@@ -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<ProcessInfo>& 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<ProcessInfo>& process_info,
@@ -21,7 +21,7 @@ uint32_t GetRawProcessList(std::vector<ProcessInfo>& 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<ProcessInfo>& 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<double>(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<IProcess
// • 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 }: IProcessInfoNode, depth: number): IProcessTreeNode => ({
+ 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[];
}
@@ -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<double>(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.')
}
}
+24 -13
View File
@@ -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
+3 -3
View File
@@ -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
@@ -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({
@@ -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<NodeJS.ProcessEnv>
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}`)
}
@@ -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<T>(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
)
})
})
})
@@ -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())
)
}
@@ -71,7 +71,8 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
})
}
supportsLocation = supportsCodexStructuredLocation
supportsLocation = (location: Parameters<typeof supportsCodexStructuredLocation>[0]): boolean =>
supportsCodexStructuredLocation(location, this.deps.isWindowsProcessStartTimeAvailable)
acquire = (input: StructuredAgentSessionAcquireInput): Promise<AgentSessionAcquisition> =>
acquireCodexStructuredSession({
@@ -41,6 +41,8 @@ export type CodexStructuredSessionAdapterDeps = {
resolveLaunch: (input: {
identity: AgentSessionJournalIdentity
}) => Promise<CodexStructuredLaunch>
/** Host capability seam; production uses the native Windows process table. */
isWindowsProcessStartTimeAvailable?: () => boolean
onEvent?: (event: CodexStructuredSessionEvent) => void
openConnection?: typeof openCodexAppServerConnection
readProcessStartTime?: (pid: number) => Promise<number | null>
+23 -8
View File
@@ -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(),
+2 -2
View File
@@ -52,8 +52,8 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void {
}
)
ipcMain.handle('runtime:getStatus', (): RuntimeStatus => {
return runtime.getStatus()
ipcMain.handle('runtime:getStatus', (): Promise<RuntimeStatus> => {
return runtime.getStatusAfterWindowsProcessStartTimeProbe()
})
ipcMain.handle(
@@ -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
@@ -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<NonNullable<StructuredAgentSessionHostDeps['adapter']['acquire']>>()
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()
})
})
@@ -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) {
@@ -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<StructuredAgentSessionAdapter['acquire']>()
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')
@@ -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)
}
@@ -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({
+21 -1
View File
@@ -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<RuntimeStatus> {
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
@@ -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<object>()),
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)
})
})
@@ -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)) {
+2 -2
View File
@@ -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
+4 -1
View File
@@ -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()
})
})
@@ -407,6 +407,7 @@ describe('OrcaRuntimeRpcServer', () => {
getRuntimeId: () => 'proxy-runtime-test',
getStartedAt: () => 1,
getStatus: () => ({ graphStatus: 'unavailable' }),
getStatusAfterWindowsProcessStartTimeProbe: async () => ({ graphStatus: 'unavailable' }),
cleanupSubscriptionsForConnection: () => {},
cancelMobileDictationForConnection: () => {},
onClientDisconnected: () => {}
@@ -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
@@ -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 {
@@ -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()
@@ -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 })
}
})
})
@@ -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[]> | string[]
resolveLaunchEnv?: () => Promise<NodeJS.ProcessEnv>
resolveLaunchEnvOverlay?: () => Promise<Record<string, string>> | Record<string, string>
@@ -145,6 +151,11 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise<Install
}
})
try {
// Populate the synchronous admission cache before any restore or attach checks it.
if (process.platform === 'win32' && !deps.isWindowsProcessStartTimeAvailable) {
await probeWindowsProcessStartTimeAvailability()
}
const isWindowsProcessStartTimeAvailable = deps.isWindowsProcessStartTimeAvailable
let host: StructuredAgentSessionHost | null = null
let recoveryChain = Promise.resolve()
const codex = new CodexStructuredSessionAdapter({
@@ -152,10 +163,16 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise<Install
store,
resolveWorkspacePath: deps.resolveWorkspacePath,
resolveEnvironment,
...(deps.resolveCodexCommand ? { resolveCommand: deps.resolveCodexCommand } : {})
...(deps.resolveCodexCommand ? { resolveCommand: deps.resolveCodexCommand } : {}),
...(isWindowsProcessStartTimeAvailable ? { isWindowsProcessStartTimeAvailable } : {})
}),
...(deps.openCodexConnection ? { openConnection: deps.openCodexConnection } : {}),
...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}),
...(isWindowsProcessStartTimeAvailable ? { isWindowsProcessStartTimeAvailable } : {}),
...(deps.captureTurnProcesses ? { captureTurnProcesses: deps.captureTurnProcesses } : {}),
...(deps.terminateTurnProcesses
? { terminateTurnProcesses: deps.terminateTurnProcesses }
: {}),
onEvent: (event) => {
if (event.type !== 'ended' || !('cause' in event) || event.cause !== 'unexpected-exit') {
return
+47 -6
View File
@@ -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 () => {
+95 -13
View File
@@ -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<number>()
let readSequence = 0
let nativeReaderEpoch = 0
let nativeProcessStartTimeCapability: boolean | undefined
let nativeProcessStartTimeProbe: Promise<boolean> | 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<WindowsProcessRow[]> {
@@ -199,7 +217,7 @@ function readNativeRows(): Promise<WindowsProcessRow[]> {
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<WindowsProcessRow[]> {
// 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<WindowsProcessRow[]> {
// 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<WindowsProcessRow[]> {
* so nothing downstream reads it as proof a process died.
*/
async function readCimRows(): Promise<WindowsProcessRow[]> {
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<boolean> {
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
}
/**
@@ -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()
@@ -132,7 +132,7 @@ export function NativeChatExperimentalSetting({
<p className="text-xs text-muted-foreground">
{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."
)}
</p>
</div>
+1 -1
View File
@@ -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": {
@@ -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<void> {
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({}),
@@ -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)
@@ -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<unknown>>()
.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
})
})
})
@@ -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
}
@@ -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
}
}
+2
View File
@@ -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.