mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
feat(windows): enable structured Codex chat on native Windows (#18519)
* feat(native-chat): enable Windows structured sessions
* fix(codex): prove native Windows process identity
* style(codex): format Windows session seam
* fix Windows structured Codex admission
* fix(windows): reprobe missing process identity capability
* fix(windows): decide folder-workspace WSL routing before the click
Review found pathUsesWslUnc exported but unused, and the folder composer
hardcoding worktreeUsesWslPath:false. Together those meant a folder picked
under a \\wsl.localhost\ parent routed to structured chat, then got refused
by the host and fell back AFTER the click -- which defeats the lane's own
design goal that create cannot fail after the click.
The group's parentPath is in scope at submit and the workspace is created
under it, so the parent decides WSL-ness pre-click. Wires pathUsesWslUnc
there and adds tests for the helper, including the unhydrated-store case
that previously threw.
* fix(windows): collapse the gate derivation to one call, restoring max-lines
CI static analysis failed: launch-agent-in-new-tab.ts crossed the 300-line
oxlint ceiling. Adding a max-lines disable is forbidden, so the two gate
derivations collapse into one readWindowsStructuredGateInputs() call --
a store-backed site now adds one line and one import name instead of two.
Better shape anyway: one derivation entry point rather than two reads a
call site must remember to pair.
* fix(windows): engage the legacy fallback when the host THROWS a refusal
Review found a P1 this merge composes: neither parent could reach it. At the
lane head the only structured entry was launch-agent-in-new-tab (full
store-backed WSL check); on main all win32 was refused. The merge enables
win32 in creation flows that pass no projectRuntime, so a WSL folder
workspace, a WSL-configured repo, or a repair-required runtime now routes
structured -- and the host refuses correctly, but by THROWING rather than
returning {ok:false, refusal}.
Callers engage their legacy-terminal fallback on the refusal CLASS, so an
unmapped throw arrives as a generic RPC rejection: no fallback, empty
workspace, error toast, prompt stranded in the launch outbox. Pre-merge the
same action opened a legacy terminal agent.
Map the host's thrown definitive refusals onto the refusal class at the
launch boundary, so every creation flow -- present and future -- degrades to
the legacy terminal instead of stranding. Narrow predicate: unrelated
failures (ECONNRESET, empty message, non-Error) still propagate untouched.
Ablation-proven: removing the mapping reddens the fallback test.
* fix(windows): teach the mobile RPC double the status probe the lane added
CI's first-ever run on this lane caught a pre-existing lane defect. The lane
changed status.get to resolve through
runtime.getStatusAfterWindowsProcessStartTimeProbe(), but never taught the
mobile-surface runtime double about it, so status.get failed for mobile
clients with "not a function". The lane's own test list did not include this
file and the lane had zero CI, so nothing ever ran it.
The real runtime always implements the method; the double omitted it.
* chore: merge current main and regenerate the localization runtime catalog
CI static analysis failed on a stale en-runtime-required.json: main added
onboarding integration-capability keys, and the generated catalog is checked
against the PR MERGE result, not the branch alone -- so it read clean locally
while failing in CI. Merging current main (90780acb85) and regenerating.
Gates after the merge: pnpm tc 0, oxlint 0, changed-code quality 0/56,
7 gate/lane test files 69 tests green.
* fix: route structured launches by execution host platform
* fix: recover paired structured session mirror on host swap
* Revert "fix: recover paired structured session mirror on host swap"
This reverts commit 81bfca0007.
* Revert "fix: route structured launches by execution host platform"
This reverts commit 47abbd354a.
* fix(windows): refuse structured chat in a paired web client
Reverts the two review-loop commits (restoring a tree byte-identical to the
validated head) and closes the hole they were aiming at, without their cost.
A paired web client's `platform` describes the browser's machine, not the host
that will run the agent, so the Windows gate cannot be evaluated there. Before
this, a browser on macOS driving a Windows runtime read "not win32", skipped the
creation-time proof entirely and allowed structured chat — fail-OPEN, the
dangerous direction, bypassing the guarantee this lane is built on.
`isWebClient` is a required input like the other gate fields, so the compiler
enumerated all seven call sites. Refusal is synchronous and fail-closed: no
async round-trip, no null window, no cache to invalidate — unlike keying on an
asynchronously-fetched host platform, which would have made every desktop
launch wait on a round-trip to fix a paired-web-only hole.
Paired web therefore gets the legacy chat until the host publishes eligibility
itself; that is the proper fix and belongs in its own PR.
Ablation-proven: removing the guard reddens both refusal tests; the
desktop-unaffected test is a preservation check and passes either way.
Gates: tc 0, oxlint 0.
Known open: repos-onboarding-folder-startup.test.ts fails on this branch and
passes on plain main — under investigation, NOT caused by this commit.
* test(onboarding): mock the web-client check the store path now reaches
The web-client refusal added `isWebClientLocation()` to the launch-route
inputs, which this suite's store path reaches while adding the FIRST folder.
The suite stubs `window` as `{ api }` with no `location`, so the function
cleared its `typeof window === 'undefined'` guard and then threw on
`window.location.pathname`.
That threw inside addNonGitFolder's own catch, so folder-1 never activated;
folder-2 then returned early (a project already existed) before reaching the
call at all, leaving exactly one activation with no startup seed.
Test artifact, not a product defect: a real renderer always has
`window.location`, so the seeding path is intact for users. Mocking the module
is the convention 7 other suites already use, and keeps product code free of
defensive branches that only exist to satisfy a stub.
Ablation-proven: removing the mock reproduces the original failure exactly.
* fix(renderer): make the web-client check total over a partial window
isWebClientLocation() guarded `typeof window === 'undefined'` and then assumed
`window.location` existed. A window stubbed without a location cleared the
guard and threw on `.pathname`.
That matters because this branch put the call on the launch-routing path,
where the throw is swallowed by the caller's catch and silently becomes a
FAILED LAUNCH rather than a visible error. CI caught it as 9 failures in
launch-work-item-direct.test.ts.
I previously "fixed" this by mocking the module in the one suite I knew about.
That was whack-a-mole against an unbounded set, and it missed this one. The
defect is the partial-window assumption, so fix it there: the mock is removed
from the onboarding suite and both suites now pass on the hardening alone.
Ablation-proven: reverting to the unguarded form reddens 11 tests across the
new unit suite and launch-work-item-direct.
Gates: tc 0, oxlint 0, changed-code quality 0/58.
* Move Codex's Windows structured-chat eligibility onto the host createSupport probe
The renderer no longer decides Codex win32 eligibility: launchStructuredAgentSession
probes agentSession.createSupport for both providers, the host answers via
supportsCodexStructuredLocation (process start-time proof + WSL refusal), and the
create path re-checks live. Deletes the client-side windows gate module and its
routing inputs (windowsProcessStartTime, worktreeUsesWslPath, isWebClient, platform)
from six call sites. Splits killCodexAppServerProcessTree out of
codex-app-server-session to hold the max-lines ceiling without a disable.
* fix(ci): keep pnpm lockfile stable
* test(windows): align foreground snapshot flags
* Restore main's pane-snapshot flag contract
Main asks for CreationTime on both projections; this branch's hot-path
isolation went away with the async probe it served.
---------
Co-authored-by: Orca Worker <orca-worker@localhost>
Co-authored-by: Merge Sim <sim@local>
Co-authored-by: Merge Sim <merge@localhost>
This commit is contained in:
co-authored by
Orca Worker
Merge Sim
Merge Sim
parent
8cd0abf76a
commit
a899f92402
@@ -173,6 +173,28 @@ describe('rebuild-native-deps patched node-pty rebuild', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses a Windows rebuild when the process creation-time patch is missing', () => {
|
||||
const projectDir = mkTempProject()
|
||||
|
||||
try {
|
||||
writeFakeUsableElectronPackage(projectDir, { platform: 'win32' })
|
||||
writeFakeElectronRebuild(projectDir)
|
||||
writeFakeNodePtyConptyPayload(projectDir, 'x64')
|
||||
writeFakeWindowsProcessTreeWithNodeAddonApi(projectDir, { creationTimePatchApplied: false })
|
||||
|
||||
const result = runRebuildScript(
|
||||
projectDir,
|
||||
{ npm_config_platform: 'win32', npm_config_arch: 'x64' },
|
||||
['--platform=win32', '--arch=x64', '--force']
|
||||
)
|
||||
|
||||
expect(result.status).not.toBe(0)
|
||||
expect(result.stderr).toContain('process creation-time patch')
|
||||
} finally {
|
||||
removeTreeSync(projectDir)
|
||||
}
|
||||
})
|
||||
|
||||
it('restores the ConPTY runtime payload after a Windows Electron rebuild', () => {
|
||||
const projectDir = mkTempProject()
|
||||
|
||||
|
||||
@@ -374,13 +374,18 @@ export function writeFakeWindowsProcessTree(projectDir) {
|
||||
|
||||
export function writeFakeWindowsProcessTreeWithNodeAddonApi(
|
||||
projectDir,
|
||||
{ commandLinePatchApplied = true } = {}
|
||||
{ commandLinePatchApplied = true, creationTimePatchApplied = true } = {}
|
||||
) {
|
||||
const processTreeDir = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree')
|
||||
const nodeAddonApiDir = join(processTreeDir, 'node_modules', 'node-addon-api')
|
||||
mkdirSync(nodeAddonApiDir, { recursive: true })
|
||||
writeFileSync(join(processTreeDir, 'package.json'), '{"dependencies":{"node-addon-api":"*"}}\n')
|
||||
writeFileSync(join(processTreeDir, 'index.js'), 'module.exports = {}\n')
|
||||
writeFileSync(
|
||||
join(processTreeDir, 'index.js'),
|
||||
creationTimePatchApplied
|
||||
? 'exports.ProcessDataFlag = { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 }\n'
|
||||
: 'exports.ProcessDataFlag = { None: 0, Memory: 1, CommandLine: 2 }\n'
|
||||
)
|
||||
mkdirSync(join(processTreeDir, 'src'), { recursive: true })
|
||||
writeFileSync(
|
||||
join(processTreeDir, 'src', 'process_commandline.cc'),
|
||||
@@ -388,6 +393,36 @@ export function writeFakeWindowsProcessTreeWithNodeAddonApi(
|
||||
? '// kProcessCommandLineInformation = 60\n'
|
||||
: unpatchedWindowsProcessTreeCommandLineSource()
|
||||
)
|
||||
writeFileSync(
|
||||
join(processTreeDir, 'src', 'process.h'),
|
||||
creationTimePatchApplied
|
||||
? 'enum ProcessDataFlags { NONE = 0, MEMORY = 1, COMMANDLINE = 2, CREATIONTIME = 4 };\nULONGLONG creationTimeMs;\n'
|
||||
: 'enum ProcessDataFlags { NONE = 0, MEMORY = 1, COMMANDLINE = 2 };\n'
|
||||
)
|
||||
writeFileSync(
|
||||
join(processTreeDir, 'src', 'process.cc'),
|
||||
creationTimePatchApplied
|
||||
? 'GetProcessCreationTime(pinfo);\nGetProcessTimes(hProcess, &creationTime, &exitTime, &kernelTime, &userTime);\n'
|
||||
: 'GetProcessMemoryUsage(pinfo);\n'
|
||||
)
|
||||
writeFileSync(
|
||||
join(processTreeDir, 'src', 'process_worker.cc'),
|
||||
creationTimePatchApplied ? 'object.Set("creationTimeMs", process.creationTimeMs);\n' : '\n'
|
||||
)
|
||||
mkdirSync(join(processTreeDir, 'lib'), { recursive: true })
|
||||
writeFileSync(
|
||||
join(processTreeDir, 'lib', 'index.js'),
|
||||
creationTimePatchApplied ? 'exports.ProcessDataFlag["CreationTime"] = 4;\n' : '\n'
|
||||
)
|
||||
writeFileSync(
|
||||
join(processTreeDir, 'lib', 'index.ts'),
|
||||
creationTimePatchApplied ? 'export enum ProcessDataFlag { CreationTime = 4 }\n' : '\n'
|
||||
)
|
||||
mkdirSync(join(processTreeDir, 'typings'), { recursive: true })
|
||||
writeFileSync(
|
||||
join(processTreeDir, 'typings', 'windows-process-tree.d.ts'),
|
||||
creationTimePatchApplied ? 'creationTimeMs?: number\n' : '\n'
|
||||
)
|
||||
writeFileSync(join(nodeAddonApiDir, 'package.json'), '{"name":"node-addon-api"}\n')
|
||||
writeFileSync(join(nodeAddonApiDir, 'napi.h'), '// napi.h\n')
|
||||
writeFileSync(join(nodeAddonApiDir, 'napi-inl.h'), '// napi-inl.h\n')
|
||||
|
||||
@@ -33,6 +33,17 @@ export const WINDOWS_PROCESS_TREE_PATCH_PATH = join(
|
||||
/** Only the patched reader defines this; the upstream one walks the PEB. */
|
||||
const COMMAND_LINE_PATCH_MARKER = 'kProcessCommandLineInformation'
|
||||
|
||||
const CREATION_TIME_PATCH_MARKERS = [
|
||||
['src/process.h', 'CREATIONTIME = 4'],
|
||||
['src/process.h', 'ULONGLONG creationTimeMs'],
|
||||
['src/process.cc', 'GetProcessCreationTime(pinfo)'],
|
||||
['src/process.cc', 'GetProcessTimes(hProcess, &creationTime'],
|
||||
['src/process_worker.cc', 'object.Set("creationTimeMs"'],
|
||||
['lib/index.js', '["CreationTime"] = 4'],
|
||||
['lib/index.ts', 'CreationTime = 4'],
|
||||
['typings/windows-process-tree.d.ts', 'creationTimeMs?: number']
|
||||
]
|
||||
|
||||
export const WINDOWS_PROCESS_TREE_NODE_ADDON_API_HEADERS = [
|
||||
'napi.h',
|
||||
'napi-inl.h',
|
||||
@@ -83,6 +94,36 @@ export function inspectWindowsProcessTreeAddon(addonPath) {
|
||||
return readFileSync(addonPath).includes(FLAGGED_IMPORT) ? 'unpatched' : 'clean'
|
||||
}
|
||||
|
||||
export function assertWindowsProcessTreeCreationTimePatch(
|
||||
packageDir = WINDOWS_PROCESS_TREE_PACKAGE_DIR
|
||||
) {
|
||||
for (const [relativePath, expected] of CREATION_TIME_PATCH_MARKERS) {
|
||||
const filePath = join(packageDir, relativePath)
|
||||
if (!existsSync(filePath)) {
|
||||
throw new Error(
|
||||
`${filePath} is missing, so the process creation-time patch cannot be verified. ` +
|
||||
'Run pnpm install.'
|
||||
)
|
||||
}
|
||||
if (!readFileSync(filePath, 'utf8').includes(expected)) {
|
||||
throw new Error(
|
||||
`${relativePath} does not contain the process creation-time patch (${expected}). ` +
|
||||
'Run pnpm install.'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function assertWindowsProcessTreeRuntimeCreationTime(windowsProcessTree) {
|
||||
if (windowsProcessTree?.ProcessDataFlag?.CreationTime !== 4) {
|
||||
throw new Error(
|
||||
'@vscode/windows-process-tree does not expose ProcessDataFlag.CreationTime, so native ' +
|
||||
'Windows structured agent-session process ownership cannot be PID-reuse safe. Rebuild it ' +
|
||||
'(pnpm run rebuild:electron) rather than using the published prebuild.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse to compile or load the upstream command-line reader.
|
||||
*
|
||||
@@ -159,6 +200,7 @@ export function ensureWindowsProcessTreeCommandLinePatch(
|
||||
rmSync(windowsProcessTreeAddonPath(packageDir), { force: true })
|
||||
repaired = true
|
||||
}
|
||||
assertWindowsProcessTreeCreationTimePatch(packageDir)
|
||||
|
||||
return repaired
|
||||
}
|
||||
|
||||
@@ -12,12 +12,15 @@ import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
assertWindowsProcessTreeCreationTimePatch,
|
||||
assertWindowsProcessTreeRuntimeCreationTime,
|
||||
inspectWindowsProcessTreeAddon,
|
||||
nodeGypRebuildInvocation,
|
||||
stageWindowsProcessTreeNodeAddonApiHeaders,
|
||||
WINDOWS_PROCESS_TREE_NODE_ADDON_API_HEADERS,
|
||||
WINDOWS_PROCESS_TREE_PACKAGE_DIR
|
||||
} from './windows-process-tree-gyp-rebuild.mjs'
|
||||
import { writeFakeWindowsProcessTreeWithNodeAddonApi } from './rebuild-native-deps-test-fixtures.mjs'
|
||||
|
||||
describe('windows-process-tree node-gyp rebuild', () => {
|
||||
it("resolves node-addon-api's gyp target from the rebuild cwd", () => {
|
||||
@@ -97,3 +100,47 @@ describe('inspecting a compiled windows-process-tree addon', () => {
|
||||
expect(inspectWindowsProcessTreeAddon(staged)).toBe('unpatched')
|
||||
})
|
||||
})
|
||||
|
||||
describe('windows-process-tree CreationTime patch assertion', () => {
|
||||
let dir
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'orca-windows-process-tree-creation-time-'))
|
||||
})
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('accepts a package whose source and JS surfaces expose process creation time', () => {
|
||||
writeFakeWindowsProcessTreeWithNodeAddonApi(dir)
|
||||
|
||||
expect(() =>
|
||||
assertWindowsProcessTreeCreationTimePatch(
|
||||
join(dir, 'node_modules', '@vscode', 'windows-process-tree')
|
||||
)
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a package missing the process creation-time patch', () => {
|
||||
writeFakeWindowsProcessTreeWithNodeAddonApi(dir, { creationTimePatchApplied: false })
|
||||
|
||||
expect(() =>
|
||||
assertWindowsProcessTreeCreationTimePatch(
|
||||
join(dir, 'node_modules', '@vscode', 'windows-process-tree')
|
||||
)
|
||||
).toThrow('process creation-time patch')
|
||||
})
|
||||
|
||||
it('requires the runtime ProcessDataFlag.CreationTime enum', () => {
|
||||
expect(() =>
|
||||
assertWindowsProcessTreeRuntimeCreationTime({
|
||||
ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 }
|
||||
})
|
||||
).not.toThrow()
|
||||
expect(() =>
|
||||
assertWindowsProcessTreeRuntimeCreationTime({
|
||||
ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 }
|
||||
})
|
||||
).toThrow('ProcessDataFlag.CreationTime')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"../src/main/codex/codex-app-server-capability-cache.ts",
|
||||
"../src/main/codex/codex-app-server-capability-signal.ts",
|
||||
"../src/main/codex/codex-app-server-client.ts",
|
||||
"../src/main/codex/codex-app-server-process-tree-kill.ts",
|
||||
"../src/main/codex/codex-app-server-record-reader.ts",
|
||||
"../src/main/codex/codex-app-server-session.ts",
|
||||
"../src/main/codex/codex-config-mirror.ts",
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
runCodexHookTrustGrantSession,
|
||||
type CodexHookTrustGrantRequest
|
||||
} from './codex-app-server-client'
|
||||
import { killCodexAppServerProcessTree, runCodexAppServerSession } from './codex-app-server-session'
|
||||
import { killCodexAppServerProcessTree } from './codex-app-server-process-tree-kill'
|
||||
import { runCodexAppServerSession } from './codex-app-server-session'
|
||||
|
||||
// Stub codex app-server speaking the same JSONL protocol: initialize →
|
||||
// initialized → hooks/list → config/batchWrite → hooks/list. Scenario-driven
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import type { ChildProcessHandle, ProcessSpec } from '../../shared/child-process/process-spec'
|
||||
import { spawnProcess } from '../../shared/child-process/run-process'
|
||||
import { normalizeHookTrustKeyForLookup } from './config-toml-trust'
|
||||
import { runCodexAppServerSession, type CodexAppServerInvocation } from './codex-app-server-session'
|
||||
|
||||
@@ -105,7 +106,12 @@ function collectHookListings(result: unknown): CodexHookListing[] {
|
||||
*/
|
||||
export async function runCodexHookTrustGrantSession(
|
||||
request: CodexHookTrustGrantRequest,
|
||||
spawnImpl: typeof spawn = spawn
|
||||
spawnImpl: (
|
||||
program: string,
|
||||
args: string[],
|
||||
options: Record<string, unknown>
|
||||
) => ChildProcessHandle = (program, args, options) =>
|
||||
spawnProcess({ program, args, ...options } as ProcessSpec)
|
||||
): Promise<CodexHookTrustGrantSessionResult> {
|
||||
return runCodexAppServerSession(
|
||||
request.invocation,
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { spawnProcess } from '../../shared/child-process/run-process'
|
||||
import type { ChildProcessHandle, ProcessSpec } from '../../shared/child-process/process-spec'
|
||||
import { admitProcessTreeKill } from '../../shared/child-process/process-tree-kill-gate'
|
||||
|
||||
/** Spawn seam for tests; production always goes through the hardened spawnProcess wrapper. */
|
||||
export type CodexAppServerSpawn = (
|
||||
program: string,
|
||||
args: string[],
|
||||
options: Record<string, unknown>
|
||||
) => ChildProcessHandle
|
||||
|
||||
export const spawnCodexAppServerProcess: CodexAppServerSpawn = (program, args, options) =>
|
||||
spawnProcess({ program, args, ...options } as ProcessSpec)
|
||||
|
||||
export function killCodexAppServerProcessTree(
|
||||
child: Pick<ChildProcessHandle, 'pid' | 'kill'>,
|
||||
options: { platform?: NodeJS.Platform; spawnImpl?: CodexAppServerSpawn } = {}
|
||||
): void {
|
||||
const platform = options.platform ?? process.platform
|
||||
const spawnImpl = options.spawnImpl ?? spawnCodexAppServerProcess
|
||||
if (platform === 'win32' && child.pid) {
|
||||
if (
|
||||
!admitProcessTreeKill({
|
||||
pid: child.pid,
|
||||
site: 'codex-app-server-session-deadline',
|
||||
scope: 'win-taskkill-tree'
|
||||
})
|
||||
) {
|
||||
// Refusal blocks the tree walk, not the termination: the root kill is
|
||||
// handle-addressed, so it cannot reach the recycled pid we refused.
|
||||
child.kill('SIGKILL')
|
||||
return
|
||||
}
|
||||
try {
|
||||
// Why: npm-installed Codex runs behind cmd.exe; killing only that wrapper
|
||||
// leaves the app-server child alive after a timeout or failed shutdown.
|
||||
const killer = spawnImpl('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true
|
||||
})
|
||||
let fellBack = false
|
||||
const killDirectChild = (): void => {
|
||||
if (!fellBack) {
|
||||
fellBack = true
|
||||
child.kill('SIGKILL')
|
||||
}
|
||||
}
|
||||
killer.on('error', killDirectChild)
|
||||
killer.on('exit', (code) => {
|
||||
if (code !== 0) {
|
||||
killDirectChild()
|
||||
}
|
||||
})
|
||||
killer.unref()
|
||||
return
|
||||
} catch {
|
||||
// Fall through to the direct-child best effort when taskkill cannot start.
|
||||
}
|
||||
}
|
||||
if (child.pid) {
|
||||
try {
|
||||
// npm/package-manager launchers insert a shim child on POSIX. Reap its
|
||||
// direct descendants before signalling the wrapper itself.
|
||||
const descendants = spawnImpl('pkill', ['-KILL', '-P', String(child.pid)], {
|
||||
stdio: 'ignore'
|
||||
})
|
||||
// A missing pkill surfaces as an async 'error' event, and an unhandled one
|
||||
// takes down the main process.
|
||||
descendants.on('error', () => undefined)
|
||||
descendants.unref()
|
||||
} catch {
|
||||
// The direct kill below remains the fallback when pkill is unavailable.
|
||||
}
|
||||
}
|
||||
child.kill('SIGKILL')
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
import { spawn, type ChildProcess, type ChildProcessWithoutNullStreams } from 'node:child_process'
|
||||
import type { ChildProcessWithoutNullStreams } from 'node:child_process'
|
||||
import { waitForProcessExitUntil } from './codex-process-exit-deadline'
|
||||
import { stderrIndicatesMissingAppServer } from './codex-app-server-capability-signal'
|
||||
import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution'
|
||||
import {
|
||||
killCodexAppServerProcessTree,
|
||||
spawnCodexAppServerProcess,
|
||||
type CodexAppServerSpawn
|
||||
} from './codex-app-server-process-tree-kill'
|
||||
import { createCodexAppServerRecordReader } from './codex-app-server-record-reader'
|
||||
import { admitProcessTreeKill } from '../../shared/child-process/process-tree-kill-gate'
|
||||
|
||||
// Why: `codex app-server` is Orca's sanctioned RPC surface into Codex-owned
|
||||
// state (hook trust hashes, the sqlite thread index). This module owns the
|
||||
@@ -68,69 +72,6 @@ export type CodexAppServerRpc = {
|
||||
const JSON_RPC_METHOD_NOT_FOUND = -32601
|
||||
const STDERR_TAIL_MAX_BYTES = 8192
|
||||
|
||||
export function killCodexAppServerProcessTree(
|
||||
child: Pick<ChildProcess, 'pid' | 'kill'>,
|
||||
options: { platform?: NodeJS.Platform; spawnImpl?: typeof spawn } = {}
|
||||
): void {
|
||||
const platform = options.platform ?? process.platform
|
||||
const spawnImpl = options.spawnImpl ?? spawn
|
||||
if (platform === 'win32' && child.pid) {
|
||||
if (
|
||||
!admitProcessTreeKill({
|
||||
pid: child.pid,
|
||||
site: 'codex-app-server-session-deadline',
|
||||
scope: 'win-taskkill-tree'
|
||||
})
|
||||
) {
|
||||
// Refusal blocks the tree walk, not the termination: the root kill is
|
||||
// handle-addressed, so it cannot reach the recycled pid we refused.
|
||||
child.kill('SIGKILL')
|
||||
return
|
||||
}
|
||||
try {
|
||||
// Why: npm-installed Codex runs behind cmd.exe; killing only that wrapper
|
||||
// leaves the app-server child alive after a timeout or failed shutdown.
|
||||
const killer = spawnImpl('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true
|
||||
})
|
||||
let fellBack = false
|
||||
const killDirectChild = (): void => {
|
||||
if (!fellBack) {
|
||||
fellBack = true
|
||||
child.kill('SIGKILL')
|
||||
}
|
||||
}
|
||||
killer.on('error', killDirectChild)
|
||||
killer.on('exit', (code) => {
|
||||
if (code !== 0) {
|
||||
killDirectChild()
|
||||
}
|
||||
})
|
||||
killer.unref()
|
||||
return
|
||||
} catch {
|
||||
// Fall through to the direct-child best effort when taskkill cannot start.
|
||||
}
|
||||
}
|
||||
if (child.pid) {
|
||||
try {
|
||||
// npm/package-manager launchers insert a shim child on POSIX. Reap its
|
||||
// direct descendants before signalling the wrapper itself.
|
||||
const descendants = spawnImpl('pkill', ['-KILL', '-P', String(child.pid)], {
|
||||
stdio: 'ignore'
|
||||
})
|
||||
// A missing pkill surfaces as an async 'error' event, and an unhandled one
|
||||
// takes down the main process.
|
||||
descendants.on('error', () => undefined)
|
||||
descendants.unref()
|
||||
} catch {
|
||||
// The direct kill below remains the fallback when pkill is unavailable.
|
||||
}
|
||||
}
|
||||
child.kill('SIGKILL')
|
||||
}
|
||||
|
||||
/** Codex answering "no such method" is the only response that proves the RPC
|
||||
* surface is absent rather than temporarily failing. */
|
||||
export function isCodexMethodNotFoundError(error: unknown): boolean {
|
||||
@@ -152,7 +93,7 @@ export function isCodexMethodNotFoundError(error: unknown): boolean {
|
||||
export async function runCodexAppServerSession<T>(
|
||||
invocation: CodexAppServerInvocation,
|
||||
body: (rpc: CodexAppServerRpc) => Promise<T>,
|
||||
spawnImpl: typeof spawn = spawn
|
||||
spawnImpl: CodexAppServerSpawn = spawnCodexAppServerProcess
|
||||
): Promise<T> {
|
||||
// Why: a default-home grant must run against the real ~/.codex, so strip an
|
||||
// inherited CODEX_HOME (envToDelete) after applying the overlay, not before.
|
||||
|
||||
@@ -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,47 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record'
|
||||
import { supportsCodexStructuredLocation } from './codex-structured-location-support'
|
||||
|
||||
const LOCAL_WINDOWS_LOCATION: AgentSessionExecutionLocation = {
|
||||
executionHostId: 'local',
|
||||
wslDistro: null,
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceKind: 'folder'
|
||||
}
|
||||
|
||||
const WSL_WINDOWS_LOCATION: AgentSessionExecutionLocation = {
|
||||
...LOCAL_WINDOWS_LOCATION,
|
||||
wslDistro: 'Ubuntu'
|
||||
}
|
||||
|
||||
function withPlatform<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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects WSL locations while retaining native folder support on Windows', () => {
|
||||
withPlatform('win32', () => {
|
||||
expect(supportsCodexStructuredLocation(WSL_WINDOWS_LOCATION, () => true)).toBe(false)
|
||||
expect(supportsCodexStructuredLocation(LOCAL_WINDOWS_LOCATION, () => true)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,10 +2,14 @@ import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
|
||||
import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record'
|
||||
import { isWindowsProcessStartTimeAvailable } from '../windows/windows-process-table'
|
||||
|
||||
export function supportsCodexStructuredLocation(location: AgentSessionExecutionLocation): boolean {
|
||||
export function supportsCodexStructuredLocation(
|
||||
location: AgentSessionExecutionLocation,
|
||||
// Injected by the adapter, which owns this dep for every other Codex gate too.
|
||||
hasWindowsProcessStartTimeProof: () => boolean = isWindowsProcessStartTimeAvailable
|
||||
): boolean {
|
||||
return (
|
||||
location.executionHostId === LOCAL_EXECUTION_HOST_ID &&
|
||||
location.wslDistro === null &&
|
||||
(process.platform !== 'win32' || isWindowsProcessStartTimeAvailable())
|
||||
(process.platform !== 'win32' || hasWindowsProcessStartTimeProof())
|
||||
)
|
||||
}
|
||||
|
||||
@@ -74,7 +74,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>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import {
|
||||
AgentSessionPreSpawnError,
|
||||
isAgentSessionPreSpawnError,
|
||||
rethrowAfterAgentSessionAcquisitionCleanup
|
||||
} from './structured-agent-session-adapter'
|
||||
import { journalIdentityFor } from './structured-agent-session-attach'
|
||||
import type { AttachFlowInput } from './structured-agent-session-attach-flow'
|
||||
import { readNativeSessionOptions } from './structured-agent-session-option-restoration'
|
||||
|
||||
/** A reservation with no process behind it is only a promise to spawn; the
|
||||
* adapter makes it real and the store then grants the writer. */
|
||||
export async function acquireOwner(
|
||||
input: AttachFlowInput,
|
||||
record: AgentSessionRecord
|
||||
): Promise<{ record: AgentSessionRecord; acquisitionGeneration: string | null }> {
|
||||
const fence = record.lease.runtimeFence
|
||||
const spawnToken = record.lease.reservedSpawnToken
|
||||
if (!spawnToken) {
|
||||
throw new Error('agent_session_ownership_unknown')
|
||||
}
|
||||
// Pre-spawn proof is single-use: this retry may create a child after the durable clear.
|
||||
try {
|
||||
try {
|
||||
record = await input.store.setReservationProcesslessProof({
|
||||
sessionId: record.sessionId,
|
||||
fence,
|
||||
spawnToken,
|
||||
processlessAt: null,
|
||||
now: input.now()
|
||||
})
|
||||
await input.onAcquiring?.()
|
||||
} catch (error) {
|
||||
throw new AgentSessionPreSpawnError(error)
|
||||
}
|
||||
const acquired = await input.adapter.acquire({
|
||||
identity: journalIdentityFor(record, input.params),
|
||||
fence,
|
||||
// Retries must recover the original reservation, not mint a second child.
|
||||
spawnToken,
|
||||
...(record.options ? { options: record.options } : {}),
|
||||
...(input.eventSink ? { events: input.eventSink } : {})
|
||||
})
|
||||
const options = await readNativeSessionOptions({
|
||||
adapter: input.adapter,
|
||||
sessionId: record.sessionId,
|
||||
fence,
|
||||
...(record.options ? { priorOptions: record.options } : {})
|
||||
})
|
||||
if (record.lease.ownerProcess === null) {
|
||||
await input.store.commitProcessIdentity({
|
||||
sessionId: record.sessionId,
|
||||
fence,
|
||||
process: acquired.process,
|
||||
now: input.now()
|
||||
})
|
||||
} else if (!isDeepStrictEqual(record.lease.ownerProcess, acquired.process)) {
|
||||
throw new Error('agent_session_ownership_unknown')
|
||||
}
|
||||
const proved = await input.store.proveOwner({
|
||||
sessionId: record.sessionId,
|
||||
fence,
|
||||
link: acquired.link,
|
||||
now: input.now(),
|
||||
...(options ? { options } : {})
|
||||
})
|
||||
return {
|
||||
record: proved,
|
||||
acquisitionGeneration: acquired.acquisitionGeneration ?? null
|
||||
}
|
||||
} catch (error) {
|
||||
if (isAgentSessionPreSpawnError(error)) {
|
||||
throw error
|
||||
}
|
||||
return rethrowAfterAgentSessionAcquisitionCleanup(input.adapter, record.sessionId, error)
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@
|
||||
// the decisions that must not be client-supplied — the spawn token, the claim
|
||||
// key, the owner probe — and passes them in.
|
||||
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import type {
|
||||
AgentSessionAttachResult,
|
||||
AgentSessionMutationResult
|
||||
@@ -16,7 +15,6 @@ import {
|
||||
admitAttachOrRefuse,
|
||||
attachJournal,
|
||||
classifyStoreFailure,
|
||||
journalIdentityFor,
|
||||
reserveRequestFor,
|
||||
type AgentSessionAttachAuthority,
|
||||
type AgentSessionAttachParams,
|
||||
@@ -24,18 +22,18 @@ import {
|
||||
} from './structured-agent-session-attach'
|
||||
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
|
||||
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
|
||||
import { adapterSupportsCreateIfDeclared } from './structured-agent-session-provider-support'
|
||||
import {
|
||||
AgentSessionAcquisitionExitUnprovenError,
|
||||
AgentSessionAcquisitionRootExitObservedError,
|
||||
AgentSessionAcquisitionRefusal,
|
||||
AgentSessionPreSpawnError,
|
||||
isAgentSessionPreSpawnError,
|
||||
rethrowAfterAgentSessionAcquisitionCleanup
|
||||
} from './structured-agent-session-adapter'
|
||||
import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink'
|
||||
import { readNativeSessionOptions } from './structured-agent-session-option-restoration'
|
||||
import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome'
|
||||
import { readAgentSessionHydrationPage } from './agent-session-history-page'
|
||||
import { acquireOwner } from './structured-agent-session-acquisition'
|
||||
import {
|
||||
importAdoptedTranscript,
|
||||
prepareAdoptedTranscript
|
||||
@@ -72,15 +70,27 @@ export async function performAttach(
|
||||
input: AttachFlowInput
|
||||
): Promise<AgentSessionMutationResult<AgentSessionAttachResult>> {
|
||||
const { params, store } = input
|
||||
const unsupported = (): AgentSessionMutationResult<AgentSessionAttachResult> => ({
|
||||
ok: false,
|
||||
refusal: {
|
||||
code: 'structured_agent_session_unsupported',
|
||||
message: 'This execution host cannot create the requested structured agent session.'
|
||||
}
|
||||
})
|
||||
const sessionId = params.envelope.sessionId
|
||||
const admitted = admitAttachOrRefuse(params)
|
||||
if (!admitted.ok) {
|
||||
return admitted
|
||||
}
|
||||
// Ensure/recovery bypass create-intent, so recheck before reserving or spawning.
|
||||
if (!adapterSupportsCreateIfDeclared(input.adapter, params.location, params.agent)) {
|
||||
return unsupported()
|
||||
}
|
||||
|
||||
let record: AgentSessionRecord
|
||||
let acquisitionGeneration: string | null = null
|
||||
let reservedRecord: AgentSessionRecord | null = null
|
||||
let unsupportedReservationSettlementAttempted = false
|
||||
let replayed = false
|
||||
const preparedTranscript = store.getRecord(sessionId)
|
||||
? { ok: true as const, items: null }
|
||||
@@ -101,6 +111,21 @@ export async function performAttach(
|
||||
)
|
||||
record = reserved.record
|
||||
replayed = reserved.disposition === 'replayed'
|
||||
// Capability can change while the durable reservation is in flight. Recheck
|
||||
// every reservation at its effect boundary so it cannot bypass the support
|
||||
// gate, and release a pending reservation that support drift invalidated.
|
||||
reservedRecord = record
|
||||
if (!adapterSupportsCreateIfDeclared(input.adapter, params.location, params.agent)) {
|
||||
if (
|
||||
record.lease.claimStatus === 'reserved' &&
|
||||
record.lease.handoffStage === 'new-owner-proving' &&
|
||||
record.lease.reservedSpawnToken
|
||||
) {
|
||||
unsupportedReservationSettlementAttempted = true
|
||||
await settleUnsupportedReservation(input, record)
|
||||
}
|
||||
return unsupported()
|
||||
}
|
||||
if (
|
||||
replayed &&
|
||||
reserved.operationRow.outcome.status !== 'pending' &&
|
||||
@@ -115,7 +140,6 @@ export async function performAttach(
|
||||
return { ok: false, refusal: replay.refusal }
|
||||
}
|
||||
}
|
||||
reservedRecord = record
|
||||
if (!agentSessionLeaseAdmitsWriter(record.lease)) {
|
||||
const acquired = await acquireOwner(input, record)
|
||||
record = acquired.record
|
||||
@@ -123,7 +147,7 @@ export async function performAttach(
|
||||
}
|
||||
} catch (error) {
|
||||
const spawnToken = reservedRecord?.lease.reservedSpawnToken
|
||||
if (reservedRecord && spawnToken) {
|
||||
if (reservedRecord && spawnToken && !unsupportedReservationSettlementAttempted) {
|
||||
// A pre-spawn failure is its own processless proof; the settlement records the
|
||||
// evidence and the failed operation in one durable transaction.
|
||||
const exitProof = isAgentSessionPreSpawnError(error)
|
||||
@@ -217,6 +241,34 @@ export async function performAttach(
|
||||
}
|
||||
}
|
||||
|
||||
async function settleUnsupportedReservation(
|
||||
input: AttachFlowInput,
|
||||
record: AgentSessionRecord
|
||||
): Promise<void> {
|
||||
const spawnToken = record.lease.reservedSpawnToken
|
||||
if (!spawnToken) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await input.store.settleFailedAcquisition({
|
||||
sessionId: record.sessionId,
|
||||
fence: record.lease.runtimeFence,
|
||||
spawnToken,
|
||||
callerKey: input.callerKey,
|
||||
operationId: input.params.envelope.clientOperationId,
|
||||
outcome: {
|
||||
status: 'failed',
|
||||
code: 'structured_agent_session_unsupported',
|
||||
message: 'Structured session support changed before the provider could start.'
|
||||
},
|
||||
exitProof: 'processless',
|
||||
now: input.now()
|
||||
})
|
||||
} catch (error) {
|
||||
throw new AggregateError([error], 'agent session unsupported reservation settlement failed')
|
||||
}
|
||||
}
|
||||
|
||||
async function settlePostAcquisitionAttachFailure(
|
||||
input: AttachFlowInput,
|
||||
record: AgentSessionRecord,
|
||||
@@ -261,71 +313,3 @@ async function settlePostAcquisitionAttachFailure(
|
||||
}
|
||||
throw cleanupError
|
||||
}
|
||||
|
||||
/** A reservation with no process behind it is only a promise to spawn; the
|
||||
* adapter makes it real and the store then grants the writer. */
|
||||
async function acquireOwner(
|
||||
input: AttachFlowInput,
|
||||
record: AgentSessionRecord
|
||||
): Promise<{ record: AgentSessionRecord; acquisitionGeneration: string | null }> {
|
||||
const fence = record.lease.runtimeFence
|
||||
const spawnToken = record.lease.reservedSpawnToken
|
||||
if (!spawnToken) {
|
||||
throw new Error('agent_session_ownership_unknown')
|
||||
}
|
||||
// Pre-spawn proof is single-use: this retry may create a child after the durable clear.
|
||||
try {
|
||||
try {
|
||||
record = await input.store.setReservationProcesslessProof({
|
||||
sessionId: record.sessionId,
|
||||
fence,
|
||||
spawnToken,
|
||||
processlessAt: null,
|
||||
now: input.now()
|
||||
})
|
||||
await input.onAcquiring?.()
|
||||
} catch (error) {
|
||||
throw new AgentSessionPreSpawnError(error)
|
||||
}
|
||||
const acquired = await input.adapter.acquire({
|
||||
identity: journalIdentityFor(record, input.params),
|
||||
fence,
|
||||
// Retries must recover the original reservation, not mint a second child.
|
||||
spawnToken,
|
||||
...(record.options ? { options: record.options } : {}),
|
||||
...(input.eventSink ? { events: input.eventSink } : {})
|
||||
})
|
||||
const options = await readNativeSessionOptions({
|
||||
adapter: input.adapter,
|
||||
sessionId: record.sessionId,
|
||||
fence,
|
||||
...(record.options ? { priorOptions: record.options } : {})
|
||||
})
|
||||
if (record.lease.ownerProcess === null) {
|
||||
await input.store.commitProcessIdentity({
|
||||
sessionId: record.sessionId,
|
||||
fence,
|
||||
process: acquired.process,
|
||||
now: input.now()
|
||||
})
|
||||
} else if (!isDeepStrictEqual(record.lease.ownerProcess, acquired.process)) {
|
||||
throw new Error('agent_session_ownership_unknown')
|
||||
}
|
||||
const proved = await input.store.proveOwner({
|
||||
sessionId: record.sessionId,
|
||||
fence,
|
||||
link: acquired.link,
|
||||
now: input.now(),
|
||||
...(options ? { options } : {})
|
||||
})
|
||||
return {
|
||||
record: proved,
|
||||
acquisitionGeneration: acquired.acquisitionGeneration ?? null
|
||||
}
|
||||
} catch (error) {
|
||||
if (isAgentSessionPreSpawnError(error)) {
|
||||
throw error
|
||||
}
|
||||
return rethrowAfterAgentSessionAcquisitionCleanup(input.adapter, record.sessionId, error)
|
||||
}
|
||||
}
|
||||
|
||||
+186
@@ -11,6 +11,7 @@ import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
|
||||
import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
|
||||
import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open'
|
||||
import { createDeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink'
|
||||
import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types'
|
||||
import {
|
||||
acquireNativeHandoffOwner,
|
||||
createStructuredAgentSessionHostHandoff,
|
||||
@@ -202,6 +203,191 @@ describe('native handoff acquisition', () => {
|
||||
|
||||
expect(order).toEqual(['append-entered', 'append-complete', 'unbind', 'acquire'])
|
||||
})
|
||||
|
||||
it('refuses an unsupported adapter before unbinding the TUI owner', async () => {
|
||||
const location: AgentSessionExecutionLocation = {
|
||||
executionHostId: LOCAL_EXECUTION_HOST_ID,
|
||||
wslDistro: null,
|
||||
workspaceId: 'workspace-unsupported',
|
||||
workspaceKind: 'folder'
|
||||
}
|
||||
const operationId = `${now}-00000000000000000000000000000011`
|
||||
const reserved = await store.reserveOwner({
|
||||
sessionId: 'session-handoff-unsupported',
|
||||
location,
|
||||
provider: 'codex',
|
||||
accountHome: { variable: 'CODEX_HOME', path: join(root, 'codex-home') },
|
||||
runtimeKind: 'native',
|
||||
expectedFence: null,
|
||||
spawnToken: 'unsupported-spawn',
|
||||
claimKeyId: 'key-1',
|
||||
handoffOperationId: operationId,
|
||||
probe: { outcome: 'reservation-unused' },
|
||||
operation: { callerKey: 'test', operationId, fingerprint: 'unsupported' },
|
||||
now
|
||||
})
|
||||
const journal = await journals.open({
|
||||
identity: {
|
||||
sessionId: 'session-handoff-unsupported',
|
||||
workspaceId: location.workspaceId,
|
||||
hostId: location.executionHostId,
|
||||
agent: 'codex',
|
||||
providerHandle: { kind: 'codex', threadId: 'unsupported-thread' }
|
||||
},
|
||||
journalDir: join(root, 'unsupported-journal')
|
||||
})
|
||||
const eventSink = createDeferredStructuredAgentSessionEventSink()
|
||||
eventSink.bind({ journal, fence: reserved.record.lease.runtimeFence, publish: () => undefined })
|
||||
const unbind = vi.spyOn(eventSink, 'unbind')
|
||||
const acquire = vi.fn<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,
|
||||
findSession: () => session,
|
||||
eventSink: () => eventSink,
|
||||
flush: async () => undefined,
|
||||
serialize: async (_sessionId, task) => task(),
|
||||
subscribers: {
|
||||
publish: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
handoff: vi.fn(),
|
||||
snapshot: vi.fn()
|
||||
} as never,
|
||||
now: () => now
|
||||
},
|
||||
{
|
||||
sessionId: 'session-handoff-unsupported',
|
||||
fence: reserved.record.lease.runtimeFence,
|
||||
spawnToken: 'unsupported-spawn'
|
||||
}
|
||||
)
|
||||
).rejects.toThrow('structured_agent_session_unsupported')
|
||||
expect(unbind).not.toHaveBeenCalled()
|
||||
expect(acquire).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rechecks adapter support immediately before handoff acquisition', async () => {
|
||||
const sessionId = 'session-handoff-drift'
|
||||
const location: AgentSessionExecutionLocation = {
|
||||
executionHostId: LOCAL_EXECUTION_HOST_ID,
|
||||
wslDistro: null,
|
||||
workspaceId: 'workspace-drift',
|
||||
workspaceKind: 'folder'
|
||||
}
|
||||
const operationId = `${now}-00000000000000000000000000000021`
|
||||
const reserved = await store.reserveOwner({
|
||||
sessionId,
|
||||
location,
|
||||
provider: 'codex',
|
||||
accountHome: { variable: 'CODEX_HOME', path: join(root, 'codex-home') },
|
||||
runtimeKind: 'native',
|
||||
expectedFence: null,
|
||||
spawnToken: 'drift-spawn',
|
||||
claimKeyId: 'key-1',
|
||||
handoffOperationId: operationId,
|
||||
probe: { outcome: 'reservation-unused' },
|
||||
operation: { callerKey: 'test', operationId, fingerprint: 'drift' },
|
||||
now
|
||||
})
|
||||
const journal = await journals.open({
|
||||
identity: {
|
||||
sessionId,
|
||||
workspaceId: location.workspaceId,
|
||||
hostId: location.executionHostId,
|
||||
agent: 'codex',
|
||||
providerHandle: { kind: 'codex', threadId: 'drift-thread' }
|
||||
},
|
||||
journalDir: join(root, 'drift-journal')
|
||||
})
|
||||
const eventSink = createDeferredStructuredAgentSessionEventSink()
|
||||
eventSink.bind({ journal, fence: reserved.record.lease.runtimeFence, publish: () => undefined })
|
||||
const unbind = vi.spyOn(eventSink, 'unbind')
|
||||
const supportsLocation = vi.fn(() => true)
|
||||
supportsLocation.mockReturnValueOnce(true).mockReturnValueOnce(false)
|
||||
const acquire = vi.fn<NonNullable<StructuredAgentSessionHostDeps['adapter']['acquire']>>()
|
||||
const adapter = { supportsLocation, acquire }
|
||||
const session = {
|
||||
journal,
|
||||
params: {
|
||||
envelope: {
|
||||
sessionId,
|
||||
clientOperationId: `${now}-00000000000000000000000000000022`,
|
||||
expectedRuntimeFence: reserved.record.lease.runtimeFence,
|
||||
payloadFingerprint: 'drift'
|
||||
},
|
||||
location,
|
||||
provider: 'codex' as const,
|
||||
agent: 'codex' as const,
|
||||
accountHome: { variable: 'CODEX_HOME' as const, path: join(root, 'codex-home') },
|
||||
runtimeKind: 'native' as const,
|
||||
providerHandle: { kind: 'codex' as const, threadId: 'drift-thread' }
|
||||
},
|
||||
fence: reserved.record.lease.runtimeFence,
|
||||
hasProviderChild: false,
|
||||
acquisitionGeneration: null
|
||||
}
|
||||
|
||||
await expect(
|
||||
acquireNativeHandoffOwner(
|
||||
{
|
||||
store,
|
||||
adapter: adapter as never,
|
||||
journalRoot: root,
|
||||
claimKeyId: 'key-1'
|
||||
},
|
||||
{
|
||||
session: () => session,
|
||||
findSession: () => session,
|
||||
eventSink: () => eventSink,
|
||||
flush: async () => undefined,
|
||||
serialize: async (_sessionId, task) => task(),
|
||||
subscribers: {
|
||||
publish: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
handoff: vi.fn(),
|
||||
snapshot: vi.fn()
|
||||
} as never,
|
||||
now: () => now
|
||||
},
|
||||
{ sessionId, fence: reserved.record.lease.runtimeFence, spawnToken: 'drift-spawn' }
|
||||
)
|
||||
).rejects.toThrow('structured_agent_session_unsupported')
|
||||
expect(supportsLocation).toHaveBeenCalledTimes(2)
|
||||
expect(unbind).toHaveBeenCalledOnce()
|
||||
expect(acquire).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handoff status published for a session the host no longer holds', () => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { recoverDeadTuiHandoffStatus } from './structured-agent-session-dead-tui
|
||||
import { readNativeSessionOptions } from './structured-agent-session-option-restoration'
|
||||
import type { AgentSessionSubscribers } from './structured-agent-session-subscribers'
|
||||
import { StructuredTuiTranscriptCatchup } from './structured-tui-transcript-catchup'
|
||||
import { adapterSupportsCreateIfDeclared } from './structured-agent-session-provider-support'
|
||||
import { retryLoadedStructuredAgentSessionSettlement } from './structured-agent-session-settlement-retry'
|
||||
|
||||
type HostHandoffAccess = {
|
||||
@@ -195,12 +196,21 @@ export async function acquireNativeHandoffOwner(
|
||||
if (!record) {
|
||||
throw new Error('agent_session_identity_required')
|
||||
}
|
||||
// Native handoff bypasses attach admission; reject before unbinding TUI ownership.
|
||||
if (!adapterSupportsCreateIfDeclared(deps.adapter, record.location, record.provider)) {
|
||||
throw new Error('structured_agent_session_unsupported')
|
||||
}
|
||||
const eventSink = host.eventSink(input.sessionId)
|
||||
const priorBarrier = await eventSink.drained()
|
||||
if (!priorBarrier.ok) {
|
||||
throw priorBarrier.error
|
||||
}
|
||||
eventSink.unbind()
|
||||
// Recheck immediately before acquisition; capability probes may drift while
|
||||
// the old TUI event sink is draining.
|
||||
if (!adapterSupportsCreateIfDeclared(deps.adapter, record.location, record.provider)) {
|
||||
throw new Error('structured_agent_session_unsupported')
|
||||
}
|
||||
const acquired = await deps.adapter.acquire({
|
||||
identity: journalIdentityFor(record, session.params),
|
||||
fence: input.fence,
|
||||
|
||||
+145
@@ -64,6 +64,151 @@ function attachParams(
|
||||
}
|
||||
|
||||
describe('processless structured session reservation', () => {
|
||||
it('refuses an adapter that declares no create support before reserving a lease', async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-unsupported-attach-'))
|
||||
const store = await AgentSessionRecordStore.open({
|
||||
directory: join(root, 'store'),
|
||||
hostId: 'local'
|
||||
})
|
||||
const reserveOwner = vi.spyOn(store, 'reserveOwner')
|
||||
const acquire = vi.fn<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('refuses a replay when adapter support drifts after durable reservation', async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-replay-support-drift-'))
|
||||
const store = await AgentSessionRecordStore.open({
|
||||
directory: join(root, 'store'),
|
||||
hostId: 'local'
|
||||
})
|
||||
const supportsCreate = vi
|
||||
.fn<NonNullable<StructuredAgentSessionAdapter['supportsCreate']>>()
|
||||
.mockReturnValueOnce(true)
|
||||
.mockReturnValueOnce(true)
|
||||
.mockReturnValueOnce(false)
|
||||
const adapter = {
|
||||
supportsCreate,
|
||||
acquire: vi.fn(async ({ fence, spawnToken }) => ({
|
||||
process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken },
|
||||
link: {
|
||||
linkId: 'link-1',
|
||||
handle: { provider: 'codex' as const, threadId: 'thread-1' },
|
||||
origin: 'created' as const,
|
||||
mintedAtFence: fence,
|
||||
observedAt: NOW
|
||||
}
|
||||
}))
|
||||
} as unknown as StructuredAgentSessionAdapter
|
||||
const input = {
|
||||
store,
|
||||
adapter,
|
||||
journalRoot: root,
|
||||
authority: {
|
||||
spawnToken: 'spawn-a',
|
||||
claimKeyId: 'key-1',
|
||||
handoffOperationId: OPERATION,
|
||||
probe: { outcome: 'reservation-unused' as const }
|
||||
},
|
||||
callerKey: 'client-1',
|
||||
params: attachParams(),
|
||||
now: () => NOW,
|
||||
onAttached: () => {}
|
||||
}
|
||||
|
||||
await expect(performAttach(input)).resolves.toMatchObject({ ok: true })
|
||||
await expect(performAttach(input)).resolves.toMatchObject({
|
||||
ok: false,
|
||||
refusal: { code: 'structured_agent_session_unsupported' }
|
||||
})
|
||||
expect(supportsCreate).toHaveBeenCalledTimes(3)
|
||||
expect(adapter.acquire).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('releases a new reservation when support drifts before acquisition', async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-support-drift-reservation-'))
|
||||
const store = await AgentSessionRecordStore.open({
|
||||
directory: join(root, 'store'),
|
||||
hostId: 'local'
|
||||
})
|
||||
const supportsCreate = vi
|
||||
.fn<NonNullable<StructuredAgentSessionAdapter['supportsCreate']>>()
|
||||
.mockReturnValueOnce(true)
|
||||
.mockReturnValueOnce(false)
|
||||
.mockReturnValueOnce(true)
|
||||
.mockReturnValueOnce(true)
|
||||
const acquire = vi.fn<StructuredAgentSessionAdapter['acquire']>()
|
||||
const adapter = { supportsCreate, acquire } as unknown as StructuredAgentSessionAdapter
|
||||
const input = {
|
||||
store,
|
||||
adapter,
|
||||
journalRoot: root,
|
||||
authority: {
|
||||
spawnToken: 'spawn-drift',
|
||||
claimKeyId: 'key-1',
|
||||
handoffOperationId: OPERATION,
|
||||
probe: { outcome: 'reservation-unused' as const }
|
||||
},
|
||||
callerKey: 'client-1',
|
||||
params: attachParams(),
|
||||
now: () => NOW,
|
||||
onAttached: () => {}
|
||||
}
|
||||
|
||||
await expect(performAttach(input)).resolves.toMatchObject({
|
||||
ok: false,
|
||||
refusal: { code: 'structured_agent_session_unsupported' }
|
||||
})
|
||||
|
||||
expect(acquire).not.toHaveBeenCalled()
|
||||
expect(store.getRecord(SESSION)?.lease).toMatchObject({
|
||||
claimStatus: 'released',
|
||||
handoffStage: null,
|
||||
reservedSpawnToken: null,
|
||||
processlessAt: null,
|
||||
runtimeFence: 2,
|
||||
deathEvidence: { kind: 'pid-absent', detail: 'reservation failed before spawn' }
|
||||
})
|
||||
expect(store.listOperationRows()[0]?.outcome).toMatchObject({
|
||||
status: 'failed',
|
||||
code: 'structured_agent_session_unsupported'
|
||||
})
|
||||
await expect(performAttach(input)).resolves.toMatchObject({
|
||||
ok: false,
|
||||
refusal: { code: 'structured_agent_session_unsupported' }
|
||||
})
|
||||
expect(acquire).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('settles a pre-spawn failure and its processless evidence in one durable transaction', async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-processless-reservation-'))
|
||||
const storeDir = join(root, 'store')
|
||||
|
||||
+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)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
admitSelfInitiatedTreeKill,
|
||||
installMainProcessTreeKillGate
|
||||
} from './own-chromium-tree-kill-guard'
|
||||
import { killCodexAppServerProcessTree } from './codex/codex-app-server-session'
|
||||
import { killCodexAppServerProcessTree } from './codex/codex-app-server-process-tree-kill'
|
||||
import { setProcessTreeKillGate } from '../shared/child-process/process-tree-kill-gate'
|
||||
import { resetSelfInitiatedTreeKillLogForTest } from './crash-reporting/self-initiated-tree-kill-log'
|
||||
import {
|
||||
|
||||
@@ -34,7 +34,7 @@ import { terminateNotebookProcessTree } from './ipc/notebook'
|
||||
import { killLocalPrecheckProcessTree } from './automations/precheck-runner'
|
||||
import { killRecipeProcess } from '../shared/ephemeral-vm-recipe-process'
|
||||
import { killSpawnedCommandTree } from './git/command-runner/spawned-command-tree-kill'
|
||||
import { killCodexAppServerProcessTree } from './codex/codex-app-server-session'
|
||||
import { killCodexAppServerProcessTree } from './codex/codex-app-server-process-tree-kill'
|
||||
import { signalProcessTree } from '../shared/child-process/process-tree-termination'
|
||||
import { killSourceControlAgentProcess } from './text-generation/source-control-local-process'
|
||||
import { terminateCodexTurnProcesses } from './codex/codex-structured-turn-processes'
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { isWindowsProcessStartTimeAvailable, readWindowsProcessIdentityTableFresh } = vi.hoisted(
|
||||
() => ({
|
||||
isWindowsProcessStartTimeAvailable: vi.fn(() => true),
|
||||
readWindowsProcessIdentityTableFresh: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('../windows/windows-process-table', async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
isWindowsProcessStartTimeAvailable,
|
||||
readWindowsProcessIdentityTableFresh
|
||||
}))
|
||||
|
||||
const { readProcessStartTimesMs } = await import('./agent-session-process-identity-probe')
|
||||
|
||||
const START_TIME = 1_700_000_000_000
|
||||
|
||||
afterEach(() => {
|
||||
isWindowsProcessStartTimeAvailable.mockReset()
|
||||
isWindowsProcessStartTimeAvailable.mockReturnValue(true)
|
||||
readWindowsProcessIdentityTableFresh.mockReset()
|
||||
})
|
||||
|
||||
describe('Windows owner identity batch probe', () => {
|
||||
it('reads Windows start times for a batch from one process-table snapshot', async () => {
|
||||
readWindowsProcessIdentityTableFresh.mockResolvedValue([
|
||||
{ pid: 4242, ppid: 1, name: 'codex.exe', creationTimeMs: START_TIME },
|
||||
{ pid: 4243, ppid: 1, name: 'codex.exe', creationTimeMs: START_TIME + 10 }
|
||||
])
|
||||
|
||||
await expect(readProcessStartTimesMs([4242, 4243, 4242], 'win32')).resolves.toEqual(
|
||||
new Map([
|
||||
[4242, START_TIME],
|
||||
[4243, START_TIME + 10]
|
||||
])
|
||||
)
|
||||
|
||||
expect(readWindowsProcessIdentityTableFresh).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -131,6 +131,25 @@ async function readWindowsProcessStartTimeMs(pid: number): Promise<number | null
|
||||
}
|
||||
}
|
||||
|
||||
async function readWindowsProcessStartTimesMs(
|
||||
pids: readonly number[]
|
||||
): Promise<Map<number, number | null>> {
|
||||
const observed = new Map<number, number | null>(pids.map((pid) => [pid, null]))
|
||||
if (pids.length === 0 || !isWindowsProcessStartTimeAvailable()) {
|
||||
return observed
|
||||
}
|
||||
try {
|
||||
const table = await readWindowsProcessIdentityTableFresh()
|
||||
const startTimesByPid = new Map(table.map((row) => [row.pid, row.creationTimeMs ?? null]))
|
||||
for (const pid of pids) {
|
||||
observed.set(pid, startTimesByPid.get(pid) ?? null)
|
||||
}
|
||||
} catch {
|
||||
// A missing process table is unknown, never evidence that every owner exited.
|
||||
}
|
||||
return observed
|
||||
}
|
||||
|
||||
/**
|
||||
* Process start time is the cross-platform PID-reuse guard when no provider hook can echo the
|
||||
* spawn token back to the owner probe.
|
||||
@@ -160,6 +179,9 @@ export async function readProcessStartTimesMs(
|
||||
const table = await readDarwinProcessStartTimesMs(uniquePids)
|
||||
return new Map(uniquePids.map((pid) => [pid, table.get(pid) ?? null]))
|
||||
}
|
||||
if (platform === 'win32') {
|
||||
return readWindowsProcessStartTimesMs(uniquePids)
|
||||
}
|
||||
return new Map(
|
||||
await Promise.all(
|
||||
uniquePids.map(async (pid) => [pid, await readProcessStartTimeMs(pid, platform)] as const)
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
browserUnavailableMessage
|
||||
} from '../../shared/runtime-types'
|
||||
import { runtimeTerminalDegradation } from './native-terminal-availability'
|
||||
import { isWindowsProcessStartTimeAvailable } from '../windows/windows-process-table'
|
||||
import type { RuntimeWorktreeLifecycleEvent } from './orca-runtime-core'
|
||||
import { WORKTREE_CREATE_RESULT_TTL_MS } from './orca-runtime-core'
|
||||
import type { RuntimePtyController } from './runtime-pty-controller-contract'
|
||||
@@ -56,6 +57,10 @@ export class OrcaRuntimeWithGetStatus extends OrcaRuntimeWithGetRuntimeId {
|
||||
const hasOffscreen = !hasRenderer && Boolean(this.offscreenBrowserBackend)
|
||||
const hasHeadlessCommands = runtimeBrowserCommandsFactoryIsHeadless()
|
||||
const canBrowse = hasRenderer || hasOffscreen
|
||||
// This field reports current Windows process-identity proof. Structured RPC
|
||||
// support itself stays advertised; agentSession.createSupport owns current eligibility.
|
||||
const windowsProcessStartTimeAvailable =
|
||||
process.platform === 'win32' && isWindowsProcessStartTimeAvailable()
|
||||
const capabilities: RuntimeCapability[] = RUNTIME_CAPABILITIES.filter(
|
||||
(capability) =>
|
||||
(capability !== 'browser.screencast.v1' || canBrowse) &&
|
||||
@@ -110,6 +115,7 @@ export class OrcaRuntimeWithGetStatus extends OrcaRuntimeWithGetRuntimeId {
|
||||
capabilities,
|
||||
...(degradations.length > 0 ? { degradations } : {}),
|
||||
worktreeCreateIdempotency: { dedupeTtlMs: WORKTREE_CREATE_RESULT_TTL_MS },
|
||||
...(windowsProcessStartTimeAvailable ? { windowsProcessStartTimeAvailable } : {}),
|
||||
hostPlatform: process.platform,
|
||||
terminalWindowsShell: this.store?.getSettings?.().terminalWindowsShell ?? null,
|
||||
floatingWorkspaceEnabled: this.store?.getSettings?.().floatingTerminalEnabled !== false,
|
||||
|
||||
@@ -22,6 +22,8 @@ import { hasPersistedStructuredAgentSessionStore as hasPersistedStructuredAgentS
|
||||
import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||
import { parseWorkspaceKey } from '../../shared/workspace-scope'
|
||||
|
||||
export class OrcaRuntimeWithResolveRecoveredStructuredTuiTranscript extends OrcaRuntimeWithStopStructuredSessionProcess {
|
||||
protected async resolveRecoveredStructuredTuiTranscript(input: {
|
||||
@@ -95,14 +97,23 @@ export class OrcaRuntimeWithResolveRecoveredStructuredTuiTranscript extends Orca
|
||||
protected async resolveStructuredAgentSessionLocation(worktreeSelector: string) {
|
||||
const target = await this.resolveRuntimeFileTarget(worktreeSelector)
|
||||
const repo = this.store?.getRepo(target.worktree.repoId)
|
||||
// WSL routing describes *this* machine; no remote or runtime host may inherit it.
|
||||
const wslDistro =
|
||||
repo && target.executionHostId === LOCAL_EXECUTION_HOST_ID
|
||||
const folderScope = parseWorkspaceKey(target.worktree.id)
|
||||
const folderWorkspace = folderScope?.type === 'folder'
|
||||
// WSL routing describes *this* machine; no remote or runtime host may inherit
|
||||
// it. Both branches key on executionHostId: the target no longer carries a
|
||||
// connectionId, which used to spell remote, unresolved and local alike.
|
||||
const isLocalHost = target.executionHostId === LOCAL_EXECUTION_HOST_ID
|
||||
const configuredWslDistro =
|
||||
repo && isLocalHost
|
||||
? (getLocalProjectWorktreeGitOptions(this.requireStore(), repo).wslDistro ?? null)
|
||||
: null
|
||||
const folderWorkspace = this.store
|
||||
?.getFolderWorkspaces?.()
|
||||
.some((workspace) => workspace.id === target.worktree.id)
|
||||
// Folder workspaces have no repo Git options, so a WSL UNC path is the only
|
||||
// durable signal that native Windows structured Codex cannot safely use it.
|
||||
const wslDistro =
|
||||
configuredWslDistro ??
|
||||
(folderWorkspace && isLocalHost
|
||||
? (parseWslUncPath(target.worktree.path)?.distro ?? null)
|
||||
: null)
|
||||
return {
|
||||
executionHostId: target.executionHostId,
|
||||
wslDistro,
|
||||
|
||||
@@ -23,13 +23,11 @@ function decide(
|
||||
overrides: {
|
||||
params?: Parameters<typeof decideWorkerStartMode>[0]['params']
|
||||
settings?: Parameters<typeof decideWorkerStartMode>[0]['settings']
|
||||
platform?: NodeJS.Platform
|
||||
} = {}
|
||||
): WorkerStartModeReceipt {
|
||||
return decideWorkerStartMode({
|
||||
params: { agent: 'claude', ...overrides.params },
|
||||
settings: overrides.settings === undefined ? STRUCTURED_DEFAULT : overrides.settings,
|
||||
platform: overrides.platform ?? 'darwin'
|
||||
settings: overrides.settings === undefined ? STRUCTURED_DEFAULT : overrides.settings
|
||||
})
|
||||
}
|
||||
|
||||
@@ -90,12 +88,10 @@ describe('a structured default this dispatch cannot honour', () => {
|
||||
).toMatchObject({ mode: 'terminal', reason: 'tui_launch_customization' })
|
||||
})
|
||||
|
||||
it('keeps Codex terminal-backed on Windows and leaves Claude to the host', () => {
|
||||
expect(decide({ params: { agent: 'codex' }, platform: 'win32' })).toMatchObject({
|
||||
mode: 'terminal',
|
||||
reason: 'codex_on_windows'
|
||||
})
|
||||
expect(decide({ params: { agent: 'claude' }, platform: 'win32' }).mode).toBe('structured')
|
||||
// Neither provider is refused here on the client's platform: only the executing host knows
|
||||
// whether it can read a provider child's start time, and it answers at create time.
|
||||
it.each(['claude', 'codex'] as const)('leaves a Windows %s worker to the host', (agent) => {
|
||||
expect(decide({ params: { agent } }).mode).toBe('structured')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -87,7 +87,6 @@ const BLOCKER_REASON: Record<
|
||||
'floating-workspace': 'structured_unsupported_on_host',
|
||||
'tui-launch-customization': 'tui_launch_customization',
|
||||
'remote-execution-host': 'remote_execution_host',
|
||||
'codex-on-windows': 'codex_on_windows',
|
||||
'project-runtime': 'wsl_execution_runtime',
|
||||
'runtime-capability': 'structured_sessions_unavailable'
|
||||
}
|
||||
@@ -105,7 +104,6 @@ const HOST_SUPPORT_REASON: Record<
|
||||
export function decideWorkerStartMode(args: {
|
||||
params: WorkerStartModePlacement
|
||||
settings: WorkerStartModeSettings | null | undefined
|
||||
platform: NodeJS.Platform
|
||||
}): WorkerStartModeReceipt {
|
||||
const { params, settings } = args
|
||||
if (!prefersStructuredNativeChatByDefault(settings)) {
|
||||
@@ -125,7 +123,6 @@ export function decideWorkerStartMode(args: {
|
||||
agent,
|
||||
// Set only by --on, which the placement check above already turned into a fallback.
|
||||
executionHostId: 'local',
|
||||
platform: args.platform,
|
||||
hostCapabilities: RUNTIME_CAPABILITIES,
|
||||
// Orchestration resolves a managed worktree or folder workspace; a floating terminal is never
|
||||
// a worker placement. WSL is left to the executing host's own create-support probe, which
|
||||
|
||||
@@ -51,8 +51,7 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [
|
||||
await assertWorkerStartTaskSpecWithinPromptBudget(params.spec ?? existingTask!.spec)
|
||||
const mode = decideWorkerStartMode({
|
||||
params,
|
||||
settings: readWorkerStartModeSettings(runtime),
|
||||
platform: process.platform
|
||||
settings: readWorkerStartModeSettings(runtime)
|
||||
})
|
||||
if (params.on) {
|
||||
// A remote worker is always a terminal agent; the mode receipt rides along so the
|
||||
|
||||
@@ -80,7 +80,10 @@ export function requireStructuredCleanupHost(ctx: RpcContext): StructuredAgentSe
|
||||
export async function ensureStructuredHostInstalled(ctx: RpcContext): Promise<void> {
|
||||
// Gated first: a client that cannot read structured sessions must not be able
|
||||
// to make the host exist, which is an observable side effect of the surface.
|
||||
if (!supportsStructuredSessions(ctx) || getStructuredAgentSessionHost()) {
|
||||
if (!supportsStructuredSessions(ctx)) {
|
||||
return
|
||||
}
|
||||
if (getStructuredAgentSessionHost()) {
|
||||
return
|
||||
}
|
||||
await ctx.runtime.ensureStructuredAgentSessionHost()
|
||||
|
||||
@@ -8,9 +8,11 @@ import { createTrackedJournalOpener } from '../native-chat/agent-session-journal
|
||||
import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store'
|
||||
import type {
|
||||
AgentSessionClaimStatus,
|
||||
AgentSessionExecutionLocation,
|
||||
AgentSessionProcessIdentity,
|
||||
AgentSessionRecord
|
||||
} from '../../shared/agent-session-record'
|
||||
import { __setWindowsProcessTreeLoaderForTests } from '../windows/windows-process-table'
|
||||
import {
|
||||
createStructuredAgentSessionOwnerProbe,
|
||||
createStructuredAgentSessionOwnerProbes
|
||||
@@ -270,6 +272,35 @@ describe('structured agent-session runtime install', () => {
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('does not infer Windows process identity support from an injected reader', async () => {
|
||||
stateDirectory = await mkdtemp(join(tmpdir(), 'orca-structured-runtime-'))
|
||||
const originalPlatform = process.platform
|
||||
const location: AgentSessionExecutionLocation = {
|
||||
executionHostId: 'local',
|
||||
wslDistro: null,
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceKind: 'folder'
|
||||
}
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
__setWindowsProcessTreeLoaderForTests(() => null)
|
||||
try {
|
||||
const host = await ensureStructuredAgentSessionHost({
|
||||
stateDirectory,
|
||||
hostId: HOST_ID,
|
||||
claimKeyId: 'key-1',
|
||||
resolveWorkspacePath: async () => stateDirectory!,
|
||||
resolveEnvironment: async () => ({}),
|
||||
resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }),
|
||||
readProcessStartTime: async () => 1_700_000_000_000
|
||||
})
|
||||
|
||||
expect(host.supportsCreate(location, 'codex')).toBe(false)
|
||||
} finally {
|
||||
__setWindowsProcessTreeLoaderForTests()
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// A stop whose teardown fails must not forget the runtime it was tearing down.
|
||||
|
||||
@@ -6,6 +6,21 @@ import {
|
||||
} from '../native-chat/agent-session-wire/structured-agent-session-registry'
|
||||
import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate'
|
||||
|
||||
const { isWindowsProcessStartTimeAvailable } = vi.hoisted(() => ({
|
||||
isWindowsProcessStartTimeAvailable: vi.fn(() => true)
|
||||
}))
|
||||
|
||||
vi.mock('../windows/windows-process-table', async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
isWindowsProcessStartTimeAvailable
|
||||
}))
|
||||
|
||||
const originalPlatform = process.platform
|
||||
|
||||
function setPlatform(platform: NodeJS.Platform): void {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
|
||||
}
|
||||
|
||||
type InstallEffects = {
|
||||
storeOpened: boolean
|
||||
writeGateAttached: boolean
|
||||
@@ -94,6 +109,9 @@ async function expectSupportWithoutInstall(input: {
|
||||
|
||||
describe('structured agent-session create-support probe', () => {
|
||||
afterEach(() => {
|
||||
setPlatform(originalPlatform)
|
||||
isWindowsProcessStartTimeAvailable.mockReset()
|
||||
isWindowsProcessStartTimeAvailable.mockReturnValue(true)
|
||||
setStructuredAgentSessionHost(null)
|
||||
agentSessionPtyWriteGate.detachRecordLookup()
|
||||
vi.restoreAllMocks()
|
||||
@@ -111,6 +129,27 @@ describe('structured agent-session create-support probe', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
['codex', true, { supported: true }],
|
||||
['codex', false, { supported: false, reason: 'agent' }],
|
||||
['claude', true, { supported: true }],
|
||||
['claude', false, { supported: false, reason: 'agent' }]
|
||||
] as const)(
|
||||
'requires native Windows process identity proof before answering %s support (%s)',
|
||||
async (agent, proofAvailable, expected) => {
|
||||
setPlatform('win32')
|
||||
isWindowsProcessStartTimeAvailable.mockReturnValue(proofAvailable)
|
||||
|
||||
await expectSupportWithoutInstall({
|
||||
agent,
|
||||
location: { executionHostId: 'local', wslDistro: null },
|
||||
expected
|
||||
})
|
||||
|
||||
expect(isWindowsProcessStartTimeAvailable).toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['codex', 'claude'] as const)(
|
||||
'still reports an unsupported remote %s location without installing the host',
|
||||
async (agent) => {
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
type AgentLaunchRoutingInput
|
||||
} from '@/lib/agent-launch-routing'
|
||||
import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
|
||||
import { CLIENT_PLATFORM } from '@/lib/new-workspace'
|
||||
import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import { readLocalRuntimeCapabilities } from '@/runtime/local-runtime-capabilities'
|
||||
import { useAppStore } from '@/store'
|
||||
@@ -47,7 +46,6 @@ export function resolveAiVaultSessionResumeInChatForWorkspace(args: {
|
||||
useAppStore.getState(),
|
||||
targetWorkspaceId as string
|
||||
),
|
||||
platform: CLIENT_PLATFORM,
|
||||
hostCapabilities: readLocalRuntimeCapabilities(),
|
||||
workspaceKind: (targetWorkspaceId as string).startsWith('folder:')
|
||||
? 'folder'
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
CLIENT_PLATFORM,
|
||||
ensureAgentStartupInTerminal,
|
||||
type LinkedWorkItemSummary
|
||||
} from '@/lib/new-workspace'
|
||||
import { ensureAgentStartupInTerminal, type LinkedWorkItemSummary } from '@/lib/new-workspace'
|
||||
import { seedNativeChatLaunchDraftForAgentTab } from '@/lib/agent-launch-prompt-delivery'
|
||||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
import { buildAgentStartupPlan } from '@/lib/tui-agent-startup'
|
||||
@@ -151,7 +147,6 @@ export async function submitFolderWorkspaceCreate({
|
||||
executionHostId: runtimeEnvironmentId
|
||||
? `runtime:${encodeURIComponent(runtimeEnvironmentId)}`
|
||||
: (projectGroup.connectionId ?? 'local'),
|
||||
platform: CLIENT_PLATFORM,
|
||||
hostCapabilities: readLocalRuntimeCapabilities(),
|
||||
workspaceKind: 'folder',
|
||||
promptDelivery: launchDraftPrompt ? 'draft' : 'auto-submit',
|
||||
|
||||
@@ -33,7 +33,7 @@ import type { PendingSmartGitHubSubmitResolution } from './source-selection-deci
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { settleComposerSubmit } from '@/lib/composer-submit-cancellation'
|
||||
import { toFolderWorkspaceLinkedTask } from '@/components/sidebar/folder-workspace-composer-helpers'
|
||||
import { CLIENT_PLATFORM, ensureAgentStartupInTerminal } from '@/lib/new-workspace'
|
||||
import { ensureAgentStartupInTerminal } from '@/lib/new-workspace'
|
||||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache'
|
||||
@@ -140,7 +140,6 @@ export function useFullCreationExecution(input: FullCreationExecutionInput) {
|
||||
agent: tuiAgent,
|
||||
settings,
|
||||
executionHostId: selectedRepoExecutionHostId ?? 'local',
|
||||
platform: CLIENT_PLATFORM,
|
||||
hostCapabilities: readLocalRuntimeCapabilities(),
|
||||
workspaceKind: selectedRepoIsGit ? 'git-worktree' : 'folder',
|
||||
promptDelivery: startupPlan?.draftPrompt ? 'draft' : 'auto-submit',
|
||||
|
||||
@@ -51,7 +51,6 @@ import {
|
||||
resolveAgentLaunchRoute
|
||||
} from '@/lib/agent-launch-routing'
|
||||
import { readLocalRuntimeCapabilities } from '@/runtime/local-runtime-capabilities'
|
||||
import { CLIENT_PLATFORM } from '@/lib/new-workspace'
|
||||
|
||||
export function useQuickCreationExecution(input: QuickCreationExecutionInput) {
|
||||
const {
|
||||
@@ -206,7 +205,6 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) {
|
||||
executionHostId: ephemeralVmRecipe
|
||||
? 'runtime:pending-ephemeral-vm'
|
||||
: (workspaceRunContext?.hostId ?? selectedRepoExecutionHostId ?? 'local'),
|
||||
platform: CLIENT_PLATFORM,
|
||||
hostCapabilities: readLocalRuntimeCapabilities(),
|
||||
workspaceKind: selectedRepoIsGit ? 'git-worktree' : 'folder',
|
||||
promptDelivery: quickDraftPrompt ? 'draft' : 'auto-submit',
|
||||
|
||||
@@ -19,7 +19,6 @@ function route(overrides: Partial<Parameters<typeof resolveAgentLaunchRoute>[0]>
|
||||
agent: 'codex',
|
||||
settings,
|
||||
executionHostId: 'local',
|
||||
platform: 'darwin',
|
||||
hostCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY],
|
||||
workspaceKind: 'git-worktree',
|
||||
nativeChatTranscriptIsLocalReadable: true,
|
||||
@@ -41,57 +40,16 @@ describe('resolveAgentLaunchRoute', () => {
|
||||
}
|
||||
)
|
||||
|
||||
/** Boundary guard between this lane and the one that owns Windows Codex. Codex's win32 refusal is
|
||||
* deliberate, so it is asserted against whatever currently lets Claude through rather than
|
||||
* against one host answer — a future gate swap must not be able to flip Codex on quietly. */
|
||||
describe("Codex's Windows refusal", () => {
|
||||
it('holds in the exact situation that routes Claude to structured', () => {
|
||||
const onWindows = { platform: 'win32' } as const
|
||||
expect(route({ ...onWindows, agent: 'claude' })).toBe('structured-native-chat')
|
||||
expect(route({ ...onWindows, agent: 'codex' })).toBe('legacy-native-chat')
|
||||
})
|
||||
|
||||
it('holds for every host capability set, including ones that carry extra gates', () => {
|
||||
for (const hostCapabilities of [
|
||||
[STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY],
|
||||
[STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, 'agent-session.structured.claude.v1'],
|
||||
[STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, 'agent-session.structured.hold.v1']
|
||||
]) {
|
||||
expect(route({ agent: 'codex', platform: 'win32', hostCapabilities })).toBe(
|
||||
'legacy-native-chat'
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('holds for prompted and folder-workspace launches too', () => {
|
||||
expect(
|
||||
route({
|
||||
agent: 'codex',
|
||||
platform: 'win32',
|
||||
launchText: 'go',
|
||||
promptDelivery: 'auto-submit'
|
||||
})
|
||||
).toBe('legacy-native-chat')
|
||||
expect(route({ agent: 'codex', platform: 'win32', workspaceKind: 'folder' })).toBe(
|
||||
'legacy-native-chat'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
/** Pins Codex's whole platform answer, not just win32, so no platform silently changes here. */
|
||||
it.each([
|
||||
['darwin', 'structured-native-chat'],
|
||||
['linux', 'structured-native-chat'],
|
||||
['win32', 'legacy-native-chat']
|
||||
] as const)('leaves Codex routing on %s unchanged', (platform, expected) => {
|
||||
expect(route({ agent: 'codex', platform })).toBe(expected)
|
||||
})
|
||||
|
||||
/** Claude's Windows answer is not a client-side platform guess: the route lets it through and the
|
||||
* executing host settles it with agentSession.createSupport at create time. */
|
||||
it('lets a Windows Claude launch reach the host-measured create support check', () => {
|
||||
expect(route({ agent: 'claude', platform: 'win32' })).toBe('structured-native-chat')
|
||||
})
|
||||
/** Windows eligibility is no client-side platform guess for either provider: the route lets the
|
||||
* launch through and the executing host settles it with agentSession.createSupport at create
|
||||
* time. A stale caller still passing the removed `platform` input must not flip Codex off the
|
||||
* structured route — the field is gone, not reinterpreted. */
|
||||
it.each(['claude', 'codex'] as const)(
|
||||
'routes %s to structured even when the caller claims a win32 client platform',
|
||||
(agent) => {
|
||||
expect(route({ agent, ...({ platform: 'win32' } as object) })).toBe('structured-native-chat')
|
||||
}
|
||||
)
|
||||
|
||||
it('routes a supported local Codex launch to structured native chat', () => {
|
||||
expect(route()).toBe('structured-native-chat')
|
||||
@@ -134,15 +92,13 @@ describe('resolveAgentLaunchRoute', () => {
|
||||
it.each(['git-worktree', 'folder'] as const)(
|
||||
'supports a local %s without widening floating-terminal scope',
|
||||
(workspaceKind) => {
|
||||
expect(route({ workspaceKind, platform: 'linux' })).toBe('structured-native-chat')
|
||||
expect(route({ workspaceKind })).toBe('structured-native-chat')
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps floating, WSL, and repair-required launches terminal-backed', () => {
|
||||
expect(route({ workspaceKind: 'floating' })).toBe('legacy-native-chat')
|
||||
expect(route({ agent: 'claude', workspaceKind: 'floating', platform: 'win32' })).toBe(
|
||||
'legacy-native-chat'
|
||||
)
|
||||
expect(route({ agent: 'claude', workspaceKind: 'floating' })).toBe('legacy-native-chat')
|
||||
expect(
|
||||
route({
|
||||
projectRuntime: {
|
||||
|
||||
@@ -30,7 +30,6 @@ export type AgentLaunchRoutingInput = {
|
||||
| null
|
||||
| undefined
|
||||
executionHostId: string
|
||||
platform: NodeJS.Platform
|
||||
hostCapabilities: readonly string[]
|
||||
workspaceKind?: 'git-worktree' | 'folder' | 'floating'
|
||||
projectRuntime?: ProjectExecutionRuntimeResolution | null
|
||||
@@ -68,7 +67,6 @@ export function structuredAgentLaunchSupported(
|
||||
resolveStructuredNativeChatSupport({
|
||||
agent: input.agent,
|
||||
executionHostId: input.executionHostId,
|
||||
platform: input.platform,
|
||||
hostCapabilities: input.hostCapabilities,
|
||||
workspaceKind: input.workspaceKind,
|
||||
projectRuntime: input.projectRuntime,
|
||||
|
||||
@@ -213,7 +213,6 @@ function launchAgentInNewTabInternal(
|
||||
agent,
|
||||
settings: store.settings,
|
||||
executionHostId: getExecutionHostIdForWorktree(store, worktreeId),
|
||||
platform: CLIENT_PLATFORM,
|
||||
hostCapabilities: readLocalRuntimeCapabilities(),
|
||||
workspaceKind,
|
||||
projectRuntime: getLocalProjectExecutionRuntimeContext(store, worktreeId),
|
||||
|
||||
@@ -19,37 +19,43 @@ describe('structured agent session launch', () => {
|
||||
})
|
||||
|
||||
it('creates a native session with a host-verifiable launch intent', async () => {
|
||||
vi.mocked(callStructuredAgentSession).mockImplementation(async (_target, _method, params) => ({
|
||||
ok: true,
|
||||
replayed: false,
|
||||
fence: 1,
|
||||
cursor: { epoch: 'epoch-1', sequence: 0 },
|
||||
value: {
|
||||
sessionId: (params as { envelope: { sessionId: string } }).envelope.sessionId,
|
||||
fence: 1,
|
||||
page: {
|
||||
sessionId: 'session-1',
|
||||
epoch: 'epoch-1',
|
||||
direction: 'tail',
|
||||
items: [],
|
||||
removedItemIds: [],
|
||||
submissions: [],
|
||||
window: {
|
||||
oldest: null,
|
||||
newest: null,
|
||||
nextCursor: { epoch: 'epoch-1', sequence: 0 }
|
||||
},
|
||||
liveCursor: { epoch: 'epoch-1', sequence: 0 },
|
||||
hasOlder: false,
|
||||
hasNewer: false
|
||||
},
|
||||
unconfirmedClientMessageIds: []
|
||||
}
|
||||
}))
|
||||
vi.mocked(callStructuredAgentSession).mockImplementation(async (_target, method, params) =>
|
||||
method === 'agentSession.createSupport'
|
||||
? { supported: true }
|
||||
: {
|
||||
ok: true,
|
||||
replayed: false,
|
||||
fence: 1,
|
||||
cursor: { epoch: 'epoch-1', sequence: 0 },
|
||||
value: {
|
||||
sessionId: (params as { envelope: { sessionId: string } }).envelope.sessionId,
|
||||
fence: 1,
|
||||
page: {
|
||||
sessionId: 'session-1',
|
||||
epoch: 'epoch-1',
|
||||
direction: 'tail',
|
||||
items: [],
|
||||
removedItemIds: [],
|
||||
submissions: [],
|
||||
window: {
|
||||
oldest: null,
|
||||
newest: null,
|
||||
nextCursor: { epoch: 'epoch-1', sequence: 0 }
|
||||
},
|
||||
liveCursor: { epoch: 'epoch-1', sequence: 0 },
|
||||
hasOlder: false,
|
||||
hasNewer: false
|
||||
},
|
||||
unconfirmedClientMessageIds: []
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const intent = createStructuredAgentSessionLaunchIntent('workspace-1', 'codex')
|
||||
const receipt = await launchStructuredAgentSession(intent)
|
||||
const params = vi.mocked(callStructuredAgentSession).mock.calls[0]?.[2] as {
|
||||
const params = vi
|
||||
.mocked(callStructuredAgentSession)
|
||||
.mock.calls.find(([, method]) => method === 'agentSession.create')?.[2] as {
|
||||
envelope: { sessionId: string; payloadFingerprint: string }
|
||||
worktree: string
|
||||
agent: 'codex'
|
||||
@@ -87,40 +93,46 @@ describe('structured agent session launch', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('asks the executing host for create support before creating a Claude session', async () => {
|
||||
vi.mocked(callStructuredAgentSession).mockImplementation(async (_target, method) =>
|
||||
method === 'agentSession.createSupport'
|
||||
? { supported: true }
|
||||
: { ok: true, replayed: false, value: { sessionId: 'claude_1', fence: 1 } }
|
||||
)
|
||||
it.each(['claude', 'codex'] as const)(
|
||||
'asks the executing host for create support before creating a %s session',
|
||||
async (agent) => {
|
||||
vi.mocked(callStructuredAgentSession).mockImplementation(async (_target, method) =>
|
||||
method === 'agentSession.createSupport'
|
||||
? { supported: true }
|
||||
: { ok: true, replayed: false, value: { sessionId: `${agent}_1`, fence: 1 } }
|
||||
)
|
||||
|
||||
const intent = createStructuredAgentSessionLaunchIntent('workspace-1', 'claude')
|
||||
await launchStructuredAgentSession(intent)
|
||||
const intent = createStructuredAgentSessionLaunchIntent('workspace-1', agent)
|
||||
await launchStructuredAgentSession(intent)
|
||||
|
||||
expect(vi.mocked(callStructuredAgentSession).mock.calls.map(([, method]) => method)).toEqual([
|
||||
'agentSession.createSupport',
|
||||
'agentSession.create'
|
||||
])
|
||||
expect(callStructuredAgentSession).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
{ kind: 'local' },
|
||||
'agentSession.createSupport',
|
||||
{ worktree: 'id:workspace-1', agent: 'claude' }
|
||||
)
|
||||
})
|
||||
expect(vi.mocked(callStructuredAgentSession).mock.calls.map(([, method]) => method)).toEqual([
|
||||
'agentSession.createSupport',
|
||||
'agentSession.create'
|
||||
])
|
||||
expect(callStructuredAgentSession).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
{ kind: 'local' },
|
||||
'agentSession.createSupport',
|
||||
{ worktree: 'id:workspace-1', agent }
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('refuses a Claude launch the host says it cannot support, without creating', async () => {
|
||||
vi.mocked(callStructuredAgentSession).mockResolvedValue({ supported: false, reason: 'agent' })
|
||||
it.each(['claude', 'codex'] as const)(
|
||||
'refuses a %s launch the host says it cannot support, without creating',
|
||||
async (agent) => {
|
||||
vi.mocked(callStructuredAgentSession).mockResolvedValue({ supported: false, reason: 'agent' })
|
||||
|
||||
const intent = createStructuredAgentSessionLaunchIntent('workspace-1', 'claude')
|
||||
const intent = createStructuredAgentSessionLaunchIntent('workspace-1', agent)
|
||||
|
||||
await expect(launchStructuredAgentSession(intent)).rejects.toBeInstanceOf(
|
||||
StructuredAgentSessionCreateRefusalError
|
||||
)
|
||||
expect(vi.mocked(callStructuredAgentSession).mock.calls.map(([, method]) => method)).toEqual([
|
||||
'agentSession.createSupport'
|
||||
])
|
||||
})
|
||||
await expect(launchStructuredAgentSession(intent)).rejects.toBeInstanceOf(
|
||||
StructuredAgentSessionCreateRefusalError
|
||||
)
|
||||
expect(vi.mocked(callStructuredAgentSession).mock.calls.map(([, method]) => method)).toEqual([
|
||||
'agentSession.createSupport'
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
it('fails closed when the create support probe cannot be answered', async () => {
|
||||
vi.mocked(callStructuredAgentSession).mockRejectedValue(new Error('runtime unreachable'))
|
||||
@@ -212,46 +224,40 @@ describe('structured agent session launch', () => {
|
||||
expect(callStructuredAgentSession).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
/** Codex's support answer is settled by the launch route and owned elsewhere; this pins that the
|
||||
* Claude probe did not change Codex's wire traffic. */
|
||||
it('does not probe create support for Codex', async () => {
|
||||
vi.mocked(callStructuredAgentSession).mockResolvedValue({
|
||||
ok: true,
|
||||
replayed: false,
|
||||
value: { sessionId: 'codex_1', fence: 1 }
|
||||
})
|
||||
|
||||
await launchStructuredAgentSession(
|
||||
createStructuredAgentSessionLaunchIntent('workspace-1', 'codex')
|
||||
/** The probe now runs for Codex too, so create-outcome tests script it to say yes. */
|
||||
function mockSupportedCreate(create: () => unknown): void {
|
||||
vi.mocked(callStructuredAgentSession).mockImplementation(async (_target, method) =>
|
||||
method === 'agentSession.createSupport' ? { supported: true } : create()
|
||||
)
|
||||
|
||||
expect(vi.mocked(callStructuredAgentSession).mock.calls.map(([, method]) => method)).toEqual([
|
||||
'agentSession.create'
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
it('replays the exact create envelope when an unknown outcome is retried', async () => {
|
||||
const intent = createStructuredAgentSessionLaunchIntent('workspace-retry', 'codex')
|
||||
vi.mocked(callStructuredAgentSession).mockRejectedValue(new Error('response lost'))
|
||||
mockSupportedCreate(() => {
|
||||
throw new Error('response lost')
|
||||
})
|
||||
|
||||
await expect(launchStructuredAgentSession(intent)).rejects.toThrow('response lost')
|
||||
await expect(launchStructuredAgentSession(intent)).rejects.toThrow('response lost')
|
||||
|
||||
const first = vi.mocked(callStructuredAgentSession).mock.calls[0]?.[2]
|
||||
const second = vi.mocked(callStructuredAgentSession).mock.calls[1]?.[2]
|
||||
const createCalls = vi
|
||||
.mocked(callStructuredAgentSession)
|
||||
.mock.calls.filter(([, method]) => method === 'agentSession.create')
|
||||
const first = createCalls[0]?.[2]
|
||||
const second = createCalls[1]?.[2]
|
||||
expect(first).toBe(intent.params)
|
||||
expect(second).toBe(first)
|
||||
expect(intent.params.envelope.clientOperationId).toMatch(/^\d{13}-[0-9a-f]{32}$/)
|
||||
})
|
||||
|
||||
it('preserves an unknown refusal code without classifying it as fallback-safe', async () => {
|
||||
vi.mocked(callStructuredAgentSession).mockResolvedValue({
|
||||
mockSupportedCreate(() => ({
|
||||
ok: false,
|
||||
refusal: {
|
||||
code: 'agent_session_operation_unknown',
|
||||
message: 'The chat may already exist.'
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
const error = await launchStructuredAgentSession(
|
||||
createStructuredAgentSessionLaunchIntent('workspace-unknown', 'codex')
|
||||
@@ -266,13 +272,13 @@ describe('structured agent session launch', () => {
|
||||
/** The class is the verdict, so a refusal message that happens to end in a definitive token
|
||||
* must not be re-read into one by the transport-error matcher. */
|
||||
it('keeps an unknown outcome unknown even when its message ends in a definitive token', async () => {
|
||||
vi.mocked(callStructuredAgentSession).mockResolvedValue({
|
||||
mockSupportedCreate(() => ({
|
||||
ok: false,
|
||||
refusal: {
|
||||
code: 'agent_session_ownership_unknown',
|
||||
message: 'Owner check failed: method_not_found'
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
const error = await launchStructuredAgentSession(
|
||||
createStructuredAgentSessionLaunchIntent('workspace-unknown-token', 'codex')
|
||||
@@ -283,13 +289,13 @@ describe('structured agent session launch', () => {
|
||||
})
|
||||
|
||||
it('preserves a definitive refusal code for the fallback path', async () => {
|
||||
vi.mocked(callStructuredAgentSession).mockResolvedValue({
|
||||
mockSupportedCreate(() => ({
|
||||
ok: false,
|
||||
refusal: {
|
||||
code: 'structured_agent_session_unsupported',
|
||||
message: 'Structured chat is unavailable.'
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
const error = await launchStructuredAgentSession(
|
||||
createStructuredAgentSessionLaunchIntent('workspace-unsupported', 'codex')
|
||||
@@ -303,9 +309,9 @@ describe('structured agent session launch', () => {
|
||||
it.each(['method_not_found', 'structured_agent_session_unsupported'])(
|
||||
'turns an old-host %s error into a definitive transport refusal',
|
||||
async (code) => {
|
||||
vi.mocked(callStructuredAgentSession).mockRejectedValueOnce(
|
||||
Object.assign(new Error(code), { code })
|
||||
)
|
||||
mockSupportedCreate(() => {
|
||||
throw Object.assign(new Error(code), { code })
|
||||
})
|
||||
const oldHostError = await launchStructuredAgentSession(
|
||||
createStructuredAgentSessionLaunchIntent(`workspace-old-host-${code}`, 'codex')
|
||||
).catch((caught: unknown) => caught)
|
||||
@@ -316,9 +322,9 @@ describe('structured agent session launch', () => {
|
||||
)
|
||||
|
||||
it('keeps an unclassified transport failure outcome unknown', async () => {
|
||||
vi.mocked(callStructuredAgentSession).mockRejectedValueOnce(
|
||||
Object.assign(new Error('Connection lost'), { code: 'runtime_error' })
|
||||
)
|
||||
mockSupportedCreate(() => {
|
||||
throw Object.assign(new Error('Connection lost'), { code: 'runtime_error' })
|
||||
})
|
||||
const transportError = await launchStructuredAgentSession(
|
||||
createStructuredAgentSessionLaunchIntent('workspace-offline', 'codex')
|
||||
).catch((caught: unknown) => caught)
|
||||
|
||||
@@ -174,17 +174,10 @@ async function hostSupportsCreate(intent: StructuredAgentSessionLaunchIntent): P
|
||||
/**
|
||||
* Only the host that will execute the session can answer whether it supports creating one there —
|
||||
* on Windows that means reading the provider child's process start time, which a client cannot
|
||||
* observe.
|
||||
*
|
||||
* Codex is absent on purpose: its answer is settled by the launch route and owned elsewhere, so
|
||||
* probing here would change Codex's wire traffic. Note that this early return is also why the
|
||||
* unresolvable-selector race above has never been able to refuse a Codex launch — the race is
|
||||
* identical for Codex, nothing asks. Whoever gives Codex a probe inherits it.
|
||||
* observe. Both providers ask: the host classifies per agent, and Codex inherits the
|
||||
* unresolvable-selector retry above along with the probe.
|
||||
*/
|
||||
async function requireHostCreateSupport(intent: StructuredAgentSessionLaunchIntent): Promise<void> {
|
||||
if (intent.agent !== 'claude') {
|
||||
return
|
||||
}
|
||||
if (!(await hostSupportsCreate(intent))) {
|
||||
abandonStructuredAgentSessionLaunchIntent(intent)
|
||||
throw new StructuredAgentSessionCreateRefusalError(
|
||||
|
||||
@@ -97,7 +97,6 @@ export async function prepareDirectWorkItemAgentLaunch(args: {
|
||||
agent: effectiveAgent,
|
||||
settings: args.settings,
|
||||
executionHostId: getExecutionHostIdForWorktree(args.latestStore, args.worktreeId),
|
||||
platform: CLIENT_PLATFORM,
|
||||
hostCapabilities: readLocalRuntimeCapabilities(),
|
||||
workspaceKind: 'git-worktree',
|
||||
projectRuntime: getLocalProjectExecutionRuntimeContext(
|
||||
|
||||
@@ -135,7 +135,6 @@ export function resolveDismissedOnboardingFolderAgentLaunch(args: {
|
||||
agent,
|
||||
settings: args.settings,
|
||||
executionHostId: args.executionHostId,
|
||||
platform: getClientPlatform(),
|
||||
hostCapabilities: readLocalRuntimeCapabilities(),
|
||||
workspaceKind: 'folder',
|
||||
nativeChatTranscriptIsLocalReadable: args.nativeChatTranscriptIsLocalReadable,
|
||||
|
||||
@@ -58,6 +58,9 @@ type CreateReply = { ok: boolean; refusal?: { code: string; message: string } }
|
||||
function replyToCreates(...replies: CreateReply[]): void {
|
||||
let index = 0
|
||||
mocks.call.mockImplementation(async (_target: unknown, method: string, params: unknown) => {
|
||||
if (method === 'agentSession.createSupport') {
|
||||
return { supported: true }
|
||||
}
|
||||
if (method !== 'agentSession.create') {
|
||||
return { ok: true, page: { fence: 1 } }
|
||||
}
|
||||
|
||||
@@ -63,11 +63,16 @@ describe('a launch that adopts a conversation is its own identity', () => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
mocks.refresh.mockResolvedValue([])
|
||||
mocks.call.mockImplementation(async (_target: unknown, method: string) =>
|
||||
method === 'agentSession.create'
|
||||
? new Promise(() => {})
|
||||
: { ok: true, value: { submission: { dispatchState: 'accepted' } } }
|
||||
)
|
||||
mocks.call.mockImplementation(async (_target: unknown, method: string) => {
|
||||
if (method === 'agentSession.create') {
|
||||
return new Promise(() => {})
|
||||
}
|
||||
// Both providers now ask the executing host before creating.
|
||||
if (method === 'agentSession.createSupport') {
|
||||
return { supported: true }
|
||||
}
|
||||
return { ok: true, value: { submission: { dispatchState: 'accepted' } } }
|
||||
})
|
||||
})
|
||||
|
||||
it('does not hand a resume the blank launch already pending for the same worktree', async () => {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { isWebClientLocation } from './web-client-location'
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('isWebClientLocation', () => {
|
||||
it('reports false when there is no window at all', () => {
|
||||
vi.stubGlobal('window', undefined)
|
||||
expect(isWebClientLocation()).toBe(false)
|
||||
})
|
||||
|
||||
// Why: this runs on the launch-routing path, where a throw is swallowed and
|
||||
// silently becomes a failed launch. A window without a usable `location`
|
||||
// must answer the question, not throw.
|
||||
it('does not throw when window exists without a location', () => {
|
||||
vi.stubGlobal('window', { api: {} })
|
||||
expect(() => isWebClientLocation()).not.toThrow()
|
||||
expect(isWebClientLocation()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not throw when location exists without a pathname', () => {
|
||||
vi.stubGlobal('window', { location: {} })
|
||||
expect(() => isWebClientLocation()).not.toThrow()
|
||||
expect(isWebClientLocation()).toBe(false)
|
||||
})
|
||||
|
||||
it('detects the web client by its entry path', () => {
|
||||
vi.stubGlobal('window', { location: { pathname: '/web-index.html' } })
|
||||
expect(isWebClientLocation()).toBe(true)
|
||||
})
|
||||
|
||||
it('detects the web client by its global marker', () => {
|
||||
vi.stubGlobal('window', { __ORCA_WEB_CLIENT__: true, location: { pathname: '/' } })
|
||||
expect(isWebClientLocation()).toBe(true)
|
||||
})
|
||||
|
||||
it('reports false for a normal desktop renderer path', () => {
|
||||
vi.stubGlobal('window', { location: { pathname: '/index.html' } })
|
||||
expect(isWebClientLocation()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -2,8 +2,13 @@ export function isWebClientLocation(): boolean {
|
||||
if (typeof window === 'undefined') {
|
||||
return false
|
||||
}
|
||||
// Why the pathname guard: `window` can exist without a usable `location`
|
||||
// (partial test doubles, and any embedder that stubs the global), and this
|
||||
// runs on the launch-routing path where a throw is swallowed and silently
|
||||
// turns into a failed launch rather than a visible error.
|
||||
const pathname = (window as { location?: { pathname?: unknown } }).location?.pathname
|
||||
return (
|
||||
Boolean((window as unknown as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__) ||
|
||||
window.location.pathname.endsWith('/web-index.html')
|
||||
(typeof pathname === 'string' && pathname.endsWith('/web-index.html'))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -70,6 +70,7 @@ function stubTerminalCapabilityApi(args: {
|
||||
wslDistros?: string[]
|
||||
gitBashAvailable?: boolean
|
||||
hostPlatform?: NodeJS.Platform | null
|
||||
windowsProcessStartTimeAvailable?: boolean
|
||||
}): {
|
||||
wslIsAvailable: ReturnType<typeof vi.fn>
|
||||
wslListDistros: ReturnType<typeof vi.fn>
|
||||
@@ -81,9 +82,12 @@ function stubTerminalCapabilityApi(args: {
|
||||
const wslListDistros = vi.fn().mockResolvedValue(args.wslDistros ?? [])
|
||||
const pwshIsAvailable = vi.fn().mockResolvedValue(args.pwshAvailable)
|
||||
const isGitBashAvailable = vi.fn().mockResolvedValue(args.gitBashAvailable ?? false)
|
||||
const runtimeGetStatus = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ hostPlatform: 'hostPlatform' in args ? args.hostPlatform : 'win32' })
|
||||
const runtimeGetStatus = vi.fn().mockResolvedValue({
|
||||
hostPlatform: 'hostPlatform' in args ? args.hostPlatform : 'win32',
|
||||
...(args.windowsProcessStartTimeAvailable !== undefined
|
||||
? { windowsProcessStartTimeAvailable: args.windowsProcessStartTimeAvailable }
|
||||
: {})
|
||||
})
|
||||
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
@@ -583,7 +587,8 @@ describe('windows terminal capabilities', () => {
|
||||
const { wslIsAvailable, wslListDistros } = stubTerminalCapabilityApi({
|
||||
wslAvailable: false,
|
||||
pwshAvailable: true,
|
||||
wslDistros: []
|
||||
wslDistros: [],
|
||||
windowsProcessStartTimeAvailable: true
|
||||
})
|
||||
wslIsAvailable.mockResolvedValueOnce(false).mockResolvedValue(true)
|
||||
wslListDistros.mockResolvedValueOnce([]).mockResolvedValue(['Ubuntu'])
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,36 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('windows terminal capability re-probe', () => {
|
||||
it('reprobes usable WSL until Windows process identity is proved', async () => {
|
||||
vi.useFakeTimers()
|
||||
let current: WindowsTerminalCapabilities = USABLE_WSL
|
||||
const probe = vi.fn(async () => {
|
||||
current = { ...current, windowsProcessStartTimeAvailable: true }
|
||||
return current
|
||||
})
|
||||
const readCached = () => current
|
||||
startWindowsTerminalCapabilityReprobe({ ownerKey: 'local', probe, readCached })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
expect(probe).toHaveBeenCalledTimes(1)
|
||||
expect(readCached().windowsProcessStartTimeAvailable).toBe(true)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30 * 60_000)
|
||||
expect(probe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('resets the backoff when only process identity capability changes', async () => {
|
||||
vi.useFakeTimers()
|
||||
const identityAvailable = { ...ABSENT_WSL, windowsProcessStartTimeAvailable: true }
|
||||
const { probe, readCached } = createWatcher([identityAvailable, identityAvailable])
|
||||
startWindowsTerminalCapabilityReprobe({ ownerKey: 'local', probe, readCached })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
expect(probe).toHaveBeenCalledTimes(1)
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
expect(probe).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('backs off to a five-minute ceiling on a stable answer', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { probe, readCached } = createWatcher()
|
||||
@@ -55,7 +85,9 @@ describe('windows terminal capability re-probe', () => {
|
||||
|
||||
it('still re-checks a transient absent answer, then stops once WSL answers', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { probe, readCached } = createWatcher([USABLE_WSL])
|
||||
const { probe, readCached } = createWatcher([
|
||||
{ ...USABLE_WSL, windowsProcessStartTimeAvailable: true }
|
||||
])
|
||||
startWindowsTerminalCapabilityReprobe({ ownerKey: 'local', probe, readCached })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
|
||||
@@ -31,13 +31,21 @@ function capabilitySignature(capabilities: WindowsTerminalCapabilities): string
|
||||
capabilities.wslDistros.join('\u0000'),
|
||||
capabilities.pwshAvailable,
|
||||
capabilities.gitBashAvailable,
|
||||
capabilities.hostPlatform ?? ''
|
||||
capabilities.hostPlatform ?? '',
|
||||
capabilities.windowsProcessStartTimeAvailable
|
||||
].join('|')
|
||||
}
|
||||
|
||||
/** The answer #11295 waits for: a usable WSL. Nothing further to watch for. */
|
||||
/** A usable WSL is settled only after Windows hosts also prove PID identity. */
|
||||
function isSettled(capabilities: WindowsTerminalCapabilities): boolean {
|
||||
return capabilities.wslAvailable && capabilities.wslDistros.length > 0
|
||||
if (!capabilities.wslAvailable || capabilities.wslDistros.length === 0) {
|
||||
return false
|
||||
}
|
||||
if (capabilities.hostPlatform === 'win32') {
|
||||
return capabilities.windowsProcessStartTimeAvailable === true
|
||||
}
|
||||
// A missing platform means the status probe may have failed; keep checking until it recovers.
|
||||
return capabilities.hostPlatform !== null
|
||||
}
|
||||
|
||||
function clearRunnerTimer(runner: CapabilityReprobeRunner): void {
|
||||
|
||||
@@ -57,7 +57,6 @@ src/main/codex-accounts/legacy-wsl-runtime-auth-drain-recovery-script-harness.ts
|
||||
src/main/codex-accounts/legacy-wsl-runtime-auth-drain-script-harness.ts
|
||||
src/main/codex-accounts/legacy-wsl-runtime-auth-drain-script-interference-shims.ts
|
||||
src/main/codex-accounts/service.ts
|
||||
src/main/codex/codex-app-server-client.ts
|
||||
src/main/codex/codex-app-server-posix-supervisor.ts
|
||||
src/main/codex/codex-app-server-session.ts
|
||||
src/main/codex/codex-state-db-backfill-recovery.ts
|
||||
|
||||
@@ -29,7 +29,7 @@ const CHILD_PROCESS_IMPORT_ALLOWLIST: readonly string[] = readFileSync(
|
||||
* May only ever be DECREASED, and only by migrating a file off
|
||||
* `node:child_process`. Raising it is never the fix.
|
||||
*/
|
||||
const DIRECT_IMPORTER_PIN = 156
|
||||
const DIRECT_IMPORTER_PIN = 155
|
||||
|
||||
const IMPORT_PATTERN =
|
||||
/(?:from\s+['"]node:child_process['"]|from\s+['"]child_process['"]|require\(\s*['"]node:child_process['"]|require\(\s*['"]child_process['"])/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -22,7 +22,6 @@ function support(overrides: Partial<StructuredNativeChatSupportInput> = {}) {
|
||||
return resolveStructuredNativeChatSupport({
|
||||
agent: 'claude',
|
||||
executionHostId: 'local',
|
||||
platform: 'darwin',
|
||||
hostCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY],
|
||||
workspaceKind: 'git-worktree',
|
||||
...overrides
|
||||
@@ -64,7 +63,6 @@ describe('per-launch structured feasibility', () => {
|
||||
['a floating workspace', { workspaceKind: 'floating' }, 'floating-workspace'],
|
||||
['a custom TUI launch', { requiresTuiLaunchCustomization: true }, 'tui-launch-customization'],
|
||||
['an SSH host', { executionHostId: 'ssh:host-a' }, 'remote-execution-host'],
|
||||
['Codex on Windows', { agent: 'codex', platform: 'win32' }, 'codex-on-windows'],
|
||||
['a missing capability', { hostCapabilities: [] }, 'runtime-capability']
|
||||
] as [string, Partial<StructuredNativeChatSupportInput>, string][])(
|
||||
'names %s as the blocker',
|
||||
@@ -73,9 +71,14 @@ describe('per-launch structured feasibility', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('leaves a Windows Claude launch to the executing host', () => {
|
||||
expect(support({ agent: 'claude', platform: 'win32' })).toEqual({ supported: true })
|
||||
})
|
||||
// The client cannot see whether the host can read a provider child's start time, so neither
|
||||
// provider is refused here on platform; agentSession.createSupport answers that at create time.
|
||||
it.each(['claude', 'codex'] as const)(
|
||||
'leaves a Windows %s launch to the executing host',
|
||||
(agent) => {
|
||||
expect(support({ agent })).toEqual({ supported: true })
|
||||
}
|
||||
)
|
||||
|
||||
it('blocks a WSL or repair-required project runtime', () => {
|
||||
expect(
|
||||
|
||||
@@ -26,7 +26,6 @@ export type StructuredNativeChatBlocker =
|
||||
| 'floating-workspace'
|
||||
| 'tui-launch-customization'
|
||||
| 'remote-execution-host'
|
||||
| 'codex-on-windows'
|
||||
| 'project-runtime'
|
||||
| 'runtime-capability'
|
||||
|
||||
@@ -37,7 +36,6 @@ export type StructuredNativeChatSupport =
|
||||
export type StructuredNativeChatSupportInput = {
|
||||
agent: TuiAgent
|
||||
executionHostId: string
|
||||
platform: NodeJS.Platform
|
||||
hostCapabilities: readonly string[]
|
||||
workspaceKind?: 'git-worktree' | 'folder' | 'floating'
|
||||
projectRuntime?: ProjectExecutionRuntimeResolution | null
|
||||
@@ -82,12 +80,6 @@ export function resolveStructuredNativeChatSupport(
|
||||
if (input.executionHostId !== 'local') {
|
||||
return { supported: false, blocker: 'remote-execution-host' }
|
||||
}
|
||||
// Codex's Windows refusal is deliberate and settled elsewhere, so it stays a client-side answer.
|
||||
// Claude's is measured by the executing host at create time (agentSession.createSupport) because
|
||||
// only that host knows whether it can read a provider child's start time.
|
||||
if (input.agent === 'codex' && input.platform === 'win32') {
|
||||
return { supported: false, blocker: 'codex-on-windows' }
|
||||
}
|
||||
const projectRuntime = input.projectRuntime
|
||||
if (projectRuntime?.status === 'repair-required' || projectRuntime?.runtime.kind === 'wsl') {
|
||||
return { supported: false, blocker: 'project-runtime' }
|
||||
|
||||
Reference in New Issue
Block a user