mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
fix(codex): prove native Windows process identity
This commit is contained in:
@@ -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.')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+3
-3
@@ -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>
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+41
@@ -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')
|
||||
|
||||
+25
-7
@@ -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)
|
||||
}
|
||||
|
||||
+13
-2
@@ -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({
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user